Files cleaning

This commit is contained in:
2025-08-30 14:48:04 +09:00
parent 5c263e6e5d
commit b1ba48e9bc
355 changed files with 1 additions and 158423 deletions

View File

2
.gitignore vendored
View File

@@ -19,7 +19,7 @@ wheels/
*.egg-info/
.installed.cfg
*.egg
.history
# Environments
.env
.venv

View File

@@ -1,58 +0,0 @@
# Виртуальное окружение
.venv/
venv/
env/
# Кэш Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Логи
logs/
*.log
# Локальные настройки и данные
.env.local
*.db
*.sqlite3
# Git и GitHub файлы
.git/
.github/
.gitignore
.gitattributes
# IDE файлы
.idea/
.vscode/
*.swp
*.swo
# История и временные файлы
.history/
*.tmp
*.bak
# Файлы Windows
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/

View File

@@ -1,58 +0,0 @@
# Виртуальное окружение
.venv/
venv/
env/
# Кэш Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Логи
logs/
*.log
# Локальные настройки и данные
.env.local
*.db
*.sqlite3
# Git и GitHub файлы
.git/
.github/
.gitignore
.gitattributes
# IDE файлы
.idea/
.vscode/
*.swp
*.swo
# История и временные файлы
.history/
*.tmp
*.bak
# Файлы Windows
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/

View File

@@ -1,59 +0,0 @@
# Виртуальное окружение
.venv/
venv/
env/
.env
# Кэш Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Логи
logs/
*.log
# Локальные настройки и данные
.env.local
*.db
*.sqlite3
# Git и GitHub файлы
.git/
.github/
.gitignore
.gitattributes
# IDE файлы
.idea/
.vscode/
*.swp
*.swo
# История и временные файлы
.history/
*.tmp
*.bak
# Файлы Windows
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/

View File

@@ -1,26 +0,0 @@
# Telegram Bot API
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321 # ID пользователей-администраторов через запятую
# Synology NAS
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000 # Обычно 5000 для HTTP и 5001 для HTTPS
SYNOLOGY_USERNAME=your_username
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=True # Использовать HTTPS
SYNOLOGY_VERIFY_SSL=False # Проверка SSL-сертификата
SYNOLOGY_TIMEOUT=10 # Таймаут для API запросов в секундах
SYNOLOGY_API_VERSION=1 # Версия API
SYNOLOGY_POWER_API=SYNO.Core.System # API для управления питанием
# WOL (Wake-on-LAN)
MAC_ADDRESS=00:11:22:33:44:55 # MAC-адрес Synology NAS
WOL_BROADCAST=255.255.255.255 # Broadcast-адрес для WOL
WOL_PORT=9 # Порт для WOL (обычно 7 или 9)
# Logging
LOG_LEVEL=INFO
# Docker specific
DOCKER_ENV=true # Указывает, что приложение запущено в Docker
HEALTHCHECK_PORT=8080 # Порт для healthcheck

View File

@@ -1,26 +0,0 @@
# Telegram Bot API
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321 # ID пользователей-администраторов через запятую
# Synology NAS
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000 # Обычно 5000 для HTTP и 5001 для HTTPS
SYNOLOGY_USERNAME=your_username
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=True # Использовать HTTPS
SYNOLOGY_VERIFY_SSL=False # Проверка SSL-сертификата
SYNOLOGY_TIMEOUT=10 # Таймаут для API запросов в секундах
SYNOLOGY_API_VERSION=1 # Версия API
SYNOLOGY_POWER_API=SYNO.Core.System # API для управления питанием
# WOL (Wake-on-LAN)
MAC_ADDRESS=00:11:22:33:44:55 # MAC-адрес Synology NAS
WOL_BROADCAST=255.255.255.255 # Broadcast-адрес для WOL
WOL_PORT=9 # Порт для WOL (обычно 7 или 9)
# Logging
LOG_LEVEL=INFO
# Docker specific
DOCKER_ENV=true # Указывает, что приложение запущено в Docker
HEALTHCHECK_PORT=8080 # Порт для healthcheck

View File

@@ -1,15 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9

View File

@@ -1,15 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9

View File

@@ -1,15 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.0.
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9

View File

@@ -1,15 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.0.102
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=superadmin
SYNOLOGY_PASSWORD=Cl0ud_1985!
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9

View File

@@ -1,15 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.0.102
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=superadmin
SYNOLOGY_PASSWORD=Cl0ud_1985!
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=90:09:D0:8C:27:F9
WOL_PORT=9

View File

@@ -1,15 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=879254890:AAGOVgN6yF9Xx0PXlkTVncln8RJkh3BL1AY
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.0.102
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=superadmin
SYNOLOGY_PASSWORD=Cl0ud_1985!
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=90:09:D0:8C:27:F9
WOL_PORT=9

View File

@@ -1,15 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=879254890:AAGOVgN6yF9Xx0PXlkTVncln8RJkh3BL1AY
ADMIN_USER_IDS=556399210
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.0.102
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=superadmin
SYNOLOGY_PASSWORD=Cl0ud_1985!
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=90:09:D0:8C:27:F9
WOL_PORT=9

View File

@@ -1,16 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
SYNOLOGY_API_VERSION=1
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9

View File

@@ -1,16 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
SYNOLOGY_API_VERSION=1
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9

View File

@@ -1,16 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=879254890:AAGOVgN6yF9Xx0PXlkTVncln8RJkh3BL1AY
ADMIN_USER_IDS=556399210
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.0.102
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=superadmin
SYNOLOGY_PASSWORD=Cl0ud_1985!
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
SYNOLOGY_API_VERSION=1
# Wake-on-LAN Configuration
SYNOLOGY_MAC=90:09:D0:8C:27:F9
WOL_PORT=9

View File

@@ -1,16 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=879254890:AAGOVgN6yF9Xx0PXlkTVncln8RJkh3BL1AY
ADMIN_USER_IDS=556399210
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.0.102
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=superadmin
SYNOLOGY_PASSWORD=Cl0ud_1985!
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
SYNOLOGY_API_VERSION=2
# Wake-on-LAN Configuration
SYNOLOGY_MAC=90:09:D0:8C:27:F9
WOL_PORT=9

View File

@@ -1,20 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=879254890:AAGOVgN6yF9Xx0PXlkTVncln8RJkh3BL1AY
ADMIN_USER_IDS=556399210
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.0.102
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=superadmin
SYNOLOGY_PASSWORD=Cl0ud_1985!
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
SYNOLOGY_API_VERSION=2
# API Configuration
SYNOLOGY_POWER_API=SYNO.Core.Hardware.PowerRecovery
SYNOLOGY_INFO_API=SYNO.DSM.Info
# Wake-on-LAN Configuration
SYNOLOGY_MAC=90:09:D0:8C:27:F9
WOL_PORT=9

View File

@@ -1,20 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=879254890:AAGOVgN6yF9Xx0PXlkTVncln8RJkh3BL1AY
ADMIN_USER_IDS=556399210
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.0.102
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=superadmin
SYNOLOGY_PASSWORD=Cl0ud_1985!
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
SYNOLOGY_API_VERSION=2
# API Configuration
SYNOLOGY_POWER_API=SYNO.Core.Hardware.PowerRecovery
SYNOLOGY_INFO_API=SYNO.DSM.Info
# Wake-on-LAN Configuration
SYNOLOGY_MAC=90:09:D0:8C:27:F9
WOL_PORT=9

View File

@@ -1,20 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=879254890:AAGOVgN6yF9Xx0PXlkTVncln8RJkh3BL1AY
ADMIN_USER_IDS=556399210
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.0.102
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=superadmin
SYNOLOGY_PASSWORD=Cl0ud_1985!
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
SYNOLOGY_API_VERSION=6
# API Configuration
SYNOLOGY_POWER_API=SYNO.Core.Hardware.PowerRecovery
SYNOLOGY_INFO_API=SYNO.DSM.Info
# Wake-on-LAN Configuration
SYNOLOGY_MAC=90:09:D0:8C:27:F9
WOL_PORT=9

View File

@@ -1,20 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=879254890:AAGOVgN6yF9Xx0PXlkTVncln8RJkh3BL1AY
ADMIN_USER_IDS=556399210
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.0.102
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=superadmin
SYNOLOGY_PASSWORD=Cl0ud_1985!
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
SYNOLOGY_API_VERSION=6
# API Configuration
SYNOLOGY_POWER_API=SYNO.Core.System
SYNOLOGY_INFO_API=SYNO.DSM.Info
# Wake-on-LAN Configuration
SYNOLOGY_MAC=90:09:D0:8C:27:F9
WOL_PORT=9

View File

@@ -1,20 +0,0 @@
# Telegram Bot Configuration
TELEGRAM_TOKEN=879254890:AAGOVgN6yF9Xx0PXlkTVncln8RJkh3BL1AY
ADMIN_USER_IDS=556399210
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.0.102
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=superadmin
SYNOLOGY_PASSWORD=Cl0ud_1985!
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
SYNOLOGY_API_VERSION=6
# API Configuration
SYNOLOGY_POWER_API=SYNO.Core.System
SYNOLOGY_INFO_API=SYNO.DSM.Info
# Wake-on-LAN Configuration
SYNOLOGY_MAC=90:09:D0:8C:27:F9
WOL_PORT=9

View File

@@ -1,44 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Logs
logs/
*.log
# IDE specific files
.idea/
.vscode/
*.swp
*.swo
# OS specific files
.DS_Store
Thumbs.db

View File

@@ -1,44 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Logs
logs/
*.log
# IDE specific files
.idea/
.vscode/
*.swp
*.swo
# OS specific files
.DS_Store
Thumbs.db

View File

