From 9126ec38ac6195124e5460fcef43c862bb7f3045 Mon Sep 17 00:00:00 2001 From: DerrtSML <93052047+DerrtSML@users.noreply.github.com> Date: Mon, 23 Jun 2025 18:35:49 +0300 Subject: [PATCH] Update bot.py --- bot.py | 158 ++++++++++++++++++++++++++++++++------------------------- 1 file changed, 90 insertions(+), 68 deletions(-) diff --git a/bot.py b/bot.py index d05ee12..4bc89d2 100644 --- a/bot.py +++ b/bot.py @@ -23,7 +23,6 @@ logging.basicConfig( 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") @@ -36,20 +35,15 @@ 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 @@ -64,38 +58,34 @@ def init_qbittorrent_client(): # --- Команда /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 для просмотра текущих загрузок.\n" + "Используй /stop_torrent для остановки загрузки." ) # --- Команда /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. Проверьте переменные окружения и доступность сервера." @@ -103,7 +93,6 @@ async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: return try: - # Получаем информацию о торрентах torrents = qb.torrents_info() if not torrents: await update.message.reply_text("Загрузок не найдено.") @@ -111,16 +100,14 @@ async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 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 + if torrent.eta == 8640000: eta_str = "∞" elif torrent.eta > 0: hours, remainder = divmod(torrent.eta, 3600) @@ -134,17 +121,15 @@ async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 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 + f" Размер: {(torrent.size / (1024*1024*1024)):.2f} ГБ" ) - # Отправляем сообщения о статусе торрентов await update.message.reply_text( "\n\n".join(status_messages), parse_mode="Markdown" ) @@ -156,17 +141,81 @@ async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 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: - # Защитная проверка на наличие объекта сообщения +# --- Команда /stop_torrent (новая команда) --- +async def stop_torrent(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.") + logger.warning("Received an update without a message object in stop_torrent handler.") return - text = update.message.text - logger.info(f"Received URL/Magnet: {text} from {update.effective_user.id}") + logger.info(f"Received /stop_torrent command from {update.effective_user.id}") + + if not init_qbittorrent_client(): + await update.message.reply_text( + "Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера." + ) + return + + try: + torrents = qb.torrents_info(status_filter='downloading') # Только активные загрузки + if not torrents: + await update.message.reply_text("Нет активных загрузок для остановки.") + return + + keyboard = [] + for torrent in torrents: + # Используем сокращенный хэш или имя, если хэш слишком длинный для кнопки + display_name = torrent.name + if len(display_name) > 40: # Обрезаем длинные имена + display_name = display_name[:37] + "..." + + # callback_data будет содержать полный хэш торрента + keyboard.append([InlineKeyboardButton(display_name, callback_data=f"stop_hash_{torrent.hash}")]) + + reply_markup = InlineKeyboardMarkup(keyboard) + await update.message.reply_text("Выберите торрент для остановки:", reply_markup=reply_markup) + + except APIError as e: + logger.error(f"Error fetching torrents for stopping: {e}") + await update.message.reply_text(f"Ошибка при получении списка торрентов для остановки: {e}") + except Exception as e: + logger.error(f"An unexpected error occurred in stop_torrent command: {e}") + await update.message.reply_text(f"Произошла непредвиденная ошибка: {e}") + +# --- Обработка кнопки "Остановить торрент" (новая функция) --- +async def stop_torrent_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + await query.answer() # Всегда отвечайте на CallbackQuery + + # Извлекаем хэш торрента из callback_data + torrent_hash = query.data.replace("stop_hash_", "") + logger.info(f"Attempting to stop torrent with hash: {torrent_hash}") + + if not init_qbittorrent_client(): + await query.edit_message_text( + "Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера." + ) + return + + try: + # qBittorrent API метод для остановки торрента + qb.torrents_stop(torrent_hashes=torrent_hash) + await query.edit_message_text(f"Торрент ({torrent_hash[:6]}...) успешно остановлен.") + except APIError as e: + logger.error(f"Error stopping torrent {torrent_hash}: {e}") + await query.edit_message_text(f"Ошибка при остановке торрента: {e}") + except Exception as e: + logger.error(f"An unexpected error occurred during torrent stopping: {e}") + await query.edit_message_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. Проверьте переменные окружения и доступность сервера." @@ -174,13 +223,12 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None return try: - # Сохраняем URL торрента в user_data контекста, чтобы использовать его позже context.user_data['current_torrent_url'] = text # Запрашиваем категории - ИЗМЕНЕНО НА qb.torrents_categories() - categories_dict = qb.torrents_categories() # Это вернет словарь категорий + categories_dict = qb.torrents_categories() category_keyboard = [] - for category_name in categories_dict.keys(): # Итерируем по ключам словаря + for category_name in categories_dict.keys(): category_keyboard.append([InlineKeyboardButton(category_name, callback_data=f"select_category_{category_name}")]) category_keyboard.append([InlineKeyboardButton("Без категории", callback_data="select_category_no_category")]) @@ -202,22 +250,17 @@ async def select_category_callback(update: Update, context: ContextTypes.DEFAULT 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. Проверьте переменные окружения и доступность сервера." @@ -225,29 +268,22 @@ async def send_directory_options(query, context): return try: - # !!! ВНИМАНИЕ: qBittorrent API не предоставляет список "доступных" директорий автоматически. - # Вам нужно будет вручную указать те директории, которые вы хотите предложить пользователю. - # ЗАМЕНИТЕ ЭТОТ СПИСОК СВОИМИ АКТУАЛЬНЫМИ ПУТЯМИ! - # Пример: available_paths = [ - qb.app.default_save_path, # Дефолтный путь qBittorrent, например: /downloads/complete - "/share/Data/torrents", # Пример пути для Linux/NAS + qb.app.default_save_path, + "/share/Data/torrents", # ЗАМЕНИТЕ НА СВОИ АКТУАЛЬНЫЕ ПУТИ "/share/Data/Films", - "/share/Data/Serials", # Ещё один пример пути - # Добавьте здесь свои реальные пути здесь + "/share/Data/Serials", ] - # Удаляем дубликаты и пустые пути, нормализуем слеши для отображения 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) # Изменяем только разметку, чтобы сообщение осталось + await query.edit_message_reply_markup(reply_markup=reply_markup) except APIError as e: logger.error(f"Error fetching default save path: {e}") @@ -263,9 +299,7 @@ async def select_directory_callback(update: Update, context: ContextTypes.DEFAUL await query.answer() data = query.data.split('_') - # action = data[0] # select - # type = data[1] # dir - selected_directory = data[2] # выбранный путь + selected_directory = data[2] logger.info(f"Selected directory: {selected_directory}") @@ -276,7 +310,6 @@ async def select_directory_callback(update: Update, context: ContextTypes.DEFAUL await query.edit_message_text("Ошибка: URL торрента не найден. Пожалуйста, попробуйте снова.") return - # Попытка инициализации клиента qBittorrent if not init_qbittorrent_client(): await query.edit_message_text( "Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера." @@ -284,18 +317,16 @@ async def select_directory_callback(update: Update, context: ContextTypes.DEFAUL return try: - # Добавление торрента с выбранной категорией и директорией qb.torrents_add( urls=torrent_url, category=category, - save_path=selected_directory # Указываем директорию сохранения + 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: @@ -316,7 +347,6 @@ async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> N # --- Обработчик для неизвестных команд --- 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 @@ -325,7 +355,6 @@ async def unknown_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> # --- Обработчик для любого другого текста (для отладки) --- 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 @@ -335,7 +364,6 @@ async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: # --- Основная функция, запускающая бота --- def main() -> None: - # Критическая проверка токена бота if not TELEGRAM_BOT_TOKEN: logger.critical("TELEGRAM_BOT_TOKEN environment variable is not set. Exiting.") exit(1) @@ -345,16 +373,11 @@ def main() -> None: # --- Добавление обработчиков команд --- application.add_handler(CommandHandler("start", start)) application.add_handler(CommandHandler("status", status)) + # Новая команда для остановки торрентов + application.add_handler(CommandHandler("stop_torrent", stop_torrent)) # --- Добавление обработчиков сообщений --- - # Перехватывает URL и Magnet-ссылки - url_regex = r"magnet:\?xt=urn:[a-z0-9]+" # Регулярное выражение для magnet-ссылок - # Обновленное регулярное выражение для URL торрент-файлов - # Включает: - # - обычные .torrent ссылки (например, example.com/file.torrent) - # - /torrent.php?hash= (некоторые трекеры используют это) - # - /download/ (например, d.rutor.info/download/1042274) - # - любые URL, оканчивающиеся на число, которое может быть ID торрента + url_regex = r"magnet:\?xt=urn:[a-z0-9]+" 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)) @@ -363,13 +386,14 @@ def main() -> None: application.add_handler(CallbackQueryHandler(select_category_callback, pattern=r"^select_category_.*")) # Добавляем CallbackQueryHandler для кнопок выбора директории application.add_handler(CallbackQueryHandler(select_directory_callback, pattern=r"^select_dir_.*")) + # Новый CallbackQueryHandler для кнопок остановки торрентов + application.add_handler(CallbackQueryHandler(stop_torrent_callback, pattern=r"^stop_hash_.*")) - # --- Обработчик для любого другого текста, не являющегося командой (для отладки) --- - # Должен быть перед обработчиком неизвестных команд + # --- Обработчик для любого другого текста, не являющегося командой --- application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo)) - # --- Обработчик для неизвестных команд (должен быть последним, чтобы не перехватывать другие команды) --- + # --- Обработчик для неизвестных команд --- application.add_handler(MessageHandler(filters.COMMAND, unknown_command)) # --- Добавление обработчика ошибок --- @@ -377,8 +401,6 @@ def main() -> None: # --- Запуск бота --- logger.info("Bot started polling...") - # allowed_updates=Update.ALL_TYPES помогает убедиться, что бот получает все типы обновлений, - # что полезно для отладки, но обычно можно сузить список для продакшена. application.run_polling(allowed_updates=Update.ALL_TYPES) if __name__ == "__main__":