findFilms/app/telegram_bot.py

786 lines
35 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

#!/usr/bin/env python3
"""
Telegram Bot для поиска и загрузки фильмов через торренты
Дублирует функциональность веб-интерфейса в Telegram
"""
import os
import asyncio
import httpx
import logging
import json
from typing import Dict, List, Optional
from dataclasses import dataclass
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, InputMediaPhoto
from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes
from telegram.constants import ParseMode
# Настройка логирования
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO
)
logger = logging.getLogger(__name__)
# Конфигурация
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
TMDB_PROXY_URL = os.getenv("TMDB_PROXY_URL", "http://localhost:8001")
TORRENT_SEARCH_URL = os.getenv("TORRENT_SEARCH_URL", "http://localhost:8443")
TORRENT_ADD_URL = os.getenv("TORRENT_ADD_URL", "http://localhost:8444")
QBITTORRENT_HOST = os.getenv("QBITTORRENT_HOST", "localhost")
QBITTORRENT_PORT = os.getenv("QBITTORRENT_PORT", "8082")
QBITTORRENT_USERNAME = os.getenv("QBITTORRENT_USERNAME", "admin")
QBITTORRENT_PASSWORD = os.getenv("QBITTORRENT_PASSWORD", "vrubel07")
@dataclass
class Movie:
"""Структура данных для фильма"""
id: int
title: str
original_title: str
overview: str
release_date: str
vote_average: float
poster_path: str
backdrop_path: str
genre_ids: List[int]
@dataclass
class Torrent:
"""Структура данных для торрента"""
id: str
title: str
size_bytes: int
size_readable: str
resolution: str
quality: str
seeds: int
peers: int
magnet: str
provider: str
class MovieSearchBot:
"""Основной класс Telegram бота"""
def __init__(self):
self.application = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
self.user_states = {} # Состояния пользователей
self.download_monitor = None # Мониторинг загрузок
self.setup_handlers()
def setup_handlers(self):
"""Настройка обработчиков команд"""
# Команды
self.application.add_handler(CommandHandler("start", self.start_command))
self.application.add_handler(CommandHandler("help", self.help_command))
self.application.add_handler(CommandHandler("find", self.find_command))
# Обработчики сообщений
self.application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_message))
# Обработчики callback запросов
self.application.add_handler(CallbackQueryHandler(self.handle_callback))
async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработчик команды /start"""
user = update.effective_user
welcome_text = f"""
🎬 <b>Добро пожаловать в Movie Search Bot!</b>
Привет, {user.first_name}! 👋
Этот бот поможет вам найти и скачать фильмы через торренты.
<b>Доступные команды:</b>
/find - Найти фильм
/help - Помощь
<b>Как использовать:</b>
1. Нажмите /find или введите название фильма
2. Выберите нужный фильм из результатов
3. Выберите торрент для скачивания
4. Фильм автоматически добавится в qBittorrent
Начнем поиск? 🚀
"""
await update.message.reply_text(
welcome_text,
parse_mode=ParseMode.HTML
)
async def help_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработчик команды /help"""
help_text = """
📖 <b>Справка по использованию бота</b>
<b>Основные команды:</b>
/find - Начать поиск фильма
/help - Показать эту справку
/start - Перезапустить бота
<b>Пошаговая инструкция:</b>
1⃣ <b>Поиск фильма</b>
• Нажмите /find или просто введите название фильма
• Бот найдет фильмы через TMDB API
2⃣ <b>Выбор фильма</b>
• Выберите нужный фильм из списка
• Бот покажет постер и информацию о фильме
3⃣ <b>Поиск торрентов</b>
• Бот автоматически найдет доступные торренты
• Результаты будут отсортированы по качеству и количеству сидов
4⃣ <b>Скачивание</b>
• Выберите нужный торрент
• Он автоматически добавится в qBittorrent
• Вы получите уведомление о начале загрузки
<b>Поддерживаемые трекеры:</b>
• RuTracker
• Kinozal
• RuTor
• NoNameClub
<b>Проблемы?</b>
Если что-то не работает, попробуйте команду /start
"""
await update.message.reply_text(
help_text,
parse_mode=ParseMode.HTML
)
async def find_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработчик команды /find"""
user_id = update.effective_user.id
# Устанавливаем состояние ожидания названия фильма
self.user_states[user_id] = "waiting_movie_title"
await update.message.reply_text(
"🔍 <b>Поиск фильма</b>\n\nВведите название фильма, который хотите найти:",
parse_mode=ParseMode.HTML
)
async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработчик текстовых сообщений"""
user_id = update.effective_user.id
text = update.message.text
# Проверяем состояние пользователя
if user_id not in self.user_states:
# Если пользователь не в состоянии, но отправил текст, считаем это поиском
await self.search_movies(update, context, text)
return
state = self.user_states[user_id]
if state == "waiting_movie_title":
await self.search_movies(update, context, text)
else:
# Неизвестное состояние
await update.message.reply_text(
"❌ Неизвестная команда. Используйте /find для поиска фильма или /help для справки."
)
async def search_movies(self, update: Update, context: ContextTypes.DEFAULT_TYPE, query: str):
"""Поиск фильмов через TMDB API"""
user_id = update.effective_user.id
try:
# Показываем индикатор загрузки
loading_msg = await update.message.reply_text("🔍 Ищу фильмы...")
# Поиск через TMDB API
movies = await self.tmdb_search_movies(query)
if not movies:
await loading_msg.edit_text("❌ Фильмы не найдены. Попробуйте другое название.")
return
# Ограничиваем количество результатов
movies = movies[:10]
# Создаем клавиатуру с результатами
keyboard = []
for i, movie in enumerate(movies):
year = movie.release_date[:4] if movie.release_date else "N/A"
button_text = f"{movie.title} ({year})"
if len(button_text) > 50:
button_text = button_text[:47] + "..."
keyboard.append([InlineKeyboardButton(
button_text,
callback_data=f"movie_{movie.id}"
)])
# Добавляем кнопку отмены
keyboard.append([InlineKeyboardButton("❌ Отмена", callback_data="cancel")])
reply_markup = InlineKeyboardMarkup(keyboard)
# Отправляем результаты
results_text = f"🎬 <b>Найдено фильмов: {len(movies)}</b>\n\nВыберите нужный фильм:"
await loading_msg.edit_text(
results_text,
parse_mode=ParseMode.HTML,
reply_markup=reply_markup
)
# Сохраняем результаты в контексте
context.user_data['search_results'] = movies
self.user_states[user_id] = "movie_selected"
except Exception as e:
logger.error(f"Error searching movies: {e}")
await update.message.reply_text(
f"❌ Ошибка при поиске фильмов: {str(e)}\n\nПопробуйте еще раз или используйте /help"
)
async def tmdb_search_movies(self, query: str) -> List[Movie]:
"""Поиск фильмов через TMDB Proxy"""
async with httpx.AsyncClient(timeout=60.0) as client:
try:
response = await client.get(
f"{TMDB_PROXY_URL}/search/movie",
params={
"query": query,
"language": "ru-RU",
"include_adult": False
}
)
response.raise_for_status()
data = response.json()
movies = []
for movie_data in data.get("results", []):
movie = Movie(
id=movie_data["id"],
title=movie_data.get("title", ""),
original_title=movie_data.get("original_title", ""),
overview=movie_data.get("overview", ""),
release_date=movie_data.get("release_date", ""),
vote_average=movie_data.get("vote_average", 0.0),
poster_path=movie_data.get("poster_path", ""),
backdrop_path=movie_data.get("backdrop_path", ""),
genre_ids=movie_data.get("genre_ids", [])
)
movies.append(movie)
return movies
except Exception as e:
logger.error(f"TMDB Proxy error: {e}")
return []
async def handle_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Обработчик callback запросов"""
query = update.callback_query
await query.answer()
data = query.data
user_id = update.effective_user.id
if data == "cancel":
await self.cancel_operation(update, context)
return
if data.startswith("movie_"):
movie_id = int(data.split("_")[1])
await self.show_movie_details(update, context, movie_id)
elif data.startswith("torrent_"):
torrent_id = data.split("_")[1]
await self.add_torrent_to_client(update, context, torrent_id)
elif data == "search_torrents":
await self.search_torrents_for_movie(update, context)
elif data == "new_search":
await self.start_new_search(update, context)
async def cancel_operation(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Отмена операции"""
user_id = update.effective_user.id
self.user_states[user_id] = None
await update.callback_query.edit_message_text(
"❌ Операция отменена.\n\nИспользуйте /find для нового поиска."
)
async def start_new_search(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Начать новый поиск"""
user_id = update.effective_user.id
self.user_states[user_id] = "waiting_movie_title"
# Очищаем данные пользователя
context.user_data.clear()
await update.callback_query.edit_message_text(
"🔍 <b>Новый поиск фильма</b>\n\nВведите название фильма, который хотите найти:",
parse_mode=ParseMode.HTML
)
async def show_movie_details(self, update: Update, context: ContextTypes.DEFAULT_TYPE, movie_id: int):
"""Показать детали фильма"""
try:
# Находим фильм в сохраненных результатах
movies = context.user_data.get('search_results', [])
movie = next((m for m in movies if m.id == movie_id), None)
if not movie:
await update.callback_query.edit_message_text("❌ Фильм не найден.")
return
# Получаем детальную информацию о фильме
movie_details = await self.get_movie_details(movie_id)
if movie_details:
movie = movie_details
# Формируем текст сообщения
year = movie.release_date[:4] if movie.release_date else "N/A"
rating = f"{movie.vote_average:.1f}/10" if movie.vote_average > 0 else "⭐ N/A"
text = f"""
🎬 <b>{movie.title}</b>
📅 <b>Год:</b> {year}
{rating}
📝 <b>Описание:</b>
{movie.overview[:500]}{'...' if len(movie.overview) > 500 else ''}
"""
# Создаем клавиатуру
keyboard = [
[InlineKeyboardButton("🔍 Найти торренты", callback_data="search_torrents")],
[InlineKeyboardButton("❌ Отмена", callback_data="cancel")]
]
reply_markup = InlineKeyboardMarkup(keyboard)
# Сохраняем выбранный фильм
context.user_data['selected_movie'] = movie
# Отправляем сообщение с постером
if movie.poster_path:
poster_url = f"https://image.tmdb.org/t/p/w500{movie.poster_path}"
try:
await update.callback_query.edit_message_media(
InputMediaPhoto(
media=poster_url,
caption=text,
parse_mode=ParseMode.HTML
),
reply_markup=reply_markup
)
except:
# Если не удалось отправить с постером, отправляем текст
await update.callback_query.edit_message_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=reply_markup
)
else:
await update.callback_query.edit_message_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=reply_markup
)
except Exception as e:
logger.error(f"Error showing movie details: {e}")
await update.callback_query.edit_message_text(
f"❌ Ошибка при получении информации о фильме: {str(e)}"
)
async def get_movie_details(self, movie_id: int) -> Optional[Movie]:
"""Получение детальной информации о фильме через TMDB Proxy"""
async with httpx.AsyncClient(timeout=60.0) as client:
try:
response = await client.get(
f"{TMDB_PROXY_URL}/movie/{movie_id}",
params={
"language": "ru-RU",
"append_to_response": "external_ids"
}
)
response.raise_for_status()
data = response.json()
return Movie(
id=data["id"],
title=data.get("title", ""),
original_title=data.get("original_title", ""),
overview=data.get("overview", ""),
release_date=data.get("release_date", ""),
vote_average=data.get("vote_average", 0.0),
poster_path=data.get("poster_path", ""),
backdrop_path=data.get("backdrop_path", ""),
genre_ids=data.get("genre_ids", [])
)
except Exception as e:
logger.error(f"Error getting movie details: {e}")
return None
async def search_torrents_for_movie(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Поиск торрентов для выбранного фильма"""
try:
movie = context.user_data.get('selected_movie')
if not movie:
await update.callback_query.edit_message_text("❌ Фильм не выбран.")
return
# Показываем индикатор загрузки
try:
await update.callback_query.edit_message_text("🔍 Ищу торренты...")
except:
# Если не можем отредактировать (например, сообщение с изображением), отправляем новое
await update.callback_query.message.reply_text("🔍 Ищу торренты...")
# Поиск торрентов
torrents = await self.search_torrents(movie)
if not torrents:
try:
await update.callback_query.edit_message_text(
f"❌ Торренты для фильма '{movie.title}' не найдены.\n\nПопробуйте другой фильм."
)
except:
await update.callback_query.message.reply_text(
f"❌ Торренты для фильма '{movie.title}' не найдены.\n\nПопробуйте другой фильм."
)
return
# Ограничиваем количество результатов
torrents = torrents[:15]
# Создаем клавиатуру с торрентами
keyboard = []
for i, torrent in enumerate(torrents):
# Формируем текст кнопки
button_text = f"{torrent.quality} {torrent.resolution} - {torrent.size_readable}"
if torrent.seeds > 0:
button_text += f" (👥 {torrent.seeds})"
if len(button_text) > 50:
button_text = button_text[:47] + "..."
keyboard.append([InlineKeyboardButton(
button_text,
callback_data=f"torrent_{torrent.id}"
)])
# Добавляем кнопку отмены
keyboard.append([InlineKeyboardButton("❌ Отмена", callback_data="cancel")])
reply_markup = InlineKeyboardMarkup(keyboard)
# Формируем текст с результатами
text = f"""
🎬 <b>{movie.title}</b>
🔍 <b>Найдено торрентов: {len(torrents)}</b>
Выберите торрент для скачивания:
"""
try:
await update.callback_query.edit_message_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=reply_markup
)
except:
# Если не можем отредактировать, отправляем новое сообщение
await update.callback_query.message.reply_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=reply_markup
)
# Сохраняем торренты в контексте
context.user_data['torrents'] = torrents
except Exception as e:
logger.error(f"Error searching torrents: {e}")
try:
await update.callback_query.edit_message_text(
f"❌ Ошибка при поиске торрентов: {str(e)}"
)
except:
await update.callback_query.message.reply_text(
f"❌ Ошибка при поиске торрентов: {str(e)}"
)
async def search_torrents(self, movie: Movie) -> List[Torrent]:
"""Поиск торрентов для фильма"""
try:
logger.info(f"Searching torrents for movie: {movie.title}")
# Используем правильный API endpoint через movie-search сервис
async with httpx.AsyncClient(timeout=60.0) as client:
# URL-кодируем название фильма
import urllib.parse
encoded_title = urllib.parse.quote(movie.title)
url = f"http://movie-search:8000/api/torrents/{encoded_title}"
params = {
"year": movie.release_date[:4] if movie.release_date else None,
"original_title": movie.original_title if movie.original_title != movie.title else None
}
logger.info(f"Making request to: {url} with params: {params}")
response = await client.get(url, params=params)
logger.info(f"Response status: {response.status_code}")
if response.status_code == 200:
data = response.json()
torrents_data = data.get("torrents", [])
logger.info(f"Found {len(torrents_data)} torrents")
torrents = []
for torrent_data in torrents_data:
torrent = Torrent(
id=torrent_data.get("id", ""),
title=torrent_data.get("title", ""),
size_bytes=torrent_data.get("size_bytes", 0),
size_readable=torrent_data.get("size_readable", ""),
resolution=torrent_data.get("resolution", ""),
quality=torrent_data.get("quality", ""),
seeds=torrent_data.get("seeds", 0),
peers=torrent_data.get("peers", 0),
magnet=torrent_data.get("magnet", ""),
provider=torrent_data.get("provider", "")
)
torrents.append(torrent)
logger.info(f"Processed {len(torrents)} torrents")
return torrents
else:
logger.error(f"Torrent search API error: {response.status_code} - {response.text}")
return []
except Exception as e:
logger.error(f"Error searching torrents: {e}")
import traceback
traceback.print_exc()
return []
async def add_torrent_to_client(self, update: Update, context: ContextTypes.DEFAULT_TYPE, torrent_id: str):
"""Добавление торрента в qBittorrent"""
try:
# Показываем индикатор загрузки
await update.callback_query.edit_message_text("⬇️ Добавляю торрент в qBittorrent...")
# Используем правильный API endpoint через movie-search сервис
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
"http://movie-search:8000/api/add-torrent",
data={"torrent_id": torrent_id}
)
if response.status_code == 200:
data = response.json()
if data.get("status") == "success":
message = f"{data.get('message', 'Торрент успешно добавлен!')}"
# Добавляем в мониторинг загрузок
if self.download_monitor and data.get("torrent_hash"):
user_id = update.effective_user.id
movie = context.user_data.get('selected_movie')
torrent_name = data.get("torrent_name", "Unknown")
if movie:
self.download_monitor.add_download(
torrent_hash=data.get("torrent_hash"),
user_id=user_id,
movie_title=movie.title,
torrent_name=torrent_name
)
else:
message = f"{data.get('message', 'Ошибка при добавлении торрента')}"
else:
message = f"❌ Ошибка API: {response.status_code}"
# Создаем клавиатуру для возврата к поиску
keyboard = [
[InlineKeyboardButton("🔍 Найти другой фильм", callback_data="new_search")],
[InlineKeyboardButton("❌ Закрыть", callback_data="cancel")]
]
reply_markup = InlineKeyboardMarkup(keyboard)
await update.callback_query.edit_message_text(
message,
reply_markup=reply_markup
)
except Exception as e:
logger.error(f"Error adding torrent: {e}")
await update.callback_query.edit_message_text(
f"❌ Ошибка при добавлении торрента: {str(e)}"
)
def run(self):
"""Запуск бота"""
logger.info("Starting Movie Search Bot...")
# Инициализируем мониторинг загрузок
self.download_monitor = DownloadMonitor(self)
# Запускаем мониторинг в фоновом режиме
def start_monitoring():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(self.download_monitor.start_monitoring())
import threading
monitor_thread = threading.Thread(target=start_monitoring, daemon=True)
monitor_thread.start()
self.application.run_polling()
class DownloadMonitor:
"""Мониторинг загрузок в qBittorrent и отправка уведомлений"""
def __init__(self, bot_instance: MovieSearchBot):
self.bot = bot_instance
self.active_downloads = {} # {torrent_hash: {user_id, movie_title, torrent_name}}
self.qbittorrent_url = f"http://{QBITTORRENT_HOST}:{QBITTORRENT_PORT}"
self.qbittorrent_username = QBITTORRENT_USERNAME
self.qbittorrent_password = QBITTORRENT_PASSWORD
self.session_cookie = None
async def authenticate_qbittorrent(self):
"""Аутентификация в qBittorrent"""
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{self.qbittorrent_url}/api/v2/auth/login",
data={
"username": self.qbittorrent_username,
"password": self.qbittorrent_password
}
)
if response.status_code == 200 and response.text == "Ok.":
# Сохраняем cookie для последующих запросов
self.session_cookie = response.cookies.get('SID')
logger.info("qBittorrent authentication successful")
return True
else:
logger.error(f"qBittorrent authentication failed: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Error authenticating with qBittorrent: {e}")
return False
async def get_torrents_info(self):
"""Получение информации о торрентах"""
try:
if not self.session_cookie:
await self.authenticate_qbittorrent()
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"{self.qbittorrent_url}/api/v2/torrents/info",
cookies={"SID": self.session_cookie}
)
if response.status_code == 200:
return response.json()
else:
logger.error(f"Failed to get torrents info: {response.status_code}")
return []
except Exception as e:
logger.error(f"Error getting torrents info: {e}")
return []
async def check_downloads(self):
"""Проверка статуса загрузок и отправка уведомлений"""
try:
torrents = await self.get_torrents_info()
if not torrents:
return
for torrent in torrents:
torrent_hash = torrent.get('hash')
torrent_name = torrent.get('name', 'Unknown')
state = torrent.get('state')
progress = torrent.get('progress', 0)
# Проверяем, отслеживаем ли мы этот торрент
if torrent_hash in self.active_downloads:
download_info = self.active_downloads[torrent_hash]
user_id = download_info['user_id']
movie_title = download_info['movie_title']
# Если загрузка завершена (state == 'uploading' или progress == 1.0)
if state in ['uploading', 'stalledUP'] or progress >= 1.0:
try:
# Отправляем уведомление
await self.bot.application.bot.send_message(
chat_id=user_id,
text=f"🎉 <b>Загрузка завершена!</b>\n\n"
f"🎬 <b>Фильм:</b> {movie_title}\n"
f"📁 <b>Торрент:</b> {torrent_name}\n"
f"✅ <b>Статус:</b> Готов к просмотру!",
parse_mode=ParseMode.HTML
)
# Удаляем из отслеживания
del self.active_downloads[torrent_hash]
logger.info(f"Download completed notification sent for {movie_title}")
except Exception as e:
logger.error(f"Error sending completion notification: {e}")
# Если загрузка остановлена с ошибкой
elif state in ['error', 'missingFiles']:
try:
await self.bot.application.bot.send_message(
chat_id=user_id,
text=f"❌ <b>Ошибка загрузки</b>\n\n"
f"🎬 <b>Фильм:</b> {movie_title}\n"
f"📁 <b>Торрент:</b> {torrent_name}\n"
f"⚠️ <b>Статус:</b> {state}",
parse_mode=ParseMode.HTML
)
# Удаляем из отслеживания
del self.active_downloads[torrent_hash]
logger.info(f"Download error notification sent for {movie_title}")
except Exception as e:
logger.error(f"Error sending error notification: {e}")
except Exception as e:
logger.error(f"Error checking downloads: {e}")
def add_download(self, torrent_hash: str, user_id: int, movie_title: str, torrent_name: str):
"""Добавление торрента в отслеживание"""
self.active_downloads[torrent_hash] = {
'user_id': user_id,
'movie_title': movie_title,
'torrent_name': torrent_name
}
logger.info(f"Added download to monitoring: {movie_title} for user {user_id}")
async def start_monitoring(self):
"""Запуск мониторинга загрузок"""
logger.info("Starting download monitoring...")
while True:
try:
await self.check_downloads()
await asyncio.sleep(30) # Проверяем каждые 30 секунд
except Exception as e:
logger.error(f"Error in monitoring loop: {e}")
await asyncio.sleep(60) # При ошибке ждем минуту
def main():
"""Главная функция"""
bot = MovieSearchBot()
bot.run()
if __name__ == "__main__":
main()