2025-06-23 17:50:41 +03:00

270 lines
12 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 ---
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
)
# Проверяем подключение, вызывая что-нибудь простое, например, 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}. 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}")
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}")
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 = 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(
"\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}")
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 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]
category = data[2] if len(data) > 2 else None # 'no_category' или имя категории
if category == 'no_category':
category = None
if action == 'add':
logger.info(f"Adding torrent: {torrent_url} to category: {category}")
try:
# Добавление торрента
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 torrent: {e}")
await query.edit_message_text(f"Ошибка при добавлении торрента: {e}")
except Exception as 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))
# --- Добавление обработчиков сообщений ---
# Перехватывает 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__":
main()