file manager

This commit is contained in:
2025-08-30 14:42:08 +09:00
parent 3d189c415f
commit 5c263e6e5d
60 changed files with 29336 additions and 35 deletions

View 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 от несанкционированного доступа.

View 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 от несанкционированного доступа.

View 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())

View 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())

View 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

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Файл-обёртка для запуска бота из корневой директории
"""
from src.bot import main
if __name__ == "__main__":
main()

View 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

View 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

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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
)

View 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)

View 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)

View 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()

View 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()

View 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()

View 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()

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

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

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

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

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

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

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

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

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

View 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 от несанкционированного доступа.

View 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
View 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

View 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
View 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)

View File

@@ -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))

View File

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