2025-06-23 18:25:45 +03:00

387 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logging
import os
from datetime import datetime
# Импорты для Telegram Bot API
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
filters,
ContextTypes,
CallbackQueryHandler,
)
# Импорты для qBittorrent API
from qbittorrentapi import Client, APIError
# --- Настройка логирования ---
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
)
logger = logging.getLogger(__name__)
# --- Переменные окружения для qBittorrent ---
# Важно: эти переменные должны быть установлены в Portainer
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
QBT_HOST = os.getenv("QBT_HOST")
QBT_PORT = os.getenv("QBT_PORT")
QBT_USERNAME = os.getenv("QBT_USERNAME")
QBT_PASSWORD = os.getenv("QBT_PASSWORD")
# --- Глобальная переменная для qBittorrent клиента ---
qb = None
# --- Инициализация qBittorrent клиента ---
def init_qbittorrent_client():
global qb
# Проверяем, что все необходимые переменные окружения установлены
if not all([QBT_HOST, QBT_PORT, QBT_USERNAME, QBT_PASSWORD]):
logger.error("QBittorrent credentials (host, port, username, password) are not fully set in environment variables.")
return False
try:
# Используем qbittorrentapi: передаем учетные данные прямо в конструктор Client
# host ожидает строку в формате "IP:PORT"
qb = Client(
host=f"{QBT_HOST}:{QBT_PORT}",
username=QBT_USERNAME,
password=QBT_PASSWORD
)
# Проверяем подключение, обращаясь к версии приложения qBittorrent.
# Это неявно проверяет соединение и аутентификацию.
qb_version = qb.app.version
logger.info(f"Connected to qBittorrent API v{qb_version} on {QBT_HOST}:{QBT_PORT}")
return True
except APIError as e:
logger.error(f"Failed to connect or login to qBittorrent: {e}. Check your qBittorrent Web UI address and credentials.")
qb = None
return False
except Exception as e:
logger.error(f"An unexpected error occurred during qBittorrent connection: {e}")
qb = None
return False
# --- Команда /start ---
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Защитная проверка на наличие объекта сообщения
if update.message is None:
logger.warning("Received an update without a message object in start handler.")
return
logger.info(f"Received /start command from {update.effective_user.id}")
# Попытка инициализации клиента qBittorrent
if not init_qbittorrent_client():
await update.message.reply_text(
"Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера."
)
return
# Отправка приветственного сообщения
await update.message.reply_text(
"Привет! Я бот для управления qBittorrent.\n"
"Отправь мне magnet-ссылку или URL torrent-файла, "
"чтобы добавить загрузку.\n"
"Используй /status для просмотра текущих загрузок."
)
# --- Команда /status ---
async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Защитная проверка на наличие объекта сообщения
if update.message is None:
logger.warning("Received an update without a message object in status handler.")
return
logger.info(f"Received /status command from {update.effective_user.id}")
# Попытка инициализации клиента qBittorrent
if not init_qbittorrent_client():
await update.message.reply_text(
"Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера."
)
return
try:
# Получаем информацию о торрентах
torrents = qb.torrents_info()
if not torrents:
await update.message.reply_text("Загрузок не найдено.")
return
status_messages = []
for torrent in torrents:
# Преобразование скорости из B/s в KB/s или MB/s
download_speed = torrent.dlspeed / (1024 * 1024) if torrent.dlspeed >= (1024 * 1024) else torrent.dlspeed / 1024
upload_speed = torrent.upspeed / (1024 * 1024) if torrent.upspeed >= (1024 * 1024) else torrent.upspeed / 1024
dl_unit = "MB/s" if torrent.dlspeed >= (1024 * 1024) else "KB/s"
up_unit = "MB/s" if torrent.upspeed >= (1024 * 1024) else "KB/s"
# Форматирование времени выполнения (eta - Estimated Time of Arrival)
eta_str = ""
if torrent.eta == 8640000: # qBittorrent's way of saying infinite
eta_str = ""
elif torrent.eta > 0:
hours, remainder = divmod(torrent.eta, 3600)
minutes, seconds = divmod(remainder, 60)
if hours > 0:
eta_str = f"{int(hours)}ч {int(minutes)}мин"
elif minutes > 0:
eta_str = f"{int(minutes)}мин {int(seconds)}с"
else:
eta_str = f"{int(seconds)}с"
else:
eta_str = "Завершено"
status_messages.append(
f"📊 *{torrent.name}*\n"
f" Состояние: {torrent.state}\n"
f" Прогресс: {torrent.progress:.2%}\n"
f" ⬇️ {download_speed:.2f} {dl_unit} ⬆️ {upload_speed:.2f} {up_unit}\n"
f" ETA: {eta_str}\n"
f" Размер: {(torrent.size / (1024*1024*1024)):.2f} ГБ" # Convert bytes to GB
)
# Отправляем сообщения о статусе торрентов
await update.message.reply_text(
"\n\n".join(status_messages), parse_mode="Markdown"
)
except APIError as e:
logger.error(f"Error getting torrent status: {e}")
await update.message.reply_text(f"Ошибка при получении статуса торрентов: {e}")
except Exception as e:
logger.error(f"An unexpected error occurred in status command: {e}")
await update.message.reply_text(f"Произошла непредвиденная ошибка: {e}")
# --- Обработка magnet-ссылок и URL ---
async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Защитная проверка на наличие объекта сообщения
if update.message is None:
logger.warning("Received an update without a message object in handle_url handler.")
return
text = update.message.text
logger.info(f"Received URL/Magnet: {text} from {update.effective_user.id}")
# Попытка инициализации клиента qBittorrent
if not init_qbittorrent_client():
await update.message.reply_text(
"Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера."
)
return
try:
# Сохраняем URL торрента в user_data контекста, чтобы использовать его позже
context.user_data['current_torrent_url'] = text
# Запрашиваем категории
categories = qb.categories.info()
category_keyboard = []
for category_name in categories.keys():
category_keyboard.append([InlineKeyboardButton(category_name, callback_data=f"select_category_{category_name}")])
category_keyboard.append([InlineKeyboardButton("Без категории", callback_data="select_category_no_category")])
reply_markup = InlineKeyboardMarkup(category_keyboard)
await update.message.reply_text('Выберите категорию для загрузки:', reply_markup=reply_markup)
except APIError as e:
logger.error(f"Error fetching categories: {e}")
await update.message.reply_text(f"Ошибка при получении категорий: {e}")
except Exception as e:
logger.error(f"An unexpected error occurred in handle_url: {e}")
await update.message.reply_text(f"Произошла непредвиденная ошибка: {e}")
# --- Обработка выбора категории ---
async def select_category_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
data = query.data.split('_')
# action = data[0] # select
# type = data[1] # category
selected_category = data[2] if len(data) > 2 and data[2] != 'no_category' else None
logger.info(f"Selected category: {selected_category}")
# Сохраняем выбранную категорию
context.user_data['selected_category'] = selected_category
# Теперь предложим выбор директорий
await query.edit_message_text("Выберите директорию загрузки:")
await send_directory_options(query, context)
# --- Отправка опций директорий ---
async def send_directory_options(query, context):
# Попытка инициализации клиента qBittorrent
if not init_qbittorrent_client():
await query.edit_message_text(
"Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера."
)
return
try:
# !!! ВНИМАНИЕ: qBittorrent API не предоставляет список "доступных" директорий автоматически.
# Вам нужно будет вручную указать те директории, которые вы хотите предложить пользователю.
# ЗАМЕНИТЕ ЭТОТ СПИСОК СВОИМИ АКТУАЛЬНЫМИ ПУТЯМИ!
# Пример:
available_paths = [
qb.app.default_save_path, # Дефолтный путь qBittorrent, например: /downloads/complete
"/share/Data/Films",
"/share/Data/Serials",
"/share/Data/Music",
"/share/Data/torrents",
# Добавьте здесь другие пути, если они у вас есть
]
# Удаляем дубликаты и пустые пути, нормализуем слеши для отображения
available_paths = sorted(list(set([p.replace(os.sep, '/') for p in available_paths if p])))
directory_keyboard = []
for path in available_paths:
# Для отображения: используем basename, если путь очень длинный, или просто путь
display_path = os.path.basename(path) if len(path) > 30 else path
directory_keyboard.append([InlineKeyboardButton(display_path, callback_data=f"select_dir_{path}")])
reply_markup = InlineKeyboardMarkup(directory_keyboard)
await query.edit_message_reply_markup(reply_markup=reply_markup) # Изменяем только разметку, чтобы сообщение осталось
except APIError as e:
logger.error(f"Error fetching default save path: {e}")
await query.edit_message_text(f"Ошибка при получении директорий сохранения: {e}")
except Exception as e:
logger.error(f"An unexpected error occurred in send_directory_options: {e}")
await query.edit_message_text(f"Произошла непредвиденная ошибка: {e}")
# --- Обработка выбора директории и добавление торрента ---
async def select_directory_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
data = query.data.split('_')
# action = data[0] # select
# type = data[1] # dir
selected_directory = data[2] # выбранный путь
logger.info(f"Selected directory: {selected_directory}")
torrent_url = context.user_data.get('current_torrent_url')
category = context.user_data.get('selected_category')
if not torrent_url:
await query.edit_message_text("Ошибка: URL торрента не найден. Пожалуйста, попробуйте снова.")
return
# Попытка инициализации клиента qBittorrent
if not init_qbittorrent_client():
await query.edit_message_text(
"Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера."
)
return
try:
# Добавление торрента с выбранной категорией и директорией
qb.torrents_add(
urls=torrent_url,
category=category,
save_path=selected_directory # Указываем директорию сохранения
)
await query.edit_message_text(
f"Торрент успешно добавлен в qBittorrent.\n"
f"Категория: {category or 'Без категории'}\n"
f"Директория: {selected_directory}"
)
# Очищаем данные из context.user_data после использования
if 'current_torrent_url' in context.user_data:
del context.user_data['current_torrent_url']
if 'selected_category' in context.user_data:
del context.user_data['selected_category']
except APIError as e:
logger.error(f"Error adding torrent with path: {e}")
await query.edit_message_text(f"Ошибка при добавлении торрента: {e}")
except Exception as e:
logger.error(f"An unexpected error occurred during torrent addition with path: {e}")
await query.edit_message_text(f"Произошла непредвиденная ошибка: {e}")
# --- Обработчик ошибок Telegram Bot API ---
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
logger.error(f"Exception while handling an update: {context.error}")
if update and update.effective_message:
await update.effective_message.reply_text(f"Произошла внутренняя ошибка бота: {context.error}")
# --- Обработчик для неизвестных команд ---
async def unknown_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Защитная проверка на наличие объекта сообщения
if update.message is None:
logger.warning("Received an unknown command update without a message object.")
return
logger.info(f"Received unknown command: {update.message.text} from {update.effective_user.id}")
await update.message.reply_text("Извините, я не понял эту команду.")
# --- Обработчик для любого другого текста (для отладки) ---
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Защитная проверка на наличие объекта сообщения
if update.message is None:
logger.warning(f"Received non-text update in echo handler: {update}")
return
logger.info(f"Received non-command text: {update.message.text} from {update.effective_user.id}")
await update.message.reply_text(f"Вы сказали: {update.message.text}")
# --- Основная функция, запускающая бота ---
def main() -> None:
# Критическая проверка токена бота
if not TELEGRAM_BOT_TOKEN:
logger.critical("TELEGRAM_BOT_TOKEN environment variable is not set. Exiting.")
exit(1)
application = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
# --- Добавление обработчиков команд ---
application.add_handler(CommandHandler("start", start))
application.add_handler(CommandHandler("status", status))
# --- Добавление обработчиков сообщений ---
# Перехватывает URL и Magnet-ссылки
url_regex = r"magnet:\?xt=urn:[a-z0-9]+" # Регулярное выражение для magnet-ссылок
# Обновленное регулярное выражение для URL торрент-файлов
# Включает:
# - обычные .torrent ссылки (например, example.com/file.torrent)
# - /torrent.php?hash= (некоторые трекеры используют это)
# - /download/<ID> (например, d.rutor.info/download/1042274)
# - любые URL, оканчивающиеся на число, которое может быть ID торрента
torrent_url_regex = r"https?://[^\s]+(?:/\d+|/\w+\.torrent|/download/\d+|\/torrent\.php\?hash=)[\S]*"
application.add_handler(MessageHandler(filters.TEXT & (filters.Regex(url_regex) | filters.Regex(torrent_url_regex)), handle_url))
# Добавляем CallbackQueryHandler для кнопок выбора категории
application.add_handler(CallbackQueryHandler(select_category_callback, pattern=r"^select_category_.*"))
# Добавляем CallbackQueryHandler для кнопок выбора директории
application.add_handler(CallbackQueryHandler(select_directory_callback, pattern=r"^select_dir_.*"))
# --- Обработчик для любого другого текста, не являющегося командой (для отладки) ---
# Должен быть перед обработчиком неизвестных команд
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
# --- Обработчик для неизвестных команд (должен быть последним, чтобы не перехватывать другие команды) ---
application.add_handler(MessageHandler(filters.COMMAND, unknown_command))
# --- Добавление обработчика ошибок ---
application.add_error_handler(error_handler)
# --- Запуск бота ---
logger.info("Bot started polling...")
# allowed_updates=Update.ALL_TYPES помогает убедиться, что бот получает все типы обновлений,
# что полезно для отладки, но обычно можно сузить список для продакшена.
application.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()