from __future__ import annotations import contextlib from telegram import Update from telegram.ext import Application, CommandHandler, ContextTypes from app.db.repositories import PlaylistRunRepository, SavedTrackRepository, UserRepository from app.services.app_services import AppServices class TelegramBotRunner: def __init__(self, token: str, session_factory, services: AppServices, app_base_url: str) -> None: self.token = token self.session_factory = session_factory self.services = services self.app_base_url = app_base_url.rstrip("/") self.application = Application.builder().token(token).build() self._setup_handlers() self._running = False def _setup_handlers(self) -> None: self.application.add_handler(CommandHandler("start", self.start)) self.application.add_handler(CommandHandler("help", self.help)) self.application.add_handler(CommandHandler("connect", self.connect)) self.application.add_handler(CommandHandler("status", self.status)) self.application.add_handler(CommandHandler("generate", self.generate)) self.application.add_handler(CommandHandler("latest", self.latest)) self.application.add_handler(CommandHandler("setsize", self.set_size)) self.application.add_handler(CommandHandler("setratio", self.set_ratio)) self.application.add_handler(CommandHandler("sync", self.sync_likes)) async def start_polling(self) -> None: await self.application.initialize() await self.application.start() if self.application.updater is None: raise RuntimeError("Telegram updater is not available") await self.application.updater.start_polling(drop_pending_updates=False) self._running = True async def stop(self) -> None: if not self._running: return with contextlib.suppress(Exception): if self.application.updater: await self.application.updater.stop() with contextlib.suppress(Exception): await self.application.stop() with contextlib.suppress(Exception): await self.application.shutdown() self._running = False async def send_message(self, chat_id: str, text: str) -> None: await self.application.bot.send_message(chat_id=int(chat_id), text=text, disable_web_page_preview=False) async def _ensure_user(self, update: Update): chat = update.effective_chat user = update.effective_user if not chat: return None async with self.session_factory() as session: repo = UserRepository(session) db_user = await repo.get_or_create_by_chat( chat_id=str(chat.id), username=user.username if user else None, ) await session.commit() return db_user async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await self._ensure_user(update) await update.message.reply_text( "Я бот для Spotify daily vibe playlist.\n" "Команды: /connect /status /generate /latest /setsize 30 /setratio 0.8 /sync" ) async def help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text( "/connect - привязать Spotify\n" "/status - статус аккаунта и последнего плейлиста\n" "/generate - сгенерировать плейлист сейчас\n" "/latest - показать ссылку на последний плейлист\n" "/setsize N - размер плейлиста\n" "/setratio X - доля новых треков (0.5..1.0)\n" "/sync - обновить лайкнутые треки из Spotify" ) async def connect(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat = update.effective_chat user = update.effective_user if not chat: return url = await self.services.auth.create_connect_url(str(chat.id), user.username if user else None) await update.message.reply_text(f"Открой ссылку и авторизуй Spotify:\n{url}", disable_web_page_preview=True) async def status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat = update.effective_chat if not chat: return async with self.session_factory() as session: users = UserRepository(session) runs = PlaylistRunRepository(session) saved = SavedTrackRepository(session) db_user = await users.get_by_chat_id(str(chat.id)) if not db_user: await update.message.reply_text("Пользователь не найден. Напиши /start") return latest = await runs.latest_for_user(db_user.id) saved_count = await saved.count_for_user(db_user.id) connected = "yes" if db_user.spotify_refresh_token else "no" text = ( f"Connected: {connected}\n" f"Spotify user: {db_user.spotify_user_id or '-'}\n" f"Liked tracks cached: {saved_count}\n" f"Playlist size: {db_user.playlist_size}\n" f"Min new ratio: {db_user.min_new_ratio:.2f}\n" f"Last generated: {db_user.last_generated_date or '-'}" ) if latest: text += f"\nLast run: {latest.status}, tracks={latest.total_tracks}" if latest.playlist_url: text += f"\n{latest.playlist_url}" await update.message.reply_text(text, disable_web_page_preview=False) async def latest(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat = update.effective_chat if not chat: return async with self.session_factory() as session: users = UserRepository(session) db_user = await users.get_by_chat_id(str(chat.id)) if not db_user or not db_user.latest_playlist_url: await update.message.reply_text("Пока нет сгенерированного плейлиста.") return await update.message.reply_text(db_user.latest_playlist_url, disable_web_page_preview=False) async def generate(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat = update.effective_chat if not chat: return async with self.session_factory() as session: users = UserRepository(session) db_user = await users.get_by_chat_id(str(chat.id)) if not db_user: await update.message.reply_text("Пользователь не найден. Напиши /start") return user_id = db_user.id await update.message.reply_text("Генерирую плейлист, это может занять 20-60 секунд...") outcome = await self.services.jobs.generate_for_user(user_id=user_id, force=True, notify=False) if outcome.ok: msg = outcome.message if outcome.playlist_url: msg += f"\n{outcome.playlist_url}" await update.message.reply_text(msg, disable_web_page_preview=False) else: await update.message.reply_text(f"Ошибка: {outcome.message}") async def set_size(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat = update.effective_chat if not chat: return if not context.args: await update.message.reply_text("Использование: /setsize 30") return try: value = int(context.args[0]) if value < 5 or value > 100: raise ValueError except ValueError: await update.message.reply_text("Размер должен быть числом от 5 до 100.") return async with self.session_factory() as session: users = UserRepository(session) db_user = await users.get_or_create_by_chat(str(chat.id), update.effective_user.username if update.effective_user else None) db_user.playlist_size = value await session.commit() await update.message.reply_text(f"Размер плейлиста установлен: {value}") async def set_ratio(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat = update.effective_chat if not chat: return if not context.args: await update.message.reply_text("Использование: /setratio 0.8") return try: value = float(context.args[0]) if value < 0.5 or value > 1.0: raise ValueError except ValueError: await update.message.reply_text("Значение должно быть от 0.5 до 1.0") return async with self.session_factory() as session: users = UserRepository(session) db_user = await users.get_or_create_by_chat(str(chat.id), update.effective_user.username if update.effective_user else None) db_user.min_new_ratio = value await session.commit() await update.message.reply_text(f"Минимальная доля новых треков: {value:.2f}") async def sync_likes(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: chat = update.effective_chat if not chat: return async with self.session_factory() as session: users = UserRepository(session) db_user = await users.get_by_chat_id(str(chat.id)) if not db_user: await update.message.reply_text("Пользователь не найден. Напиши /start") return if not db_user.spotify_refresh_token: await update.message.reply_text("Сначала /connect") return access_token = await self.services.auth.ensure_valid_access_token(session, db_user) await self.services.recommendation.sync_saved_tracks(session, db_user, access_token) await session.commit() saved_count = await SavedTrackRepository(session).count_for_user(db_user.id) await update.message.reply_text(f"Лайкнутые треки обновлены: {saved_count}")