Files cleaning
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,7 +19,7 @@ wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
.history
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
- Ограничьте доступ к боту только доверенным пользователям
|
||||
- Рассмотрите возможность запуска на выделенной сети или с дополнительными ограничениями доступа
|
||||
@@ -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
|
||||
- Ограничьте доступ к боту только доверенным пользователям
|
||||
- Рассмотрите возможность запуска на выделенной сети или с дополнительными ограничениями доступа
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 от несанкционированного доступа.
|
||||
@@ -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 от несанкционированного доступа.
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Создаем директорию для логов, если она не существует
|
||||
mkdir -p /app/logs
|
||||
|
||||
# Запускаем бота
|
||||
exec python /app/run.py
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Создаем директорию для логов, если она не существует
|
||||
mkdir -p /app/logs
|
||||
|
||||
# Запускаем бота
|
||||
exec python /app/run.py
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -1,4 +0,0 @@
|
||||
python-telegram-bot>=20.0
|
||||
requests>=2.28.0
|
||||
python-dotenv>=1.0.0
|
||||
urllib3>=2.0.0
|
||||
@@ -1,4 +0,0 @@
|
||||
python-telegram-bot>=20.0
|
||||
requests>=2.28.0
|
||||
python-dotenv>=1.0.0
|
||||
urllib3>=2.0.0
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
python-telegram-bot>=20.0
|
||||
requests>=2.28.0
|
||||
python-dotenv>=1.0.0
|
||||
urllib3>=2.0.0
|
||||
@@ -1,4 +0,0 @@
|
||||
python-telegram-bot>=20.0
|
||||
requests>=2.28.0
|
||||
python-dotenv>=1.0.0
|
||||
urllib3>=2.0.0
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Точка входа для запуска телеграм-бота
|
||||
"""
|
||||
|
||||
from src.bot import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Точка входа для запуска телеграм-бота
|
||||
"""
|
||||
|
||||
from src.bot import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Файл-обёртка для запуска бота из корневой директории
|
||||
"""
|
||||
|
||||
from src.bot import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Файл-обёртка для запуска бота из корневой директории
|
||||
"""
|
||||
|
||||
from src.bot import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Файл-обёртка для запуска бота из корневой директории
|
||||
"""
|
||||
|
||||
from src.bot import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user