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" + )