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: # Получаем список категорий из qBittorrent categories = qb.categories.info() keyboard = [] for category_name in categories.keys(): # Формируем данные для кнопки: "add_URL_CATEGORYNAME" keyboard.append([InlineKeyboardButton(category_name, callback_data=f"add_{text}_{category_name}")]) # Добавляем кнопку для "Без категории" keyboard.append([InlineKeyboardButton("Без категории", callback_data=f"add_{text}_no_category")]) reply_markup = InlineKeyboardMarkup(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}") # --- Обработка нажатий Inline-кнопок --- async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query await query.answer() # Обязательно ответьте на запрос обратного вызова, чтобы он не зависал data = query.data.split('_') action = data[0] torrent_url = data[1] # Если категория не указана (например, "no_category"), устанавливаем ее в None category = data[2] if len(data) > 2 and data[2] != 'no_category' else None logger.info(f"Button callback - Action: {action}, URL: {torrent_url}, Category: {category}") # Попытка инициализации клиента qBittorrent if not init_qbittorrent_client(): await query.edit_message_text( "Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера." ) return if action == 'add': try: # Добавление торрента qb.torrents_add(urls=torrent_url, category=category) await query.edit_message_text(f"Торрент успешно добавлен в qBittorrent (Категория: {category or 'Без категории'})!")