from __future__ import annotations import contextlib from collections.abc import Awaitable, Callable from telegram import ReplyKeyboardMarkup, Update from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters from app.db.repositories import PlaylistRunRepository, SavedTrackRepository, UserRepository from app.services.app_services import AppServices Lang = str I18N: dict[str, dict[str, str]] = { "ru": { "button_connect": "🔗 Подключить Spotify", "button_generate": "✨ Сгенерировать", "button_status": "📊 Статус", "button_latest": "🎵 Последний плейлист", "button_sync": "🔄 Обновить лайки", "button_help": "❓ Помощь", "button_settings": "⚙️ Настройки", "button_language": "🌐 Язык / Language", "button_size_20": "📏 Размер 20", "button_size_30": "📏 Размер 30", "button_size_50": "📏 Размер 50", "button_ratio_60": "🆕 Новые 60%", "button_ratio_80": "🆕 Новые 80%", "button_ratio_100": "🆕 Новые 100%", "button_lang_ru": "🇺🇦 Русский", "button_lang_en": "🇺🇸 English", "button_back": "⬅️ Назад", "start_text": ( "Я бот для Spotify daily vibe playlist.\n" "Делаю плейлисты по вашему recent listening + liked tracks, стараюсь давать больше новых треков и меньше повторов.\n\n" "/connect - привязать Spotify\n" "/status - статус аккаунта и последнего плейлиста\n" "/generate - сгенерировать плейлист сейчас\n" "/latest - показать ссылку на последний плейлист\n" "/setsize N - размер плейлиста\n" "/setratio X - доля новых треков (0.5..1.0)\n" "/sync - обновить лайкнутые треки из Spotify\n" "/lang [ru|en] - сменить язык" ), "help_text": ( "Я бот для Spotify daily vibe playlist.\n" "Делаю плейлисты по вашему recent listening + liked tracks, стараюсь давать больше новых треков и меньше повторов.\n\n" "/connect - привязать Spotify\n" "/status - статус аккаунта и последнего плейлиста\n" "/generate - сгенерировать плейлист сейчас\n" "/latest - показать ссылку на последний плейлист\n" "/setsize N - размер плейлиста\n" "/setratio X - доля новых треков (0.5..1.0)\n" "/sync - обновить лайкнутые треки из Spotify\n" "/lang [ru|en] - сменить язык" ), "connect_open_link": "Открой ссылку и авторизуй Spotify:\n{url}", "spotify_connected_notice": "Spotify подключен: {display_name}\nТеперь можно /generate", "user_not_found_start": "Пользователь не найден. Напиши /start", "no_playlist_yet": "Пока нет сгенерированного плейлиста.", "generate_wait": "Генерирую плейлист, это может занять 20-60 секунд...", "error_prefix": "Ошибка: {message}", "size_usage": "Использование: /setsize 30", "size_invalid": "Размер должен быть числом от 5 до 100.", "size_set": "Размер плейлиста установлен: {value}", "ratio_usage": "Использование: /setratio 0.8", "ratio_invalid": "Значение должно быть от 0.5 до 1.0", "ratio_set": "Минимальная доля новых треков: {value:.2f}", "sync_need_connect": "Сначала /connect", "sync_done": "Лайкнутые треки обновлены: {count}", "lang_choose": "Выбери язык интерфейса:", "lang_set": "Язык интерфейса: {lang_name}", "lang_invalid": "Поддерживаются только `ru` и `en`.", "unknown_action": "Не понял команду. Используйте кнопки ниже или /help.", "main_menu_hint": "Главное меню", "settings_menu_hint": "Настройки", "status_yes": "да", "status_no": "нет", "status_text": ( "Connected: {connected}\n" "Spotify user: {spotify_user}\n" "Liked tracks cached: {saved_count}\n" "Playlist size: {playlist_size}\n" "Min new ratio: {min_new_ratio:.2f}\n" "Last generated: {last_generated}" ), "status_last_run": "Last run: {status}, tracks={tracks}", "status_last_url": "{url}", }, "en": { "button_connect": "🔗 Connect Spotify", "button_generate": "✨ Generate", "button_status": "📊 Status", "button_latest": "🎵 Latest Playlist", "button_sync": "🔄 Sync Likes", "button_help": "❓ Help", "button_settings": "⚙️ Settings", "button_language": "🌐 Language / Язык", "button_size_20": "📏 Size 20", "button_size_30": "📏 Size 30", "button_size_50": "📏 Size 50", "button_ratio_60": "🆕 New 60%", "button_ratio_80": "🆕 New 80%", "button_ratio_100": "🆕 New 100%", "button_lang_ru": "🇺🇦 Русский", "button_lang_en": "🇺🇸 English", "button_back": "⬅️ Back", "start_text": ( "I am a Spotify daily vibe playlist bot.\n" "I build playlists from your recent listening + liked tracks and try to keep them fresh with fewer repeats.\n\n" "/connect - connect Spotify\n" "/status - account status and latest playlist\n" "/generate - generate playlist now\n" "/latest - show latest playlist link\n" "/setsize N - playlist size\n" "/setratio X - min new ratio (0.5..1.0)\n" "/sync - sync liked tracks from Spotify\n" "/lang [ru|en] - change language" ), "help_text": ( "I am a Spotify daily vibe playlist bot.\n" "I build playlists from your recent listening + liked tracks and try to keep them fresh with fewer repeats.\n\n" "/connect - connect Spotify\n" "/status - account status and latest playlist\n" "/generate - generate playlist now\n" "/latest - show latest playlist link\n" "/setsize N - playlist size\n" "/setratio X - min new ratio (0.5..1.0)\n" "/sync - sync liked tracks from Spotify\n" "/lang [ru|en] - change language" ), "connect_open_link": "Open the link and authorize Spotify:\n{url}", "spotify_connected_notice": "Spotify connected: {display_name}\nNow you can use /generate", "user_not_found_start": "User not found. Send /start first", "no_playlist_yet": "No generated playlist yet.", "generate_wait": "Generating playlist, this can take 20-60 seconds...", "error_prefix": "Error: {message}", "size_usage": "Usage: /setsize 30", "size_invalid": "Size must be a number from 5 to 100.", "size_set": "Playlist size set to: {value}", "ratio_usage": "Usage: /setratio 0.8", "ratio_invalid": "Value must be between 0.5 and 1.0", "ratio_set": "Minimum new ratio: {value:.2f}", "sync_need_connect": "Use /connect first", "sync_done": "Liked tracks synced: {count}", "lang_choose": "Choose interface language:", "lang_set": "Interface language: {lang_name}", "lang_invalid": "Only `ru` and `en` are supported.", "unknown_action": "I did not understand that. Use the buttons below or /help.", "main_menu_hint": "Main menu", "settings_menu_hint": "Settings", "status_yes": "yes", "status_no": "no", "status_text": ( "Connected: {connected}\n" "Spotify user: {spotify_user}\n" "Liked tracks cached: {saved_count}\n" "Playlist size: {playlist_size}\n" "Min new ratio: {min_new_ratio:.2f}\n" "Last generated: {last_generated}" ), "status_last_run": "Last run: {status}, tracks={tracks}", "status_last_url": "{url}", }, } LANG_NAMES = {"ru": "Русский", "en": "English"} 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._running = False self._chat_lang: dict[str, Lang] = {} self._setup_handlers() 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)) self.application.add_handler(CommandHandler("lang", self.lang)) self.application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.on_text_button)) 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 send_spotify_connected_notice(self, chat_id: str, display_name: str) -> None: lang = self._chat_lang.get(str(chat_id), "ru") await self.send_message(chat_id, self._t(lang, "spotify_connected_notice", display_name=display_name)) 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 def _t(self, lang: Lang, key: str, **kwargs) -> str: template = I18N.get(lang, I18N["en"]).get(key, I18N["en"].get(key, key)) return template.format(**kwargs) def _detect_lang(self, update: Update) -> Lang: chat = update.effective_chat if chat: cached = self._chat_lang.get(str(chat.id)) if cached in {"ru", "en"}: return cached user = update.effective_user tg_lang = (getattr(user, "language_code", None) or "").lower() lang = "ru" if tg_lang.startswith("ru") else "en" if chat: self._chat_lang[str(chat.id)] = lang return lang def _set_chat_lang(self, chat_id: str, lang: Lang) -> None: if lang in {"ru", "en"}: self._chat_lang[str(chat_id)] = lang async def _reply( self, update: Update, text: str, *, lang: Lang | None = None, disable_web_page_preview: bool = False, reply_markup=None, ) -> None: msg = update.effective_message if msg is None: return if reply_markup is None: lang = lang or self._detect_lang(update) reply_markup = self._main_menu(lang) await msg.reply_text(text, disable_web_page_preview=disable_web_page_preview, reply_markup=reply_markup) def _main_menu(self, lang: Lang) -> ReplyKeyboardMarkup: kb = [ [self._t(lang, "button_connect"), self._t(lang, "button_generate")], [self._t(lang, "button_status"), self._t(lang, "button_sync")], [self._t(lang, "button_help"), self._t(lang, "button_settings")], ] return ReplyKeyboardMarkup(kb, resize_keyboard=True, is_persistent=True) def _settings_menu(self, lang: Lang) -> ReplyKeyboardMarkup: kb = [ [ self._t(lang, "button_size_20"), self._t(lang, "button_size_30"), self._t(lang, "button_size_50"), ], [ self._t(lang, "button_ratio_60"), self._t(lang, "button_ratio_80"), self._t(lang, "button_ratio_100"), ], [self._t(lang, "button_language")], [self._t(lang, "button_back")], ] return ReplyKeyboardMarkup(kb, resize_keyboard=True, is_persistent=True) def _language_menu(self, lang: Lang) -> ReplyKeyboardMarkup: kb = [ [self._t(lang, "button_lang_ru"), self._t(lang, "button_lang_en")], [self._t(lang, "button_back")], ] return ReplyKeyboardMarkup(kb, resize_keyboard=True, is_persistent=True) def _button_labels(self, key: str) -> set[str]: return {I18N["ru"][key], I18N["en"][key]} async def on_text_button(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: text = (update.effective_message.text if update.effective_message else "") or "" text = text.strip() lang = self._detect_lang(update) action_handlers: list[tuple[str, Callable[[Update, ContextTypes.DEFAULT_TYPE], Awaitable[None]]]] = [ ("button_connect", self.connect), ("button_generate", self.generate), ("button_status", self.status), ("button_sync", self.sync_likes), ("button_help", self.help), ("button_settings", self.open_settings), ("button_language", self.lang), ] for key, handler in action_handlers: if text in self._button_labels(key): await handler(update, context) return if text in self._button_labels("button_lang_ru"): await self._set_language_from_ui(update, "ru", reply_markup=self._settings_menu("ru")) return if text in self._button_labels("button_lang_en"): await self._set_language_from_ui(update, "en", reply_markup=self._settings_menu("en")) return if text in self._button_labels("button_back"): await self._reply(update, self._t(lang, "main_menu_hint"), lang=lang) return size_map = { "button_size_20": 20, "button_size_30": 30, "button_size_50": 50, } for key, value in size_map.items(): if text in self._button_labels(key): await self._apply_size(update, value, lang=lang, reply_markup=self._settings_menu(lang)) return ratio_map = { "button_ratio_60": 0.6, "button_ratio_80": 0.8, "button_ratio_100": 1.0, } for key, value in ratio_map.items(): if text in self._button_labels(key): await self._apply_ratio(update, value, lang=lang, reply_markup=self._settings_menu(lang)) return await self._reply(update, self._t(lang, "unknown_action"), lang=lang) async def _set_language_from_ui(self, update: Update, lang: Lang, reply_markup=None) -> None: chat = update.effective_chat if not chat: return self._set_chat_lang(str(chat.id), lang) await self._ensure_user(update) await self._reply( update, self._t(lang, "lang_set", lang_name=LANG_NAMES[lang]), lang=lang, reply_markup=reply_markup or self._main_menu(lang), ) async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await self._ensure_user(update) await self.help(update, context) async def help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: lang = self._detect_lang(update) await self._reply(update, self._t(lang, "start_text"), lang=lang) async def open_settings(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: lang = self._detect_lang(update) await self._reply( update, self._t(lang, "settings_menu_hint"), lang=lang, reply_markup=self._settings_menu(lang), ) async def lang(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: lang = self._detect_lang(update) args = list(getattr(context, "args", []) or []) if args: requested = args[0].strip().lower() if requested.startswith("ru"): await self._set_language_from_ui(update, "ru") return if requested.startswith("en"): await self._set_language_from_ui(update, "en") return await self._reply(update, self._t(lang, "lang_invalid"), lang=lang) return await self._reply( update, self._t(lang, "lang_choose"), lang=lang, reply_markup=self._language_menu(lang), ) async def connect(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: lang = self._detect_lang(update) chat = update.effective_chat user = update.effective_user if not chat: return self._set_chat_lang(str(chat.id), lang) url = await self.services.auth.create_connect_url(str(chat.id), user.username if user else None) await self._reply(update, self._t(lang, "connect_open_link", url=url), lang=lang, disable_web_page_preview=True) async def status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: lang = self._detect_lang(update) 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 self._reply(update, self._t(lang, "user_not_found_start"), lang=lang) return latest = await runs.latest_for_user(db_user.id) saved_count = await saved.count_for_user(db_user.id) connected = self._t(lang, "status_yes") if db_user.spotify_refresh_token else self._t(lang, "status_no") text = self._t( lang, "status_text", connected=connected, spotify_user=db_user.spotify_user_id or "-", saved_count=saved_count, playlist_size=db_user.playlist_size, min_new_ratio=db_user.min_new_ratio, last_generated=db_user.last_generated_date or "-", ) if latest: text += "\n" + self._t(lang, "status_last_run", status=latest.status, tracks=latest.total_tracks) if latest.playlist_url: text += "\n" + self._t(lang, "status_last_url", url=latest.playlist_url) await self._reply(update, text, lang=lang, disable_web_page_preview=False) async def latest(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: lang = self._detect_lang(update) 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 self._reply(update, self._t(lang, "no_playlist_yet"), lang=lang) return await self._reply(update, db_user.latest_playlist_url, lang=lang, disable_web_page_preview=False) async def generate(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: lang = self._detect_lang(update) 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 self._reply(update, self._t(lang, "user_not_found_start"), lang=lang) return user_id = db_user.id await self._reply(update, self._t(lang, "generate_wait"), lang=lang) 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 self._reply(update, msg, lang=lang, disable_web_page_preview=False) else: await self._reply(update, self._t(lang, "error_prefix", message=outcome.message), lang=lang) async def _apply_size(self, update: Update, value: int, *, lang: Lang, reply_markup=None) -> None: chat = update.effective_chat if not chat: return if value < 5 or value > 100: await self._reply(update, self._t(lang, "size_invalid"), lang=lang, reply_markup=reply_markup) 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 self._reply(update, self._t(lang, "size_set", value=value), lang=lang, reply_markup=reply_markup) async def set_size(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: lang = self._detect_lang(update) if not context.args: await self._reply(update, self._t(lang, "size_usage"), lang=lang) return try: value = int(context.args[0]) except ValueError: await self._reply(update, self._t(lang, "size_invalid"), lang=lang) return await self._apply_size(update, value, lang=lang) async def _apply_ratio(self, update: Update, value: float, *, lang: Lang, reply_markup=None) -> None: chat = update.effective_chat if not chat: return if value < 0.5 or value > 1.0: await self._reply(update, self._t(lang, "ratio_invalid"), lang=lang, reply_markup=reply_markup) 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 self._reply(update, self._t(lang, "ratio_set", value=value), lang=lang, reply_markup=reply_markup) async def set_ratio(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: lang = self._detect_lang(update) if not context.args: await self._reply(update, self._t(lang, "ratio_usage"), lang=lang) return try: value = float(context.args[0]) except ValueError: await self._reply(update, self._t(lang, "ratio_invalid"), lang=lang) return await self._apply_ratio(update, value, lang=lang) async def sync_likes(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: lang = self._detect_lang(update) 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 self._reply(update, self._t(lang, "user_not_found_start"), lang=lang) return if not db_user.spotify_refresh_token: await self._reply(update, self._t(lang, "sync_need_connect"), lang=lang) 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 self._reply(update, self._t(lang, "sync_done", count=saved_count), lang=lang)