@@ -1,218 +0,0 @@
# Synology Power Control Bot - Руководство по развертыванию в Docker
## Подготовка к развертыванию
Это руководство поможет вам развернуть бота для управления питанием Synology NAS в Docker-контейнере. Развертывание в Docker имеет следующие преимущества:
- Изоляция приложения и его зависимостей
- Простота управления и обновления
- Автоматический перезапуск при сбоях
- Возможность легкого переноса между системами
## Предварительные требования
1. **Установка Docker и Docker Compose**:
**Для Ubuntu/Debian**:
```bash
# Установка Docker
sudo apt update
sudo apt install apt-transport-https ca-certificates curl software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io
# Установка Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/download/v2.15.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
```
**Для CentOS/RHEL**:
```bash
# Установка Docker
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
sudo yum install docker-ce docker-ce-cli containerd.io
sudo systemctl start docker
sudo systemctl enable docker
# Установка Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/download/v2.15.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
```
**Для Windows**:
- Скачайте и установите Docker Desktop с [официального сайта Docker](https://www.docker.com/products/docker-desktop/)
2. **Настройка проекта**:
```bash
# Клонирование репозитория (если используете Git)
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
# Или распакуйте архив с исходным кодом
```
## Конфигурация
1. **Создайте файл .env**:
Создайте файл `.env` на основе `.env-example` и настройте его с вашими параметрами:
```bash
cp .env-example .env
nano .env # или любой другой текстовый редактор
```
Заполните следующие параметры:
- `TELEGRAM_TOKEN`: Токен вашего Telegram-бота от @BotFather
- `ADMIN_USER_IDS`: ID пользователей Telegram с доступом к боту
- `SYNOLOGY_HOST`: IP-адрес вашего Synology NAS
- `SYNOLOGY_USERNAME` и `SYNOLOGY_PASSWORD`: Учетные данные для DSM
- `MAC_ADDRESS`: MAC-адрес Synology NAS для Wake-on-LAN
## Развертывание
### С использованием скриптов:
**Linux**:
```bash
chmod +x deploy.sh
./deploy.sh
```
**Windows**:
```
deploy.cmd
```
### Вручную с Docker Compose:
1. **Сборка и запуск**:
```bash
docker-compose up -d --build
```
2. **Проверка статуса**:
```bash
docker-compose ps
```
3. **Просмотр логов**:
```bash
docker-compose logs -f
```
4. **Остановка**:
```bash
docker-compose down
```
## Управление контейнером
### Перезапуск бота:
```bash
docker-compose restart
```
### Обновление:
```bash
# Остановка
docker-compose down
# Обновление (если используете Git)
git pull
# Пересборка и запуск
docker-compose up -d --build
```
### Резервное копирование данных:
Важные данные хранятся в томе `logs`, который можно скопировать:
```bash
# Создание бэкапа логов
tar -czvf synology_bot_logs_backup.tar.gz ./logs
```
## Проверка работоспособности
После развертывания можно проверить состояние бота с помощью следующих команд:
1. **Проверка статуса контейнера**:
```bash
docker-compose ps
```
2. **Проверка health-check**:
```bash
curl http://localhost:8080/health
```
Должен вернуть `OK`.
3. **Проверка логов**:
```bash
docker-compose logs -f
```
Ищите строки с успешной инициализацией бота.
## Решение проблем
### Контейнер не запускается или сразу завершает работу
- Проверьте логи: `docker-compose logs -f`
- Проверьте файл `.env` на наличие всех необходимых параметров
- Убедитесь, что порт 8080 не занят другим приложением
### Проблемы с подключением к Synology NAS
- Проверьте доступность NAS из контейнера:
```bash
docker-compose exec synology-bot ping $SYNOLOGY_HOST
```
- Проверьте правильность учетных данных
- Убедитесь, что API DSM включено в настройках NAS
### Telegram-бот не отвечает
- Проверьте корректность TELEGRAM_TOKEN
- Убедитесь, что бот запущен: `/start` в чате с ботом
- Проверьте, что ваш Telegram ID указан в ADMIN_USER_IDS
## Автоматический запуск при перезагрузке сервера
Docker и Docker Compose по умолчанию настроены на автоматический запуск контейнеров при перезагрузке системы благодаря параметру `restart: unless-stopped` в docker-compose.yml.
Если эта опция не работает, вы можете настроить systemd:
1. **Создайте файл сервиса**:
```bash
sudo nano /etc/systemd/system/synology-bot.service
```
2. **Добавьте следующее содержимое**:
```
[Unit]
Description=Synology Power Control Bot
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/path/to/synology_power_control_bot
ExecStart=/usr/local/bin/docker-compose up -d
ExecStop=/usr/local/bin/docker-compose down
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
```
3. **Активируйте и запустите сервис**:
```bash
sudo systemctl enable synology-bot.service
sudo systemctl start synology-bot.service
```
## Безопасность
- Не передавайте файл `.env` с учетными данными третьим лицам
- Регулярно меняйте пароль от DSM
- Ограничьте доступ к боту только доверенным пользователям
- Рассмотрите возможность запуска на выделенной сети или с дополнительными ограничениями доступа

View File

@@ -1,218 +0,0 @@
# Synology Power Control Bot - Руководство по развертыванию в Docker
## Подготовка к развертыванию
Это руководство поможет вам развернуть бота для управления питанием Synology NAS в Docker-контейнере. Развертывание в Docker имеет следующие преимущества:
- Изоляция приложения и его зависимостей
- Простота управления и обновления
- Автоматический перезапуск при сбоях
- Возможность легкого переноса между системами
## Предварительные требования
1. **Установка Docker и Docker Compose**:
**Для Ubuntu/Debian**:
```bash
# Установка Docker
sudo apt update
sudo apt install apt-transport-https ca-certificates curl software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io
# Установка Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/download/v2.15.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
```
**Для CentOS/RHEL**:
```bash
# Установка Docker
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
sudo yum install docker-ce docker-ce-cli containerd.io
sudo systemctl start docker
sudo systemctl enable docker
# Установка Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/download/v2.15.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
```
**Для Windows**:
- Скачайте и установите Docker Desktop с [официального сайта Docker](https://www.docker.com/products/docker-desktop/)
2. **Настройка проекта**:
```bash
# Клонирование репозитория (если используете Git)
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
# Или распакуйте архив с исходным кодом
```
## Конфигурация
1. **Создайте файл .env**:
Создайте файл `.env` на основе `.env-example` и настройте его с вашими параметрами:
```bash
cp .env-example .env
nano .env # или любой другой текстовый редактор
```
Заполните следующие параметры:
- `TELEGRAM_TOKEN`: Токен вашего Telegram-бота от @BotFather
- `ADMIN_USER_IDS`: ID пользователей Telegram с доступом к боту
- `SYNOLOGY_HOST`: IP-адрес вашего Synology NAS
- `SYNOLOGY_USERNAME` и `SYNOLOGY_PASSWORD`: Учетные данные для DSM
- `MAC_ADDRESS`: MAC-адрес Synology NAS для Wake-on-LAN
## Развертывание
### С использованием скриптов:
**Linux**:
```bash
chmod +x deploy.sh
./deploy.sh
```
**Windows**:
```
deploy.cmd
```
### Вручную с Docker Compose:
1. **Сборка и запуск**:
```bash
docker-compose up -d --build
```
2. **Проверка статуса**:
```bash
docker-compose ps
```
3. **Просмотр логов**:
```bash
docker-compose logs -f
```
4. **Остановка**:
```bash
docker-compose down
```
## Управление контейнером
### Перезапуск бота:
```bash
docker-compose restart
```
### Обновление:
```bash
# Остановка
docker-compose down
# Обновление (если используете Git)
git pull
# Пересборка и запуск
docker-compose up -d --build
```
### Резервное копирование данных:
Важные данные хранятся в томе `logs`, который можно скопировать:
```bash
# Создание бэкапа логов
tar -czvf synology_bot_logs_backup.tar.gz ./logs
```
## Проверка работоспособности
После развертывания можно проверить состояние бота с помощью следующих команд:
1. **Проверка статуса контейнера**:
```bash
docker-compose ps
```
2. **Проверка health-check**:
```bash
curl http://localhost:8080/health
```
Должен вернуть `OK`.
3. **Проверка логов**:
```bash
docker-compose logs -f
```
Ищите строки с успешной инициализацией бота.
## Решение проблем
### Контейнер не запускается или сразу завершает работу
- Проверьте логи: `docker-compose logs -f`
- Проверьте файл `.env` на наличие всех необходимых параметров
- Убедитесь, что порт 8080 не занят другим приложением
### Проблемы с подключением к Synology NAS
- Проверьте доступность NAS из контейнера:
```bash
docker-compose exec synology-bot ping $SYNOLOGY_HOST
```
- Проверьте правильность учетных данных
- Убедитесь, что API DSM включено в настройках NAS
### Telegram-бот не отвечает
- Проверьте корректность TELEGRAM_TOKEN
- Убедитесь, что бот запущен: `/start` в чате с ботом
- Проверьте, что ваш Telegram ID указан в ADMIN_USER_IDS
## Автоматический запуск при перезагрузке сервера
Docker и Docker Compose по умолчанию настроены на автоматический запуск контейнеров при перезагрузке системы благодаря параметру `restart: unless-stopped` в docker-compose.yml.
Если эта опция не работает, вы можете настроить systemd:
1. **Создайте файл сервиса**:
```bash
sudo nano /etc/systemd/system/synology-bot.service
```
2. **Добавьте следующее содержимое**:
```
[Unit]
Description=Synology Power Control Bot
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/path/to/synology_power_control_bot
ExecStart=/usr/local/bin/docker-compose up -d
ExecStop=/usr/local/bin/docker-compose down
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
```
3. **Активируйте и запустите сервис**:
```bash
sudo systemctl enable synology-bot.service
sudo systemctl start synology-bot.service
```
## Безопасность
- Не передавайте файл `.env` с учетными данными третьим лицам
- Регулярно меняйте пароль от DSM
- Ограничьте доступ к боту только доверенным пользователям
- Рассмотрите возможность запуска на выделенной сети или с дополнительными ограничениями доступа

View File

@@ -1,20 +0,0 @@
FROM python:3.11-slim
# Устанавливаем рабочую директорию
WORKDIR /app
# Копируем файлы зависимостей
COPY requirements.txt .
# Устанавливаем зависимости
RUN pip install --no-cache-dir -r requirements.txt
# Копируем исходный код
COPY . .
# Указываем переменные окружения
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
# Запускаем приложение
CMD ["python", "run.py"]

View File

@@ -1,23 +0,0 @@
FROM python:3.11-slim
# Устанавливаем рабочую директорию
WORKDIR /app
# Копируем файлы зависимостей
COPY requirements.txt .
# Устанавливаем зависимости
RUN pip install --no-cache-dir -r requirements.txt
# Копируем исходный код
COPY . .
# Делаем entrypoint исполняемым
RUN chmod +x /app/entrypoint.sh
# Указываем переменные окружения
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
# Используем entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -1,23 +0,0 @@
FROM python:3.11-slim
# Устанавливаем рабочую директорию
WORKDIR /app
# Копируем файлы зависимостей
COPY requirements.txt .
# Устанавливаем зависимости
RUN pip install --no-cache-dir -r requirements.txt
# Копируем исходный код
COPY . .
# Делаем entrypoint исполняемым
RUN chmod +x /app/entrypoint.sh
# Указываем переменные окружения
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
# Используем entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -1,101 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы
- ✅ Проверка статуса и получение информации о системе
- ✅ Ограничение доступа по ID пользователей
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
## Установка и настройка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python -m src.bot
```
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS
- `/help` - Вывод справочной информации
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,101 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы
- ✅ Проверка статуса и получение информации о системе
- ✅ Ограничение доступа по ID пользователей
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
## Установка и настройка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python -m src.bot
```
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS
- `/help` - Вывод справочной информации
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,117 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
## Установка и настройка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python -m src.bot
```
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS
- `/help` - Вывод справочной информации
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,125 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
## Установка и настройка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python -m src.bot
```
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Расширенные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,125 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
## Установка и настройка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python -m src.bot
```
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Расширенные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,126 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
- Доступ к порту API Synology DSM (обычно 5000 или 5001)
## Установка и настройка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python -m src.bot
```
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Расширенные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,126 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
- Доступ к порту API Synology DSM (обычно 5000 или 5001)
## Установка и настройка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python -m src.bot
```
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Расширенные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,128 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
- ✅ Список активных процессов
- ✅ Мониторинг сетевых подключений
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
- Доступ к порту API Synology DSM (обычно 5000 или 5001)
## Установка и настройка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python -m src.bot
```
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Расширенные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,131 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
- ✅ Список активных процессов
- ✅ Мониторинг сетевых подключений
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
- ✅ Просмотр файлов и папок
- ✅ Поиск файлов
- ✅ Мониторинг квот пользователей
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
- Доступ к порту API Synology DSM (обычно 5000 или 5001)
## Установка и настройка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python -m src.bot
```
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Расширенные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,146 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
- ✅ Список активных процессов
- ✅ Мониторинг сетевых подключений
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
- ✅ Просмотр файлов и папок
- ✅ Поиск файлов
- ✅ Мониторинг квот пользователей
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
- Доступ к порту API Synology DSM (обычно 5000 или 5001)
## Установка и настройка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python -m src.bot
```
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Информационные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
- `/temperature` - Температура устройства
- `/processes` - Список активных процессов
- `/network` - Сетевая информация
### Расширенные команды
- `/schedule` - Расписание питания
- `/browse` - Просмотр файлов
- `/search <запрос>` - Поиск файлов
- `/updates` - Проверка обновлений
- `/backup` - Статус резервного копирования
- `/quota` - Квоты пользователей
### Быстрые команды
- `/quickreboot` - Быстрая перезагрузка
- `/wakeup` - Пробуждение NAS (WOL)
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,151 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
- ✅ Список активных процессов
- ✅ Мониторинг сетевых подключений
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
- ✅ Просмотр файлов и папок
- ✅ Поиск файлов
- ✅ Мониторинг квот пользователей
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
### Дополнительные функции
- ✅ Мониторинг обновлений DSM и пакетов
- ✅ Управление расписанием питания
- ✅ Проверка статуса резервного копирования
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
- Доступ к порту API Synology DSM (обычно 5000 или 5001)
## Установка и настройка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python -m src.bot
```
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Информационные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
- `/temperature` - Температура устройства
- `/processes` - Список активных процессов
- `/network` - Сетевая информация
### Расширенные команды
- `/schedule` - Расписание питания
- `/browse` - Просмотр файлов
- `/search <запрос>` - Поиск файлов
- `/updates` - Проверка обновлений
- `/backup` - Статус резервного копирования
- `/quota` - Квоты пользователей
### Быстрые команды
- `/quickreboot` - Быстрая перезагрузка
- `/wakeup` - Пробуждение NAS (WOL)
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,151 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
- ✅ Список активных процессов
- ✅ Мониторинг сетевых подключений
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
- ✅ Просмотр файлов и папок
- ✅ Поиск файлов
- ✅ Мониторинг квот пользователей
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
### Дополнительные функции
- ✅ Мониторинг обновлений DSM и пакетов
- ✅ Управление расписанием питания
- ✅ Проверка статуса резервного копирования
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
- Доступ к порту API Synology DSM (обычно 5000 или 5001)
## Установка и настройка
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python -m src.bot
```
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Информационные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
- `/temperature` - Температура устройства
- `/processes` - Список активных процессов
- `/network` - Сетевая информация
### Расширенные команды
- `/schedule` - Расписание питания
- `/browse` - Просмотр файлов
- `/search <запрос>` - Поиск файлов
- `/updates` - Проверка обновлений
- `/backup` - Статус резервного копирования
- `/quota` - Квоты пользователей
### Быстрые команды
- `/quickreboot` - Быстрая перезагрузка
- `/wakeup` - Пробуждение NAS (WOL)
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,192 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
- ✅ Список активных процессов
- ✅ Мониторинг сетевых подключений
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
- ✅ Просмотр файлов и папок
- ✅ Поиск файлов
- ✅ Мониторинг квот пользователей
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
### Дополнительные функции
- ✅ Мониторинг обновлений DSM и пакетов
- ✅ Управление расписанием питания
- ✅ Проверка статуса резервного копирования
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
- Доступ к порту API Synology DSM (обычно 5000 или 5001)
## Установка и настройка
### Метод 1: Локальный запуск
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python run.py
```
### Метод 2: Docker
1. Убедитесь, что Docker и Docker Compose установлены в вашей системе.
2. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
3. Создайте файл `.env` на основе `.env-example` и заполните необходимыми значениями.
4. Запустите скрипт развертывания:
```bash
# Linux/macOS
chmod +x deploy.sh
./deploy.sh
# Windows
deploy.cmd
```
Или запустите вручную:
```bash
docker-compose up -d --build
```
5. Проверьте статус:
```bash
docker-compose ps
```
6. Просмотр логов:
```bash
docker-compose logs -f
```
Дополнительная информация о Docker-развертывании доступна в файле [README_DOCKER.md](README_DOCKER.md).
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Информационные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
- `/temperature` - Температура устройства
- `/processes` - Список активных процессов
- `/network` - Сетевая информация
### Расширенные команды
- `/schedule` - Расписание питания
- `/browse` - Просмотр файлов
- `/search <запрос>` - Поиск файлов
- `/updates` - Проверка обновлений
- `/backup` - Статус резервного копирования
- `/quota` - Квоты пользователей
### Быстрые команды
- `/quickreboot` - Быстрая перезагрузка
- `/wakeup` - Пробуждение NAS (WOL)
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .gitignore # Файл игнорирования Git
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,201 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
- ✅ Список активных процессов
- ✅ Мониторинг сетевых подключений
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
- ✅ Просмотр файлов и папок
- ✅ Поиск файлов
- ✅ Мониторинг квот пользователей
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
### Дополнительные функции
- ✅ Мониторинг обновлений DSM и пакетов
- ✅ Управление расписанием питания
- ✅ Проверка статуса резервного копирования
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
- Доступ к порту API Synology DSM (обычно 5000 или 5001)
## Установка и настройка
### Метод 1: Локальный запуск
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python run.py
```
### Метод 2: Docker
1. Убедитесь, что Docker и Docker Compose установлены в вашей системе.
2. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
3. Создайте файл `.env` на основе `.env-example` и заполните необходимыми значениями.
4. Запустите скрипт развертывания:
```bash
# Linux/macOS
chmod +x deploy.sh
./deploy.sh
# Windows
deploy.cmd
```
Или запустите вручную:
```bash
docker-compose up -d --build
```
5. Проверьте статус:
```bash
docker-compose ps
```
6. Просмотр логов:
```bash
docker-compose logs -f
```
Дополнительная информация о Docker-развертывании доступна в файле [README_DOCKER.md](README_DOCKER.md).
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Информационные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
- `/temperature` - Температура устройства
- `/processes` - Список активных процессов
- `/network` - Сетевая информация
### Расширенные команды
- `/schedule` - Расписание питания
- `/browse` - Просмотр файлов
- `/search <запрос>` - Поиск файлов
- `/updates` - Проверка обновлений
- `/backup` - Статус резервного копирования
- `/quota` - Квоты пользователей
### Быстрые команды
- `/quickreboot` - Быстрая перезагрузка
- `/wakeup` - Пробуждение NAS (WOL)
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .env-example # Пример файла переменных окружения
├── .gitignore # Файл игнорирования Git
├── .dockerignore # Файлы, игнорируемые при сборке Docker-образа
├── Dockerfile # Инструкции для сборки Docker-образа
├── docker-compose.yml # Конфигурация Docker Compose
├── deploy.sh # Скрипт развёртывания для Linux
├── deploy.cmd # Скрипт развёртывания для Windows
├── entrypoint.sh # Скрипт для запуска в Docker
├── README.md # Основная документация
├── README_DOCKER.md # Документация по Docker-развёртыванию
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Лицензия
MIT

View File

@@ -1,236 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
- ✅ Список активных процессов
- ✅ Мониторинг сетевых подключений
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
- ✅ Просмотр файлов и папок
- ✅ Поиск файлов
- ✅ Мониторинг квот пользователей
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
### Дополнительные функции
- ✅ Мониторинг обновлений DSM и пакетов
- ✅ Управление расписанием питания
- ✅ Проверка статуса резервного копирования
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
- Доступ к порту API Synology DSM (обычно 5000 или 5001)
## Установка и настройка
### Метод 1: Локальный запуск
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python run.py
```
### Метод 2: Docker
1. Убедитесь, что Docker и Docker Compose установлены в вашей системе.
2. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
3. Создайте файл `.env` на основе `.env-example` и заполните необходимыми значениями.
4. Запустите скрипт развертывания:
```bash
# Linux/macOS
chmod +x deploy.sh
./deploy.sh
# Windows
deploy.cmd
```
Или запустите вручную:
```bash
docker-compose up -d --build
```
5. Проверьте статус:
```bash
docker-compose ps
```
6. Просмотр логов:
```bash
docker-compose logs -f
```
Дополнительная информация о Docker-развертывании доступна в файле [README_DOCKER.md](README_DOCKER.md).
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Информационные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
- `/temperature` - Температура устройства
- `/processes` - Список активных процессов
- `/network` - Сетевая информация
### Расширенные команды
- `/schedule` - Расписание питания
- `/browse` - Просмотр файлов
- `/search <запрос>` - Поиск файлов
- `/updates` - Проверка обновлений
- `/backup` - Статус резервного копирования
- `/quota` - Квоты пользователей
### Быстрые команды
- `/quickreboot` - Быстрая перезагрузка
- `/wakeup` - Пробуждение NAS (WOL)
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .env-example # Пример файла переменных окружения
├── .gitignore # Файл игнорирования Git
├── .dockerignore # Файлы, игнорируемые при сборке Docker-образа
├── Dockerfile # Инструкции для сборки Docker-образа
├── docker-compose.yml # Конфигурация Docker Compose
├── deploy.sh # Скрипт развёртывания для Linux
├── deploy.cmd # Скрипт развёртывания для Windows
├── entrypoint.sh # Скрипт для запуска в Docker
├── README.md # Основная документация
├── README_DOCKER.md # Документация по Docker-развёртыванию
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Устранение неисправностей
### Проблемы с подключением к Synology NAS
1. Убедитесь, что NAS доступен по сети (можно проверить с помощью команды `ping`).
2. Проверьте правильность логина и пароля в `.env`.
3. Убедитесь, что DSM API включено в настройках NAS.
### Проблемы с Docker
1. Проверьте статус контейнера: `docker-compose ps`
2. Просмотрите логи: `docker-compose logs -f`
3. Перезапустите контейнер: `docker-compose restart`
4. Проверьте состояние здоровья: `docker inspect --format="{{json .State.Health}}" synology-power-control-bot`
5. Проверьте, что все переменные окружения корректно переданы в контейнер.
### Обновление в Docker
Для обновления бота в Docker:
1. Остановите контейнеры:
```bash
docker-compose down
```
2. Загрузите обновления (если используете Git):
```bash
git pull
```
3. Запустите контейнеры заново:
```bash
docker-compose up -d --build
```
## Лицензия
MIT

View File

@@ -1,236 +0,0 @@
# Synology Power Control Bot
Telegram-бот для удаленного управления питанием сетевого хранилища Synology NAS (DS223j и другие модели).
## Возможности
### Управление питанием
- ✅ Включение питания через Wake-on-LAN
- ✅ Выключение питания через API DSM
- ✅ Перезагрузка системы с отслеживанием статуса
### Мониторинг системы
- ✅ Проверка онлайн статуса NAS
- ✅ Информация о системе (модель, версия DSM, время работы)
- ✅ Мониторинг загрузки CPU и памяти
- ✅ Данные о температуре и сетевой активности
- ✅ Статус хранилища и дисков
- ✅ Информация о безопасности системы
- ✅ Список активных процессов
- ✅ Мониторинг сетевых подключений
### Управление данными
- ✅ Просмотр списка общих папок
- ✅ Информация о томах и дисках
- ✅ Статистика использования дисков
- ✅ Просмотр файлов и папок
- ✅ Поиск файлов
- ✅ Мониторинг квот пользователей
### Безопасность
- ✅ Ограничение доступа по ID пользователей Telegram
- ✅ Безопасное хранение учетных данных
### Дополнительные функции
- ✅ Мониторинг обновлений DSM и пакетов
- ✅ Управление расписанием питания
- ✅ Проверка статуса резервного копирования
## Требования
- Python 3.8+
- Synology NAS с включенным WoL
- Учетная запись администратора DSM
- Telegram Bot API Token
- Доступ к порту API Synology DSM (обычно 5000 или 5001)
## Установка и настройка
### Метод 1: Локальный запуск
1. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
2. Установите зависимости:
```bash
pip install -r requirements.txt
```
3. Настройте параметры в файле `.env`:
```
# Telegram Bot Configuration
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321
# Synology NAS Configuration
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000
SYNOLOGY_USERNAME=admin
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=False
SYNOLOGY_TIMEOUT=10
# Wake-on-LAN Configuration
SYNOLOGY_MAC=00:11:22:33:44:55
WOL_PORT=9
```
4. Запустите бота:
```bash
python run.py
```
### Метод 2: Docker
1. Убедитесь, что Docker и Docker Compose установлены в вашей системе.
2. Клонируйте репозиторий:
```bash
git clone https://github.com/yourusername/synology_power_control_bot.git
cd synology_power_control_bot
```
3. Создайте файл `.env` на основе `.env-example` и заполните необходимыми значениями.
4. Запустите скрипт развертывания:
```bash
# Linux/macOS
chmod +x deploy.sh
./deploy.sh
# Windows
deploy.cmd
```
Или запустите вручную:
```bash
docker-compose up -d --build
```
5. Проверьте статус:
```bash
docker-compose ps
```
6. Просмотр логов:
```bash
docker-compose logs -f
```
Дополнительная информация о Docker-развертывании доступна в файле [README_DOCKER.md](README_DOCKER.md).
## Подготовка Synology NAS
1. Включите Wake-on-LAN в настройках DSM:
- Панель управления > Сеть > Общие > Wake-on-LAN
2. Убедитесь, что API DSM включено:
- Панель управления > Службы терминала и SNMP > Включить DSM API
3. Узнайте MAC-адрес вашего NAS:
- Панель управления > Сеть > Сетевой интерфейс
## Команды бота
### Основные команды
- `/start` - Начало работы с ботом
- `/status` - Проверка текущего статуса NAS
- `/power` - Управление питанием NAS (включение, выключение, перезагрузка)
- `/help` - Вывод справочной информации
### Информационные команды
- `/system` - Подробная информация о системе
- `/storage` - Информация о хранилище и дисках
- `/shares` - Список общих папок
- `/load` - Текущая нагрузка на систему
- `/security` - Статус безопасности системы
- `/temperature` - Температура устройства
- `/processes` - Список активных процессов
- `/network` - Сетевая информация
### Расширенные команды
- `/schedule` - Расписание питания
- `/browse` - Просмотр файлов
- `/search <запрос>` - Поиск файлов
- `/updates` - Проверка обновлений
- `/backup` - Статус резервного копирования
- `/quota` - Квоты пользователей
### Быстрые команды
- `/quickreboot` - Быстрая перезагрузка
- `/wakeup` - Пробуждение NAS (WOL)
## Структура проекта
```
synology_power_control_bot/
├── logs/ # Директория для логов
├── src/ # Исходный код
│ ├── api/ # Модули для работы с API
│ │ └── synology.py # API для работы с Synology NAS
│ ├── config/ # Модули конфигурации
│ │ └── config.py # Основная конфигурация
│ ├── handlers/ # Обработчики команд бота
│ │ └── command_handlers.py
│ ├── utils/ # Вспомогательные утилиты
│ │ └── logger.py # Настройка логирования
│ └── bot.py # Основной файл запуска бота
├── .env # Файл с переменными окружения
├── .env-example # Пример файла переменных окружения
├── .gitignore # Файл игнорирования Git
├── .dockerignore # Файлы, игнорируемые при сборке Docker-образа
├── Dockerfile # Инструкции для сборки Docker-образа
├── docker-compose.yml # Конфигурация Docker Compose
├── deploy.sh # Скрипт развёртывания для Linux
├── deploy.cmd # Скрипт развёртывания для Windows
├── entrypoint.sh # Скрипт для запуска в Docker
├── README.md # Основная документация
├── README_DOCKER.md # Документация по Docker-развёртыванию
└── requirements.txt # Зависимости проекта
```
## Безопасность
Бот имеет систему авторизации на основе ID пользователей Telegram. Убедитесь, что указали правильные ID в переменной `ADMIN_USER_IDS` в файле `.env`.
## Устранение неисправностей
### Проблемы с подключением к Synology NAS
1. Убедитесь, что NAS доступен по сети (можно проверить с помощью команды `ping`).
2. Проверьте правильность логина и пароля в `.env`.
3. Убедитесь, что DSM API включено в настройках NAS.
### Проблемы с Docker
1. Проверьте статус контейнера: `docker-compose ps`
2. Просмотрите логи: `docker-compose logs -f`
3. Перезапустите контейнер: `docker-compose restart`
4. Проверьте состояние здоровья: `docker inspect --format="{{json .State.Health}}" synology-power-control-bot`
5. Проверьте, что все переменные окружения корректно переданы в контейнер.
### Обновление в Docker
Для обновления бота в Docker:
1. Остановите контейнеры:
```bash
docker-compose down
```
2. Загрузите обновления (если используете Git):
```bash
git pull
```
3. Запустите контейнеры заново:
```bash
docker-compose up -d --build
```
## Лицензия
MIT

View File

@@ -1,106 +0,0 @@
# Synology Power Control Bot - Docker Deployment
## Подготовка к развертыванию
Перед развертыванием в Docker убедитесь, что:
1. Docker и Docker Compose установлены в вашей системе.
2. Файл `.env` настроен с правильными значениями.
## Структура проекта для Docker
```
synology_power_control_bot/
├── src/ # Исходный код бота
├── logs/ # Папка для логов (будет смонтирована как том)
├── .env # Файл с переменными окружения
├── requirements.txt # Зависимости Python
├── Dockerfile # Инструкции для сборки образа
├── docker-compose.yml # Конфигурация Docker Compose
└── run.py # Точка входа
```
## Настройка переменных окружения
Убедитесь, что файл `.env` содержит все необходимые переменные:
```
# Telegram Bot API
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321 # ID пользователей-администраторов через запятую
# Synology NAS
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000 # Обычно 5000 для HTTP и 5001 для HTTPS
SYNOLOGY_USERNAME=your_username
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=True # Использовать HTTPS
SYNOLOGY_VERIFY_SSL=False # Проверка SSL-сертификата
SYNOLOGY_TIMEOUT=10 # Таймаут для API запросов в секундах
SYNOLOGY_API_VERSION=1 # Версия API
SYNOLOGY_POWER_API=SYNO.Core.System # API для управления питанием
# WOL (Wake-on-LAN)
MAC_ADDRESS=00:11:22:33:44:55 # MAC-адрес Synology NAS
WOL_BROADCAST=255.255.255.255 # Broadcast-адрес для WOL
WOL_PORT=9 # Порт для WOL (обычно 7 или 9)
# Logging
LOG_LEVEL=INFO
```
## Сборка и запуск
### Сборка и запуск контейнеров
```bash
docker-compose up -d --build
```
### Просмотр логов
```bash
docker-compose logs -f
```
### Остановка контейнеров
```bash
docker-compose down
```
## Обновление
Для обновления бота:
1. Остановите контейнеры:
```bash
docker-compose down
```
2. Скачайте последние изменения (если используете Git):
```bash
git pull
```
3. Соберите и запустите контейнеры заново:
```bash
docker-compose up -d --build
```
## Устранение неполадок
### Проверка статуса контейнера
```bash
docker-compose ps
```
### Проверка логов контейнера
```bash
docker-compose logs -f synology-bot
```
### Подключение к контейнеру
```bash
docker-compose exec synology-bot bash
```

View File

@@ -1,106 +0,0 @@
# Synology Power Control Bot - Docker Deployment
## Подготовка к развертыванию
Перед развертыванием в Docker убедитесь, что:
1. Docker и Docker Compose установлены в вашей системе.
2. Файл `.env` настроен с правильными значениями.
## Структура проекта для Docker
```
synology_power_control_bot/
├── src/ # Исходный код бота
├── logs/ # Папка для логов (будет смонтирована как том)
├── .env # Файл с переменными окружения
├── requirements.txt # Зависимости Python
├── Dockerfile # Инструкции для сборки образа
├── docker-compose.yml # Конфигурация Docker Compose
└── run.py # Точка входа
```
## Настройка переменных окружения
Убедитесь, что файл `.env` содержит все необходимые переменные:
```
# Telegram Bot API
TELEGRAM_TOKEN=your_telegram_bot_token
ADMIN_USER_IDS=123456789,987654321 # ID пользователей-администраторов через запятую
# Synology NAS
SYNOLOGY_HOST=192.168.1.100
SYNOLOGY_PORT=5000 # Обычно 5000 для HTTP и 5001 для HTTPS
SYNOLOGY_USERNAME=your_username
SYNOLOGY_PASSWORD=your_password
SYNOLOGY_SECURE=True # Использовать HTTPS
SYNOLOGY_VERIFY_SSL=False # Проверка SSL-сертификата
SYNOLOGY_TIMEOUT=10 # Таймаут для API запросов в секундах
SYNOLOGY_API_VERSION=1 # Версия API
SYNOLOGY_POWER_API=SYNO.Core.System # API для управления питанием
# WOL (Wake-on-LAN)
MAC_ADDRESS=00:11:22:33:44:55 # MAC-адрес Synology NAS
WOL_BROADCAST=255.255.255.255 # Broadcast-адрес для WOL
WOL_PORT=9 # Порт для WOL (обычно 7 или 9)
# Logging
LOG_LEVEL=INFO
```
## Сборка и запуск
### Сборка и запуск контейнеров
```bash
docker-compose up -d --build
```
### Просмотр логов
```bash
docker-compose logs -f
```
### Остановка контейнеров
```bash
docker-compose down
```
## Обновление
Для обновления бота:
1. Остановите контейнеры:
```bash
docker-compose down
```
2. Скачайте последние изменения (если используете Git):
```bash
git pull
```
3. Соберите и запустите контейнеры заново:
```bash
docker-compose up -d --build
```
## Устранение неполадок
### Проверка статуса контейнера
```bash
docker-compose ps
```
### Проверка логов контейнера
```bash
docker-compose logs -f synology-bot
```
### Подключение к контейнеру
```bash
docker-compose exec synology-bot bash
```

View File

@@ -1,49 +0,0 @@
#!/bin/bash
# deploy.sh - Скрипт для развертывания Synology Power Control Bot
# Цвета для вывода
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Проверяем наличие Docker и Docker Compose
echo -e "${YELLOW}Проверка наличия Docker...${NC}"
if ! [ -x "$(command -v docker)" ]; then
echo -e "${RED}Ошибка: Docker не установлен.${NC}" >&2
echo -e "Установите Docker, следуя инструкциям: https://docs.docker.com/get-docker/"
exit 1
fi
echo -e "${YELLOW}Проверка наличия Docker Compose...${NC}"
if ! [ -x "$(command -v docker-compose)" ] && ! [ -x "$(command -v docker compose)" ]; then
echo -e "${RED}Ошибка: Docker Compose не установлен.${NC}" >&2
echo -e "Установите Docker Compose, следуя инструкциям: https://docs.docker.com/compose/install/"
exit 1
fi
# Проверяем наличие файла .env
echo -e "${YELLOW}Проверка файла .env...${NC}"
if [ ! -f ".env" ]; then
echo -e "${RED}Ошибка: Файл .env не найден.${NC}" >&2
echo -e "Создайте файл .env с необходимыми переменными окружения."
exit 1
fi
# Создаем директорию для логов
echo -e "${YELLOW}Создание директории для логов...${NC}"
mkdir -p logs
chmod 777 logs
# Сборка и запуск Docker контейнеров
echo -e "${YELLOW}Сборка и запуск Docker контейнеров...${NC}"
docker-compose down
docker-compose up -d --build
# Проверка статуса контейнеров
echo -e "${YELLOW}Проверка статуса контейнеров...${NC}"
docker-compose ps
echo -e "${GREEN}Развертывание завершено успешно!${NC}"
echo -e "Для просмотра логов: ${YELLOW}docker-compose logs -f${NC}"
echo -e "Для остановки: ${YELLOW}docker-compose down${NC}"

View File

@@ -1,45 +0,0 @@
@echo off
REM deploy.cmd - Скрипт для развертывания Synology Power Control Bot на Windows
echo Проверка наличия Docker...
where docker >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo Ошибка: Docker не установлен.
echo Установите Docker Desktop, следуя инструкциям: https://docs.docker.com/desktop/windows/install/
exit /b 1
)
echo Проверка наличия Docker Compose...
where docker-compose >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
docker compose version >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo Ошибка: Docker Compose не установлен.
echo Установите Docker Desktop, следуя инструкциям: https://docs.docker.com/desktop/windows/install/
exit /b 1
)
)
echo Проверка файла .env...
if not exist .env (
echo Ошибка: Файл .env не найден.
echo Создайте файл .env с необходимыми переменными окружения.
exit /b 1
)
echo Создание директории для логов...
if not exist logs mkdir logs
echo Сборка и запуск Docker контейнеров...
docker-compose down
docker-compose up -d --build
echo Проверка статуса контейнеров...
docker-compose ps
echo.
echo Развертывание завершено успешно!
echo Для просмотра логов: docker-compose logs -f
echo Для остановки: docker-compose down
pause

View File

@@ -1,45 +0,0 @@
@echo off
REM deploy.cmd - Скрипт для развертывания Synology Power Control Bot на Windows
echo Проверка наличия Docker...
where docker >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo Ошибка: Docker не установлен.
echo Установите Docker Desktop, следуя инструкциям: https://docs.docker.com/desktop/windows/install/
exit /b 1
)
echo Проверка наличия Docker Compose...
where docker-compose >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
docker compose version >nul 2>&1
if %ERRORLEVEL% NEQ 0 (
echo Ошибка: Docker Compose не установлен.
echo Установите Docker Desktop, следуя инструкциям: https://docs.docker.com/desktop/windows/install/
exit /b 1
)
)
echo Проверка файла .env...
if not exist .env (
echo Ошибка: Файл .env не найден.
echo Создайте файл .env с необходимыми переменными окружения.
exit /b 1
)
echo Создание директории для логов...
if not exist logs mkdir logs
echo Сборка и запуск Docker контейнеров...
docker-compose down
docker-compose up -d --build
echo Проверка статуса контейнеров...
docker-compose ps
echo.
echo Развертывание завершено успешно!
echo Для просмотра логов: docker-compose logs -f
echo Для остановки: docker-compose down
pause

View File

@@ -1,49 +0,0 @@
#!/bin/bash
# deploy.sh - Скрипт для развертывания Synology Power Control Bot
# Цвета для вывода
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Проверяем наличие Docker и Docker Compose
echo -e "${YELLOW}Проверка наличия Docker...${NC}"
if ! [ -x "$(command -v docker)" ]; then
echo -e "${RED}Ошибка: Docker не установлен.${NC}" >&2
echo -e "Установите Docker, следуя инструкциям: https://docs.docker.com/get-docker/"
exit 1
fi
echo -e "${YELLOW}Проверка наличия Docker Compose...${NC}"
if ! [ -x "$(command -v docker-compose)" ] && ! [ -x "$(command -v docker compose)" ]; then
echo -e "${RED}Ошибка: Docker Compose не установлен.${NC}" >&2
echo -e "Установите Docker Compose, следуя инструкциям: https://docs.docker.com/compose/install/"
exit 1
fi
# Проверяем наличие файла .env
echo -e "${YELLOW}Проверка файла .env...${NC}"
if [ ! -f ".env" ]; then
echo -e "${RED}Ошибка: Файл .env не найден.${NC}" >&2
echo -e "Создайте файл .env с необходимыми переменными окружения."
exit 1
fi
# Создаем директорию для логов
echo -e "${YELLOW}Создание директории для логов...${NC}"
mkdir -p logs
chmod 777 logs
# Сборка и запуск Docker контейнеров
echo -e "${YELLOW}Сборка и запуск Docker контейнеров...${NC}"
docker-compose down
docker-compose up -d --build
# Проверка статуса контейнеров
echo -e "${YELLOW}Проверка статуса контейнеров...${NC}"
docker-compose ps
echo -e "${GREEN}Развертывание завершено успешно!${NC}"
echo -e "Для просмотра логов: ${YELLOW}docker-compose logs -f${NC}"
echo -e "Для остановки: ${YELLOW}docker-compose down${NC}"

View File

@@ -1,91 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Диагностический скрипт для определения совместимых API
"""
import os
import sys
import logging
import argparse
from pathlib import Path
# Добавляем родительскую директорию в sys.path
parent_dir = str(Path(__file__).resolve().parent.parent)
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
from src.api.api_discovery import discover_available_apis
from src.config.config import SYNOLOGY_HOST, SYNOLOGY_PORT, SYNOLOGY_SECURE
# Настройка логгера
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def main():
"""Точка входа для диагностического скрипта"""
parser = argparse.ArgumentParser(description='Synology API Diagnostic Tool')
parser.add_argument('--host', help='Synology host address', default=SYNOLOGY_HOST)
parser.add_argument('--port', type=int, help='Synology host port', default=SYNOLOGY_PORT)
parser.add_argument('--secure', action='store_true', help='Use HTTPS', default=SYNOLOGY_SECURE)
args = parser.parse_args()
protocol = "https" if args.secure else "http"
base_url = f"{protocol}://{args.host}:{args.port}/webapi"
print(f"Scanning APIs at {base_url}...")
apis = discover_available_apis(base_url)
if not apis:
print("No APIs were discovered. Check connection parameters.")
return
print(f"Discovered {len(apis)} APIs")
# Анализ результатов
# 1. Ищем API для управления питанием
print("\nPower Management APIs:")
power_apis = [name for name in apis.keys() if "power" in name.lower()]
for api in power_apis:
info = apis[api]
print(f" - {api} (v{info.get('minVersion', 1)}-{info.get('maxVersion', 1)}), path: {info.get('path', 'entry.cgi')}")
# 2. Ищем API для информации о системе
print("\nSystem Information APIs:")
system_info_apis = [name for name in apis.keys() if ("system" in name.lower() or "dsm" in name.lower()) and "info" in name.lower()]
for api in system_info_apis:
info = apis[api]
print(f" - {api} (v{info.get('minVersion', 1)}-{info.get('maxVersion', 1)}), path: {info.get('path', 'entry.cgi')}")
# 3. Ищем API для перезагрузки
print("\nReboot/Restart APIs:")
reboot_apis = [name for name in apis.keys() if any(word in name.lower() for word in ["restart", "reboot", "power"])]
for api in reboot_apis:
info = apis[api]
print(f" - {api} (v{info.get('minVersion', 1)}-{info.get('maxVersion', 1)}), path: {info.get('path', 'entry.cgi')}")
print("\nRecommended API Settings:")
if power_apis:
recommended_power_api = max(power_apis, key=lambda x: apis[x].get('maxVersion', 1))
print(f"Power API: {recommended_power_api}, version: {apis[recommended_power_api].get('maxVersion', 1)}")
else:
print("Power API: Not found, falling back to SYNO.Core.System")
if system_info_apis:
recommended_info_api = max(system_info_apis, key=lambda x: apis[x].get('maxVersion', 1))
print(f"System Info API: {recommended_info_api}, version: {apis[recommended_info_api].get('maxVersion', 1)}")
else:
print("System Info API: Not found, falling back to SYNO.DSM.Info")
print("\nThese settings should be added to your .env file.")
if __name__ == "__main__":
main()

View File

@@ -1,91 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Диагностический скрипт для определения совместимых API
"""
import os
import sys
import logging
import argparse
from pathlib import Path
# Добавляем родительскую директорию в sys.path
parent_dir = str(Path(__file__).resolve().parent.parent)
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
from src.api.api_discovery import discover_available_apis
from src.config.config import SYNOLOGY_HOST, SYNOLOGY_PORT, SYNOLOGY_SECURE
# Настройка логгера
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def main():
"""Точка входа для диагностического скрипта"""
parser = argparse.ArgumentParser(description='Synology API Diagnostic Tool')
parser.add_argument('--host', help='Synology host address', default=SYNOLOGY_HOST)
parser.add_argument('--port', type=int, help='Synology host port', default=SYNOLOGY_PORT)
parser.add_argument('--secure', action='store_true', help='Use HTTPS', default=SYNOLOGY_SECURE)
args = parser.parse_args()
protocol = "https" if args.secure else "http"
base_url = f"{protocol}://{args.host}:{args.port}/webapi"
print(f"Scanning APIs at {base_url}...")
apis = discover_available_apis(base_url)
if not apis:
print("No APIs were discovered. Check connection parameters.")
return
print(f"Discovered {len(apis)} APIs")
# Анализ результатов
# 1. Ищем API для управления питанием
print("\nPower Management APIs:")
power_apis = [name for name in apis.keys() if "power" in name.lower()]
for api in power_apis:
info = apis[api]
print(f" - {api} (v{info.get('minVersion', 1)}-{info.get('maxVersion', 1)}), path: {info.get('path', 'entry.cgi')}")
# 2. Ищем API для информации о системе
print("\nSystem Information APIs:")
system_info_apis = [name for name in apis.keys() if ("system" in name.lower() or "dsm" in name.lower()) and "info" in name.lower()]
for api in system_info_apis:
info = apis[api]
print(f" - {api} (v{info.get('minVersion', 1)}-{info.get('maxVersion', 1)}), path: {info.get('path', 'entry.cgi')}")
# 3. Ищем API для перезагрузки
print("\nReboot/Restart APIs:")
reboot_apis = [name for name in apis.keys() if any(word in name.lower() for word in ["restart", "reboot", "power"])]
for api in reboot_apis:
info = apis[api]
print(f" - {api} (v{info.get('minVersion', 1)}-{info.get('maxVersion', 1)}), path: {info.get('path', 'entry.cgi')}")
print("\nRecommended API Settings:")
if power_apis:
recommended_power_api = max(power_apis, key=lambda x: apis[x].get('maxVersion', 1))
print(f"Power API: {recommended_power_api}, version: {apis[recommended_power_api].get('maxVersion', 1)}")
else:
print("Power API: Not found, falling back to SYNO.Core.System")
if system_info_apis:
recommended_info_api = max(system_info_apis, key=lambda x: apis[x].get('maxVersion', 1))
print(f"System Info API: {recommended_info_api}, version: {apis[recommended_info_api].get('maxVersion', 1)}")
else:
print("System Info API: Not found, falling back to SYNO.DSM.Info")
print("\nThese settings should be added to your .env file.")
if __name__ == "__main__":
main()

View File

@@ -1,293 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Тестовый скрипт для прямого доступа к API Synology для получения информации о системе.
Используется для отладки и определения совместимых API.
"""
import requests
import logging
import json
import sys
import os
import urllib3
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
# Добавляем корневой каталог в путь для импорта
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.config.config import (
SYNOLOGY_HOST,
SYNOLOGY_PORT,
SYNOLOGY_USERNAME,
SYNOLOGY_PASSWORD,
SYNOLOGY_SECURE
)
# Отключение предупреждений о небезопасных SSL-соединениях
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Настройка логирования
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def direct_api_test():
"""Прямой тест API без использования классов для определения проблемы"""
# Создаем базовую сессию
session = requests.Session()
session.verify = False # Отключаем проверку SSL
# Добавляем повторные попытки для HTTP-запросов
retry_strategy = Retry(
total=3,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST"],
backoff_factor=1.0
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("http://", adapter)
session.mount("https://", adapter)
# Формируем базовый URL
protocol = "https" if SYNOLOGY_SECURE else "http"
base_url = f"{protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/webapi"
logger.info(f"Тестирование прямого API доступа к {base_url}")
# Шаг 1: Авторизация
logger.info("Шаг 1: Попытка авторизации...")
# Сначала получаем информацию об API авторизации
api_info_url = f"{base_url}/entry.cgi"
api_info_params = {
"api": "SYNO.API.Info",
"version": "1",
"method": "query",
"query": "SYNO.API.Auth"
}
try:
auth_info_response = session.get(api_info_url, params=api_info_params, timeout=10)
auth_info_data = auth_info_response.json()
if auth_info_data.get("success"):
auth_info = auth_info_data.get("data", {}).get("SYNO.API.Auth", {})
auth_path = auth_info.get("path", "auth.cgi")
auth_max_version = auth_info.get("maxVersion", 6)
logger.info(f"API авторизации: путь={auth_path}, макс. версия={auth_max_version}")
# Пробуем версию 6 или максимальную доступную
auth_version = min(6, auth_max_version)
# Выполняем авторизацию
auth_url = f"{base_url}/{auth_path}"
auth_params = {
"api": "SYNO.API.Auth",
"version": str(auth_version),
"method": "login",
"account": SYNOLOGY_USERNAME,
"passwd": SYNOLOGY_PASSWORD,
"session": "DirectApiTest",
"format": "cookie"
}
# Для версии 6+ используем немного другой формат
if auth_version >= 6:
auth_params["enable_syno_token"] = "yes"
logger.info(f"Авторизация с использованием SYNO.API.Auth v{auth_version}")
auth_response = session.get(auth_url, params=auth_params, timeout=10)
auth_data = auth_response.json()
if auth_data.get("success"):
sid = auth_data.get("data", {}).get("sid")
logger.info(f"Авторизация успешна! SID: {sid[:10]}...")
# Шаг 2: Тестирование различных API для получения информации о системе
logger.info("Шаг 2: Тестирование различных API для получения информации о системе")
# Создаем список API для тестирования
api_to_test = [
{"name": "SYNO.DSM.Info", "method": "getinfo", "version": 1},
{"name": "SYNO.DSM.Info", "method": "getinfo", "version": 2},
{"name": "SYNO.Core.System", "method": "info", "version": 1},
{"name": "SYNO.Core.System", "method": "info", "version": 2},
{"name": "SYNO.Core.System.Status", "method": "get", "version": 1},
{"name": "SYNO.Core.System.Status", "method": "get", "version": 2},
{"name": "SYNO.Core.System.Utilization", "method": "get", "version": 1},
{"name": "SYNO.Core.CurrentConnection", "method": "list", "version": 1}
]
# Перебираем все API и тестируем их
for api in api_to_test:
# Сначала получаем информацию о конкретном API
try:
api_info_params = {
"api": "SYNO.API.Info",
"version": "1",
"method": "query",
"query": api["name"]
}
api_info_resp = session.get(api_info_url, params=api_info_params, timeout=10)
api_info_data = api_info_resp.json()
if api_info_data.get("success") and api["name"] in api_info_data.get("data", {}):
api_details = api_info_data["data"][api["name"]]
api_path = api_details.get("path", "entry.cgi")
api_min_version = api_details.get("minVersion", 1)
api_max_version = api_details.get("maxVersion", 1)
# Проверяем, поддерживается ли указанная версия
if api["version"] < api_min_version:
logger.warning(f"{api['name']} v{api['version']} ниже минимальной {api_min_version}, используем {api_min_version}")
test_version = api_min_version
elif api["version"] > api_max_version:
logger.warning(f"{api['name']} v{api['version']} выше максимальной {api_max_version}, используем {api_max_version}")
test_version = api_max_version
else:
test_version = api["version"]
# Выполняем запрос API
test_url = f"{base_url}/{api_path}"
test_params = {
"api": api["name"],
"version": str(test_version),
"method": api["method"],
"_sid": sid # Используем sid для аутентификации
}
logger.info(f"Тестирование {api['name']}.{api['method']} v{test_version}")
test_response = session.get(test_url, params=test_params, timeout=10)
test_data = test_response.json()
if test_data.get("success"):
logger.info(f"API {api['name']}.{api['method']} v{test_version} РАБОТАЕТ!")
logger.info(f"Результат: {json.dumps(test_data.get('data', {}), indent=2)[:200]}...")
else:
error_code = test_data.get("error", {}).get("code", -1)
logger.error(f"API {api['name']}.{api['method']} v{test_version} ОШИБКА: {error_code}")
# Если ошибка связана с сессией, попробуем еще раз авторизоваться
if error_code == 119: # Session timeout
logger.info("Повторная авторизация из-за ошибки 119...")
# Создаем новую сессию
new_session = requests.Session()
new_session.verify = False
auth_response = new_session.get(auth_url, params=auth_params, timeout=10)
auth_data = auth_response.json()
if auth_data.get("success"):
new_sid = auth_data.get("data", {}).get("sid")
logger.info(f"Повторная авторизация успешна! Новый SID: {new_sid[:10]}...")
# Пробуем запрос с новым SID
test_params["_sid"] = new_sid
logger.info(f"Повторное тестирование {api['name']}.{api['method']} v{test_version}")
test_response = new_session.get(test_url, params=test_params, timeout=10)
test_data = test_response.json()
if test_data.get("success"):
logger.info(f"API {api['name']}.{api['method']} v{test_version} теперь РАБОТАЕТ!")
logger.info(f"Результат с новой сессией: {json.dumps(test_data.get('data', {}), indent=2)[:200]}...")
else:
error_code = test_data.get("error", {}).get("code", -1)
logger.error(f"API {api['name']}.{api['method']} v{test_version} ВСЕ ЕЩЕ С ОШИБКОЙ: {error_code}")
else:
logger.warning(f"API {api['name']} не найден в информации API")
except Exception as e:
logger.error(f"Ошибка при тестировании {api['name']}.{api['method']} v{api['version']}: {str(e)}")
# Шаг 3: Тестирование комбинации запросов для решения проблемы
logger.info("Шаг 3: Тестирование комбинации запросов для решения проблемы")
# Создаем новую сессию для каждого запроса
for api in [{"name": "SYNO.DSM.Info", "method": "getinfo", "version": 1}]:
try:
fresh_session = requests.Session()
fresh_session.verify = False
# Авторизуемся
auth_response = fresh_session.get(auth_url, params=auth_params, timeout=10)
auth_data = auth_response.json()
if auth_data.get("success"):
fresh_sid = auth_data.get("data", {}).get("sid")
logger.info(f"Авторизация в новой сессии успешна! SID: {fresh_sid[:10]}...")
# Сразу же делаем запрос для получения информации в той же сессии
test_params = {
"api": api["name"],
"version": str(api["version"]),
"method": api["method"],
"_sid": fresh_sid
}
test_url = f"{base_url}/entry.cgi" # Используем entry.cgi по умолчанию
logger.info(f"Тест в свежей сессии: {api['name']}.{api['method']} v{api['version']}")
test_response = fresh_session.get(test_url, params=test_params, timeout=10)
test_data = test_response.json()
if test_data.get("success"):
logger.info(f"API в свежей сессии РАБОТАЕТ!")
logger.info(f"Результат: {json.dumps(test_data.get('data', {}), indent=2)[:200]}...")
else:
error_code = test_data.get("error", {}).get("code", -1)
logger.error(f"API в свежей сессии ОШИБКА: {error_code}")
except Exception as e:
logger.error(f"Ошибка при тестировании свежей сессии: {str(e)}")
# Шаг 4: Получаем информацию об остальных API
logger.info("Шаг 4: Получаем информацию о доступных API для уточнения проблемы")
# Запрашиваем все API из SYNO.API.Info
try:
all_api_params = {
"api": "SYNO.API.Info",
"version": "1",
"method": "query",
"query": "all"
}
all_api_response = session.get(api_info_url, params=all_api_params, timeout=15) # Больший таймаут для большого ответа
all_api_data = all_api_response.json()
if all_api_data.get("success"):
api_list = all_api_data.get("data", {})
logger.info(f"Получен список всех API. Найдено {len(api_list)} API.")
# Ищем интересующие нас API для отладки
interested_in = ["SYNO.DSM.Info", "SYNO.Core.System", "SYNO.Core.Hardware",
"SYNO.Core.System.Status", "SYNO.API.Auth"]
logger.info("Информация о важных API:")
for api_name in interested_in:
if api_name in api_list:
logger.info(f"{api_name}: {api_list[api_name]}")
else:
logger.warning(f"API {api_name} не найден")
else:
logger.error("Не удалось получить список всех API")
except Exception as e:
logger.error(f"Ошибка при получении списка API: {str(e)}")
else:
error_code = auth_data.get("error", {}).get("code", -1)
logger.error(f"Авторизация не удалась! Код ошибки: {error_code}")
else:
logger.error("Не удалось получить информацию об API авторизации")
except Exception as e:
logger.error(f"Произошла ошибка при выполнении теста: {str(e)}")
if __name__ == "__main__":
logger.info("Запуск прямого теста API Synology")
direct_api_test()

View File

@@ -1,293 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Тестовый скрипт для прямого доступа к API Synology для получения информации о системе.
Используется для отладки и определения совместимых API.
"""
import requests
import logging
import json
import sys
import os
import urllib3
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
# Добавляем корневой каталог в путь для импорта
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.config.config import (
SYNOLOGY_HOST,
SYNOLOGY_PORT,
SYNOLOGY_USERNAME,
SYNOLOGY_PASSWORD,
SYNOLOGY_SECURE
)
# Отключение предупреждений о небезопасных SSL-соединениях
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Настройка логирования
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
def direct_api_test():
"""Прямой тест API без использования классов для определения проблемы"""
# Создаем базовую сессию
session = requests.Session()
session.verify = False # Отключаем проверку SSL
# Добавляем повторные попытки для HTTP-запросов
retry_strategy = Retry(
total=3,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST"],
backoff_factor=1.0
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("http://", adapter)
session.mount("https://", adapter)
# Формируем базовый URL
protocol = "https" if SYNOLOGY_SECURE else "http"
base_url = f"{protocol}://{SYNOLOGY_HOST}:{SYNOLOGY_PORT}/webapi"
logger.info(f"Тестирование прямого API доступа к {base_url}")
# Шаг 1: Авторизация
logger.info("Шаг 1: Попытка авторизации...")
# Сначала получаем информацию об API авторизации
api_info_url = f"{base_url}/entry.cgi"
api_info_params = {
"api": "SYNO.API.Info",
"version": "1",
"method": "query",
"query": "SYNO.API.Auth"
}
try:
auth_info_response = session.get(api_info_url, params=api_info_params, timeout=10)
auth_info_data = auth_info_response.json()
if auth_info_data.get("success"):
auth_info = auth_info_data.get("data", {}).get("SYNO.API.Auth", {})
auth_path = auth_info.get("path", "auth.cgi")
auth_max_version = auth_info.get("maxVersion", 6)
logger.info(f"API авторизации: путь={auth_path}, макс. версия={auth_max_version}")
# Пробуем версию 6 или максимальную доступную
auth_version = min(6, auth_max_version)
# Выполняем авторизацию
auth_url = f"{base_url}/{auth_path}"
auth_params = {
"api": "SYNO.API.Auth",
"version": str(auth_version),
"method": "login",
"account": SYNOLOGY_USERNAME,
"passwd": SYNOLOGY_PASSWORD,
"session": "DirectApiTest",
"format": "cookie"
}
# Для версии 6+ используем немного другой формат
if auth_version >= 6:
auth_params["enable_syno_token"] = "yes"
logger.info(f"Авторизация с использованием SYNO.API.Auth v{auth_version}")
auth_response = session.get(auth_url, params=auth_params, timeout=10)
auth_data = auth_response.json()
if auth_data.get("success"):
sid = auth_data.get("data", {}).get("sid")
logger.info(f"Авторизация успешна! SID: {sid[:10]}...")
# Шаг 2: Тестирование различных API для получения информации о системе
logger.info("Шаг 2: Тестирование различных API для получения информации о системе")
# Создаем список API для тестирования
api_to_test = [
{"name": "SYNO.DSM.Info", "method": "getinfo", "version": 1},
{"name": "SYNO.DSM.Info", "method": "getinfo", "version": 2},
{"name": "SYNO.Core.System", "method": "info", "version": 1},
{"name": "SYNO.Core.System", "method": "info", "version": 2},
{"name": "SYNO.Core.System.Status", "method": "get", "version": 1},
{"name": "SYNO.Core.System.Status", "method": "get", "version": 2},
{"name": "SYNO.Core.System.Utilization", "method": "get", "version": 1},
{"name": "SYNO.Core.CurrentConnection", "method": "list", "version": 1}
]
# Перебираем все API и тестируем их
for api in api_to_test:
# Сначала получаем информацию о конкретном API
try:
api_info_params = {
"api": "SYNO.API.Info",
"version": "1",
"method": "query",
"query": api["name"]
}
api_info_resp = session.get(api_info_url, params=api_info_params, timeout=10)
api_info_data = api_info_resp.json()
if api_info_data.get("success") and api["name"] in api_info_data.get("data", {}):
api_details = api_info_data["data"][api["name"]]
api_path = api_details.get("path", "entry.cgi")
api_min_version = api_details.get("minVersion", 1)
api_max_version = api_details.get("maxVersion", 1)
# Проверяем, поддерживается ли указанная версия
if api["version"] < api_min_version:
logger.warning(f"{api['name']} v{api['version']} ниже минимальной {api_min_version}, используем {api_min_version}")
test_version = api_min_version
elif api["version"] > api_max_version:
logger.warning(f"{api['name']} v{api['version']} выше максимальной {api_max_version}, используем {api_max_version}")
test_version = api_max_version
else:
test_version = api["version"]
# Выполняем запрос API
test_url = f"{base_url}/{api_path}"
test_params = {
"api": api["name"],
"version": str(test_version),
"method": api["method"],
"_sid": sid # Используем sid для аутентификации
}
logger.info(f"Тестирование {api['name']}.{api['method']} v{test_version}")
test_response = session.get(test_url, params=test_params, timeout=10)
test_data = test_response.json()
if test_data.get("success"):
logger.info(f"API {api['name']}.{api['method']} v{test_version} РАБОТАЕТ!")
logger.info(f"Результат: {json.dumps(test_data.get('data', {}), indent=2)[:200]}...")
else:
error_code = test_data.get("error", {}).get("code", -1)
logger.error(f"API {api['name']}.{api['method']} v{test_version} ОШИБКА: {error_code}")
# Если ошибка связана с сессией, попробуем еще раз авторизоваться
if error_code == 119: # Session timeout
logger.info("Повторная авторизация из-за ошибки 119...")
# Создаем новую сессию
new_session = requests.Session()
new_session.verify = False
auth_response = new_session.get(auth_url, params=auth_params, timeout=10)
auth_data = auth_response.json()
if auth_data.get("success"):
new_sid = auth_data.get("data", {}).get("sid")
logger.info(f"Повторная авторизация успешна! Новый SID: {new_sid[:10]}...")
# Пробуем запрос с новым SID
test_params["_sid"] = new_sid
logger.info(f"Повторное тестирование {api['name']}.{api['method']} v{test_version}")
test_response = new_session.get(test_url, params=test_params, timeout=10)
test_data = test_response.json()
if test_data.get("success"):
logger.info(f"API {api['name']}.{api['method']} v{test_version} теперь РАБОТАЕТ!")
logger.info(f"Результат с новой сессией: {json.dumps(test_data.get('data', {}), indent=2)[:200]}...")
else:
error_code = test_data.get("error", {}).get("code", -1)
logger.error(f"API {api['name']}.{api['method']} v{test_version} ВСЕ ЕЩЕ С ОШИБКОЙ: {error_code}")
else:
logger.warning(f"API {api['name']} не найден в информации API")
except Exception as e:
logger.error(f"Ошибка при тестировании {api['name']}.{api['method']} v{api['version']}: {str(e)}")
# Шаг 3: Тестирование комбинации запросов для решения проблемы
logger.info("Шаг 3: Тестирование комбинации запросов для решения проблемы")
# Создаем новую сессию для каждого запроса
for api in [{"name": "SYNO.DSM.Info", "method": "getinfo", "version": 1}]:
try:
fresh_session = requests.Session()
fresh_session.verify = False
# Авторизуемся
auth_response = fresh_session.get(auth_url, params=auth_params, timeout=10)
auth_data = auth_response.json()
if auth_data.get("success"):
fresh_sid = auth_data.get("data", {}).get("sid")
logger.info(f"Авторизация в новой сессии успешна! SID: {fresh_sid[:10]}...")
# Сразу же делаем запрос для получения информации в той же сессии
test_params = {
"api": api["name"],
"version": str(api["version"]),
"method": api["method"],
"_sid": fresh_sid
}
test_url = f"{base_url}/entry.cgi" # Используем entry.cgi по умолчанию
logger.info(f"Тест в свежей сессии: {api['name']}.{api['method']} v{api['version']}")
test_response = fresh_session.get(test_url, params=test_params, timeout=10)
test_data = test_response.json()
if test_data.get("success"):
logger.info(f"API в свежей сессии РАБОТАЕТ!")
logger.info(f"Результат: {json.dumps(test_data.get('data', {}), indent=2)[:200]}...")
else:
error_code = test_data.get("error", {}).get("code", -1)
logger.error(f"API в свежей сессии ОШИБКА: {error_code}")
except Exception as e:
logger.error(f"Ошибка при тестировании свежей сессии: {str(e)}")
# Шаг 4: Получаем информацию об остальных API
logger.info("Шаг 4: Получаем информацию о доступных API для уточнения проблемы")
# Запрашиваем все API из SYNO.API.Info
try:
all_api_params = {
"api": "SYNO.API.Info",
"version": "1",
"method": "query",
"query": "all"
}
all_api_response = session.get(api_info_url, params=all_api_params, timeout=15) # Больший таймаут для большого ответа
all_api_data = all_api_response.json()
if all_api_data.get("success"):
api_list = all_api_data.get("data", {})
logger.info(f"Получен список всех API. Найдено {len(api_list)} API.")
# Ищем интересующие нас API для отладки
interested_in = ["SYNO.DSM.Info", "SYNO.Core.System", "SYNO.Core.Hardware",
"SYNO.Core.System.Status", "SYNO.API.Auth"]
logger.info("Информация о важных API:")
for api_name in interested_in:
if api_name in api_list:
logger.info(f"{api_name}: {api_list[api_name]}")
else:
logger.warning(f"API {api_name} не найден")
else:
logger.error("Не удалось получить список всех API")
except Exception as e:
logger.error(f"Ошибка при получении списка API: {str(e)}")
else:
error_code = auth_data.get("error", {}).get("code", -1)
logger.error(f"Авторизация не удалась! Код ошибки: {error_code}")
else:
logger.error("Не удалось получить информацию об API авторизации")
except Exception as e:
logger.error(f"Произошла ошибка при выполнении теста: {str(e)}")
if __name__ == "__main__":
logger.info("Запуск прямого теста API Synology")
direct_api_test()

View File

@@ -1,21 +0,0 @@
version: '3.8'
services:
synology-bot:
build:
context: .
dockerfile: Dockerfile
container_name: synology-power-control-bot
restart: unless-stopped
env_file:
- .env
volumes:
- ./logs:/app/logs
# Если у вас есть файлы конфигурации или данные, которые нужно сохранять:
# - ./data:/app/data
networks:
- bot-network
networks:
bot-network:
driver: bridge

View File

@@ -1,36 +0,0 @@
version: '3.8'
services:
synology-bot:
build:
context: .
dockerfile: Dockerfile
container_name: synology-power-control-bot
restart: unless-stopped
env_file:
- .env
volumes:
- ./logs:/app/logs
# Если у вас есть файлы конфигурации или данные, которые нужно сохранять:
# - ./data:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8080/health', timeout=5)"]
interval: 60s
timeout: 10s
retries: 3
start_period: 20s
networks:
- bot-network
# Для ограничения ресурсов (раскомментируйте и настройте при необходимости):
# deploy:
# resources:
# limits:
# cpus: '0.50'
# memory: 512M
# reservations:
# cpus: '0.25'
# memory: 256M
networks:
bot-network:
driver: bridge

View File

@@ -1,38 +0,0 @@
version: '3.8'
services:
synology-bot:
build:
context: .
dockerfile: Dockerfile
container_name: synology-power-control-bot
restart: unless-stopped
env_file:
- .env
environment:
- DOCKER_ENV=true
volumes:
- ./logs:/app/logs
# Если у вас есть файлы конфигурации или данные, которые нужно сохранять:
# - ./data:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8080/health', timeout=5)"]
interval: 60s
timeout: 10s
retries: 3
start_period: 20s
networks:
- bot-network
# Для ограничения ресурсов (раскомментируйте и настройте при необходимости):
# deploy:
# resources:
# limits:
# cpus: '0.50'
# memory: 512M
# reservations:
# cpus: '0.25'
# memory: 256M
networks:
bot-network:
driver: bridge

View File

@@ -1,38 +0,0 @@
version: '3.8'
services:
synology-bot:
build:
context: .
dockerfile: Dockerfile
container_name: synology-power-control-bot
restart: unless-stopped
env_file:
- .env
environment:
- DOCKER_ENV=true
volumes:
- ./logs:/app/logs
# Если у вас есть файлы конфигурации или данные, которые нужно сохранять:
# - ./data:/app/data
healthcheck:
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8080/health', timeout=5)"]
interval: 60s
timeout: 10s
retries: 3
start_period: 20s
networks:
- bot-network
# Для ограничения ресурсов (раскомментируйте и настройте при необходимости):
# deploy:
# resources:
# limits:
# cpus: '0.50'
# memory: 512M
# reservations:
# cpus: '0.25'
# memory: 256M
networks:
bot-network:
driver: bridge

View File

@@ -1,61 +0,0 @@
# Агент файлового менеджера для 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

@@ -1,61 +0,0 @@
# Агент файлового менеджера для 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

@@ -1,7 +0,0 @@
#!/bin/sh
# Создаем директорию для логов, если она не существует
mkdir -p /app/logs
# Запускаем бота
exec python /app/run.py

View File

@@ -1,7 +0,0 @@
#!/bin/sh
# Создаем директорию для логов, если она не существует
mkdir -p /app/logs
# Запускаем бота
exec python /app/run.py

View File

@@ -1,52 +0,0 @@
#!/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

@@ -1,52 +0,0 @@
#!/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

@@ -1,4 +0,0 @@
python-telegram-bot>=20.0
requests>=2.28.0
python-dotenv>=1.0.0
urllib3>=2.0.0

View File

@@ -1,4 +0,0 @@
python-telegram-bot>=20.0
requests>=2.28.0
python-dotenv>=1.0.0
urllib3>=2.0.0

View File

@@ -1,5 +0,0 @@
python-telegram-bot>=20.0
requests>=2.28.0
python-dotenv>=1.0.0
urllib3>=2.0.0
python-synology>=0.4.0

View File

@@ -1,5 +0,0 @@
python-telegram-bot>=20.0
requests>=2.28.0
python-dotenv>=1.0.0
urllib3>=2.0.0
python-synology>=0.4.0

View File

@@ -1,4 +0,0 @@
python-telegram-bot>=20.0
requests>=2.28.0
python-dotenv>=1.0.0
urllib3>=2.0.0

View File

@@ -1,4 +0,0 @@
python-telegram-bot>=20.0
requests>=2.28.0
python-dotenv>=1.0.0
urllib3>=2.0.0

View File

@@ -1,6 +0,0 @@
python-telegram-bot>=20.0
requests>=2.28.0
python-dotenv>=1.0.0
urllib3>=2.0.0
aiohttp>=3.8.4
async-timeout>=4.0.2

View File

@@ -1,6 +0,0 @@
python-telegram-bot>=20.0
requests>=2.28.0
python-dotenv>=1.0.0
urllib3>=2.0.0
aiohttp>=3.8.4
async-timeout>=4.0.2

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Точка входа для запуска телеграм-бота
"""
import os
from src.bot import main
from src.healthcheck import start_health_server
if __name__ == "__main__":
# Запускаем healthcheck сервер в Docker-окружении
if os.environ.get("DOCKER_ENV", "False").lower() == "true":
start_health_server()
# Запускаем основной бот
main()

View File

@@ -1,18 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Точка входа для запуска телеграм-бота
"""
import os
from src.bot import main
from src.healthcheck import start_health_server
if __name__ == "__main__":
# Запускаем healthcheck сервер в Docker-окружении
if os.environ.get("DOCKER_ENV", "False").lower() == "true":
start_health_server()
# Запускаем основной бот
main()

View File

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

View File

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

View File

@@ -1,13 +0,0 @@
#!/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

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

View File

@@ -1,9 +0,0 @@
#!/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

@@ -1,9 +0,0 @@
#!/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

@@ -1,653 +0,0 @@
#!/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

@@ -1,653 +0,0 @@
#!/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

@@ -1,693 +0,0 @@
#!/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

@@ -1,743 +0,0 @@
#!/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

@@ -1,750 +0,0 @@
#!/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

@@ -1,750 +0,0 @@
#!/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

@@ -1,751 +0,0 @@
#!/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

@@ -1,756 +0,0 @@
#!/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

@@ -1,757 +0,0 @@
#!/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

@@ -1,757 +0,0 @@
#!/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

@@ -1,757 +0,0 @@
#!/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

@@ -1,757 +0,0 @@
#!/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

@@ -1,760 +0,0 @@
#!/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

@@ -1,763 +0,0 @@
#!/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

@@ -1,766 +0,0 @@
#!/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

@@ -1,769 +0,0 @@
#!/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
)

Some files were not shown because too many files have changed in this diff Show More