Update bot.py

This commit is contained in:
DerrtSML 2025-06-23 18:35:49 +03:00 committed by GitHub
parent 8b887ea1b3
commit 9126ec38ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

158
bot.py
View File

@ -23,7 +23,6 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --- Переменные окружения для qBittorrent --- # --- Переменные окружения для qBittorrent ---
# Важно: эти переменные должны быть установлены в Portainer
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
QBT_HOST = os.getenv("QBT_HOST") QBT_HOST = os.getenv("QBT_HOST")
QBT_PORT = os.getenv("QBT_PORT") QBT_PORT = os.getenv("QBT_PORT")
@ -36,20 +35,15 @@ qb = None
# --- Инициализация qBittorrent клиента --- # --- Инициализация qBittorrent клиента ---
def init_qbittorrent_client(): def init_qbittorrent_client():
global qb global qb
# Проверяем, что все необходимые переменные окружения установлены
if not all([QBT_HOST, QBT_PORT, QBT_USERNAME, QBT_PASSWORD]): 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.") logger.error("QBittorrent credentials (host, port, username, password) are not fully set in environment variables.")
return False return False
try: try:
# Используем qbittorrentapi: передаем учетные данные прямо в конструктор Client
# host ожидает строку в формате "IP:PORT"
qb = Client( qb = Client(
host=f"{QBT_HOST}:{QBT_PORT}", host=f"{QBT_HOST}:{QBT_PORT}",
username=QBT_USERNAME, username=QBT_USERNAME,
password=QBT_PASSWORD password=QBT_PASSWORD
) )
# Проверяем подключение, обращаясь к версии приложения qBittorrent.
# Это неявно проверяет соединение и аутентификацию.
qb_version = qb.app.version qb_version = qb.app.version
logger.info(f"Connected to qBittorrent API v{qb_version} on {QBT_HOST}:{QBT_PORT}") logger.info(f"Connected to qBittorrent API v{qb_version} on {QBT_HOST}:{QBT_PORT}")
return True return True
@ -64,38 +58,34 @@ def init_qbittorrent_client():
# --- Команда /start --- # --- Команда /start ---
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Защитная проверка на наличие объекта сообщения
if update.message is None: if update.message is None:
logger.warning("Received an update without a message object in start handler.") logger.warning("Received an update without a message object in start handler.")
return return
logger.info(f"Received /start command from {update.effective_user.id}") logger.info(f"Received /start command from {update.effective_user.id}")
# Попытка инициализации клиента qBittorrent
if not init_qbittorrent_client(): if not init_qbittorrent_client():
await update.message.reply_text( await update.message.reply_text(
"Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера." "Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера."
) )
return return
# Отправка приветственного сообщения
await update.message.reply_text( await update.message.reply_text(
"Привет! Я бот для управления qBittorrent.\n" "Привет! Я бот для управления qBittorrent.\n"
"Отправь мне magnet-ссылку или URL torrent-файла, " "Отправь мне magnet-ссылку или URL torrent-файла, "
"чтобы добавить загрузку.\n" "чтобы добавить загрузку.\n"
"Используй /status для просмотра текущих загрузок." "Используй /status для просмотра текущих загрузок.\n"
"Используй /stop_torrent для остановки загрузки."
) )
# --- Команда /status --- # --- Команда /status ---
async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Защитная проверка на наличие объекта сообщения
if update.message is None: if update.message is None:
logger.warning("Received an update without a message object in status handler.") logger.warning("Received an update without a message object in status handler.")
return return
logger.info(f"Received /status command from {update.effective_user.id}") logger.info(f"Received /status command from {update.effective_user.id}")
# Попытка инициализации клиента qBittorrent
if not init_qbittorrent_client(): if not init_qbittorrent_client():
await update.message.reply_text( await update.message.reply_text(
"Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера." "Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера."
@ -103,7 +93,6 @@ async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
return return
try: try:
# Получаем информацию о торрентах
torrents = qb.torrents_info() torrents = qb.torrents_info()
if not torrents: if not torrents:
await update.message.reply_text("Загрузок не найдено.") await update.message.reply_text("Загрузок не найдено.")
@ -111,16 +100,14 @@ async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
status_messages = [] status_messages = []
for torrent in torrents: 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 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 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" dl_unit = "MB/s" if torrent.dlspeed >= (1024 * 1024) else "KB/s"
up_unit = "MB/s" if torrent.upspeed >= (1024 * 1024) else "KB/s" up_unit = "MB/s" if torrent.upspeed >= (1024 * 1024) else "KB/s"
# Форматирование времени выполнения (eta - Estimated Time of Arrival)
eta_str = "" eta_str = ""
if torrent.eta == 8640000: # qBittorrent's way of saying infinite if torrent.eta == 8640000:
eta_str = "" eta_str = ""
elif torrent.eta > 0: elif torrent.eta > 0:
hours, remainder = divmod(torrent.eta, 3600) hours, remainder = divmod(torrent.eta, 3600)
@ -134,17 +121,15 @@ async def status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
else: else:
eta_str = "Завершено" eta_str = "Завершено"
status_messages.append( status_messages.append(
f"📊 *{torrent.name}*\n" f"📊 *{torrent.name}*\n"
f" Состояние: {torrent.state}\n" f" Состояние: {torrent.state}\n"
f" Прогресс: {torrent.progress:.2%}\n" f" Прогресс: {torrent.progress:.2%}\n"
f" ⬇️ {download_speed:.2f} {dl_unit} ⬆️ {upload_speed:.2f} {up_unit}\n" f" ⬇️ {download_speed:.2f} {dl_unit} ⬆️ {upload_speed:.2f} {up_unit}\n"
f" ETA: {eta_str}\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( await update.message.reply_text(
"\n\n".join(status_messages), parse_mode="Markdown" "\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}") logger.error(f"An unexpected error occurred in status command: {e}")
await update.message.reply_text(f"Произошла непредвиденная ошибка: {e}") await update.message.reply_text(f"Произошла непредвиденная ошибка: {e}")
# --- Обработка magnet-ссылок и URL --- # --- Команда /stop_torrent (новая команда) ---
async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def stop_torrent(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Защитная проверка на наличие объекта сообщения
if update.message is 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 return
text = update.message.text logger.info(f"Received /stop_torrent command from {update.effective_user.id}")
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:
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(): if not init_qbittorrent_client():
await update.message.reply_text( await update.message.reply_text(
"Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера." "Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера."
@ -174,13 +223,12 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None
return return
try: try:
# Сохраняем URL торрента в user_data контекста, чтобы использовать его позже
context.user_data['current_torrent_url'] = text context.user_data['current_torrent_url'] = text
# Запрашиваем категории - ИЗМЕНЕНО НА qb.torrents_categories() # Запрашиваем категории - ИЗМЕНЕНО НА qb.torrents_categories()
categories_dict = qb.torrents_categories() # Это вернет словарь категорий categories_dict = qb.torrents_categories()
category_keyboard = [] 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(category_name, callback_data=f"select_category_{category_name}")])
category_keyboard.append([InlineKeyboardButton("Без категории", callback_data="select_category_no_category")]) 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() await query.answer()
data = query.data.split('_') 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 selected_category = data[2] if len(data) > 2 and data[2] != 'no_category' else None
logger.info(f"Selected category: {selected_category}") logger.info(f"Selected category: {selected_category}")
# Сохраняем выбранную категорию
context.user_data['selected_category'] = selected_category context.user_data['selected_category'] = selected_category
# Теперь предложим выбор директорий
await query.edit_message_text("Выберите директорию загрузки:") await query.edit_message_text("Выберите директорию загрузки:")
await send_directory_options(query, context) await send_directory_options(query, context)
# --- Отправка опций директорий --- # --- Отправка опций директорий ---
async def send_directory_options(query, context): async def send_directory_options(query, context):
# Попытка инициализации клиента qBittorrent
if not init_qbittorrent_client(): if not init_qbittorrent_client():
await query.edit_message_text( await query.edit_message_text(
"Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера." "Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера."
@ -225,29 +268,22 @@ async def send_directory_options(query, context):
return return
try: try:
# !!! ВНИМАНИЕ: qBittorrent API не предоставляет список "доступных" директорий автоматически.
# Вам нужно будет вручную указать те директории, которые вы хотите предложить пользователю.
# ЗАМЕНИТЕ ЭТОТ СПИСОК СВОИМИ АКТУАЛЬНЫМИ ПУТЯМИ!
# Пример:
available_paths = [ available_paths = [
qb.app.default_save_path, # Дефолтный путь qBittorrent, например: /downloads/complete qb.app.default_save_path,
"/share/Data/torrents", # Пример пути для Linux/NAS "/share/Data/torrents", # ЗАМЕНИТЕ НА СВОИ АКТУАЛЬНЫЕ ПУТИ
"/share/Data/Films", "/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]))) available_paths = sorted(list(set([p.replace(os.sep, '/') for p in available_paths if p])))
directory_keyboard = [] directory_keyboard = []
for path in available_paths: for path in available_paths:
# Для отображения: используем basename, если путь очень длинный, или просто путь
display_path = os.path.basename(path) if len(path) > 30 else path display_path = os.path.basename(path) if len(path) > 30 else path
directory_keyboard.append([InlineKeyboardButton(display_path, callback_data=f"select_dir_{path}")]) directory_keyboard.append([InlineKeyboardButton(display_path, callback_data=f"select_dir_{path}")])
reply_markup = InlineKeyboardMarkup(directory_keyboard) 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: except APIError as e:
logger.error(f"Error fetching default save path: {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() await query.answer()
data = query.data.split('_') data = query.data.split('_')
# action = data[0] # select selected_directory = data[2]
# type = data[1] # dir
selected_directory = data[2] # выбранный путь
logger.info(f"Selected directory: {selected_directory}") 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 торрента не найден. Пожалуйста, попробуйте снова.") await query.edit_message_text("Ошибка: URL торрента не найден. Пожалуйста, попробуйте снова.")
return return
# Попытка инициализации клиента qBittorrent
if not init_qbittorrent_client(): if not init_qbittorrent_client():
await query.edit_message_text( await query.edit_message_text(
"Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера." "Не удалось подключиться к qBittorrent. Проверьте переменные окружения и доступность сервера."
@ -284,18 +317,16 @@ async def select_directory_callback(update: Update, context: ContextTypes.DEFAUL
return return
try: try:
# Добавление торрента с выбранной категорией и директорией
qb.torrents_add( qb.torrents_add(
urls=torrent_url, urls=torrent_url,
category=category, category=category,
save_path=selected_directory # Указываем директорию сохранения save_path=selected_directory
) )
await query.edit_message_text( await query.edit_message_text(
f"Торрент успешно добавлен в qBittorrent.\n" f"Торрент успешно добавлен в qBittorrent.\n"
f"Категория: {category or 'Без категории'}\n" f"Категория: {category or 'Без категории'}\n"
f"Директория: {selected_directory}" f"Директория: {selected_directory}"
) )
# Очищаем данные из context.user_data после использования
if 'current_torrent_url' in context.user_data: if 'current_torrent_url' in context.user_data:
del context.user_data['current_torrent_url'] del context.user_data['current_torrent_url']
if 'selected_category' in context.user_data: 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: async def unknown_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Защитная проверка на наличие объекта сообщения
if update.message is None: if update.message is None:
logger.warning("Received an unknown command update without a message object.") logger.warning("Received an unknown command update without a message object.")
return 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: async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# Защитная проверка на наличие объекта сообщения
if update.message is None: if update.message is None:
logger.warning(f"Received non-text update in echo handler: {update}") logger.warning(f"Received non-text update in echo handler: {update}")
return return
@ -335,7 +364,6 @@ async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
# --- Основная функция, запускающая бота --- # --- Основная функция, запускающая бота ---
def main() -> None: def main() -> None:
# Критическая проверка токена бота
if not TELEGRAM_BOT_TOKEN: if not TELEGRAM_BOT_TOKEN:
logger.critical("TELEGRAM_BOT_TOKEN environment variable is not set. Exiting.") logger.critical("TELEGRAM_BOT_TOKEN environment variable is not set. Exiting.")
exit(1) exit(1)
@ -345,16 +373,11 @@ def main() -> None:
# --- Добавление обработчиков команд --- # --- Добавление обработчиков команд ---
application.add_handler(CommandHandler("start", start)) application.add_handler(CommandHandler("start", start))
application.add_handler(CommandHandler("status", status)) 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]+"
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]*" 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)) 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_.*")) application.add_handler(CallbackQueryHandler(select_category_callback, pattern=r"^select_category_.*"))
# Добавляем CallbackQueryHandler для кнопок выбора директории # Добавляем CallbackQueryHandler для кнопок выбора директории
application.add_handler(CallbackQueryHandler(select_directory_callback, pattern=r"^select_dir_.*")) 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.TEXT & ~filters.COMMAND, echo))
# --- Обработчик для неизвестных команд (должен быть последним, чтобы не перехватывать другие команды) --- # --- Обработчик для неизвестных команд ---
application.add_handler(MessageHandler(filters.COMMAND, unknown_command)) application.add_handler(MessageHandler(filters.COMMAND, unknown_command))
# --- Добавление обработчика ошибок --- # --- Добавление обработчика ошибок ---
@ -377,8 +401,6 @@ def main() -> None:
# --- Запуск бота --- # --- Запуск бота ---
logger.info("Bot started polling...") logger.info("Bot started polling...")
# allowed_updates=Update.ALL_TYPES помогает убедиться, что бот получает все типы обновлений,
# что полезно для отладки, но обычно можно сузить список для продакшена.
application.run_polling(allowed_updates=Update.ALL_TYPES) application.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__": if __name__ == "__main__":