481 lines
19 KiB
Python
481 lines
19 KiB
Python
"""User-bot для обработки запросов пользователей."""
|
||
import asyncio
|
||
import logging
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from telegram import Update
|
||
from telegram.ext import (
|
||
Application,
|
||
CommandHandler,
|
||
MessageHandler,
|
||
filters,
|
||
ContextTypes,
|
||
ConversationHandler,
|
||
)
|
||
from telegram.request import HTTPXRequest
|
||
|
||
from app.config import Config
|
||
from app.queue_manager import QueueManager, Task
|
||
from app.youtube_downloader import is_youtube_url, get_video_title, download_and_convert, sanitize_filename
|
||
from app.admin_manager import AdminManager
|
||
from app.statistics import Statistics
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Состояния для ConversationHandler (ожидание имени файла)
|
||
WAITING_FOR_FILENAME = 1
|
||
|
||
# Хранилище ожидающих ответа пользователей: user_id -> (url, output_path, event, result_container)
|
||
pending_filename_requests = {}
|
||
|
||
|
||
async def send_status_message(context: ContextTypes.DEFAULT_TYPE, chat_id: int, text: str) -> Optional[int]:
|
||
"""Отправить сообщение о статусе и вернуть message_id."""
|
||
try:
|
||
message = await context.bot.send_message(chat_id=chat_id, text=text)
|
||
return message.message_id
|
||
except Exception as e:
|
||
logger.error(f"Error sending status message: {e}")
|
||
return None
|
||
|
||
|
||
async def process_task(task: Task, config: Config, admin_manager: AdminManager, admin_bot_app: Application) -> str:
|
||
"""
|
||
Обработать задачу: скачать видео и сконвертировать в MP3.
|
||
|
||
Returns:
|
||
Путь к созданному MP3 файлу
|
||
"""
|
||
# Получаем название видео
|
||
title = await get_video_title(task.url, config=config)
|
||
|
||
# Если название не получено, запрашиваем у пользователя
|
||
if not title:
|
||
# Сохраняем информацию о запросе
|
||
output_path = config.workdir / f"task_{task.task_id}"
|
||
pending_filename_requests[task.user_id] = (task.url, output_path)
|
||
|
||
# Отправляем запрос пользователю
|
||
status_callback = task.callback
|
||
if status_callback:
|
||
await status_callback("Не смог определить название. Введи имя файла (без расширения .mp3).")
|
||
|
||
# Ждём ответа пользователя (таймаут 5 минут)
|
||
try:
|
||
custom_title = await asyncio.wait_for(
|
||
_wait_for_filename(task.user_id),
|
||
timeout=300.0 # 5 минут
|
||
)
|
||
title = custom_title
|
||
except asyncio.TimeoutError:
|
||
raise Exception("Таймаут ожидания имени файла (5 минут)")
|
||
finally:
|
||
# Удаляем из ожидающих
|
||
pending_filename_requests.pop(task.user_id, None)
|
||
|
||
# Формируем безопасное имя файла
|
||
safe_title = sanitize_filename(title)
|
||
output_path = config.workdir / f"task_{task.task_id}_{safe_title}"
|
||
|
||
# Скачиваем и конвертируем
|
||
mp3_path = await download_and_convert(task.url, output_path, custom_title=safe_title, config=config)
|
||
|
||
return str(mp3_path)
|
||
|
||
|
||
async def _wait_for_filename(user_id: int) -> str:
|
||
"""
|
||
Ожидание ответа пользователя с именем файла.
|
||
Используется asyncio.Event для синхронизации.
|
||
"""
|
||
event = asyncio.Event()
|
||
result_container = {'value': None}
|
||
|
||
# Сохраняем event в глобальном словаре для доступа из handler
|
||
if user_id not in pending_filename_requests:
|
||
raise Exception("Request not found")
|
||
|
||
# Получаем существующую запись и добавляем event
|
||
url, output_path = pending_filename_requests[user_id]
|
||
pending_filename_requests[user_id] = (url, output_path, event, result_container)
|
||
|
||
# Ждём события
|
||
await event.wait()
|
||
|
||
if result_container['value'] is None:
|
||
raise Exception("No filename received")
|
||
|
||
return result_container['value']
|
||
|
||
|
||
async def handle_filename_response(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Обработка ответа пользователя с именем файла."""
|
||
user_id = update.effective_user.id
|
||
text = update.message.text.strip()
|
||
language = get_user_language(update)
|
||
|
||
if user_id in pending_filename_requests:
|
||
try:
|
||
url, output_path, event, result_container = pending_filename_requests[user_id]
|
||
result_container['value'] = text
|
||
event.set()
|
||
|
||
confirm_msg = f"Использую имя: {text}.mp3" if language == 'ru' else f"Using name: {text}.mp3"
|
||
await update.message.reply_text(confirm_msg)
|
||
except (ValueError, KeyError):
|
||
# Если структура не та, что ожидалась
|
||
error_msg = "Ошибка обработки имени файла." if language == 'ru' else "Error processing filename."
|
||
await update.message.reply_text(error_msg)
|
||
return ConversationHandler.END
|
||
else:
|
||
# Если нет активного запроса, проверяем, не является ли это ссылкой
|
||
return await handle_message(update, context)
|
||
|
||
|
||
def get_user_language(update: Update) -> str:
|
||
"""Определить язык пользователя для локализации."""
|
||
user = update.effective_user
|
||
lang_code = user.language_code or 'en'
|
||
# Если язык русский или начинается с ru, возвращаем 'ru', иначе 'en'
|
||
return 'ru' if lang_code.startswith('ru') else 'en'
|
||
|
||
|
||
def get_start_message(language: str) -> str:
|
||
"""Получить приветственное сообщение в зависимости от языка."""
|
||
if language == 'ru':
|
||
return (
|
||
"Привет! 👋\n\n"
|
||
"Я бот для скачивания аудио из YouTube видео.\n\n"
|
||
"Просто отправь мне ссылку на YouTube видео, и я верну тебе MP3 файл с аудиодорожкой.\n\n"
|
||
"Поддерживаются ссылки:\n"
|
||
"• https://www.youtube.com/...\n"
|
||
"• https://youtu.be/..."
|
||
)
|
||
else:
|
||
return (
|
||
"Hello! 👋\n\n"
|
||
"I'm a bot for downloading audio from YouTube videos.\n\n"
|
||
"Just send me a link to a YouTube video, and I'll return an MP3 file with the audio track.\n\n"
|
||
"Supported links:\n"
|
||
"• https://www.youtube.com/...\n"
|
||
"• https://youtu.be/..."
|
||
)
|
||
|
||
|
||
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Обработчик команды /start для user-bot."""
|
||
user = update.effective_user
|
||
language = get_user_language(update)
|
||
message = get_start_message(language)
|
||
|
||
# Добавляем пользователя в статистику
|
||
statistics = context.bot_data.get('statistics')
|
||
if statistics:
|
||
statistics.add_user(user.id)
|
||
|
||
await update.message.reply_text(message)
|
||
logger.info(f"User {user.id} (@{user.username}) started the bot (language: {language})")
|
||
|
||
|
||
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
"""Обработка сообщения от пользователя."""
|
||
user = update.effective_user
|
||
message = update.message
|
||
|
||
# Если пользователь ожидает ввода имени файла, не обрабатываем как ссылку
|
||
if user.id in pending_filename_requests:
|
||
# Это обработается в handle_filename_response
|
||
return
|
||
|
||
text = message.text.strip()
|
||
language = get_user_language(update)
|
||
|
||
# Проверяем, является ли это ссылкой на YouTube
|
||
if not is_youtube_url(text):
|
||
error_msg = "Пришли ссылку на YouTube-видео." if language == 'ru' else "Please send a YouTube video link."
|
||
await message.reply_text(error_msg)
|
||
return
|
||
|
||
# Получаем queue_manager из context
|
||
queue_manager: QueueManager = context.bot_data.get('queue_manager')
|
||
config: Config = context.bot_data.get('config')
|
||
admin_manager: AdminManager = context.bot_data.get('admin_manager')
|
||
admin_bot_app: Application = context.bot_data.get('admin_bot_app')
|
||
|
||
if not queue_manager:
|
||
error_msg = "Ошибка: очередь не инициализирована." if language == 'ru' else "Error: queue not initialized."
|
||
await message.reply_text(error_msg)
|
||
return
|
||
|
||
# Добавляем пользователя в статистику
|
||
statistics = context.bot_data.get('statistics')
|
||
if statistics:
|
||
statistics.add_user(user.id)
|
||
|
||
status_message_ids: list[int] = []
|
||
|
||
# Callback для отправки статуса
|
||
async def status_callback(status_text: str):
|
||
msg_id = await send_status_message(context, message.chat_id, status_text)
|
||
if msg_id is not None:
|
||
status_message_ids.append(msg_id)
|
||
|
||
# Добавляем задачу в очередь
|
||
await queue_manager.add_task(
|
||
user_id=user.id,
|
||
username=user.username,
|
||
url=text,
|
||
chat_id=message.chat_id,
|
||
message_id=message.message_id,
|
||
callback=status_callback,
|
||
status_message_ids=status_message_ids
|
||
)
|
||
|
||
|
||
async def _split_audio_to_parts(file_path: Path, max_part_mb: int, bitrate_kbps: int) -> list[Path]:
|
||
"""Разбить аудио на части (перекодирование в CBR для стабильного размера)."""
|
||
parts_dir = file_path.parent / f"parts_{file_path.stem}"
|
||
parts_dir.mkdir(parents=True, exist_ok=True)
|
||
part_pattern = parts_dir / f"{file_path.stem}_part%03d.mp3"
|
||
|
||
max_bytes = max_part_mb * 1024 * 1024
|
||
segment_time = max(60, int((max_bytes * 8) / (bitrate_kbps * 1000))) # минимум 60 секунд
|
||
|
||
cmd = [
|
||
'ffmpeg',
|
||
'-i', str(file_path),
|
||
'-b:a', f'{bitrate_kbps}k',
|
||
'-f', 'segment',
|
||
'-segment_time', str(segment_time),
|
||
'-reset_timestamps', '1',
|
||
'-y',
|
||
str(part_pattern),
|
||
]
|
||
|
||
process = await asyncio.create_subprocess_exec(
|
||
*cmd,
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE
|
||
)
|
||
stdout, stderr = await process.communicate()
|
||
if process.returncode != 0:
|
||
error = stderr.decode('utf-8', errors='ignore')
|
||
raise Exception(f"Ошибка разбиения на части: {error[:200]}")
|
||
|
||
prefix = f"{file_path.stem}_part"
|
||
parts = sorted(
|
||
p for p in parts_dir.iterdir()
|
||
if p.is_file() and p.name.startswith(prefix) and p.suffix == ".mp3"
|
||
)
|
||
logger.info(f"Split into {len(parts)} parts in {parts_dir}")
|
||
return parts
|
||
|
||
|
||
async def send_file_to_user(bot, chat_id: int, file_path: str, filename: str, config: Config):
|
||
"""Отправить MP3 файл пользователю. Если файл большой — отправить частями."""
|
||
try:
|
||
path = Path(file_path)
|
||
max_bytes = config.max_part_mb * 1024 * 1024
|
||
|
||
if path.stat().st_size > max_bytes:
|
||
parts = await _split_audio_to_parts(path, config.max_part_mb, config.audio_bitrate_kbps)
|
||
total = len(parts)
|
||
if total == 0:
|
||
raise Exception("Не удалось разбить файл на части")
|
||
for idx, part in enumerate(parts, start=1):
|
||
part_name = f"{idx:02d}_{path.stem}.mp3"
|
||
with open(part, 'rb') as f:
|
||
await bot.send_document(
|
||
chat_id=chat_id,
|
||
document=f,
|
||
filename=part_name
|
||
)
|
||
logger.info(f"Sent part {idx}/{total} to user {chat_id}")
|
||
# Очистка частей
|
||
for part in parts:
|
||
part.unlink(missing_ok=True)
|
||
parts_dir = parts[0].parent if parts else None
|
||
if parts_dir:
|
||
try:
|
||
parts_dir.rmdir()
|
||
except OSError:
|
||
logger.warning(f"Parts dir not empty: {parts_dir}")
|
||
else:
|
||
with open(path, 'rb') as f:
|
||
await bot.send_document(
|
||
chat_id=chat_id,
|
||
document=f,
|
||
filename=filename
|
||
)
|
||
logger.info(f"Sent file {filename} to user {chat_id}")
|
||
except Exception as e:
|
||
logger.error(f"Error sending file to user: {e}", exc_info=True)
|
||
raise
|
||
|
||
|
||
async def send_to_admin_bot(
|
||
admin_bot_app: Application,
|
||
admin_manager: AdminManager,
|
||
config: Config,
|
||
title: str,
|
||
username: Optional[str],
|
||
user_id: int,
|
||
url: str,
|
||
file_path: str
|
||
):
|
||
"""Отправить уведомление и файл в admin-bot."""
|
||
try:
|
||
# Формируем сообщение
|
||
requested_by = f"@{username}" if username else f"user_id: {user_id}"
|
||
message_text = f"title: {title}\nrequested_by: {requested_by}\nurl: {url}"
|
||
|
||
# Определяем получателей
|
||
if config.admin_chat_id:
|
||
recipients = [int(config.admin_chat_id)]
|
||
else:
|
||
recipients = admin_manager.get_all_admins()
|
||
|
||
if not recipients:
|
||
logger.warning("No admin recipients found")
|
||
return
|
||
|
||
# Отправляем всем получателям
|
||
filename = Path(file_path).name
|
||
for chat_id in recipients:
|
||
try:
|
||
# Отправляем текст
|
||
await admin_bot_app.bot.send_message(
|
||
chat_id=chat_id,
|
||
text=message_text
|
||
)
|
||
|
||
# Отправляем файл (если большой — частями)
|
||
path = Path(file_path)
|
||
max_bytes = config.max_part_mb * 1024 * 1024
|
||
if path.stat().st_size > max_bytes:
|
||
parts = await _split_audio_to_parts(path, config.max_part_mb, config.audio_bitrate_kbps)
|
||
total = len(parts)
|
||
for idx, part in enumerate(parts, start=1):
|
||
part_name = f"{idx:02d}_{path.stem}.mp3"
|
||
with open(part, 'rb') as f:
|
||
await admin_bot_app.bot.send_document(
|
||
chat_id=chat_id,
|
||
document=f,
|
||
filename=part_name
|
||
)
|
||
for part in parts:
|
||
part.unlink(missing_ok=True)
|
||
parts_dir = parts[0].parent if parts else None
|
||
if parts_dir:
|
||
parts_dir.rmdir()
|
||
else:
|
||
with open(file_path, 'rb') as f:
|
||
await admin_bot_app.bot.send_document(
|
||
chat_id=chat_id,
|
||
document=f,
|
||
filename=filename
|
||
)
|
||
|
||
logger.info(f"Sent notification and file to admin {chat_id}")
|
||
except Exception as e:
|
||
logger.error(f"Error sending to admin {chat_id}: {e}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in send_to_admin_bot: {e}", exc_info=True)
|
||
|
||
|
||
def create_user_bot_application(
|
||
config: Config,
|
||
queue_manager: QueueManager,
|
||
admin_manager: AdminManager,
|
||
admin_bot_app: Application,
|
||
statistics: Statistics
|
||
) -> Application:
|
||
"""Создать и настроить приложение user-bot."""
|
||
|
||
# Создаём приложение с увеличенными таймаутами для отправки файлов
|
||
request = HTTPXRequest(
|
||
connect_timeout=config.tg_connect_timeout,
|
||
read_timeout=config.tg_read_timeout,
|
||
write_timeout=config.tg_write_timeout,
|
||
pool_timeout=config.tg_pool_timeout,
|
||
)
|
||
app = Application.builder().token(config.user_bot_token).request(request).build()
|
||
|
||
# Сохраняем в bot_data для доступа из handlers
|
||
app.bot_data['config'] = config
|
||
app.bot_data['queue_manager'] = queue_manager
|
||
app.bot_data['admin_manager'] = admin_manager
|
||
app.bot_data['admin_bot_app'] = admin_bot_app
|
||
app.bot_data['statistics'] = statistics
|
||
|
||
# Обработчик команды /start
|
||
app.add_handler(CommandHandler("start", start_command))
|
||
|
||
# Обработчик сообщений (сначала проверяем на ожидание имени файла, потом на ссылку)
|
||
message_handler = MessageHandler(
|
||
filters.TEXT & ~filters.COMMAND,
|
||
handle_filename_response # Этот handler проверяет оба случая
|
||
)
|
||
|
||
app.add_handler(message_handler)
|
||
|
||
return app
|
||
|
||
|
||
async def worker_function(task: Task, config: Config, admin_manager: AdminManager, admin_bot_app: Application, statistics: Statistics, user_bot_app: Optional[Application]) -> str:
|
||
"""Функция-воркер для обработки задачи."""
|
||
mp3_path = None
|
||
try:
|
||
# Обрабатываем задачу
|
||
mp3_path = await process_task(task, config, admin_manager, admin_bot_app)
|
||
|
||
# Получаем название файла
|
||
filename = Path(mp3_path).name
|
||
|
||
# Отправляем файл пользователю
|
||
if user_bot_app:
|
||
await send_file_to_user(
|
||
user_bot_app.bot,
|
||
task.chat_id,
|
||
mp3_path,
|
||
filename,
|
||
config
|
||
)
|
||
else:
|
||
logger.warning("user_bot_app is not available; skipping send to user")
|
||
|
||
# Отправляем в admin-bot
|
||
await send_to_admin_bot(
|
||
admin_bot_app,
|
||
admin_manager,
|
||
config,
|
||
filename.replace('.mp3', ''),
|
||
task.username,
|
||
task.user_id,
|
||
task.url,
|
||
mp3_path
|
||
)
|
||
|
||
# Увеличиваем счётчик обработанных ссылок
|
||
statistics.increment_processed_urls()
|
||
|
||
# Удаляем статусные сообщения после завершения
|
||
if user_bot_app and task.status_message_ids:
|
||
for msg_id in task.status_message_ids:
|
||
try:
|
||
await user_bot_app.bot.delete_message(chat_id=task.chat_id, message_id=msg_id)
|
||
except Exception as e:
|
||
logger.warning(f"Failed to delete status message {msg_id}: {e}")
|
||
|
||
return mp3_path
|
||
|
||
finally:
|
||
# Удаляем временный файл
|
||
if mp3_path:
|
||
try:
|
||
Path(mp3_path).unlink(missing_ok=True)
|
||
logger.info(f"Deleted temporary file: {mp3_path}")
|
||
except Exception as e:
|
||
logger.error(f"Error deleting file {mp3_path}: {e}")
|