Files cleaning
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,7 +19,7 @@ wheels/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
|
.history
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
.venv
|
.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