From 3d189c415fdf5643bf16e301b028520168f1def8 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Sat, 30 Aug 2025 12:38:20 +0900 Subject: [PATCH] Pre deploy commit. --- .env.example | 0 .history/src/api/synology_20250830104812.py | 1873 ++++++++++++++++ .history/src/api/synology_20250830104833.py | 1877 ++++++++++++++++ .history/src/api/synology_20250830104945.py | 1877 ++++++++++++++++ .history/src/api/synology_20250830105105.py | 1894 ++++++++++++++++ .history/src/api/synology_20250830105130.py | 1908 +++++++++++++++++ .history/src/api/synology_20250830110338.py | 1908 +++++++++++++++++ .history/src/bot_20250830110611.py | 149 ++ .history/src/bot_20250830110630.py | 154 ++ .history/src/bot_20250830110906.py | 154 ++ .../advanced_handlers_20250830104205.py | 972 +++++++++ .../advanced_handlers_20250830104340.py | 972 +++++++++ .../advanced_handlers_20250830105155.py | 982 +++++++++ .../advanced_handlers_20250830105216.py | 980 +++++++++ .../advanced_handlers_20250830110338.py | 980 +++++++++ .../command_handlers_20250830110734.py | 328 +++ .../command_handlers_20250830110754.py | 329 +++ .../command_handlers_20250830110810.py | 325 +++ .../command_handlers_20250830110839.py | 322 +++ .../command_handlers_20250830110906.py | 322 +++ .../extended_handlers_20250830104501.py | 378 ++++ .../extended_handlers_20250830104715.py | 378 ++++ .../handlers/help_handlers_20250830110705.py | 116 + .../handlers/help_handlers_20250830110906.py | 116 + .../src/utils/admin_utils_20250830110540.py | 283 +++ .../src/utils/admin_utils_20250830110906.py | 283 +++ .../src/utils/admin_utils_20250830114406.py | 302 +++ .../src/utils/admin_utils_20250830114514.py | 283 +++ src/api/synology.py | 81 +- src/bot.py | 10 + src/handlers/advanced_handlers.py | 14 +- src/handlers/command_handlers.py | 21 +- src/handlers/extended_handlers.py | 25 +- src/handlers/help_handlers.py | 7 +- src/utils/admin_utils.py | 283 +++ 35 files changed, 20843 insertions(+), 43 deletions(-) create mode 100644 .env.example create mode 100644 .history/src/api/synology_20250830104812.py create mode 100644 .history/src/api/synology_20250830104833.py create mode 100644 .history/src/api/synology_20250830104945.py create mode 100644 .history/src/api/synology_20250830105105.py create mode 100644 .history/src/api/synology_20250830105130.py create mode 100644 .history/src/api/synology_20250830110338.py create mode 100644 .history/src/bot_20250830110611.py create mode 100644 .history/src/bot_20250830110630.py create mode 100644 .history/src/bot_20250830110906.py create mode 100644 .history/src/handlers/advanced_handlers_20250830104205.py create mode 100644 .history/src/handlers/advanced_handlers_20250830104340.py create mode 100644 .history/src/handlers/advanced_handlers_20250830105155.py create mode 100644 .history/src/handlers/advanced_handlers_20250830105216.py create mode 100644 .history/src/handlers/advanced_handlers_20250830110338.py create mode 100644 .history/src/handlers/command_handlers_20250830110734.py create mode 100644 .history/src/handlers/command_handlers_20250830110754.py create mode 100644 .history/src/handlers/command_handlers_20250830110810.py create mode 100644 .history/src/handlers/command_handlers_20250830110839.py create mode 100644 .history/src/handlers/command_handlers_20250830110906.py create mode 100644 .history/src/handlers/extended_handlers_20250830104501.py create mode 100644 .history/src/handlers/extended_handlers_20250830104715.py create mode 100644 .history/src/handlers/help_handlers_20250830110705.py create mode 100644 .history/src/handlers/help_handlers_20250830110906.py create mode 100644 .history/src/utils/admin_utils_20250830110540.py create mode 100644 .history/src/utils/admin_utils_20250830110906.py create mode 100644 .history/src/utils/admin_utils_20250830114406.py create mode 100644 .history/src/utils/admin_utils_20250830114514.py create mode 100644 src/utils/admin_utils.py 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" + )