file manager
This commit is contained in:
61
.history/docs/file_manager_agent_20250830141933.md
Normal file
61
.history/docs/file_manager_agent_20250830141933.md
Normal file
@@ -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 от несанкционированного доступа.
|
||||
61
.history/docs/file_manager_agent_20250830141957.md
Normal file
61
.history/docs/file_manager_agent_20250830141957.md
Normal file
@@ -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 от несанкционированного доступа.
|
||||
52
.history/examples/file_manager_demo_20250830141907.py
Normal file
52
.history/examples/file_manager_demo_20250830141907.py
Normal file
@@ -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())
|
||||
52
.history/examples/file_manager_demo_20250830141957.py
Normal file
52
.history/examples/file_manager_demo_20250830141957.py
Normal file
@@ -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())
|
||||
13
.history/run_bot_20250830142127.py
Normal file
13
.history/run_bot_20250830142127.py
Normal file
@@ -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
|
||||
11
.history/run_bot_20250830142131.py
Normal file
11
.history/run_bot_20250830142131.py
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Файл-обёртка для запуска бота из корневой директории
|
||||
"""
|
||||
|
||||
from src.bot import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
9
.history/src/agents/__init___20250830141428.py
Normal file
9
.history/src/agents/__init___20250830141428.py
Normal file
@@ -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
|
||||
9
.history/src/agents/__init___20250830141957.py
Normal file
9
.history/src/agents/__init___20250830141957.py
Normal file
@@ -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
|
||||
653
.history/src/agents/file_manager_agent_20250830141230.py
Normal file
653
.history/src/agents/file_manager_agent_20250830141230.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
653
.history/src/agents/file_manager_agent_20250830141546.py
Normal file
653
.history/src/agents/file_manager_agent_20250830141546.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
693
.history/src/agents/file_manager_agent_20250830141613.py
Normal file
693
.history/src/agents/file_manager_agent_20250830141613.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
743
.history/src/agents/file_manager_agent_20250830141646.py
Normal file
743
.history/src/agents/file_manager_agent_20250830141646.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
750
.history/src/agents/file_manager_agent_20250830141721.py
Normal file
750
.history/src/agents/file_manager_agent_20250830141721.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
750
.history/src/agents/file_manager_agent_20250830141747.py
Normal file
750
.history/src/agents/file_manager_agent_20250830141747.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
751
.history/src/agents/file_manager_agent_20250830141805.py
Normal file
751
.history/src/agents/file_manager_agent_20250830141805.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
756
.history/src/agents/file_manager_agent_20250830141832.py
Normal file
756
.history/src/agents/file_manager_agent_20250830141832.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
757
.history/src/agents/file_manager_agent_20250830141847.py
Normal file
757
.history/src/agents/file_manager_agent_20250830141847.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
757
.history/src/agents/file_manager_agent_20250830141957.py
Normal file
757
.history/src/agents/file_manager_agent_20250830141957.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
757
.history/src/agents/file_manager_agent_20250830142055.py
Normal file
757
.history/src/agents/file_manager_agent_20250830142055.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
757
.history/src/agents/file_manager_agent_20250830142117.py
Normal file
757
.history/src/agents/file_manager_agent_20250830142117.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
760
.history/src/agents/file_manager_agent_20250830142754.py
Normal file
760
.history/src/agents/file_manager_agent_20250830142754.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
763
.history/src/agents/file_manager_agent_20250830142812.py
Normal file
763
.history/src/agents/file_manager_agent_20250830142812.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
766
.history/src/agents/file_manager_agent_20250830142848.py
Normal file
766
.history/src/agents/file_manager_agent_20250830142848.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
769
.history/src/agents/file_manager_agent_20250830142901.py
Normal file
769
.history/src/agents/file_manager_agent_20250830142901.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
775
.history/src/agents/file_manager_agent_20250830142941.py
Normal file
775
.history/src/agents/file_manager_agent_20250830142941.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
775
.history/src/agents/file_manager_agent_20250830143005.py
Normal file
775
.history/src/agents/file_manager_agent_20250830143005.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
775
.history/src/agents/file_manager_agent_20250830143049.py
Normal file
775
.history/src/agents/file_manager_agent_20250830143049.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
785
.history/src/agents/file_manager_agent_20250830143114.py
Normal file
785
.history/src/agents/file_manager_agent_20250830143114.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
785
.history/src/agents/file_manager_agent_20250830143155.py
Normal file
785
.history/src/agents/file_manager_agent_20250830143155.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
784
.history/src/agents/file_manager_agent_20250830143317.py
Normal file
784
.history/src/agents/file_manager_agent_20250830143317.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
789
.history/src/agents/file_manager_agent_20250830143333.py
Normal file
789
.history/src/agents/file_manager_agent_20250830143333.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
794
.history/src/agents/file_manager_agent_20250830143351.py
Normal file
794
.history/src/agents/file_manager_agent_20250830143351.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
812
.history/src/agents/file_manager_agent_20250830143422.py
Normal file
812
.history/src/agents/file_manager_agent_20250830143422.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
817
.history/src/agents/file_manager_agent_20250830143501.py
Normal file
817
.history/src/agents/file_manager_agent_20250830143501.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
828
.history/src/agents/file_manager_agent_20250830143531.py
Normal file
828
.history/src/agents/file_manager_agent_20250830143531.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
834
.history/src/agents/file_manager_agent_20250830143559.py
Normal file
834
.history/src/agents/file_manager_agent_20250830143559.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
844
.history/src/agents/file_manager_agent_20250830143628.py
Normal file
844
.history/src/agents/file_manager_agent_20250830143628.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
844
.history/src/agents/file_manager_agent_20250830143646.py
Normal file
844
.history/src/agents/file_manager_agent_20250830143646.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
512
.history/src/api/filestation_20250830141415.py
Normal file
512
.history/src/api/filestation_20250830141415.py
Normal file
@@ -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)
|
||||
512
.history/src/api/filestation_20250830141957.py
Normal file
512
.history/src/api/filestation_20250830141957.py
Normal file
@@ -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)
|
||||
155
.history/src/bot_20250830141501.py
Normal file
155
.history/src/bot_20250830141501.py
Normal file
@@ -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()
|
||||
158
.history/src/bot_20250830141515.py
Normal file
158
.history/src/bot_20250830141515.py
Normal file
@@ -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()
|
||||
165
.history/src/bot_20250830141529.py
Normal file
165
.history/src/bot_20250830141529.py
Normal file
@@ -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()
|
||||
165
.history/src/bot_20250830141957.py
Normal file
165
.history/src/bot_20250830141957.py
Normal file
@@ -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()
|
||||
295
.history/src/utils/admin_utils_20250830142344.py
Normal file
295
.history/src/utils/admin_utils_20250830142344.py
Normal file
@@ -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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{new_admin_id}</code> добавлен в список администраторов.",
|
||||
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(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при добавлении администратора:</b>\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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{admin_id}</code> удален из списка администраторов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
|
||||
else:
|
||||
await update.message.reply_text("❌ <b>Ошибка:</b> Не удалось найти настройки администраторов.")
|
||||
|
||||
except ValueError:
|
||||
await update.message.reply_text(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при удалении администратора:</b>\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"• <code>{admin_id}</code>" for admin_id in ADMIN_USER_IDS])
|
||||
|
||||
await update.message.reply_text(
|
||||
f"👑 <b>Список администраторов бота:</b>\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"❌ <b>Произошла ошибка при получении списка администраторов:</b>\n\n{str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
298
.history/src/utils/admin_utils_20250830142408.py
Normal file
298
.history/src/utils/admin_utils_20250830142408.py
Normal file
@@ -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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{new_admin_id}</code> добавлен в список администраторов.",
|
||||
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(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при добавлении администратора:</b>\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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{admin_id}</code> удален из списка администраторов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
|
||||
else:
|
||||
await update.message.reply_text("❌ <b>Ошибка:</b> Не удалось найти настройки администраторов.")
|
||||
|
||||
except ValueError:
|
||||
await update.message.reply_text(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при удалении администратора:</b>\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"• <code>{admin_id}</code>" for admin_id in ADMIN_USER_IDS])
|
||||
|
||||
await update.message.reply_text(
|
||||
f"👑 <b>Список администраторов бота:</b>\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"❌ <b>Произошла ошибка при получении списка администраторов:</b>\n\n{str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
301
.history/src/utils/admin_utils_20250830142452.py
Normal file
301
.history/src/utils/admin_utils_20250830142452.py
Normal file
@@ -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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{new_admin_id}</code> добавлен в список администраторов.",
|
||||
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(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при добавлении администратора:</b>\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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{admin_id}</code> удален из списка администраторов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
|
||||
else:
|
||||
await update.message.reply_text("❌ <b>Ошибка:</b> Не удалось найти настройки администраторов.")
|
||||
|
||||
except ValueError:
|
||||
await update.message.reply_text(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при удалении администратора:</b>\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"• <code>{admin_id}</code>" for admin_id in ADMIN_USER_IDS])
|
||||
|
||||
await update.message.reply_text(
|
||||
f"👑 <b>Список администраторов бота:</b>\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"❌ <b>Произошла ошибка при получении списка администраторов:</b>\n\n{str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
303
.history/src/utils/admin_utils_20250830142546.py
Normal file
303
.history/src/utils/admin_utils_20250830142546.py
Normal file
@@ -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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{new_admin_id}</code> добавлен в список администраторов.",
|
||||
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(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при добавлении администратора:</b>\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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{admin_id}</code> удален из списка администраторов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
|
||||
else:
|
||||
await update.message.reply_text("❌ <b>Ошибка:</b> Не удалось найти настройки администраторов.")
|
||||
|
||||
except ValueError:
|
||||
await update.message.reply_text(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при удалении администратора:</b>\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"• <code>{admin_id}</code>" for admin_id in ADMIN_USER_IDS])
|
||||
|
||||
await update.message.reply_text(
|
||||
f"👑 <b>Список администраторов бота:</b>\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"❌ <b>Произошла ошибка при получении списка администраторов:</b>\n\n{str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
310
.history/src/utils/admin_utils_20250830142616.py
Normal file
310
.history/src/utils/admin_utils_20250830142616.py
Normal file
@@ -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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{new_admin_id}</code> добавлен в список администраторов.",
|
||||
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(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при добавлении администратора:</b>\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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{admin_id}</code> удален из списка администраторов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
|
||||
else:
|
||||
await update.message.reply_text("❌ <b>Ошибка:</b> Не удалось найти настройки администраторов.")
|
||||
|
||||
except ValueError:
|
||||
await update.message.reply_text(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при удалении администратора:</b>\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"• <code>{admin_id}</code>" for admin_id in ADMIN_USER_IDS])
|
||||
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
f"👑 <b>Список администраторов бота:</b>\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"❌ <b>Произошла ошибка при получении списка администраторов:</b>\n\n{str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
312
.history/src/utils/admin_utils_20250830142633.py
Normal file
312
.history/src/utils/admin_utils_20250830142633.py
Normal file
@@ -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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{new_admin_id}</code> добавлен в список администраторов.",
|
||||
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(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при добавлении администратора:</b>\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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{admin_id}</code> удален из списка администраторов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
|
||||
else:
|
||||
await update.message.reply_text("❌ <b>Ошибка:</b> Не удалось найти настройки администраторов.")
|
||||
|
||||
except ValueError:
|
||||
await update.message.reply_text(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при удалении администратора:</b>\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"• <code>{admin_id}</code>" for admin_id in ADMIN_USER_IDS])
|
||||
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
f"👑 <b>Список администраторов бота:</b>\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"❌ <b>Произошла ошибка при получении списка администраторов:</b>\n\n{str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
314
.history/src/utils/admin_utils_20250830142645.py
Normal file
314
.history/src/utils/admin_utils_20250830142645.py
Normal file
@@ -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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{new_admin_id}</code> добавлен в список администраторов.",
|
||||
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(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при добавлении администратора:</b>\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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{admin_id}</code> удален из списка администраторов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
|
||||
else:
|
||||
if update.message:
|
||||
await update.message.reply_text("❌ <b>Ошибка:</b> Не удалось найти настройки администраторов.")
|
||||
|
||||
except ValueError:
|
||||
await update.message.reply_text(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при удалении администратора:</b>\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"• <code>{admin_id}</code>" for admin_id in ADMIN_USER_IDS])
|
||||
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
f"👑 <b>Список администраторов бота:</b>\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"❌ <b>Произошла ошибка при получении списка администраторов:</b>\n\n{str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
317
.history/src/utils/admin_utils_20250830142656.py
Normal file
317
.history/src/utils/admin_utils_20250830142656.py
Normal file
@@ -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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{new_admin_id}</code> добавлен в список администраторов.",
|
||||
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(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при добавлении администратора:</b>\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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{admin_id}</code> удален из списка администраторов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
|
||||
else:
|
||||
if update.message:
|
||||
await update.message.reply_text("❌ <b>Ошибка:</b> Не удалось найти настройки администраторов.")
|
||||
|
||||
except ValueError:
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing admin: {str(e)}")
|
||||
if update.message:
|
||||
await update.message.reply_text(f"❌ <b>Произошла ошибка:</b>\n\n{str(e)}", parse_mode="HTML")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при удалении администратора:</b>\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"• <code>{admin_id}</code>" for admin_id in ADMIN_USER_IDS])
|
||||
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
f"👑 <b>Список администраторов бота:</b>\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"❌ <b>Произошла ошибка при получении списка администраторов:</b>\n\n{str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
317
.history/src/utils/admin_utils_20250830143155.py
Normal file
317
.history/src/utils/admin_utils_20250830143155.py
Normal file
@@ -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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{new_admin_id}</code> добавлен в список администраторов.",
|
||||
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(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/addadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding admin: {str(e)}")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при добавлении администратора:</b>\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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{admin_id}</code> удален из списка администраторов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
|
||||
else:
|
||||
if update.message:
|
||||
await update.message.reply_text("❌ <b>Ошибка:</b> Не удалось найти настройки администраторов.")
|
||||
|
||||
except ValueError:
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing admin: {str(e)}")
|
||||
if update.message:
|
||||
await update.message.reply_text(f"❌ <b>Произошла ошибка:</b>\n\n{str(e)}", parse_mode="HTML")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при удалении администратора:</b>\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"• <code>{admin_id}</code>" for admin_id in ADMIN_USER_IDS])
|
||||
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
f"👑 <b>Список администраторов бота:</b>\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"❌ <b>Произошла ошибка при получении списка администраторов:</b>\n\n{str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
61
docs/file_manager_agent.md
Normal file
61
docs/file_manager_agent.md
Normal file
@@ -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 от несанкционированного доступа.
|
||||
52
examples/file_manager_demo.py
Normal file
52
examples/file_manager_demo.py
Normal file
@@ -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())
|
||||
9
src/agents/__init__.py
Normal file
9
src/agents/__init__.py
Normal file
@@ -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
|
||||
844
src/agents/file_manager_agent.py
Normal file
844
src/agents/file_manager_agent.py
Normal file
@@ -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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
f"📭 <i>Папка пуста или недоступна</i>",
|
||||
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"📁 <b>Путь:</b> <code>{html.escape(current_path)}</code>\n\n"
|
||||
message_text += f"📂 <b>Папок:</b> {len(folders)}\n"
|
||||
message_text += f"📄 <b>Файлов:</b> {len(files)}\n"
|
||||
|
||||
if files:
|
||||
total_size = sum(file.get('size', 0) for file in files)
|
||||
message_text += f"💾 <b>Общий размер:</b> {self.get_human_readable_size(total_size)}\n"
|
||||
|
||||
message_text += f"\n<b>Страница {current_page + 1}/{total_pages}</b>"
|
||||
|
||||
# Формируем клавиатуру с элементами и навигационными кнопками
|
||||
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"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\n"
|
||||
f"💾 <b>Размер:</b> {file_size}\n"
|
||||
f"🕒 <b>Изменён:</b> {file_time}\n"
|
||||
f"👤 <b>Владелец:</b> {file_owner}\n\n"
|
||||
f"Выберите действие:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"📄 <b>Файл:</b> <code>{html.escape(file_name)}</code>\n\n"
|
||||
f"📂 <b>Расположение:</b> <code>{html.escape(file_dir)}</code>\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"📤 <b>Загрузка файла</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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"❗ <b>Подтверждение удаления</b>\n\n"
|
||||
f"Вы действительно хотите удалить файл <code>{html.escape(file_name)}</code>?",
|
||||
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"✏️ <b>Переименование файла</b>\n\n"
|
||||
f"Текущее имя: <code>{html.escape(file_name)}</code>\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"📁 <b>Создание новой папки</b>\n\n"
|
||||
f"Путь: <code>{html.escape(path)}</code>\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
|
||||
)
|
||||
512
src/api/filestation.py
Normal file
512
src/api/filestation.py
Normal file
@@ -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)
|
||||
11
src/bot.py
11
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))
|
||||
|
||||
@@ -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(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
"❌ <b>Ошибка:</b> Необходимо указать ID пользователя.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
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"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{admin_id}</code> удален из списка администраторов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
f"✅ <b>Успешно!</b>\n\n"
|
||||
f"Пользователь с ID <code>{admin_id}</code> удален из списка администраторов.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"User {update.effective_user.id} removed admin: {admin_id}")
|
||||
else:
|
||||
await update.message.reply_text("❌ <b>Ошибка:</b> Не удалось найти настройки администраторов.")
|
||||
if update.message:
|
||||
await update.message.reply_text("❌ <b>Ошибка:</b> Не удалось найти настройки администраторов.")
|
||||
|
||||
except ValueError:
|
||||
await update.message.reply_text(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
"❌ <b>Ошибка:</b> ID пользователя должен быть целым числом.\n\n"
|
||||
"Пример использования:\n"
|
||||
"<code>/removeadmin 123456789</code>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing admin: {str(e)}")
|
||||
if update.message:
|
||||
await update.message.reply_text(f"❌ <b>Произошла ошибка:</b>\n\n{str(e)}", parse_mode="HTML")
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при удалении администратора:</b>\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"• <code>{admin_id}</code>" for admin_id in ADMIN_USER_IDS])
|
||||
|
||||
await update.message.reply_text(
|
||||
f"👑 <b>Список администраторов бота:</b>\n\n"
|
||||
f"{admin_list}\n\n"
|
||||
f"Всего администраторов: {len(ADMIN_USER_IDS)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
f"👑 <b>Список администраторов бота:</b>\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"❌ <b>Произошла ошибка при получении списка администраторов:</b>\n\n{str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
if update.message:
|
||||
await update.message.reply_text(
|
||||
f"❌ <b>Произошла ошибка при получении списка администраторов:</b>\n\n{str(e)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user