diff --git a/.history/docs/file_manager_agent_20250830141933.md b/.history/docs/file_manager_agent_20250830141933.md
new file mode 100644
index 0000000..fcd3892
--- /dev/null
+++ b/.history/docs/file_manager_agent_20250830141933.md
@@ -0,0 +1,61 @@
+# Агент файлового менеджера для Synology Power Control Bot
+
+## Описание
+
+Агент файлового менеджера предоставляет удобный интерфейс для просмотра, управления и манипулирования файловой системой Synology NAS через Telegram бота. Агент разработан с использованием модульной архитектуры и предоставляет интуитивно понятный интерфейс с кнопками и диалоговыми окнами.
+
+## Функциональность
+
+- **Просмотр содержимого директорий** - навигация по файловой системе NAS
+- **Загрузка и скачивание файлов** - передача файлов между NAS и устройством пользователя
+- **Управление файлами** - переименование, удаление, получение информации о файлах
+- **Создание папок** - создание новых директорий на NAS
+- **Пагинация** - удобная навигация при большом количестве файлов
+
+## Использование
+
+Для начала работы с файловым менеджером отправьте команду `/files` боту. После этого вы увидите список доступных общих папок на вашем NAS. Используйте интерактивные кнопки для навигации и выполнения различных действий с файлами.
+
+### Основные команды
+
+- `/files` - запуск файлового менеджера
+- `/files [path]` - открытие файлового менеджера с указанным путем
+
+### Интерфейс и навигация
+
+Интерфейс файлового менеджера состоит из:
+- Информации о текущей директории (путь, количество файлов и папок)
+- Списка папок и файлов с кнопками для взаимодействия
+- Кнопок навигации (Вверх, Вперед, Назад)
+- Кнопок действий (Загрузить файл, Создать папку)
+
+## Структура кода
+
+Агент файлового менеджера состоит из следующих основных компонентов:
+
+- **FileManagerAgent** - основной класс агента, реализующий логику файлового менеджера
+- **SynologyAPI** - класс для взаимодействия с API Synology NAS
+- **filestation.py** - модуль, расширяющий SynologyAPI методами для работы с файлами
+
+## Интеграция
+
+Агент файлового менеджера можно легко интегрировать в любого Telegram бота с помощью функции `create_file_manager_handler()`, которая возвращает готовый `ConversationHandler` для регистрации в диспетчере бота.
+
+```python
+from src.api.synology import SynologyAPI
+from src.agents.file_manager_agent import create_file_manager_handler
+from src.api.filestation import add_file_manager_methods_to_synology_api
+
+# Создание экземпляра API
+synology_api = SynologyAPI()
+
+# Создание обработчика
+file_manager_handler = create_file_manager_handler(synology_api)
+
+# Регистрация обработчика в приложении бота
+application.add_handler(file_manager_handler)
+```
+
+## Безопасность
+
+Агент файлового менеджера использует декоратор `@admin_required` для обеспечения доступа только авторизованным пользователям. Это защищает файловую систему NAS от несанкционированного доступа.
diff --git a/.history/docs/file_manager_agent_20250830141957.md b/.history/docs/file_manager_agent_20250830141957.md
new file mode 100644
index 0000000..fcd3892
--- /dev/null
+++ b/.history/docs/file_manager_agent_20250830141957.md
@@ -0,0 +1,61 @@
+# Агент файлового менеджера для Synology Power Control Bot
+
+## Описание
+
+Агент файлового менеджера предоставляет удобный интерфейс для просмотра, управления и манипулирования файловой системой Synology NAS через Telegram бота. Агент разработан с использованием модульной архитектуры и предоставляет интуитивно понятный интерфейс с кнопками и диалоговыми окнами.
+
+## Функциональность
+
+- **Просмотр содержимого директорий** - навигация по файловой системе NAS
+- **Загрузка и скачивание файлов** - передача файлов между NAS и устройством пользователя
+- **Управление файлами** - переименование, удаление, получение информации о файлах
+- **Создание папок** - создание новых директорий на NAS
+- **Пагинация** - удобная навигация при большом количестве файлов
+
+## Использование
+
+Для начала работы с файловым менеджером отправьте команду `/files` боту. После этого вы увидите список доступных общих папок на вашем NAS. Используйте интерактивные кнопки для навигации и выполнения различных действий с файлами.
+
+### Основные команды
+
+- `/files` - запуск файлового менеджера
+- `/files [path]` - открытие файлового менеджера с указанным путем
+
+### Интерфейс и навигация
+
+Интерфейс файлового менеджера состоит из:
+- Информации о текущей директории (путь, количество файлов и папок)
+- Списка папок и файлов с кнопками для взаимодействия
+- Кнопок навигации (Вверх, Вперед, Назад)
+- Кнопок действий (Загрузить файл, Создать папку)
+
+## Структура кода
+
+Агент файлового менеджера состоит из следующих основных компонентов:
+
+- **FileManagerAgent** - основной класс агента, реализующий логику файлового менеджера
+- **SynologyAPI** - класс для взаимодействия с API Synology NAS
+- **filestation.py** - модуль, расширяющий SynologyAPI методами для работы с файлами
+
+## Интеграция
+
+Агент файлового менеджера можно легко интегрировать в любого Telegram бота с помощью функции `create_file_manager_handler()`, которая возвращает готовый `ConversationHandler` для регистрации в диспетчере бота.
+
+```python
+from src.api.synology import SynologyAPI
+from src.agents.file_manager_agent import create_file_manager_handler
+from src.api.filestation import add_file_manager_methods_to_synology_api
+
+# Создание экземпляра API
+synology_api = SynologyAPI()
+
+# Создание обработчика
+file_manager_handler = create_file_manager_handler(synology_api)
+
+# Регистрация обработчика в приложении бота
+application.add_handler(file_manager_handler)
+```
+
+## Безопасность
+
+Агент файлового менеджера использует декоратор `@admin_required` для обеспечения доступа только авторизованным пользователям. Это защищает файловую систему NAS от несанкционированного доступа.
diff --git a/.history/examples/file_manager_demo_20250830141907.py b/.history/examples/file_manager_demo_20250830141907.py
new file mode 100644
index 0000000..66bb463
--- /dev/null
+++ b/.history/examples/file_manager_demo_20250830141907.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Пример использования файлового менеджера для Synology NAS
+"""
+
+import logging
+import asyncio
+from telegram.ext import Application
+
+from src.config.config import TELEGRAM_TOKEN
+from src.api.synology import SynologyAPI
+from src.agents.file_manager_agent import create_file_manager_handler
+from src.api.filestation import add_file_manager_methods_to_synology_api
+from src.utils.logger import setup_logging
+
+async def main():
+ """Главная функция демонстрации файлового менеджера"""
+ # Настройка логирования
+ 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 File Manager Demo")
+ application = Application.builder().token(TELEGRAM_TOKEN).build()
+
+ # Создание экземпляра API и добавление методов для работы с файловой системой
+ synology_api = SynologyAPI()
+
+ # Регистрация обработчика файлового менеджера
+ file_manager_handler = create_file_manager_handler(synology_api)
+ application.add_handler(file_manager_handler)
+
+ # Запуск бота
+ logger.info("Bot started with file manager. Use /files command to start. Press Ctrl+C to stop.")
+ await application.start()
+ await application.updater.start_polling()
+
+ # Ждем прерывание
+ try:
+ await asyncio.Future() # Бесконечное ожидание
+ finally:
+ await application.stop()
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/.history/examples/file_manager_demo_20250830141957.py b/.history/examples/file_manager_demo_20250830141957.py
new file mode 100644
index 0000000..66bb463
--- /dev/null
+++ b/.history/examples/file_manager_demo_20250830141957.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Пример использования файлового менеджера для Synology NAS
+"""
+
+import logging
+import asyncio
+from telegram.ext import Application
+
+from src.config.config import TELEGRAM_TOKEN
+from src.api.synology import SynologyAPI
+from src.agents.file_manager_agent import create_file_manager_handler
+from src.api.filestation import add_file_manager_methods_to_synology_api
+from src.utils.logger import setup_logging
+
+async def main():
+ """Главная функция демонстрации файлового менеджера"""
+ # Настройка логирования
+ 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 File Manager Demo")
+ application = Application.builder().token(TELEGRAM_TOKEN).build()
+
+ # Создание экземпляра API и добавление методов для работы с файловой системой
+ synology_api = SynologyAPI()
+
+ # Регистрация обработчика файлового менеджера
+ file_manager_handler = create_file_manager_handler(synology_api)
+ application.add_handler(file_manager_handler)
+
+ # Запуск бота
+ logger.info("Bot started with file manager. Use /files command to start. Press Ctrl+C to stop.")
+ await application.start()
+ await application.updater.start_polling()
+
+ # Ждем прерывание
+ try:
+ await asyncio.Future() # Бесконечное ожидание
+ finally:
+ await application.stop()
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/.history/run_bot_20250830142127.py b/.history/run_bot_20250830142127.py
new file mode 100644
index 0000000..edcba94
--- /dev/null
+++ b/.history/run_bot_20250830142127.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Файл-обёртка для запуска бота из корневой директории
+"""
+
+from src.bot import main
+
+if __name__ == "__main__":
+ main()
+
+& C:/Users/sst/synology_power_control_bot/.venv/Scripts/python.exe c:/Users/sst/synology_power_control_bot/run_bot.py
diff --git a/.history/run_bot_20250830142131.py b/.history/run_bot_20250830142131.py
new file mode 100644
index 0000000..737f58d
--- /dev/null
+++ b/.history/run_bot_20250830142131.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Файл-обёртка для запуска бота из корневой директории
+"""
+
+from src.bot import main
+
+if __name__ == "__main__":
+ main()
diff --git a/.history/src/agents/__init___20250830141428.py b/.history/src/agents/__init___20250830141428.py
new file mode 100644
index 0000000..ce1aa94
--- /dev/null
+++ b/.history/src/agents/__init___20250830141428.py
@@ -0,0 +1,9 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль агентов для Synology Power Control Bot.
+Содержит функциональные агенты, реализующие различные возможности бота.
+"""
+
+from src.agents.file_manager_agent import FileManagerAgent, create_file_manager_handler
diff --git a/.history/src/agents/__init___20250830141957.py b/.history/src/agents/__init___20250830141957.py
new file mode 100644
index 0000000..ce1aa94
--- /dev/null
+++ b/.history/src/agents/__init___20250830141957.py
@@ -0,0 +1,9 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль агентов для Synology Power Control Bot.
+Содержит функциональные агенты, реализующие различные возможности бота.
+"""
+
+from src.agents.file_manager_agent import FileManagerAgent, create_file_manager_handler
diff --git a/.history/src/agents/file_manager_agent_20250830141230.py b/.history/src/agents/file_manager_agent_20250830141230.py
new file mode 100644
index 0000000..84aebc3
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830141230.py
@@ -0,0 +1,653 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ ParseMode,
+ InputFile
+)
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id
+
+ if ":prev:" in callback_data:
+ # Предыдущая страница
+ path = callback_data.split("fm:nav:prev:")[1]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif ":next:" in callback_data:
+ # Следующая страница
+ path = callback_data.split("fm:nav:next:")[1]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif ":refresh:" in callback_data:
+ # Обновить текущую директорию
+ path = callback_data.split("fm:nav:refresh:")[1]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif "fm:nav:close" in callback_data:
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ while size_bytes >= 1024 and i < len(size_names) - 1:
+ size_bytes /= 1024.0
+ i += 1
+
+ return f"{size_bytes:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ # Здесь будет обработчик для получения нового имени файла
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ # Здесь будет обработчик для получения имени новой папки
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", lambda u, c: ConversationHandler.END),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830141546.py b/.history/src/agents/file_manager_agent_20250830141546.py
new file mode 100644
index 0000000..38ecf1d
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830141546.py
@@ -0,0 +1,653 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ ParseMode,
+ InputFile
+)
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id
+
+ if ":prev:" in callback_data:
+ # Предыдущая страница
+ path = callback_data.split("fm:nav:prev:")[1]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif ":next:" in callback_data:
+ # Следующая страница
+ path = callback_data.split("fm:nav:next:")[1]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif ":refresh:" in callback_data:
+ # Обновить текущую директорию
+ path = callback_data.split("fm:nav:refresh:")[1]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif "fm:nav:close" in callback_data:
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ while size_bytes >= 1024 and i < len(size_names) - 1:
+ size_bytes /= 1024.0
+ i += 1
+
+ return f"{size_bytes:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", lambda u, c: ConversationHandler.END),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830141613.py b/.history/src/agents/file_manager_agent_20250830141613.py
new file mode 100644
index 0000000..d843a23
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830141613.py
@@ -0,0 +1,693 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ ParseMode,
+ InputFile
+)
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id
+
+ if ":prev:" in callback_data:
+ # Предыдущая страница
+ path = callback_data.split("fm:nav:prev:")[1]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif ":next:" in callback_data:
+ # Следующая страница
+ path = callback_data.split("fm:nav:next:")[1]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif ":refresh:" in callback_data:
+ # Обновить текущую директорию
+ path = callback_data.split("fm:nav:refresh:")[1]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif "fm:nav:close" in callback_data:
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ while size_bytes >= 1024 and i < len(size_names) - 1:
+ size_bytes /= 1024.0
+ i += 1
+
+ return f"{size_bytes:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", lambda u, c: ConversationHandler.END),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830141646.py b/.history/src/agents/file_manager_agent_20250830141646.py
new file mode 100644
index 0000000..6eca0da
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830141646.py
@@ -0,0 +1,743 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ ParseMode,
+ InputFile
+)
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id
+
+ if ":prev:" in callback_data:
+ # Предыдущая страница
+ path = callback_data.split("fm:nav:prev:")[1]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif ":next:" in callback_data:
+ # Следующая страница
+ path = callback_data.split("fm:nav:next:")[1]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif ":refresh:" in callback_data:
+ # Обновить текущую директорию
+ path = callback_data.split("fm:nav:refresh:")[1]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif "fm:nav:close" in callback_data:
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ while size_bytes >= 1024 and i < len(size_names) - 1:
+ size_bytes /= 1024.0
+ i += 1
+
+ return f"{size_bytes:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", lambda u, c: ConversationHandler.END),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830141721.py b/.history/src/agents/file_manager_agent_20250830141721.py
new file mode 100644
index 0000000..e62fac1
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830141721.py
@@ -0,0 +1,750 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ ParseMode,
+ InputFile
+)
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id
+
+ if ":prev:" in callback_data:
+ # Предыдущая страница
+ path = callback_data.split("fm:nav:prev:")[1]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif ":next:" in callback_data:
+ # Следующая страница
+ path = callback_data.split("fm:nav:next:")[1]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif ":refresh:" in callback_data:
+ # Обновить текущую директорию
+ path = callback_data.split("fm:nav:refresh:")[1]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif "fm:nav:close" in callback_data:
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ while size_bytes >= 1024 and i < len(size_names) - 1:
+ size_bytes /= 1024.0
+ i += 1
+
+ return f"{size_bytes:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", lambda u, c: ConversationHandler.END),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830141747.py b/.history/src/agents/file_manager_agent_20250830141747.py
new file mode 100644
index 0000000..d3d4e57
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830141747.py
@@ -0,0 +1,750 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ ParseMode,
+ InputFile
+)
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ while size_bytes >= 1024 and i < len(size_names) - 1:
+ size_bytes /= 1024.0
+ i += 1
+
+ return f"{size_bytes:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", lambda u, c: ConversationHandler.END),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830141805.py b/.history/src/agents/file_manager_agent_20250830141805.py
new file mode 100644
index 0000000..53f9a03
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830141805.py
@@ -0,0 +1,751 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ ParseMode,
+ InputFile
+)
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", lambda u, c: ConversationHandler.END),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830141832.py b/.history/src/agents/file_manager_agent_20250830141832.py
new file mode 100644
index 0000000..66c46f6
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830141832.py
@@ -0,0 +1,756 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ ParseMode,
+ InputFile
+)
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830141847.py b/.history/src/agents/file_manager_agent_20250830141847.py
new file mode 100644
index 0000000..ff5c408
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830141847.py
@@ -0,0 +1,757 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ ParseMode,
+ InputFile
+)
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830141957.py b/.history/src/agents/file_manager_agent_20250830141957.py
new file mode 100644
index 0000000..ff5c408
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830141957.py
@@ -0,0 +1,757 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ ParseMode,
+ InputFile
+)
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830142055.py b/.history/src/agents/file_manager_agent_20250830142055.py
new file mode 100644
index 0000000..107dca2
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830142055.py
@@ -0,0 +1,757 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830142117.py b/.history/src/agents/file_manager_agent_20250830142117.py
new file mode 100644
index 0000000..107dca2
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830142117.py
@@ -0,0 +1,757 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830142754.py b/.history/src/agents/file_manager_agent_20250830142754.py
new file mode 100644
index 0000000..125e9a9
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830142754.py
@@ -0,0 +1,760 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830142812.py b/.history/src/agents/file_manager_agent_20250830142812.py
new file mode 100644
index 0000000..edf5b15
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830142812.py
@@ -0,0 +1,763 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830142848.py b/.history/src/agents/file_manager_agent_20250830142848.py
new file mode 100644
index 0000000..b9ddd27
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830142848.py
@@ -0,0 +1,766 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830142901.py b/.history/src/agents/file_manager_agent_20250830142901.py
new file mode 100644
index 0000000..748a2de
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830142901.py
@@ -0,0 +1,769 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие файла
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830142941.py b/.history/src/agents/file_manager_agent_20250830142941.py
new file mode 100644
index 0000000..c6a7314
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830142941.py
@@ -0,0 +1,775 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143005.py b/.history/src/agents/file_manager_agent_20250830143005.py
new file mode 100644
index 0000000..bc826d6
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143005.py
@@ -0,0 +1,775 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143049.py b/.history/src/agents/file_manager_agent_20250830143049.py
new file mode 100644
index 0000000..9c8a057
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143049.py
@@ -0,0 +1,775 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143114.py b/.history/src/agents/file_manager_agent_20250830143114.py
new file mode 100644
index 0000000..2a89c4c
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143114.py
@@ -0,0 +1,785 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not update.message:
+ return BROWSING
+
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите текст для имени файла."
+ )
+ return RENAMING
+
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143155.py b/.history/src/agents/file_manager_agent_20250830143155.py
new file mode 100644
index 0000000..2a89c4c
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143155.py
@@ -0,0 +1,785 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not update.message:
+ return BROWSING
+
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите текст для имени файла."
+ )
+ return RENAMING
+
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143317.py b/.history/src/agents/file_manager_agent_20250830143317.py
new file mode 100644
index 0000000..90dfea3
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143317.py
@@ -0,0 +1,784 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not update.message:
+ return BROWSING
+
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите текст для имени файла."
+ )
+ return RENAMING
+
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ # context.user_data - это уже существующий словарь, просто добавляем в него данные
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143333.py b/.history/src/agents/file_manager_agent_20250830143333.py
new file mode 100644
index 0000000..d1906ea
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143333.py
@@ -0,0 +1,789 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not update.message:
+ return BROWSING
+
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите текст для имени файла."
+ )
+ return RENAMING
+
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # Сохраняем информацию о создании папки в контексте пользователя
+ # context.user_data может быть инициализирован как None
+ if context.user_data is None:
+ # В таком случае инициализируем его как dict через контекст
+ context.chat_data.clear() # Этот трюк инициализирует user_data
+
+ # Теперь безопасно используем user_data
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143351.py b/.history/src/agents/file_manager_agent_20250830143351.py
new file mode 100644
index 0000000..69ada98
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143351.py
@@ -0,0 +1,794 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not update.message:
+ return BROWSING
+
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите текст для имени файла."
+ )
+ return RENAMING
+
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # В PTB 20+ user_data должен быть всегда доступен
+ # Просто добавляем нашу информацию в словарь
+ # Если context.user_data не инициализирован, используем setdefault
+ # чтобы добавить ключ, если его нет
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+ else:
+ # Если по какой-то причине user_data недоступен,
+ # запишем путь в context.chat_data (он более стабилен)
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ if not context.user_data or not context.user_data.get('creating_folder'):
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ parent_path = context.user_data['creating_folder'].get('path')
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143422.py b/.history/src/agents/file_manager_agent_20250830143422.py
new file mode 100644
index 0000000..e5df44c
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143422.py
@@ -0,0 +1,812 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if not context.user_data:
+ context.user_data = {}
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not update.message:
+ return BROWSING
+
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите текст для имени файла."
+ )
+ return RENAMING
+
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # В PTB 20+ user_data должен быть всегда доступен
+ # Просто добавляем нашу информацию в словарь
+ # Если context.user_data не инициализирован, используем setdefault
+ # чтобы добавить ключ, если его нет
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+ else:
+ # Если по какой-то причине user_data недоступен,
+ # запишем путь в context.chat_data (он более стабилен)
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ # Проверяем где может быть информация о папке - в user_data или в chat_data
+ parent_path = None
+
+ # Сначала проверяем user_data
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ if 'creating_folder' in context.user_data:
+ parent_path = context.user_data['creating_folder'].get('path')
+
+ # Если не нашли в user_data, проверяем в chat_data
+ if parent_path is None and hasattr(context, 'chat_data') and context.chat_data is not None:
+ if 'creating_folder' in context.chat_data:
+ parent_path = context.chat_data['creating_folder'].get('path')
+
+ if parent_path is None:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя."
+ )
+ return CREATING_FOLDER
+
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143501.py b/.history/src/agents/file_manager_agent_20250830143501.py
new file mode 100644
index 0000000..fae0a91
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143501.py
@@ -0,0 +1,817 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+ # Дополнительно сохраняем в chat_data для надежности
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not update.message:
+ return BROWSING
+
+ if not context.user_data or 'renaming' not in context.user_data:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+ old_name = os.path.basename(file_path)
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите текст для имени файла."
+ )
+ return RENAMING
+
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # В PTB 20+ user_data должен быть всегда доступен
+ # Просто добавляем нашу информацию в словарь
+ # Если context.user_data не инициализирован, используем setdefault
+ # чтобы добавить ключ, если его нет
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+ else:
+ # Если по какой-то причине user_data недоступен,
+ # запишем путь в context.chat_data (он более стабилен)
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ # Проверяем где может быть информация о папке - в user_data или в chat_data
+ parent_path = None
+
+ # Сначала проверяем user_data
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ if 'creating_folder' in context.user_data:
+ parent_path = context.user_data['creating_folder'].get('path')
+
+ # Если не нашли в user_data, проверяем в chat_data
+ if parent_path is None and hasattr(context, 'chat_data') and context.chat_data is not None:
+ if 'creating_folder' in context.chat_data:
+ parent_path = context.chat_data['creating_folder'].get('path')
+
+ if parent_path is None:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя."
+ )
+ return CREATING_FOLDER
+
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143531.py b/.history/src/agents/file_manager_agent_20250830143531.py
new file mode 100644
index 0000000..4d9b23c
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143531.py
@@ -0,0 +1,828 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+ # Дополнительно сохраняем в chat_data для надежности
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not update.message:
+ return BROWSING
+
+ # Проверяем где может быть информация о файле - в user_data или в chat_data
+ file_path = None
+ file_dir = None
+
+ # Сначала проверяем user_data
+ if hasattr(context, 'user_data') and context.user_data is not None and 'renaming' in context.user_data:
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+
+ # Если не нашли в user_data, проверяем в chat_data
+ if (file_path is None or file_dir is None) and hasattr(context, 'chat_data') and context.chat_data is not None and 'renaming' in context.chat_data:
+ file_path = context.chat_data['renaming'].get('file_path')
+ file_dir = context.chat_data['renaming'].get('file_dir')
+
+ if file_path is None or file_dir is None:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+ old_name = os.path.basename(file_path)
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите текст для имени файла."
+ )
+ return RENAMING
+
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # В PTB 20+ user_data должен быть всегда доступен
+ # Просто добавляем нашу информацию в словарь
+ # Если context.user_data не инициализирован, используем setdefault
+ # чтобы добавить ключ, если его нет
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+ else:
+ # Если по какой-то причине user_data недоступен,
+ # запишем путь в context.chat_data (он более стабилен)
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ # Проверяем где может быть информация о папке - в user_data или в chat_data
+ parent_path = None
+
+ # Сначала проверяем user_data
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ if 'creating_folder' in context.user_data:
+ parent_path = context.user_data['creating_folder'].get('path')
+
+ # Если не нашли в user_data, проверяем в chat_data
+ if parent_path is None and hasattr(context, 'chat_data') and context.chat_data is not None:
+ if 'creating_folder' in context.chat_data:
+ parent_path = context.chat_data['creating_folder'].get('path')
+
+ if parent_path is None:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя."
+ )
+ return CREATING_FOLDER
+
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143559.py b/.history/src/agents/file_manager_agent_20250830143559.py
new file mode 100644
index 0000000..a39d2dd
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143559.py
@@ -0,0 +1,834 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+ # Дополнительно сохраняем в chat_data для надежности
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not update.message:
+ return BROWSING
+
+ # Проверяем где может быть информация о файле - в user_data или в chat_data
+ file_path = None
+ file_dir = None
+
+ # Сначала проверяем user_data
+ if hasattr(context, 'user_data') and context.user_data is not None and 'renaming' in context.user_data:
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+
+ # Если не нашли в user_data, проверяем в chat_data
+ if (file_path is None or file_dir is None) and hasattr(context, 'chat_data') and context.chat_data is not None and 'renaming' in context.chat_data:
+ file_path = context.chat_data['renaming'].get('file_path')
+ file_dir = context.chat_data['renaming'].get('file_dir')
+
+ if file_path is None or file_dir is None:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+ old_name = os.path.basename(file_path)
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите текст для имени файла."
+ )
+ return RENAMING
+
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if hasattr(context, 'user_data') and context.user_data is not None and 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ if hasattr(context, 'chat_data') and context.chat_data is not None and 'renaming' in context.chat_data:
+ del context.chat_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # В PTB 20+ user_data должен быть всегда доступен
+ # Просто добавляем нашу информацию в словарь
+ # Если context.user_data не инициализирован, используем setdefault
+ # чтобы добавить ключ, если его нет
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+ else:
+ # Если по какой-то причине user_data недоступен,
+ # запишем путь в context.chat_data (он более стабилен)
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ # Проверяем где может быть информация о папке - в user_data или в chat_data
+ parent_path = None
+
+ # Сначала проверяем user_data
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ if 'creating_folder' in context.user_data:
+ parent_path = context.user_data['creating_folder'].get('path')
+
+ # Если не нашли в user_data, проверяем в chat_data
+ if parent_path is None and hasattr(context, 'chat_data') and context.chat_data is not None:
+ if 'creating_folder' in context.chat_data:
+ parent_path = context.chat_data['creating_folder'].get('path')
+
+ if parent_path is None:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя."
+ )
+ return CREATING_FOLDER
+
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Отображаем обновленное содержимое директории
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143628.py b/.history/src/agents/file_manager_agent_20250830143628.py
new file mode 100644
index 0000000..7d20e9b
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143628.py
@@ -0,0 +1,844 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+ # Дополнительно сохраняем в chat_data для надежности
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not update.message:
+ return BROWSING
+
+ # Проверяем где может быть информация о файле - в user_data или в chat_data
+ file_path = None
+ file_dir = None
+
+ # Сначала проверяем user_data
+ if hasattr(context, 'user_data') and context.user_data is not None and 'renaming' in context.user_data:
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+
+ # Если не нашли в user_data, проверяем в chat_data
+ if (file_path is None or file_dir is None) and hasattr(context, 'chat_data') and context.chat_data is not None and 'renaming' in context.chat_data:
+ file_path = context.chat_data['renaming'].get('file_path')
+ file_dir = context.chat_data['renaming'].get('file_dir')
+
+ if file_path is None or file_dir is None:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+ old_name = os.path.basename(file_path)
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите текст для имени файла."
+ )
+ return RENAMING
+
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if hasattr(context, 'user_data') and context.user_data is not None and 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ if hasattr(context, 'chat_data') and context.chat_data is not None and 'renaming' in context.chat_data:
+ del context.chat_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # В PTB 20+ user_data должен быть всегда доступен
+ # Просто добавляем нашу информацию в словарь
+ # Если context.user_data не инициализирован, используем setdefault
+ # чтобы добавить ключ, если его нет
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+ else:
+ # Если по какой-то причине user_data недоступен,
+ # запишем путь в context.chat_data (он более стабилен)
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ # Проверяем где может быть информация о папке - в user_data или в chat_data
+ parent_path = None
+
+ # Сначала проверяем user_data
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ if 'creating_folder' in context.user_data:
+ parent_path = context.user_data['creating_folder'].get('path')
+
+ # Если не нашли в user_data, проверяем в chat_data
+ if parent_path is None and hasattr(context, 'chat_data') and context.chat_data is not None:
+ if 'creating_folder' in context.chat_data:
+ parent_path = context.chat_data['creating_folder'].get('path')
+
+ if parent_path is None:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя."
+ )
+ return CREATING_FOLDER
+
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Очищаем данные о создании папки
+ if hasattr(context, 'user_data') and context.user_data is not None and 'creating_folder' in context.user_data:
+ del context.user_data['creating_folder']
+
+ if hasattr(context, 'chat_data') and context.chat_data is not None and 'creating_folder' in context.chat_data:
+ del context.chat_data['creating_folder']
+
+ # Отображаем обновленное содержимое директории
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/agents/file_manager_agent_20250830143646.py b/.history/src/agents/file_manager_agent_20250830143646.py
new file mode 100644
index 0000000..7d20e9b
--- /dev/null
+++ b/.history/src/agents/file_manager_agent_20250830143646.py
@@ -0,0 +1,844 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+ # Дополнительно сохраняем в chat_data для надежности
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not update.message:
+ return BROWSING
+
+ # Проверяем где может быть информация о файле - в user_data или в chat_data
+ file_path = None
+ file_dir = None
+
+ # Сначала проверяем user_data
+ if hasattr(context, 'user_data') and context.user_data is not None and 'renaming' in context.user_data:
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+
+ # Если не нашли в user_data, проверяем в chat_data
+ if (file_path is None or file_dir is None) and hasattr(context, 'chat_data') and context.chat_data is not None and 'renaming' in context.chat_data:
+ file_path = context.chat_data['renaming'].get('file_path')
+ file_dir = context.chat_data['renaming'].get('file_dir')
+
+ if file_path is None or file_dir is None:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+ old_name = os.path.basename(file_path)
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите текст для имени файла."
+ )
+ return RENAMING
+
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if hasattr(context, 'user_data') and context.user_data is not None and 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ if hasattr(context, 'chat_data') and context.chat_data is not None and 'renaming' in context.chat_data:
+ del context.chat_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # В PTB 20+ user_data должен быть всегда доступен
+ # Просто добавляем нашу информацию в словарь
+ # Если context.user_data не инициализирован, используем setdefault
+ # чтобы добавить ключ, если его нет
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+ else:
+ # Если по какой-то причине user_data недоступен,
+ # запишем путь в context.chat_data (он более стабилен)
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ # Проверяем где может быть информация о папке - в user_data или в chat_data
+ parent_path = None
+
+ # Сначала проверяем user_data
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ if 'creating_folder' in context.user_data:
+ parent_path = context.user_data['creating_folder'].get('path')
+
+ # Если не нашли в user_data, проверяем в chat_data
+ if parent_path is None and hasattr(context, 'chat_data') and context.chat_data is not None:
+ if 'creating_folder' in context.chat_data:
+ parent_path = context.chat_data['creating_folder'].get('path')
+
+ if parent_path is None:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя."
+ )
+ return CREATING_FOLDER
+
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Очищаем данные о создании папки
+ if hasattr(context, 'user_data') and context.user_data is not None and 'creating_folder' in context.user_data:
+ del context.user_data['creating_folder']
+
+ if hasattr(context, 'chat_data') and context.chat_data is not None and 'creating_folder' in context.chat_data:
+ del context.chat_data['creating_folder']
+
+ # Отображаем обновленное содержимое директории
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/.history/src/api/filestation_20250830141415.py b/.history/src/api/filestation_20250830141415.py
new file mode 100644
index 0000000..ec73411
--- /dev/null
+++ b/.history/src/api/filestation_20250830141415.py
@@ -0,0 +1,512 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль для взаимодействия с файловой системой Synology NAS через API FileStation
+"""
+
+import os
+import logging
+import requests
+from typing import Dict, Any, Optional, List, Union
+
+from src.api.synology import SynologyAPI
+
+logger = logging.getLogger(__name__)
+
+def add_file_manager_methods_to_synology_api(api_class):
+ """Добавляет методы для работы с файловой системой к классу SynologyAPI"""
+
+ def list_files(self, folder_path: str = "/") -> List[Dict[str, Any]]:
+ """Получение списка файлов и папок в указанной директории
+
+ Args:
+ folder_path: Путь к директории для просмотра
+
+ Returns:
+ Список файлов и папок в указанной директории
+ """
+ logger.info(f"Listing files in directory: {folder_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file listing")
+ return []
+
+ try:
+ # Если это корневая папка, получаем список общих папок
+ if folder_path == "/":
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "list_share",
+ version=2
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "list_share",
+ version=1
+ )
+
+ if not result:
+ logger.error("Failed to list shared folders")
+ return []
+
+ return result.get("shares", [])
+ else:
+ # Получаем список файлов в указанной директории
+ params = {
+ "folder_path": folder_path,
+ "sort_by": "name",
+ "sort_direction": "ASC"
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "list",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "list",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to list files in {folder_path}")
+ return []
+
+ return result.get("files", [])
+
+ except Exception as e:
+ logger.error(f"Error listing files in {folder_path}: {str(e)}")
+ return []
+
+ def get_file_info(self, file_path: str) -> Dict[str, Any]:
+ """Получение подробной информации о файле
+
+ Args:
+ file_path: Полный путь к файлу
+
+ Returns:
+ Информация о файле
+ """
+ logger.info(f"Getting file info: {file_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file info request")
+ return {}
+
+ try:
+ params = {
+ "path": file_path,
+ "additional": "real_path,size,owner,time,perm"
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "getinfo",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "getinfo",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to get file info for {file_path}")
+ return {}
+
+ # Возвращаем информацию о первом файле в результате
+ files = result.get("files", [])
+ if files and len(files) > 0:
+ return files[0]
+
+ return {}
+
+ except Exception as e:
+ logger.error(f"Error getting file info for {file_path}: {str(e)}")
+ return {}
+
+ def download_file(self, file_path: str, local_path: str) -> bool:
+ """Скачивание файла с NAS
+
+ Args:
+ file_path: Путь к файлу на NAS
+ local_path: Локальный путь для сохранения файла
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Downloading file from {file_path} to {local_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file download")
+ return False
+
+ try:
+ # Получаем URL для скачивания файла
+ params = {
+ "path": file_path,
+ "mode": "download"
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.Download",
+ "download",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.Download",
+ "download",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to get download URL for {file_path}")
+ return False
+
+ # URL для скачивания
+ download_url = result.get("url")
+ if not download_url:
+ logger.error("No download URL received")
+ return False
+
+ # Добавляем базовый URL, если URL относительный
+ if not download_url.startswith("http"):
+ protocol = "https" if self.protocol == "https" else "http"
+ download_url = f"{protocol}://{self.base_url}/{download_url}"
+
+ # Скачиваем файл
+ response = self.session.get(download_url, stream=True, verify=False)
+ if response.status_code != 200:
+ logger.error(f"Failed to download file: HTTP {response.status_code}")
+ return False
+
+ # Сохраняем файл
+ with open(local_path, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if chunk:
+ f.write(chunk)
+
+ logger.info(f"File successfully downloaded to {local_path}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error downloading file {file_path}: {str(e)}")
+ return False
+
+ def upload_file(self, local_path: str, folder_path: str) -> bool:
+ """Загрузка файла на NAS
+
+ Args:
+ local_path: Локальный путь к файлу
+ folder_path: Путь на NAS для загрузки файла
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Uploading file from {local_path} to {folder_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file upload")
+ return False
+
+ try:
+ # Проверяем существование файла
+ if not os.path.exists(local_path):
+ logger.error(f"Local file {local_path} not found")
+ return False
+
+ # Формируем URL для загрузки
+ url = f"{self.base_url}/entry.cgi"
+
+ # Извлекаем имя файла из локального пути
+ file_name = os.path.basename(local_path)
+
+ # Подготавливаем параметры для загрузки
+ params = {
+ "api": "SYNO.FileStation.Upload",
+ "version": "2",
+ "method": "upload",
+ "path": folder_path,
+ "_sid": self.sid
+ }
+
+ # Подготавливаем файл для загрузки
+ files = {
+ 'file': (file_name, open(local_path, 'rb'))
+ }
+
+ # Выполняем запрос
+ response = self.session.post(url, params=params, files=files, verify=False)
+
+ # Закрываем файл
+ files['file'][1].close()
+
+ if response.status_code != 200:
+ logger.error(f"Failed to upload file: HTTP {response.status_code}")
+ return False
+
+ # Проверяем ответ
+ try:
+ data = response.json()
+ success = data.get("success", False)
+
+ if not success:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to upload file: Error code {error_code}")
+ return False
+
+ logger.info(f"File successfully uploaded to {folder_path}/{file_name}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error parsing upload response: {str(e)}")
+ return False
+
+ except Exception as e:
+ logger.error(f"Error uploading file {local_path}: {str(e)}")
+ return False
+
+ def delete_file(self, file_path: str) -> bool:
+ """Удаление файла на NAS
+
+ Args:
+ file_path: Путь к файлу для удаления
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Deleting file: {file_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file deletion")
+ return False
+
+ try:
+ # Подготавливаем параметры для удаления
+ params = {
+ "path": [file_path],
+ "recursive": True # Удаляем папки рекурсивно
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.Delete",
+ "delete",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.Delete",
+ "delete",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to delete file {file_path}")
+ return False
+
+ # Проверяем результат
+ task_id = result.get("taskid")
+ if not task_id:
+ logger.error("No task ID received for deletion")
+ return False
+
+ # Проверяем статус задачи
+ task_params = {
+ "taskid": task_id
+ }
+
+ # Ждем завершения задачи
+ for _ in range(10):
+ task_result = self._make_api_request(
+ "SYNO.FileStation.Delete",
+ "status",
+ version=2,
+ params=task_params
+ )
+
+ if not task_result:
+ task_result = self._make_api_request(
+ "SYNO.FileStation.Delete",
+ "status",
+ version=1,
+ params=task_params
+ )
+
+ if not task_result:
+ logger.error(f"Failed to check delete task status for {file_path}")
+ return False
+
+ # Проверяем статус задачи
+ if task_result.get("finished", False):
+ return True
+
+ # Ждем немного
+ import time
+ time.sleep(0.5)
+
+ logger.warning(f"Delete task did not complete in time for {file_path}")
+ return True # Возвращаем True, т.к. задача запущена успешно
+
+ except Exception as e:
+ logger.error(f"Error deleting file {file_path}: {str(e)}")
+ return False
+
+ def create_folder(self, parent_path: str, folder_name: str) -> bool:
+ """Создание новой папки на NAS
+
+ Args:
+ parent_path: Родительский путь для новой папки
+ folder_name: Имя новой папки
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Creating folder {folder_name} in {parent_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for folder creation")
+ return False
+
+ try:
+ # Подготавливаем параметры для создания папки
+ params = {
+ "folder_path": parent_path,
+ "name": folder_name
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.CreateFolder",
+ "create",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.CreateFolder",
+ "create",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to create folder {folder_name} in {parent_path}")
+ return False
+
+ # Проверяем результат
+ folders = result.get("folders", [])
+ if not folders:
+ logger.error("No folder information received after creation")
+ return False
+
+ logger.info(f"Folder {folder_name} created successfully in {parent_path}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error creating folder {folder_name}: {str(e)}")
+ return False
+
+ def rename_file(self, file_path: str, new_name: str) -> bool:
+ """Переименование файла или папки на NAS
+
+ Args:
+ file_path: Путь к файлу для переименования
+ new_name: Новое имя файла (без пути)
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Renaming {file_path} to {new_name}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file renaming")
+ return False
+
+ try:
+ # Получаем путь к родительской директории
+ parent_path = os.path.dirname(file_path)
+
+ # Подготавливаем параметры для переименования
+ params = {
+ "path": file_path,
+ "name": new_name
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.Rename",
+ "rename",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.Rename",
+ "rename",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to rename {file_path} to {new_name}")
+ return False
+
+ # Проверяем результат
+ files = result.get("files", [])
+ if not files:
+ logger.error("No file information received after renaming")
+ return False
+
+ logger.info(f"File {file_path} renamed to {new_name} successfully")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error renaming file {file_path}: {str(e)}")
+ return False
+
+ # Добавляем все методы в класс API
+ api_class.list_files = list_files
+ api_class.get_file_info = get_file_info
+ api_class.download_file = download_file
+ api_class.upload_file = upload_file
+ api_class.delete_file = delete_file
+ api_class.create_folder = create_folder
+ api_class.rename_file = rename_file
+
+ return api_class
+
+# Добавляем методы для работы с файлами к классу SynologyAPI
+add_file_manager_methods_to_synology_api(SynologyAPI)
diff --git a/.history/src/api/filestation_20250830141957.py b/.history/src/api/filestation_20250830141957.py
new file mode 100644
index 0000000..ec73411
--- /dev/null
+++ b/.history/src/api/filestation_20250830141957.py
@@ -0,0 +1,512 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль для взаимодействия с файловой системой Synology NAS через API FileStation
+"""
+
+import os
+import logging
+import requests
+from typing import Dict, Any, Optional, List, Union
+
+from src.api.synology import SynologyAPI
+
+logger = logging.getLogger(__name__)
+
+def add_file_manager_methods_to_synology_api(api_class):
+ """Добавляет методы для работы с файловой системой к классу SynologyAPI"""
+
+ def list_files(self, folder_path: str = "/") -> List[Dict[str, Any]]:
+ """Получение списка файлов и папок в указанной директории
+
+ Args:
+ folder_path: Путь к директории для просмотра
+
+ Returns:
+ Список файлов и папок в указанной директории
+ """
+ logger.info(f"Listing files in directory: {folder_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file listing")
+ return []
+
+ try:
+ # Если это корневая папка, получаем список общих папок
+ if folder_path == "/":
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "list_share",
+ version=2
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "list_share",
+ version=1
+ )
+
+ if not result:
+ logger.error("Failed to list shared folders")
+ return []
+
+ return result.get("shares", [])
+ else:
+ # Получаем список файлов в указанной директории
+ params = {
+ "folder_path": folder_path,
+ "sort_by": "name",
+ "sort_direction": "ASC"
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "list",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "list",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to list files in {folder_path}")
+ return []
+
+ return result.get("files", [])
+
+ except Exception as e:
+ logger.error(f"Error listing files in {folder_path}: {str(e)}")
+ return []
+
+ def get_file_info(self, file_path: str) -> Dict[str, Any]:
+ """Получение подробной информации о файле
+
+ Args:
+ file_path: Полный путь к файлу
+
+ Returns:
+ Информация о файле
+ """
+ logger.info(f"Getting file info: {file_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file info request")
+ return {}
+
+ try:
+ params = {
+ "path": file_path,
+ "additional": "real_path,size,owner,time,perm"
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "getinfo",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "getinfo",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to get file info for {file_path}")
+ return {}
+
+ # Возвращаем информацию о первом файле в результате
+ files = result.get("files", [])
+ if files and len(files) > 0:
+ return files[0]
+
+ return {}
+
+ except Exception as e:
+ logger.error(f"Error getting file info for {file_path}: {str(e)}")
+ return {}
+
+ def download_file(self, file_path: str, local_path: str) -> bool:
+ """Скачивание файла с NAS
+
+ Args:
+ file_path: Путь к файлу на NAS
+ local_path: Локальный путь для сохранения файла
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Downloading file from {file_path} to {local_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file download")
+ return False
+
+ try:
+ # Получаем URL для скачивания файла
+ params = {
+ "path": file_path,
+ "mode": "download"
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.Download",
+ "download",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.Download",
+ "download",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to get download URL for {file_path}")
+ return False
+
+ # URL для скачивания
+ download_url = result.get("url")
+ if not download_url:
+ logger.error("No download URL received")
+ return False
+
+ # Добавляем базовый URL, если URL относительный
+ if not download_url.startswith("http"):
+ protocol = "https" if self.protocol == "https" else "http"
+ download_url = f"{protocol}://{self.base_url}/{download_url}"
+
+ # Скачиваем файл
+ response = self.session.get(download_url, stream=True, verify=False)
+ if response.status_code != 200:
+ logger.error(f"Failed to download file: HTTP {response.status_code}")
+ return False
+
+ # Сохраняем файл
+ with open(local_path, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if chunk:
+ f.write(chunk)
+
+ logger.info(f"File successfully downloaded to {local_path}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error downloading file {file_path}: {str(e)}")
+ return False
+
+ def upload_file(self, local_path: str, folder_path: str) -> bool:
+ """Загрузка файла на NAS
+
+ Args:
+ local_path: Локальный путь к файлу
+ folder_path: Путь на NAS для загрузки файла
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Uploading file from {local_path} to {folder_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file upload")
+ return False
+
+ try:
+ # Проверяем существование файла
+ if not os.path.exists(local_path):
+ logger.error(f"Local file {local_path} not found")
+ return False
+
+ # Формируем URL для загрузки
+ url = f"{self.base_url}/entry.cgi"
+
+ # Извлекаем имя файла из локального пути
+ file_name = os.path.basename(local_path)
+
+ # Подготавливаем параметры для загрузки
+ params = {
+ "api": "SYNO.FileStation.Upload",
+ "version": "2",
+ "method": "upload",
+ "path": folder_path,
+ "_sid": self.sid
+ }
+
+ # Подготавливаем файл для загрузки
+ files = {
+ 'file': (file_name, open(local_path, 'rb'))
+ }
+
+ # Выполняем запрос
+ response = self.session.post(url, params=params, files=files, verify=False)
+
+ # Закрываем файл
+ files['file'][1].close()
+
+ if response.status_code != 200:
+ logger.error(f"Failed to upload file: HTTP {response.status_code}")
+ return False
+
+ # Проверяем ответ
+ try:
+ data = response.json()
+ success = data.get("success", False)
+
+ if not success:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to upload file: Error code {error_code}")
+ return False
+
+ logger.info(f"File successfully uploaded to {folder_path}/{file_name}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error parsing upload response: {str(e)}")
+ return False
+
+ except Exception as e:
+ logger.error(f"Error uploading file {local_path}: {str(e)}")
+ return False
+
+ def delete_file(self, file_path: str) -> bool:
+ """Удаление файла на NAS
+
+ Args:
+ file_path: Путь к файлу для удаления
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Deleting file: {file_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file deletion")
+ return False
+
+ try:
+ # Подготавливаем параметры для удаления
+ params = {
+ "path": [file_path],
+ "recursive": True # Удаляем папки рекурсивно
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.Delete",
+ "delete",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.Delete",
+ "delete",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to delete file {file_path}")
+ return False
+
+ # Проверяем результат
+ task_id = result.get("taskid")
+ if not task_id:
+ logger.error("No task ID received for deletion")
+ return False
+
+ # Проверяем статус задачи
+ task_params = {
+ "taskid": task_id
+ }
+
+ # Ждем завершения задачи
+ for _ in range(10):
+ task_result = self._make_api_request(
+ "SYNO.FileStation.Delete",
+ "status",
+ version=2,
+ params=task_params
+ )
+
+ if not task_result:
+ task_result = self._make_api_request(
+ "SYNO.FileStation.Delete",
+ "status",
+ version=1,
+ params=task_params
+ )
+
+ if not task_result:
+ logger.error(f"Failed to check delete task status for {file_path}")
+ return False
+
+ # Проверяем статус задачи
+ if task_result.get("finished", False):
+ return True
+
+ # Ждем немного
+ import time
+ time.sleep(0.5)
+
+ logger.warning(f"Delete task did not complete in time for {file_path}")
+ return True # Возвращаем True, т.к. задача запущена успешно
+
+ except Exception as e:
+ logger.error(f"Error deleting file {file_path}: {str(e)}")
+ return False
+
+ def create_folder(self, parent_path: str, folder_name: str) -> bool:
+ """Создание новой папки на NAS
+
+ Args:
+ parent_path: Родительский путь для новой папки
+ folder_name: Имя новой папки
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Creating folder {folder_name} in {parent_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for folder creation")
+ return False
+
+ try:
+ # Подготавливаем параметры для создания папки
+ params = {
+ "folder_path": parent_path,
+ "name": folder_name
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.CreateFolder",
+ "create",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.CreateFolder",
+ "create",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to create folder {folder_name} in {parent_path}")
+ return False
+
+ # Проверяем результат
+ folders = result.get("folders", [])
+ if not folders:
+ logger.error("No folder information received after creation")
+ return False
+
+ logger.info(f"Folder {folder_name} created successfully in {parent_path}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error creating folder {folder_name}: {str(e)}")
+ return False
+
+ def rename_file(self, file_path: str, new_name: str) -> bool:
+ """Переименование файла или папки на NAS
+
+ Args:
+ file_path: Путь к файлу для переименования
+ new_name: Новое имя файла (без пути)
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Renaming {file_path} to {new_name}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file renaming")
+ return False
+
+ try:
+ # Получаем путь к родительской директории
+ parent_path = os.path.dirname(file_path)
+
+ # Подготавливаем параметры для переименования
+ params = {
+ "path": file_path,
+ "name": new_name
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.Rename",
+ "rename",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.Rename",
+ "rename",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to rename {file_path} to {new_name}")
+ return False
+
+ # Проверяем результат
+ files = result.get("files", [])
+ if not files:
+ logger.error("No file information received after renaming")
+ return False
+
+ logger.info(f"File {file_path} renamed to {new_name} successfully")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error renaming file {file_path}: {str(e)}")
+ return False
+
+ # Добавляем все методы в класс API
+ api_class.list_files = list_files
+ api_class.get_file_info = get_file_info
+ api_class.download_file = download_file
+ api_class.upload_file = upload_file
+ api_class.delete_file = delete_file
+ api_class.create_folder = create_folder
+ api_class.rename_file = rename_file
+
+ return api_class
+
+# Добавляем методы для работы с файлами к классу SynologyAPI
+add_file_manager_methods_to_synology_api(SynologyAPI)
diff --git a/.history/src/bot_20250830141501.py b/.history/src/bot_20250830141501.py
new file mode 100644
index 0000000..97acdcc
--- /dev/null
+++ b/.history/src/bot_20250830141501.py
@@ -0,0 +1,155 @@
+#!/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,
+ ConversationHandler,
+ 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_20250830141515.py b/.history/src/bot_20250830141515.py
new file mode 100644
index 0000000..919fd40
--- /dev/null
+++ b/.history/src/bot_20250830141515.py
@@ -0,0 +1,158 @@
+#!/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,
+ ConversationHandler,
+ 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
+from src.agents.file_manager_agent import create_file_manager_handler
+from src.api.synology import SynologyAPI
+from src.api.filestation import add_file_manager_methods_to_synology_api
+
+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_20250830141529.py b/.history/src/bot_20250830141529.py
new file mode 100644
index 0000000..4db220b
--- /dev/null
+++ b/.history/src/bot_20250830141529.py
@@ -0,0 +1,165 @@
+#!/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,
+ ConversationHandler,
+ 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
+from src.agents.file_manager_agent import create_file_manager_handler
+from src.api.synology import SynologyAPI
+from src.api.filestation import add_file_manager_methods_to_synology_api
+
+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_"))
+
+ # Создание экземпляра API и добавление методов для работы с файловой системой
+ synology_api = SynologyAPI()
+
+ # Регистрация обработчика файлового менеджера
+ file_manager_handler = create_file_manager_handler(synology_api)
+ application.add_handler(file_manager_handler)
+
+ # Настройка обработчиков сигналов для корректного завершения
+ 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_20250830141957.py b/.history/src/bot_20250830141957.py
new file mode 100644
index 0000000..4db220b
--- /dev/null
+++ b/.history/src/bot_20250830141957.py
@@ -0,0 +1,165 @@
+#!/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,
+ ConversationHandler,
+ 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
+from src.agents.file_manager_agent import create_file_manager_handler
+from src.api.synology import SynologyAPI
+from src.api.filestation import add_file_manager_methods_to_synology_api
+
+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_"))
+
+ # Создание экземпляра API и добавление методов для работы с файловой системой
+ synology_api = SynologyAPI()
+
+ # Регистрация обработчика файлового менеджера
+ file_manager_handler = create_file_manager_handler(synology_api)
+ application.add_handler(file_manager_handler)
+
+ # Настройка обработчиков сигналов для корректного завершения
+ 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/utils/admin_utils_20250830142344.py b/.history/src/utils/admin_utils_20250830142344.py
new file mode 100644
index 0000000..582e2b0
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830142344.py
@@ -0,0 +1,295 @@
+#!/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(*args: Any, **kwargs: Any) -> Any:
+ # Определяем, является ли функция методом класса
+ # Если первый аргумент - это self, то второй должен быть update
+ if len(args) >= 2 and isinstance(args[1], Update):
+ self_obj = args[0]
+ update = args[1]
+ context = args[2] if len(args) > 2 else kwargs.get('context')
+ else:
+ # Если это обычная функция, то первый аргумент - update
+ update = args[0] if args else kwargs.get('update')
+ context = args[1] if len(args) > 1 else kwargs.get('context')
+ self_obj = None
+
+ # Проверяем доступность объекта 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(*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_20250830142408.py b/.history/src/utils/admin_utils_20250830142408.py
new file mode 100644
index 0000000..88c03d4
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830142408.py
@@ -0,0 +1,298 @@
+#!/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(*args: Any, **kwargs: Any) -> Any:
+ # Определяем, является ли функция методом класса
+ # Если первый аргумент - это self, то второй должен быть update
+ if len(args) >= 2 and isinstance(args[1], Update):
+ self_obj = args[0]
+ update = args[1]
+ context = args[2] if len(args) > 2 else kwargs.get('context')
+ else:
+ # Если это обычная функция, то первый аргумент - update
+ update = args[0] if args else kwargs.get('update')
+ context = args[1] if len(args) > 1 else kwargs.get('context')
+ self_obj = None
+
+ # Проверяем доступность объекта 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(*args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ if not update.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ 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_20250830142452.py b/.history/src/utils/admin_utils_20250830142452.py
new file mode 100644
index 0000000..ed11249
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830142452.py
@@ -0,0 +1,301 @@
+#!/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(*args: Any, **kwargs: Any) -> Any:
+ # Определяем, является ли функция методом класса
+ # Если первый аргумент - это self, то второй должен быть update
+ if len(args) >= 2 and isinstance(args[1], Update):
+ self_obj = args[0]
+ update = args[1]
+ context = args[2] if len(args) > 2 else kwargs.get('context')
+ else:
+ # Если это обычная функция, то первый аргумент - update
+ update = args[0] if args else kwargs.get('update')
+ context = args[1] if len(args) > 1 else kwargs.get('context')
+ self_obj = None
+
+ # Проверяем доступность объекта 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(*args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ if not update.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ 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.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ 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_20250830142546.py b/.history/src/utils/admin_utils_20250830142546.py
new file mode 100644
index 0000000..d3639c4
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830142546.py
@@ -0,0 +1,303 @@
+#!/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(*args: Any, **kwargs: Any) -> Any:
+ # Определяем, является ли функция методом класса
+ # Если первый аргумент - это self, то второй должен быть update
+ if len(args) >= 2 and isinstance(args[1], Update):
+ self_obj = args[0]
+ update = args[1]
+ context = args[2] if len(args) > 2 else kwargs.get('context')
+ else:
+ # Если это обычная функция, то первый аргумент - update
+ update = args[0] if args else kwargs.get('update')
+ context = args[1] if len(args) > 1 else kwargs.get('context')
+ self_obj = None
+
+ # Проверяем доступность объекта 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(*args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ if not update.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ 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.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ if update.message:
+ 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_20250830142616.py b/.history/src/utils/admin_utils_20250830142616.py
new file mode 100644
index 0000000..24ec801
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830142616.py
@@ -0,0 +1,310 @@
+#!/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(*args: Any, **kwargs: Any) -> Any:
+ # Определяем, является ли функция методом класса
+ # Если первый аргумент - это self, то второй должен быть update
+ if len(args) >= 2 and isinstance(args[1], Update):
+ self_obj = args[0]
+ update = args[1]
+ context = args[2] if len(args) > 2 else kwargs.get('context')
+ else:
+ # Если это обычная функция, то первый аргумент - update
+ update = args[0] if args else kwargs.get('update')
+ context = args[1] if len(args) > 1 else kwargs.get('context')
+ self_obj = None
+
+ # Проверяем доступность объекта 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(*args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ if not update.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ 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.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ if update.message:
+ 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.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ try:
+ if not ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⚠️ Список администраторов пуст.")
+ return
+
+ # Формируем сообщение со списком администраторов
+ admin_list = "\n".join([f"• {admin_id}" for admin_id in ADMIN_USER_IDS])
+
+ if update.message:
+ 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)}")
+ if update.message:
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при получении списка администраторов:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
diff --git a/.history/src/utils/admin_utils_20250830142633.py b/.history/src/utils/admin_utils_20250830142633.py
new file mode 100644
index 0000000..194e990
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830142633.py
@@ -0,0 +1,312 @@
+#!/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(*args: Any, **kwargs: Any) -> Any:
+ # Определяем, является ли функция методом класса
+ # Если первый аргумент - это self, то второй должен быть update
+ if len(args) >= 2 and isinstance(args[1], Update):
+ self_obj = args[0]
+ update = args[1]
+ context = args[2] if len(args) > 2 else kwargs.get('context')
+ else:
+ # Если это обычная функция, то первый аргумент - update
+ update = args[0] if args else kwargs.get('update')
+ context = args[1] if len(args) > 1 else kwargs.get('context')
+ self_obj = None
+
+ # Проверяем доступность объекта 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(*args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ if not update.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ 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.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ if update.message:
+ 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:
+ if update.message:
+ await update.message.reply_text("⚠️ Вы не можете удалить самого себя из администраторов.")
+ return
+
+ # Проверяем, является ли пользователь администратором
+ if admin_id not in ADMIN_USER_IDS:
+ if update.message:
+ 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.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ try:
+ if not ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⚠️ Список администраторов пуст.")
+ return
+
+ # Формируем сообщение со списком администраторов
+ admin_list = "\n".join([f"• {admin_id}" for admin_id in ADMIN_USER_IDS])
+
+ if update.message:
+ 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)}")
+ if update.message:
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при получении списка администраторов:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
diff --git a/.history/src/utils/admin_utils_20250830142645.py b/.history/src/utils/admin_utils_20250830142645.py
new file mode 100644
index 0000000..fca3a38
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830142645.py
@@ -0,0 +1,314 @@
+#!/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(*args: Any, **kwargs: Any) -> Any:
+ # Определяем, является ли функция методом класса
+ # Если первый аргумент - это self, то второй должен быть update
+ if len(args) >= 2 and isinstance(args[1], Update):
+ self_obj = args[0]
+ update = args[1]
+ context = args[2] if len(args) > 2 else kwargs.get('context')
+ else:
+ # Если это обычная функция, то первый аргумент - update
+ update = args[0] if args else kwargs.get('update')
+ context = args[1] if len(args) > 1 else kwargs.get('context')
+ self_obj = None
+
+ # Проверяем доступность объекта 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(*args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ if not update.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ 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.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ if update.message:
+ 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:
+ if update.message:
+ await update.message.reply_text("⚠️ Вы не можете удалить самого себя из администраторов.")
+ return
+
+ # Проверяем, является ли пользователь администратором
+ if admin_id not in ADMIN_USER_IDS:
+ if update.message:
+ 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)
+
+ if update.message:
+ 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:
+ if update.message:
+ 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.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ try:
+ if not ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⚠️ Список администраторов пуст.")
+ return
+
+ # Формируем сообщение со списком администраторов
+ admin_list = "\n".join([f"• {admin_id}" for admin_id in ADMIN_USER_IDS])
+
+ if update.message:
+ 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)}")
+ if update.message:
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при получении списка администраторов:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
diff --git a/.history/src/utils/admin_utils_20250830142656.py b/.history/src/utils/admin_utils_20250830142656.py
new file mode 100644
index 0000000..61a792d
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830142656.py
@@ -0,0 +1,317 @@
+#!/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(*args: Any, **kwargs: Any) -> Any:
+ # Определяем, является ли функция методом класса
+ # Если первый аргумент - это self, то второй должен быть update
+ if len(args) >= 2 and isinstance(args[1], Update):
+ self_obj = args[0]
+ update = args[1]
+ context = args[2] if len(args) > 2 else kwargs.get('context')
+ else:
+ # Если это обычная функция, то первый аргумент - update
+ update = args[0] if args else kwargs.get('update')
+ context = args[1] if len(args) > 1 else kwargs.get('context')
+ self_obj = None
+
+ # Проверяем доступность объекта 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(*args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ if not update.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ 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.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ if update.message:
+ 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:
+ if update.message:
+ await update.message.reply_text("⚠️ Вы не можете удалить самого себя из администраторов.")
+ return
+
+ # Проверяем, является ли пользователь администратором
+ if admin_id not in ADMIN_USER_IDS:
+ if update.message:
+ 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)
+
+ if update.message:
+ 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:
+ if update.message:
+ await update.message.reply_text("❌ Ошибка: Не удалось найти настройки администраторов.")
+
+ except ValueError:
+ if update.message:
+ 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)}")
+ if update.message:
+ await update.message.reply_text(f"❌ Произошла ошибка:\n\n{str(e)}", parse_mode="HTML")
+ 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.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ try:
+ if not ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⚠️ Список администраторов пуст.")
+ return
+
+ # Формируем сообщение со списком администраторов
+ admin_list = "\n".join([f"• {admin_id}" for admin_id in ADMIN_USER_IDS])
+
+ if update.message:
+ 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)}")
+ if update.message:
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при получении списка администраторов:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
diff --git a/.history/src/utils/admin_utils_20250830143155.py b/.history/src/utils/admin_utils_20250830143155.py
new file mode 100644
index 0000000..61a792d
--- /dev/null
+++ b/.history/src/utils/admin_utils_20250830143155.py
@@ -0,0 +1,317 @@
+#!/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(*args: Any, **kwargs: Any) -> Any:
+ # Определяем, является ли функция методом класса
+ # Если первый аргумент - это self, то второй должен быть update
+ if len(args) >= 2 and isinstance(args[1], Update):
+ self_obj = args[0]
+ update = args[1]
+ context = args[2] if len(args) > 2 else kwargs.get('context')
+ else:
+ # Если это обычная функция, то первый аргумент - update
+ update = args[0] if args else kwargs.get('update')
+ context = args[1] if len(args) > 1 else kwargs.get('context')
+ self_obj = None
+
+ # Проверяем доступность объекта 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(*args, **kwargs)
+
+ return wrapper
+
+async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Добавляет нового администратора бота
+
+ Args:
+ update: Объект обновления Telegram
+ context: Контекст вызова
+ """
+ if not update.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ 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.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ # Проверяем, есть ли аргументы команды
+ if not context.args or len(context.args) < 1:
+ if update.message:
+ 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:
+ if update.message:
+ await update.message.reply_text("⚠️ Вы не можете удалить самого себя из администраторов.")
+ return
+
+ # Проверяем, является ли пользователь администратором
+ if admin_id not in ADMIN_USER_IDS:
+ if update.message:
+ 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)
+
+ if update.message:
+ 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:
+ if update.message:
+ await update.message.reply_text("❌ Ошибка: Не удалось найти настройки администраторов.")
+
+ except ValueError:
+ if update.message:
+ 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)}")
+ if update.message:
+ await update.message.reply_text(f"❌ Произошла ошибка:\n\n{str(e)}", parse_mode="HTML")
+ 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.message:
+ return
+
+ # Проверяем, что команду выполняет администратор
+ if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ return
+
+ try:
+ if not ADMIN_USER_IDS:
+ if update.message:
+ await update.message.reply_text("⚠️ Список администраторов пуст.")
+ return
+
+ # Формируем сообщение со списком администраторов
+ admin_list = "\n".join([f"• {admin_id}" for admin_id in ADMIN_USER_IDS])
+
+ if update.message:
+ 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)}")
+ if update.message:
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при получении списка администраторов:\n\n{str(e)}",
+ parse_mode="HTML"
+ )
diff --git a/docs/file_manager_agent.md b/docs/file_manager_agent.md
new file mode 100644
index 0000000..fcd3892
--- /dev/null
+++ b/docs/file_manager_agent.md
@@ -0,0 +1,61 @@
+# Агент файлового менеджера для Synology Power Control Bot
+
+## Описание
+
+Агент файлового менеджера предоставляет удобный интерфейс для просмотра, управления и манипулирования файловой системой Synology NAS через Telegram бота. Агент разработан с использованием модульной архитектуры и предоставляет интуитивно понятный интерфейс с кнопками и диалоговыми окнами.
+
+## Функциональность
+
+- **Просмотр содержимого директорий** - навигация по файловой системе NAS
+- **Загрузка и скачивание файлов** - передача файлов между NAS и устройством пользователя
+- **Управление файлами** - переименование, удаление, получение информации о файлах
+- **Создание папок** - создание новых директорий на NAS
+- **Пагинация** - удобная навигация при большом количестве файлов
+
+## Использование
+
+Для начала работы с файловым менеджером отправьте команду `/files` боту. После этого вы увидите список доступных общих папок на вашем NAS. Используйте интерактивные кнопки для навигации и выполнения различных действий с файлами.
+
+### Основные команды
+
+- `/files` - запуск файлового менеджера
+- `/files [path]` - открытие файлового менеджера с указанным путем
+
+### Интерфейс и навигация
+
+Интерфейс файлового менеджера состоит из:
+- Информации о текущей директории (путь, количество файлов и папок)
+- Списка папок и файлов с кнопками для взаимодействия
+- Кнопок навигации (Вверх, Вперед, Назад)
+- Кнопок действий (Загрузить файл, Создать папку)
+
+## Структура кода
+
+Агент файлового менеджера состоит из следующих основных компонентов:
+
+- **FileManagerAgent** - основной класс агента, реализующий логику файлового менеджера
+- **SynologyAPI** - класс для взаимодействия с API Synology NAS
+- **filestation.py** - модуль, расширяющий SynologyAPI методами для работы с файлами
+
+## Интеграция
+
+Агент файлового менеджера можно легко интегрировать в любого Telegram бота с помощью функции `create_file_manager_handler()`, которая возвращает готовый `ConversationHandler` для регистрации в диспетчере бота.
+
+```python
+from src.api.synology import SynologyAPI
+from src.agents.file_manager_agent import create_file_manager_handler
+from src.api.filestation import add_file_manager_methods_to_synology_api
+
+# Создание экземпляра API
+synology_api = SynologyAPI()
+
+# Создание обработчика
+file_manager_handler = create_file_manager_handler(synology_api)
+
+# Регистрация обработчика в приложении бота
+application.add_handler(file_manager_handler)
+```
+
+## Безопасность
+
+Агент файлового менеджера использует декоратор `@admin_required` для обеспечения доступа только авторизованным пользователям. Это защищает файловую систему NAS от несанкционированного доступа.
diff --git a/examples/file_manager_demo.py b/examples/file_manager_demo.py
new file mode 100644
index 0000000..66bb463
--- /dev/null
+++ b/examples/file_manager_demo.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Пример использования файлового менеджера для Synology NAS
+"""
+
+import logging
+import asyncio
+from telegram.ext import Application
+
+from src.config.config import TELEGRAM_TOKEN
+from src.api.synology import SynologyAPI
+from src.agents.file_manager_agent import create_file_manager_handler
+from src.api.filestation import add_file_manager_methods_to_synology_api
+from src.utils.logger import setup_logging
+
+async def main():
+ """Главная функция демонстрации файлового менеджера"""
+ # Настройка логирования
+ 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 File Manager Demo")
+ application = Application.builder().token(TELEGRAM_TOKEN).build()
+
+ # Создание экземпляра API и добавление методов для работы с файловой системой
+ synology_api = SynologyAPI()
+
+ # Регистрация обработчика файлового менеджера
+ file_manager_handler = create_file_manager_handler(synology_api)
+ application.add_handler(file_manager_handler)
+
+ # Запуск бота
+ logger.info("Bot started with file manager. Use /files command to start. Press Ctrl+C to stop.")
+ await application.start()
+ await application.updater.start_polling()
+
+ # Ждем прерывание
+ try:
+ await asyncio.Future() # Бесконечное ожидание
+ finally:
+ await application.stop()
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/src/agents/__init__.py b/src/agents/__init__.py
new file mode 100644
index 0000000..ce1aa94
--- /dev/null
+++ b/src/agents/__init__.py
@@ -0,0 +1,9 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль агентов для Synology Power Control Bot.
+Содержит функциональные агенты, реализующие различные возможности бота.
+"""
+
+from src.agents.file_manager_agent import FileManagerAgent, create_file_manager_handler
diff --git a/src/agents/file_manager_agent.py b/src/agents/file_manager_agent.py
new file mode 100644
index 0000000..7d20e9b
--- /dev/null
+++ b/src/agents/file_manager_agent.py
@@ -0,0 +1,844 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Агент файлового менеджера для Synology Power Control Bot.
+Предоставляет расширенный интерфейс для работы с файловой системой Synology NAS.
+"""
+
+import os
+import time
+import logging
+import html
+from typing import Dict, List, Any, Optional, Union, Tuple
+
+from telegram import (
+ Update,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ InputFile
+)
+from telegram.constants import ParseMode
+from telegram.ext import (
+ ContextTypes,
+ ConversationHandler,
+ CallbackQueryHandler,
+ CommandHandler,
+ MessageHandler,
+ filters
+)
+
+from src.api.synology import SynologyAPI
+from src.utils.admin_utils import admin_required
+
+# Настройка логирования
+logger = logging.getLogger(__name__)
+
+# Состояния для ConversationHandler
+BROWSING, UPLOADING, RENAMING, DELETING, CREATING_FOLDER = range(5)
+
+# Константы для максимального количества элементов на странице
+MAX_ITEMS_PER_PAGE = 10
+
+class FileManagerAgent:
+ """Агент файлового менеджера для взаимодействия с файловой системой NAS."""
+
+ def __init__(self, synology_api: SynologyAPI):
+ """Инициализация агента файлового менеджера."""
+ self.synology_api = synology_api
+ self.user_data = {} # Хранилище для пользовательских данных (текущий путь и т.д.)
+
+ # Создаем обработчики для регистрации в боте
+ self.handlers = [
+ CommandHandler("files", self.start_file_manager),
+ CallbackQueryHandler(self.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(self.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(self.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(self.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(self.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(self.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(self.navigation_callback, pattern="^fm:nav:"),
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, self.handle_file_upload),
+ ]
+
+ def get_user_path(self, user_id: int) -> str:
+ """Получает текущий путь для пользователя."""
+ return self.user_data.get(user_id, {}).get('current_path', '/')
+
+ def set_user_path(self, user_id: int, path: str) -> None:
+ """Устанавливает текущий путь для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ self.user_data[user_id]['current_path'] = path
+
+ def get_user_pagination(self, user_id: int) -> dict:
+ """Получает информацию о пагинации для пользователя."""
+ return self.user_data.get(user_id, {}).get('pagination', {'page': 0, 'total_pages': 1})
+
+ def set_user_pagination(self, user_id: int, page: int, total_pages: int) -> None:
+ """Устанавливает информацию о пагинации для пользователя."""
+ if user_id not in self.user_data:
+ self.user_data[user_id] = {}
+ if 'pagination' not in self.user_data[user_id]:
+ self.user_data[user_id]['pagination'] = {}
+ self.user_data[user_id]['pagination']['page'] = page
+ self.user_data[user_id]['pagination']['total_pages'] = total_pages
+
+ @admin_required
+ async def start_file_manager(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Запускает файловый менеджер."""
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+
+ # Устанавливаем начальный путь
+ initial_path = '/'
+ if context.args and context.args[0]:
+ initial_path = context.args[0]
+ self.set_user_path(user_id, initial_path)
+
+ # Отображаем содержимое начального пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def display_directory_content(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Отображает содержимое директории."""
+ if not update.effective_user:
+ return
+
+ user_id = update.effective_user.id
+ current_path = self.get_user_path(user_id)
+ pagination = self.get_user_pagination(user_id)
+ current_page = pagination['page']
+
+ # Получаем список файлов и папок
+ files_and_folders = self.synology_api.list_files(current_path)
+
+ if not files_and_folders:
+ await self.send_or_edit_message(
+ update,
+ f"📁 Путь: {html.escape(current_path)}\n\n"
+ f"📭 Папка пуста или недоступна",
+ self.get_empty_folder_keyboard(current_path)
+ )
+ return
+
+ # Разделяем на папки и файлы, сортируем по имени
+ folders = sorted([item for item in files_and_folders if item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+ files = sorted([item for item in files_and_folders if not item.get('isdir', False)],
+ key=lambda x: x.get('name', '').lower())
+
+ # Подготавливаем информацию для пагинации
+ all_items = folders + files
+ total_items = len(all_items)
+ total_pages = max(1, (total_items + MAX_ITEMS_PER_PAGE - 1) // MAX_ITEMS_PER_PAGE)
+
+ # Корректируем текущую страницу, если она некорректна
+ if current_page >= total_pages:
+ current_page = 0
+ elif current_page < 0:
+ current_page = total_pages - 1
+
+ # Обновляем информацию о пагинации
+ self.set_user_pagination(user_id, current_page, total_pages)
+
+ # Определяем диапазон элементов для текущей страницы
+ start_idx = current_page * MAX_ITEMS_PER_PAGE
+ end_idx = min(start_idx + MAX_ITEMS_PER_PAGE, total_items)
+ current_items = all_items[start_idx:end_idx]
+
+ # Формируем сообщение с информацией о директории
+ message_text = f"📁 Путь: {html.escape(current_path)}\n\n"
+ message_text += f"📂 Папок: {len(folders)}\n"
+ message_text += f"📄 Файлов: {len(files)}\n"
+
+ if files:
+ total_size = sum(file.get('size', 0) for file in files)
+ message_text += f"💾 Общий размер: {self.get_human_readable_size(total_size)}\n"
+
+ message_text += f"\nСтраница {current_page + 1}/{total_pages}"
+
+ # Формируем клавиатуру с элементами и навигационными кнопками
+ keyboard = self.create_file_browser_keyboard(current_items, current_path, current_page, total_pages)
+
+ # Отправляем или обновляем сообщение
+ await self.send_or_edit_message(update, message_text, keyboard)
+
+ def create_file_browser_keyboard(self, items: List[Dict], current_path: str,
+ current_page: int, total_pages: int) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для просмотра файлов и папок."""
+ keyboard = []
+
+ # Добавляем кнопки для каждого элемента
+ for item in items:
+ name = item.get('name', 'Unknown')
+ is_dir = item.get('isdir', False)
+
+ if is_dir:
+ # Формируем путь к подпапке
+ folder_path = os.path.join(current_path, name).replace('\\', '/')
+ if folder_path.endswith('//'):
+ folder_path = folder_path[:-1]
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📁 {name}",
+ callback_data=f"fm:browse:{folder_path}"
+ )
+ ])
+ else:
+ # Формируем путь к файлу
+ file_path = os.path.join(current_path, name).replace('\\', '/')
+ file_size = self.get_human_readable_size(item.get('size', 0))
+
+ keyboard.append([
+ InlineKeyboardButton(
+ f"📄 {name} ({file_size})",
+ callback_data=f"fm:download:{file_path}"
+ )
+ ])
+
+ # Добавляем кнопки навигации
+ nav_buttons = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ nav_buttons.append(InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}"))
+
+ # Кнопки пагинации
+ if total_pages > 1:
+ nav_buttons.append(InlineKeyboardButton(
+ "⬅️",
+ callback_data=f"fm:nav:prev:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ f"{current_page + 1}/{total_pages}",
+ callback_data=f"fm:nav:refresh:{current_path}"
+ ))
+ nav_buttons.append(InlineKeyboardButton(
+ "➡️",
+ callback_data=f"fm:nav:next:{current_path}"
+ ))
+
+ keyboard.append(nav_buttons)
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ def get_empty_folder_keyboard(self, current_path: str) -> InlineKeyboardMarkup:
+ """Создает клавиатуру для пустой папки."""
+ keyboard = []
+
+ # Кнопка "Вверх", если не в корневой директории
+ if current_path != "/" and current_path:
+ parent_path = os.path.dirname(current_path) or "/"
+ keyboard.append([InlineKeyboardButton("⬆️ Вверх", callback_data=f"fm:browse:{parent_path}")])
+
+ # Добавляем кнопки действий
+ action_buttons = [
+ InlineKeyboardButton("📤 Загрузить файл", callback_data=f"fm:upload:{current_path}"),
+ InlineKeyboardButton("📁 Новая папка", callback_data=f"fm:mkdir:{current_path}")
+ ]
+ keyboard.append(action_buttons)
+
+ # Кнопка закрытия
+ keyboard.append([InlineKeyboardButton("🔚 Закрыть", callback_data="fm:nav:close")])
+
+ return InlineKeyboardMarkup(keyboard)
+
+ async def send_or_edit_message(self, update: Update, text: str, reply_markup: InlineKeyboardMarkup) -> None:
+ """Отправляет новое сообщение или редактирует существующее."""
+ if update.callback_query:
+ await update.callback_query.answer()
+ try:
+ await update.callback_query.edit_message_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ except Exception as e:
+ logger.error(f"Error editing message: {e}")
+ if update.callback_query.message:
+ await update.callback_query.message.edit_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+ elif update.message:
+ await update.message.reply_text(
+ text,
+ reply_markup=reply_markup,
+ parse_mode=ParseMode.HTML
+ )
+
+ async def browse_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает переходы по директориям."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:browse:")[1]
+
+ # Устанавливаем новый путь для пользователя
+ self.set_user_path(user_id, path)
+ # Сбрасываем пагинацию
+ self.set_user_pagination(user_id, 0, 1)
+
+ # Отображаем содержимое нового пути
+ await self.display_directory_content(update, context)
+ return BROWSING
+
+ async def download_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на скачивание файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ file_path = query.data.split("fm:download:")[1]
+
+ # Информация о файле
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer(f"Подготовка к скачиванию {file_name}...")
+
+ # Создаем клавиатуру с кнопками действий для файла
+ keyboard = [
+ [
+ InlineKeyboardButton("⬇️ Скачать", callback_data=f"fm:download:get:{file_path}"),
+ InlineKeyboardButton("❌ Удалить", callback_data=f"fm:delete:confirm:{file_path}")
+ ],
+ [
+ InlineKeyboardButton("✏️ Переименовать", callback_data=f"fm:rename:start:{file_path}"),
+ InlineKeyboardButton("🔙 Назад", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]
+
+ # Получаем дополнительную информацию о файле
+ file_info = self.synology_api.get_file_info(file_path)
+
+ if file_info:
+ file_size = self.get_human_readable_size(file_info.get('size', 0))
+ file_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(file_info.get('time', {}).get('mtime', 0)))
+ file_owner = file_info.get('owner', {}).get('user', 'Unknown')
+
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n"
+ f"💾 Размер: {file_size}\n"
+ f"🕒 Изменён: {file_time}\n"
+ f"👤 Владелец: {file_owner}\n\n"
+ f"Выберите действие:"
+ )
+ else:
+ message_text = (
+ f"📄 Файл: {html.escape(file_name)}\n\n"
+ f"📂 Расположение: {html.escape(file_dir)}\n\n"
+ f"Выберите действие:"
+ )
+
+ await query.edit_message_text(
+ message_text,
+ reply_markup=InlineKeyboardMarkup(keyboard),
+ parse_mode=ParseMode.HTML
+ )
+
+ return BROWSING
+
+ async def upload_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Начинает процесс загрузки файла."""
+ query = update.callback_query
+ if not query:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ path = query.data.split("fm:upload:")[1]
+
+ # Сохраняем путь для загрузки в данные пользователя
+ self.set_user_path(user_id, path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📤 Загрузка файла\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, отправьте файл, который хотите загрузить, или нажмите 'Отмена'.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return UPLOADING
+
+ async def handle_file_upload(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает загрузку файла от пользователя."""
+ if not update.effective_user:
+ return UPLOADING
+
+ user_id = update.effective_user.id
+ upload_path = self.get_user_path(user_id)
+
+ # Проверяем наличие сообщения и файла
+ if not update.message:
+ return UPLOADING
+
+ if not update.message.document:
+ await update.message.reply_text(
+ "❌ Не найден файл для загрузки. Пожалуйста, отправьте файл."
+ )
+ return UPLOADING
+
+ document = update.message.document
+ file_name = document.file_name or f"file_{int(time.time())}"
+
+ # Сообщение о начале загрузки
+ status_message = await update.message.reply_text(
+ f"⏳ Начинаем загрузку файла {file_name}..."
+ )
+
+ try:
+ # Получаем файл
+ file = await context.bot.get_file(document.file_id)
+ file_path = os.path.join(upload_path, file_name).replace("\\", "/")
+
+ # Временный путь для сохранения файла
+ temp_file_path = f"temp_{user_id}_{int(time.time())}_{file_name}"
+
+ # Скачиваем файл во временную директорию
+ await file.download_to_drive(temp_file_path)
+
+ # Загружаем файл на Synology NAS
+ success = self.synology_api.upload_file(temp_file_path, file_path)
+
+ # Удаляем временный файл
+ if os.path.exists(temp_file_path):
+ os.remove(temp_file_path)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {file_name} успешно загружен в {upload_path}"
+ )
+
+ # Показываем содержимое директории
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось загрузить файл {file_name}. Пожалуйста, попробуйте снова."
+ )
+ return UPLOADING
+
+ except Exception as e:
+ logger.error(f"Error uploading file: {e}")
+ await status_message.edit_text(
+ f"❌ Произошла ошибка при загрузке файла: {str(e)}"
+ )
+ return UPLOADING
+
+ async def delete_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на удаление файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":confirm:" in callback_data:
+ # Запрос на подтверждение удаления
+ file_path = callback_data.split("fm:delete:confirm:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer()
+ await query.edit_message_text(
+ f"❗ Подтверждение удаления\n\n"
+ f"Вы действительно хотите удалить файл {html.escape(file_name)}?",
+ reply_markup=InlineKeyboardMarkup([
+ [
+ InlineKeyboardButton("✅ Да, удалить", callback_data=f"fm:delete:execute:{file_path}"),
+ InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")
+ ]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return DELETING
+
+ elif ":execute:" in callback_data:
+ # Выполнение удаления
+ file_path = callback_data.split("fm:delete:execute:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ await query.answer("Удаление файла...")
+
+ # Удаляем файл
+ success = self.synology_api.delete_file(file_path)
+
+ if success:
+ await query.edit_message_text(
+ f"✅ Файл {file_name} успешно удален.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+ else:
+ await query.edit_message_text(
+ f"❌ Не удалось удалить файл {file_name}.",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("🔙 К папке", callback_data=f"fm:browse:{file_dir}")]
+ ])
+ )
+
+ # Возвращаемся к просмотру директории
+ return BROWSING
+
+ return BROWSING
+
+ async def rename_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на переименование файлов."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ # Извлекаем путь и режим из callback_data
+ callback_data = query.data
+ if ":start:" in callback_data:
+ # Начало процесса переименования
+ file_path = callback_data.split("fm:rename:start:")[1]
+ file_name = os.path.basename(file_path)
+ file_dir = os.path.dirname(file_path)
+
+ # Сохраняем информацию о переименовании в контексте пользователя
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+ # Дополнительно сохраняем в chat_data для надежности
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['renaming'] = {
+ 'file_path': file_path,
+ 'file_dir': file_dir
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"✏️ Переименование файла\n\n"
+ f"Текущее имя: {html.escape(file_name)}\n\n"
+ f"Пожалуйста, отправьте новое имя для файла:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{file_dir}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+ return RENAMING
+
+ return BROWSING
+
+ async def handle_rename(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает ввод нового имени файла."""
+ if not update.message:
+ return BROWSING
+
+ # Проверяем где может быть информация о файле - в user_data или в chat_data
+ file_path = None
+ file_dir = None
+
+ # Сначала проверяем user_data
+ if hasattr(context, 'user_data') and context.user_data is not None and 'renaming' in context.user_data:
+ file_path = context.user_data['renaming'].get('file_path')
+ file_dir = context.user_data['renaming'].get('file_dir')
+
+ # Если не нашли в user_data, проверяем в chat_data
+ if (file_path is None or file_dir is None) and hasattr(context, 'chat_data') and context.chat_data is not None and 'renaming' in context.chat_data:
+ file_path = context.chat_data['renaming'].get('file_path')
+ file_dir = context.chat_data['renaming'].get('file_dir')
+
+ if file_path is None or file_dir is None:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о переименовании файла отсутствует."
+ )
+ return BROWSING
+ old_name = os.path.basename(file_path)
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите текст для имени файла."
+ )
+ return RENAMING
+
+ new_name = update.message.text.strip()
+
+ # Проверяем корректность имени файла
+ if not new_name or '/' in new_name or '\\' in new_name or new_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя файла. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return RENAMING
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Переименование {old_name} в {new_name}..."
+ )
+
+ # Переименовываем файл
+ success = self.synology_api.rename_file(file_path, new_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Файл {old_name} успешно переименован в {new_name}"
+ )
+
+ # Очищаем данные о переименовании
+ if hasattr(context, 'user_data') and context.user_data is not None and 'renaming' in context.user_data:
+ del context.user_data['renaming']
+
+ if hasattr(context, 'chat_data') and context.chat_data is not None and 'renaming' in context.chat_data:
+ del context.chat_data['renaming']
+
+ # Устанавливаем путь к директории и отображаем её содержимое
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, file_dir)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось переименовать файл {old_name}. Проверьте права доступа и допустимость имени."
+ )
+ return RENAMING
+
+ async def create_folder_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает запросы на создание папок."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ path = query.data.split("fm:mkdir:")[1]
+
+ # В PTB 20+ user_data должен быть всегда доступен
+ # Просто добавляем нашу информацию в словарь
+ # Если context.user_data не инициализирован, используем setdefault
+ # чтобы добавить ключ, если его нет
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ context.user_data['creating_folder'] = {
+ 'path': path
+ }
+ else:
+ # Если по какой-то причине user_data недоступен,
+ # запишем путь в context.chat_data (он более стабилен)
+ if hasattr(context, 'chat_data') and context.chat_data is not None:
+ context.chat_data['creating_folder'] = {
+ 'path': path
+ }
+
+ await query.answer()
+ await query.edit_message_text(
+ f"📁 Создание новой папки\n\n"
+ f"Путь: {html.escape(path)}\n\n"
+ f"Пожалуйста, введите имя для новой папки:",
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("❌ Отмена", callback_data=f"fm:browse:{path}")]
+ ]),
+ parse_mode=ParseMode.HTML
+ )
+
+ return CREATING_FOLDER
+
+ async def handle_create_folder(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает создание новой папки."""
+ if not update.message:
+ return CREATING_FOLDER
+
+ # Проверяем где может быть информация о папке - в user_data или в chat_data
+ parent_path = None
+
+ # Сначала проверяем user_data
+ if hasattr(context, 'user_data') and context.user_data is not None:
+ if 'creating_folder' in context.user_data:
+ parent_path = context.user_data['creating_folder'].get('path')
+
+ # Если не нашли в user_data, проверяем в chat_data
+ if parent_path is None and hasattr(context, 'chat_data') and context.chat_data is not None:
+ if 'creating_folder' in context.chat_data:
+ parent_path = context.chat_data['creating_folder'].get('path')
+
+ if parent_path is None:
+ await update.message.reply_text(
+ "❌ Ошибка: информация о создаваемой папке отсутствует."
+ )
+ return BROWSING
+
+ if not update.message.text:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя."
+ )
+ return CREATING_FOLDER
+
+ folder_name = update.message.text.strip()
+
+ # Проверяем корректность имени папки
+ if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name in ['.', '..']:
+ await update.message.reply_text(
+ "❌ Некорректное имя папки. Пожалуйста, введите корректное имя без специальных символов."
+ )
+ return CREATING_FOLDER
+
+ # Сообщение о начале операции
+ status_message = await update.message.reply_text(
+ f"⏳ Создание папки {folder_name}..."
+ )
+
+ # Создаем папку
+ success = self.synology_api.create_folder(parent_path, folder_name)
+
+ if success:
+ await status_message.edit_text(
+ f"✅ Папка {folder_name} успешно создана в {parent_path}"
+ )
+
+ # Очищаем данные о создании папки
+ if hasattr(context, 'user_data') and context.user_data is not None and 'creating_folder' in context.user_data:
+ del context.user_data['creating_folder']
+
+ if hasattr(context, 'chat_data') and context.chat_data is not None and 'creating_folder' in context.chat_data:
+ del context.chat_data['creating_folder']
+
+ # Отображаем обновленное содержимое директории
+ if not update.effective_user:
+ return BROWSING
+
+ user_id = update.effective_user.id
+ self.set_user_path(user_id, parent_path)
+ await self.display_directory_content(update, context)
+ return BROWSING
+ else:
+ await status_message.edit_text(
+ f"❌ Не удалось создать папку {folder_name}. Проверьте права доступа и допустимость имени."
+ )
+ return CREATING_FOLDER
+
+ async def navigation_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обрабатывает навигационные запросы (пагинация, обновление, закрытие)."""
+ query = update.callback_query
+ if not query or not query.data:
+ return BROWSING
+
+ callback_data = query.data
+ user_id = update.effective_user.id if update.effective_user else 0
+
+ if callback_data.startswith("fm:nav:prev:"):
+ # Предыдущая страница
+ path = callback_data[len("fm:nav:prev:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] - 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:next:"):
+ # Следующая страница
+ path = callback_data[len("fm:nav:next:"):]
+ pagination = self.get_user_pagination(user_id)
+ page = (pagination['page'] + 1) % pagination['total_pages']
+ self.set_user_pagination(user_id, page, pagination['total_pages'])
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data.startswith("fm:nav:refresh:"):
+ # Обновить текущую директорию
+ path = callback_data[len("fm:nav:refresh:"):]
+ self.set_user_path(user_id, path)
+ await self.display_directory_content(update, context)
+
+ elif callback_data == "fm:nav:close":
+ # Закрыть файловый менеджер
+ await query.answer("Файловый менеджер закрыт")
+ await query.delete_message()
+ return ConversationHandler.END
+
+ return BROWSING
+
+ def get_human_readable_size(self, size_bytes: int) -> str:
+ """Преобразует размер в байтах в человекочитаемый формат."""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB"]
+ i = 0
+ size_float = float(size_bytes)
+ while size_float >= 1024 and i < len(size_names) - 1:
+ size_float /= 1024.0
+ i += 1
+
+ return f"{size_float:.2f} {size_names[i]}"
+
+# Функция для создания ConversationHandler для файлового менеджера
+async def cancel_conversation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
+ """Обработчик отмены диалога."""
+ if update.message:
+ await update.message.reply_text("Операция отменена.")
+ return ConversationHandler.END
+
+def create_file_manager_handler(synology_api: SynologyAPI) -> ConversationHandler:
+ """Создает и возвращает ConversationHandler для файлового менеджера."""
+ file_manager = FileManagerAgent(synology_api)
+
+ return ConversationHandler(
+ entry_points=[CommandHandler("files", file_manager.start_file_manager)],
+ states={
+ BROWSING: [
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:"),
+ CallbackQueryHandler(file_manager.download_callback, pattern="^fm:download:"),
+ CallbackQueryHandler(file_manager.upload_callback, pattern="^fm:upload:"),
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.rename_callback, pattern="^fm:rename:"),
+ CallbackQueryHandler(file_manager.create_folder_callback, pattern="^fm:mkdir:"),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:")
+ ],
+ UPLOADING: [
+ MessageHandler(filters.ATTACHMENT & ~filters.COMMAND, file_manager.handle_file_upload),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ RENAMING: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_rename),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ DELETING: [
+ CallbackQueryHandler(file_manager.delete_callback, pattern="^fm:delete:"),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ],
+ CREATING_FOLDER: [
+ MessageHandler(filters.TEXT & ~filters.COMMAND, file_manager.handle_create_folder),
+ CallbackQueryHandler(file_manager.browse_callback, pattern="^fm:browse:")
+ ]
+ },
+ fallbacks=[
+ CommandHandler("cancel", cancel_conversation),
+ CallbackQueryHandler(file_manager.navigation_callback, pattern="^fm:nav:close")
+ ],
+ name="file_manager",
+ persistent=False
+ )
diff --git a/src/api/filestation.py b/src/api/filestation.py
new file mode 100644
index 0000000..ec73411
--- /dev/null
+++ b/src/api/filestation.py
@@ -0,0 +1,512 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+Модуль для взаимодействия с файловой системой Synology NAS через API FileStation
+"""
+
+import os
+import logging
+import requests
+from typing import Dict, Any, Optional, List, Union
+
+from src.api.synology import SynologyAPI
+
+logger = logging.getLogger(__name__)
+
+def add_file_manager_methods_to_synology_api(api_class):
+ """Добавляет методы для работы с файловой системой к классу SynologyAPI"""
+
+ def list_files(self, folder_path: str = "/") -> List[Dict[str, Any]]:
+ """Получение списка файлов и папок в указанной директории
+
+ Args:
+ folder_path: Путь к директории для просмотра
+
+ Returns:
+ Список файлов и папок в указанной директории
+ """
+ logger.info(f"Listing files in directory: {folder_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file listing")
+ return []
+
+ try:
+ # Если это корневая папка, получаем список общих папок
+ if folder_path == "/":
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "list_share",
+ version=2
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "list_share",
+ version=1
+ )
+
+ if not result:
+ logger.error("Failed to list shared folders")
+ return []
+
+ return result.get("shares", [])
+ else:
+ # Получаем список файлов в указанной директории
+ params = {
+ "folder_path": folder_path,
+ "sort_by": "name",
+ "sort_direction": "ASC"
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "list",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "list",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to list files in {folder_path}")
+ return []
+
+ return result.get("files", [])
+
+ except Exception as e:
+ logger.error(f"Error listing files in {folder_path}: {str(e)}")
+ return []
+
+ def get_file_info(self, file_path: str) -> Dict[str, Any]:
+ """Получение подробной информации о файле
+
+ Args:
+ file_path: Полный путь к файлу
+
+ Returns:
+ Информация о файле
+ """
+ logger.info(f"Getting file info: {file_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file info request")
+ return {}
+
+ try:
+ params = {
+ "path": file_path,
+ "additional": "real_path,size,owner,time,perm"
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "getinfo",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.List",
+ "getinfo",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to get file info for {file_path}")
+ return {}
+
+ # Возвращаем информацию о первом файле в результате
+ files = result.get("files", [])
+ if files and len(files) > 0:
+ return files[0]
+
+ return {}
+
+ except Exception as e:
+ logger.error(f"Error getting file info for {file_path}: {str(e)}")
+ return {}
+
+ def download_file(self, file_path: str, local_path: str) -> bool:
+ """Скачивание файла с NAS
+
+ Args:
+ file_path: Путь к файлу на NAS
+ local_path: Локальный путь для сохранения файла
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Downloading file from {file_path} to {local_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file download")
+ return False
+
+ try:
+ # Получаем URL для скачивания файла
+ params = {
+ "path": file_path,
+ "mode": "download"
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.Download",
+ "download",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.Download",
+ "download",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to get download URL for {file_path}")
+ return False
+
+ # URL для скачивания
+ download_url = result.get("url")
+ if not download_url:
+ logger.error("No download URL received")
+ return False
+
+ # Добавляем базовый URL, если URL относительный
+ if not download_url.startswith("http"):
+ protocol = "https" if self.protocol == "https" else "http"
+ download_url = f"{protocol}://{self.base_url}/{download_url}"
+
+ # Скачиваем файл
+ response = self.session.get(download_url, stream=True, verify=False)
+ if response.status_code != 200:
+ logger.error(f"Failed to download file: HTTP {response.status_code}")
+ return False
+
+ # Сохраняем файл
+ with open(local_path, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ if chunk:
+ f.write(chunk)
+
+ logger.info(f"File successfully downloaded to {local_path}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error downloading file {file_path}: {str(e)}")
+ return False
+
+ def upload_file(self, local_path: str, folder_path: str) -> bool:
+ """Загрузка файла на NAS
+
+ Args:
+ local_path: Локальный путь к файлу
+ folder_path: Путь на NAS для загрузки файла
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Uploading file from {local_path} to {folder_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file upload")
+ return False
+
+ try:
+ # Проверяем существование файла
+ if not os.path.exists(local_path):
+ logger.error(f"Local file {local_path} not found")
+ return False
+
+ # Формируем URL для загрузки
+ url = f"{self.base_url}/entry.cgi"
+
+ # Извлекаем имя файла из локального пути
+ file_name = os.path.basename(local_path)
+
+ # Подготавливаем параметры для загрузки
+ params = {
+ "api": "SYNO.FileStation.Upload",
+ "version": "2",
+ "method": "upload",
+ "path": folder_path,
+ "_sid": self.sid
+ }
+
+ # Подготавливаем файл для загрузки
+ files = {
+ 'file': (file_name, open(local_path, 'rb'))
+ }
+
+ # Выполняем запрос
+ response = self.session.post(url, params=params, files=files, verify=False)
+
+ # Закрываем файл
+ files['file'][1].close()
+
+ if response.status_code != 200:
+ logger.error(f"Failed to upload file: HTTP {response.status_code}")
+ return False
+
+ # Проверяем ответ
+ try:
+ data = response.json()
+ success = data.get("success", False)
+
+ if not success:
+ error_code = data.get("error", {}).get("code", -1)
+ logger.error(f"Failed to upload file: Error code {error_code}")
+ return False
+
+ logger.info(f"File successfully uploaded to {folder_path}/{file_name}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error parsing upload response: {str(e)}")
+ return False
+
+ except Exception as e:
+ logger.error(f"Error uploading file {local_path}: {str(e)}")
+ return False
+
+ def delete_file(self, file_path: str) -> bool:
+ """Удаление файла на NAS
+
+ Args:
+ file_path: Путь к файлу для удаления
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Deleting file: {file_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file deletion")
+ return False
+
+ try:
+ # Подготавливаем параметры для удаления
+ params = {
+ "path": [file_path],
+ "recursive": True # Удаляем папки рекурсивно
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.Delete",
+ "delete",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.Delete",
+ "delete",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to delete file {file_path}")
+ return False
+
+ # Проверяем результат
+ task_id = result.get("taskid")
+ if not task_id:
+ logger.error("No task ID received for deletion")
+ return False
+
+ # Проверяем статус задачи
+ task_params = {
+ "taskid": task_id
+ }
+
+ # Ждем завершения задачи
+ for _ in range(10):
+ task_result = self._make_api_request(
+ "SYNO.FileStation.Delete",
+ "status",
+ version=2,
+ params=task_params
+ )
+
+ if not task_result:
+ task_result = self._make_api_request(
+ "SYNO.FileStation.Delete",
+ "status",
+ version=1,
+ params=task_params
+ )
+
+ if not task_result:
+ logger.error(f"Failed to check delete task status for {file_path}")
+ return False
+
+ # Проверяем статус задачи
+ if task_result.get("finished", False):
+ return True
+
+ # Ждем немного
+ import time
+ time.sleep(0.5)
+
+ logger.warning(f"Delete task did not complete in time for {file_path}")
+ return True # Возвращаем True, т.к. задача запущена успешно
+
+ except Exception as e:
+ logger.error(f"Error deleting file {file_path}: {str(e)}")
+ return False
+
+ def create_folder(self, parent_path: str, folder_name: str) -> bool:
+ """Создание новой папки на NAS
+
+ Args:
+ parent_path: Родительский путь для новой папки
+ folder_name: Имя новой папки
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Creating folder {folder_name} in {parent_path}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for folder creation")
+ return False
+
+ try:
+ # Подготавливаем параметры для создания папки
+ params = {
+ "folder_path": parent_path,
+ "name": folder_name
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.CreateFolder",
+ "create",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.CreateFolder",
+ "create",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to create folder {folder_name} in {parent_path}")
+ return False
+
+ # Проверяем результат
+ folders = result.get("folders", [])
+ if not folders:
+ logger.error("No folder information received after creation")
+ return False
+
+ logger.info(f"Folder {folder_name} created successfully in {parent_path}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error creating folder {folder_name}: {str(e)}")
+ return False
+
+ def rename_file(self, file_path: str, new_name: str) -> bool:
+ """Переименование файла или папки на NAS
+
+ Args:
+ file_path: Путь к файлу для переименования
+ new_name: Новое имя файла (без пути)
+
+ Returns:
+ True если успешно, False в противном случае
+ """
+ logger.info(f"Renaming {file_path} to {new_name}")
+
+ # Аутентифицируемся если нужно
+ if not self.sid and not self.login():
+ logger.error("Failed to authenticate for file renaming")
+ return False
+
+ try:
+ # Получаем путь к родительской директории
+ parent_path = os.path.dirname(file_path)
+
+ # Подготавливаем параметры для переименования
+ params = {
+ "path": file_path,
+ "name": new_name
+ }
+
+ result = self._make_api_request(
+ "SYNO.FileStation.Rename",
+ "rename",
+ version=2,
+ params=params
+ )
+
+ if not result:
+ # Пробуем версию 1
+ result = self._make_api_request(
+ "SYNO.FileStation.Rename",
+ "rename",
+ version=1,
+ params=params
+ )
+
+ if not result:
+ logger.error(f"Failed to rename {file_path} to {new_name}")
+ return False
+
+ # Проверяем результат
+ files = result.get("files", [])
+ if not files:
+ logger.error("No file information received after renaming")
+ return False
+
+ logger.info(f"File {file_path} renamed to {new_name} successfully")
+ return True
+
+ except Exception as e:
+ logger.error(f"Error renaming file {file_path}: {str(e)}")
+ return False
+
+ # Добавляем все методы в класс API
+ api_class.list_files = list_files
+ api_class.get_file_info = get_file_info
+ api_class.download_file = download_file
+ api_class.upload_file = upload_file
+ api_class.delete_file = delete_file
+ api_class.create_folder = create_folder
+ api_class.rename_file = rename_file
+
+ return api_class
+
+# Добавляем методы для работы с файлами к классу SynologyAPI
+add_file_manager_methods_to_synology_api(SynologyAPI)
diff --git a/src/bot.py b/src/bot.py
index 58a915d..4db220b 100644
--- a/src/bot.py
+++ b/src/bot.py
@@ -15,6 +15,7 @@ from telegram.ext import (
CommandHandler,
CallbackQueryHandler,
MessageHandler,
+ ConversationHandler,
filters
)
@@ -60,6 +61,9 @@ from src.utils.admin_utils import (
list_admins
)
from src.utils.logger import setup_logging
+from src.agents.file_manager_agent import create_file_manager_handler
+from src.api.synology import SynologyAPI
+from src.api.filestation import add_file_manager_methods_to_synology_api
async def shutdown(application: Application) -> None:
"""Корректное завершение работы бота"""
@@ -142,6 +146,13 @@ def main() -> None:
application.add_handler(CallbackQueryHandler(schedule_callback, pattern="^schedule_"))
application.add_handler(CallbackQueryHandler(browse_callback, pattern="^browse_"))
+ # Создание экземпляра API и добавление методов для работы с файловой системой
+ synology_api = SynologyAPI()
+
+ # Регистрация обработчика файлового менеджера
+ file_manager_handler = create_file_manager_handler(synology_api)
+ application.add_handler(file_manager_handler)
+
# Настройка обработчиков сигналов для корректного завершения
signal.signal(signal.SIGINT, lambda sig, frame: signal_handler(sig, frame, application))
signal.signal(signal.SIGTERM, lambda sig, frame: signal_handler(sig, frame, application))
diff --git a/src/utils/admin_utils.py b/src/utils/admin_utils.py
index f85b013..61a792d 100644
--- a/src/utils/admin_utils.py
+++ b/src/utils/admin_utils.py
@@ -37,7 +37,19 @@ def admin_required(func: Callable) -> Callable:
Обернутая функция с проверкой прав администратора
"""
@wraps(func)
- async def wrapper(update: Update, context: ContextTypes.DEFAULT_TYPE, *args: Any, **kwargs: Any) -> Any:
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
+ # Определяем, является ли функция методом класса
+ # Если первый аргумент - это self, то второй должен быть update
+ if len(args) >= 2 and isinstance(args[1], Update):
+ self_obj = args[0]
+ update = args[1]
+ context = args[2] if len(args) > 2 else kwargs.get('context')
+ else:
+ # Если это обычная функция, то первый аргумент - update
+ update = args[0] if args else kwargs.get('update')
+ context = args[1] if len(args) > 1 else kwargs.get('context')
+ self_obj = None
+
# Проверяем доступность объекта update и effective_user
if not update or not update.effective_user:
logger.warning("Update object is incomplete, unable to check admin status")
@@ -63,7 +75,7 @@ def admin_required(func: Callable) -> Callable:
return
# Если пользователь админ, вызываем оригинальную функцию
- return await func(update, context, *args, **kwargs)
+ return await func(*args, **kwargs)
return wrapper
@@ -74,6 +86,9 @@ async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
update: Объект обновления Telegram
context: Контекст вызова
"""
+ if not update.message:
+ return
+
# Проверяем, что команду выполняет администратор
if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
@@ -159,19 +174,24 @@ async def remove_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
update: Объект обновления Telegram
context: Контекст вызова
"""
+ if not update.message:
+ return
+
# Проверяем, что команду выполняет администратор
if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
- await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ if update.message:
+ 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"
- )
+ if update.message:
+ await update.message.reply_text(
+ "❌ Ошибка: Необходимо указать ID пользователя.\n\n"
+ "Пример использования:\n"
+ "/removeadmin 123456789",
+ parse_mode="HTML"
+ )
return
try:
@@ -180,12 +200,14 @@ async def remove_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
# Проверяем, не удаляет ли админ сам себя
if admin_id == update.effective_user.id:
- await update.message.reply_text("⚠️ Вы не можете удалить самого себя из администраторов.")
+ if update.message:
+ await update.message.reply_text("⚠️ Вы не можете удалить самого себя из администраторов.")
return
# Проверяем, является ли пользователь администратором
if admin_id not in ADMIN_USER_IDS:
- await update.message.reply_text(f"ℹ️ Пользователь с ID {admin_id} не является администратором.")
+ if update.message:
+ await update.message.reply_text(f"ℹ️ Пользователь с ID {admin_id} не является администратором.")
return
# Удаляем администратора
@@ -224,25 +246,30 @@ async def remove_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
# Обновляем список в памяти
ADMIN_USER_IDS.remove(admin_id)
- await update.message.reply_text(
- f"✅ Успешно!\n\n"
- f"Пользователь с ID {admin_id} удален из списка администраторов.",
- parse_mode="HTML"
- )
+ if update.message:
+ 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("❌ Ошибка: Не удалось найти настройки администраторов.")
+ if update.message:
+ await update.message.reply_text("❌ Ошибка: Не удалось найти настройки администраторов.")
except ValueError:
- await update.message.reply_text(
- "❌ Ошибка: ID пользователя должен быть целым числом.\n\n"
- "Пример использования:\n"
- "/removeadmin 123456789",
- parse_mode="HTML"
- )
+ if update.message:
+ 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)}")
+ if update.message:
+ await update.message.reply_text(f"❌ Произошла ошибка:\n\n{str(e)}", parse_mode="HTML")
await update.message.reply_text(
f"❌ Произошла ошибка при удалении администратора:\n\n{str(e)}",
parse_mode="HTML"
@@ -255,29 +282,36 @@ async def list_admins(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
update: Объект обновления Telegram
context: Контекст вызова
"""
+ if not update.message:
+ return
+
# Проверяем, что команду выполняет администратор
if not update.effective_user or update.effective_user.id not in ADMIN_USER_IDS:
- await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
+ if update.message:
+ await update.message.reply_text("⛔️ У вас нет прав для выполнения этой команды.")
return
try:
if not ADMIN_USER_IDS:
- await update.message.reply_text("⚠️ Список администраторов пуст.")
+ if update.message:
+ 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"
- )
+ if update.message:
+ 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"
- )
+ if update.message:
+ await update.message.reply_text(
+ f"❌ Произошла ошибка при получении списка администраторов:\n\n{str(e)}",
+ parse_mode="HTML"
+ )