diff --git a/bot.py b/bot.py index a9d80a0..02961a6 100644 --- a/bot.py +++ b/bot.py @@ -1,67 +1,58 @@ 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, - CallbackQueryHandler, ContextTypes, + CallbackQueryHandler, ) + +# Импорты для qBittorrent API from qbittorrentapi import Client, APIError -# Настройка логирования +# --- Настройка логирования --- logging.basicConfig( - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - level=logging.INFO + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) logger = logging.getLogger(__name__) -# --- Конфигурация (будет загружаться из переменных окружения Docker) --- +# --- Переменные окружения для qBittorrent --- TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") QBT_HOST = os.getenv("QBT_HOST") -QBT_PORT = os.getenv("QBT_PORT", "8080") +QBT_PORT = os.getenv("QBT_PORT") QBT_USERNAME = os.getenv("QBT_USERNAME") QBT_PASSWORD = os.getenv("QBT_PASSWORD") -# Определение доступных директорий (предполагаем, что они известны) -# В реальной системе можно было бы получить список из qBittorrent API -# или настроить их через переменные окружения. -DOWNLOAD_DIRECTORIES = { - "Фильмы": "/share/Data/Felms", - "Сериалы": "/share/Data/Serials", - "Музыка": "/share/Music", - "Другое": "/share/Data/torrents", -} - -# --- Инициализация qBittorrent клиента --- +# --- Глобальная переменная для qBittorrent клиента --- qb = None +# --- Инициализация qBittorrent клиента --- def init_qbittorrent_client(): global qb - # This check is still good, it ensures env vars are set - if not all([QBT_HOST, QBT_USERNAME, QBT_PASSWORD]): - logger.error("QBittorrent credentials are not fully set in environment variables.") + 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: - # --- ИСПОЛЬЗУЙТЕ ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ ЗДЕСЬ --- - # QBT_HOST и QBT_PORT уже должны быть строками из env, - # так что f-string корректно объединит их. - # QBT_USERNAME и QBT_PASSWORD также должны быть строками из env. + # Используем qbittorrentapi: передаем учетные данные прямо в конструктор Client + # host ожидает строку в формате "IP:PORT" qb = Client( - host=f"{QBT_HOST}:{QBT_PORT}", # Используем QBT_HOST и QBT_PORT из переменных окружения - username=QBT_USERNAME, # Используем QBT_USERNAME из переменных окружения - password=QBT_PASSWORD # Используем QBT_PASSWORD из переменных окружения + host=f"{QBT_HOST}:{QBT_PORT}", + username=QBT_USERNAME, + password=QBT_PASSWORD ) - # Проверим подключение, вызвав что-нибудь простое, например, api_version + # Проверяем подключение, вызывая что-нибудь простое, например, api_version # Это также выполняет аутентификацию qb.app.api_version - logger.info(f"Successfully connected to qBittorrent at {QBT_HOST}:{QBT_PORT}") return True except APIError as e: - logger.error(f"Failed to connect or login to qBittorrent: {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: @@ -69,19 +60,20 @@ def init_qbittorrent_client(): 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}") + if not init_qbittorrent_client(): await update.message.reply_text( "Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера." ) return + await update.message.reply_text( "Привет! Я бот для управления qBittorrent.\n" "Отправь мне magnet-ссылку или URL torrent-файла, " @@ -89,110 +81,189 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: "Используй /status для просмотра текущих загрузок." ) +# --- Команда /status --- async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not qb: - if not init_qbittorrent_client(): - await update.message.reply_text( - "Не удалось подключиться к qBittorrent. Попробуйте еще раз или проверьте настройки." - ) - return + 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}") + + if not init_qbittorrent_client(): + await update.message.reply_text( + "Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера." + ) + return try: - torrents = qb.torrents() + torrents = qb.torrents_info() if not torrents: - await update.message.reply_text("Нет активных загрузок.") + await update.message.reply_text("Загрузок не найдено.") return - message = "Текущие загрузки:\n\n" - for t in torrents: - progress = f"{t.progress:.2%}" - size = f"{t.size / (1024*1024*1024):.2f} GB" if t.size else "N/A" - download_speed = f"{t.dlspeed / (1024*1024):.2f} MB/s" - upload_speed = f"{t.upspeed / (1024*1024):.2f} MB/s" + 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 - message += ( - f"📝 Имя: {t.name}\n" - f"📊 Прогресс: {progress}\n" - f"📦 Размер: {size}\n" - f"⬇️ Скорость загрузки: {download_speed}\n" - f"⬆️ Скорость отдачи: {upload_speed}\n" - f"🚦 Статус: {t.state}\n" - f"--- \n" + 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 = f"{torrent.eta} сек." + 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)}мин {int(seconds)}с" + 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}\n" + f" Отдача: {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(message) + + await update.message.reply_text( + "\n\n".join(status_messages), parse_mode="Markdown" + ) except APIError as e: - logger.error(f"Error fetching torrents: {e}") - await update.message.reply_text(f"Ошибка при получении списка загрузок: {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: {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}") + + if not init_qbittorrent_client(): + await update.message.reply_text( + "Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера." + ) + return + + try: + # Получаем список категорий + categories = qb.categories.info() + keyboard = [] + for category_name in categories.keys(): + 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}") -async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not qb: - if not init_qbittorrent_client(): - await update.message.reply_text( - "Не удалось подключиться к qBittorrent. Попробуйте еще раз или проверьте настройки." - ) - return - - url = update.message.text - context.user_data['download_url'] = url - - keyboard = [] - for name, path in DOWNLOAD_DIRECTORIES.items(): - keyboard.append([InlineKeyboardButton(name, callback_data=f"dir_{path}")]) - reply_markup = InlineKeyboardMarkup(keyboard) - - await update.message.reply_text( - f"Вы хотите загрузить: `{url}`\n" - "Выберите директорию для загрузки:", - reply_markup=reply_markup, - parse_mode='Markdown' - ) - async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query - await query.answer() + await query.answer() # Обязательно ответьте на запрос обратного вызова - if query.data.startswith("dir_"): - selected_directory = query.data.replace("dir_", "") - download_url = context.user_data.get('download_url') + data = query.data.split('_') + action = data[0] + torrent_url = data[1] + category = data[2] if len(data) > 2 else None # 'no_category' или имя категории - if not download_url: - await query.edit_message_text("Ошибка: URL для загрузки не найден. Пожалуйста, отправьте ссылку снова.") - return + if category == 'no_category': + category = None + if action == 'add': + logger.info(f"Adding torrent: {torrent_url} to category: {category}") try: - qb.download_from_link(download_url, save_path=selected_directory) - await query.edit_message_text( - f"Загрузка '{download_url}' добавлена в '{selected_directory}'." - ) - logger.info(f"Added download '{download_url}' to '{selected_directory}'") + # Добавление торрента + qb.torrents_add(urls=torrent_url, category=category) + await query.edit_message_text(f"Торрент успешно добавлен в qBittorrent (Категория: {category or 'Без категории'})!") except APIError as e: - logger.error(f"Error adding download: {e}") - await query.edit_message_text(f"Ошибка при добавлении загрузки: {e}") + logger.error(f"Error adding torrent: {e}") + await query.edit_message_text(f"Ошибка при добавлении торрента: {e}") except Exception as e: - logger.error(f"An unexpected error occurred while adding download: {e}") - await query.edit_message_text(f"Произошла непредвиденная ошибка при добавлении загрузки: {e}") + logger.error(f"An unexpected error occurred during torrent addition: {e}") + await query.edit_message_text(f"Произошла непредвиденная ошибка: {e}") +# --- Обработчик ошибок --- +async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: + logger.error(f"Error occurred: {context.error}") + if 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)) - application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url)) + + # --- Добавление обработчиков сообщений --- + # Перехватывает URL и Magnet-ссылки + application.add_handler(MessageHandler(filters.TEXT & (filters.Regex(r"magnet:\?xt=urn:[a-z0-9]+") | filters.Regex(r"https?://[^\s]+(?:\.torrent|\/torrent\.php\?hash=)[\S]*")), handle_url)) + + # Добавьте CallbackQueryHandler для кнопок application.add_handler(CallbackQueryHandler(button_callback)) + # --- Обработчик для любого другого текста, не являющегося командой (для отладки) --- + 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...") application.run_polling(allowed_updates=Update.ALL_TYPES) if __name__ == "__main__": - if not TELEGRAM_BOT_TOKEN: - logger.error("TELEGRAM_BOT_TOKEN is not set. Please set the environment variable.") - exit(1) - if not all([QBT_HOST, QBT_USERNAME, QBT_PASSWORD]): - logger.warning("QBittorrent connection details (QBT_HOST, QBT_USERNAME, QBT_PASSWORD) are not fully set. Bot will attempt to connect on first use of qBittorrent related commands.") main()