Fix bot polling, downloads, and file delivery
This commit is contained in:
commit
8a21cbe18a
16 changed files with 1712 additions and 0 deletions
481
app/user_bot.py
Normal file
481
app/user_bot.py
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
"""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}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue