Files
spotify_vibe/app/bot/telegram_bot.py
2026-02-26 20:49:42 +00:00

751 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
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_lang_uk": "🇺🇦 Українська",
"button_lang_nl": "🇳🇱 Nederlands",
"button_back": "⬅️ Назад",
"start_text": (
"🎧 Я бот для Spotify Daily Vibe playlist.\n"
"Помогаю собирать плейлисты по вашей недавней истории прослушиваний и лайкнутым трекам.\n"
"Главная цель: предлагать новые треки, которые вы, скорее всего, еще не слышали, и при этом снижать количество повторов.\n\n"
"⚡ Быстрый старт:\n"
"1. /connect — подключить Spotify\n"
"2. /sync — подтянуть лайкнутые треки (рекомендуется после подключения)\n"
"3. /generate — сгенерировать плейлист\n"
"4. /status — посмотреть статус и ссылку на последний результат\n\n"
"✨ Что умею:\n"
"• учитывать недавние прослушивания и лайкнутые треки\n"
"• в первую очередь искать новые треки, которые с высокой вероятностью вам незнакомы\n"
"• снижать количество повторов между генерациями\n"
"• запоминать, что уже рекомендовал раньше\n"
"• настраивать размер плейлиста и долю новых треков\n"
"• работать на нескольких языках интерфейса\n\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|uk|nl] - сменить язык"
),
"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`, `uk` и `nl`.",
"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_lang_uk": "🇺🇦 Українська",
"button_lang_nl": "🇳🇱 Nederlands",
"button_back": "⬅️ Back",
"start_text": (
"🎧 I am a Spotify Daily Vibe playlist bot.\n"
"I build playlists using your recent listening history and liked tracks.\n"
"My main goal is to suggest new tracks you most likely have not heard before, while reducing repeats.\n\n"
"⚡ Quick start:\n"
"1. /connect - connect Spotify\n"
"2. /sync - sync liked tracks (recommended after connecting)\n"
"3. /generate - generate a playlist\n"
"4. /status - check status and the latest result link\n\n"
"✨ What I do:\n"
"• use your recent listening history and liked tracks\n"
"• prioritize new tracks that are likely unfamiliar to you\n"
"• reduce repeats across generations\n"
"• remember what I already recommended before\n"
"• let you tune playlist size and new-track ratio\n"
"• support multiple interface languages\n\n"
"🛠 Commands:\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|uk|nl] - 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`, `en`, `uk`, and `nl` 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}",
},
"uk": {
"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_lang_uk": "🇺🇦 Українська",
"button_lang_nl": "🇳🇱 Nederlands",
"button_back": "⬅️ Назад",
"start_text": (
"🎧 Я бот для Spotify Daily Vibe playlist.\n"
"Допомагаю збирати плейлисти на основі вашої недавньої історії прослуховувань і лайкнутих треків.\n"
"Головна мета: пропонувати нові треки, які ви, найімовірніше, ще не чули, і водночас зменшувати кількість повторів.\n\n"
"⚡ Швидкий старт:\n"
"1. /connect — підключити Spotify\n"
"2. /sync — підтягнути лайкнуті треки (рекомендовано після підключення)\n"
"3. /generate — згенерувати плейлист\n"
"4. /status — переглянути статус і посилання на останній результат\n\n"
"✨ Що вмію:\n"
"• враховувати недавні прослуховування й лайкнуті треки\n"
"• насамперед шукати нові треки, які з високою ймовірністю вам незнайомі\n"
"• зменшувати кількість повторів між генераціями\n"
"• пам’ятати, що вже рекомендував раніше\n"
"• налаштовувати розмір плейлиста та частку нових треків\n"
"• працювати кількома мовами інтерфейсу\n\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|uk|nl] - змінити мову"
),
"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`, `uk` та `nl`.",
"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}",
},
"nl": {
"button_connect": "🔗 Spotify koppelen",
"button_generate": "✨ Genereren",
"button_status": "📊 Status",
"button_latest": "🎵 Laatste playlist",
"button_sync": "🔄 Likes syncen",
"button_help": "❓ Help",
"button_settings": "⚙️ Instellingen",
"button_language": "🌐 Taal / Language",
"button_size_20": "📏 Grootte 20",
"button_size_30": "📏 Grootte 30",
"button_size_50": "📏 Grootte 50",
"button_ratio_60": "🆕 Nieuw 60%",
"button_ratio_80": "🆕 Nieuw 80%",
"button_ratio_100": "🆕 Nieuw 100%",
"button_lang_ru": "🇺🇦 Русский",
"button_lang_en": "🇺🇸 English",
"button_lang_uk": "🇺🇦 Українська",
"button_lang_nl": "🇳🇱 Nederlands",
"button_back": "⬅️ Terug",
"start_text": (
"🎧 Ik ben een Spotify Daily Vibe playlist-bot.\n"
"Ik maak playlists op basis van je recente luistergeschiedenis en gelikete nummers.\n"
"Het hoofddoel: nieuwe tracks voorstellen die je waarschijnlijk nog niet hebt gehoord, met minder herhalingen.\n\n"
"⚡ Snel starten:\n"
"1. /connect - Spotify koppelen\n"
"2. /sync - gelikete nummers synchroniseren (aanbevolen na koppelen)\n"
"3. /generate - een playlist genereren\n"
"4. /status - status en link naar het laatste resultaat bekijken\n\n"
"✨ Wat ik doe:\n"
"• je recente luistergeschiedenis en gelikete nummers gebruiken\n"
"• vooral nieuwe tracks zoeken die waarschijnlijk nieuw voor je zijn\n"
"• herhalingen tussen generaties verminderen\n"
"• onthouden wat al eerder is aanbevolen\n"
"• playlistgrootte en ratio nieuwe tracks instellen\n"
"• meerdere interfacetalen ondersteunen\n\n"
"🛠 Commando's:\n"
"/connect - Spotify koppelen\n"
"/status - accountstatus en laatste playlist\n"
"/generate - nu een playlist genereren\n"
"/latest - link naar de laatste playlist tonen\n"
"/setsize N - playlistgrootte\n"
"/setratio X - minimale nieuwe ratio (0.5..1.0)\n"
"/sync - gelikete nummers uit Spotify synchroniseren\n"
"/lang [ru|en|uk|nl] - taal wijzigen"
),
"connect_open_link": "Open de link en autoriseer Spotify:\n{url}",
"spotify_connected_notice": "Spotify gekoppeld: {display_name}\nJe kunt nu /generate gebruiken",
"user_not_found_start": "Gebruiker niet gevonden. Stuur /start",
"no_playlist_yet": "Nog geen gegenereerde playlist.",
"generate_wait": "Playlist wordt gegenereerd, dit kan 20-60 seconden duren...",
"error_prefix": "Fout: {message}",
"size_usage": "Gebruik: /setsize 30",
"size_invalid": "Grootte moet een getal zijn van 5 t/m 100.",
"size_set": "Playlistgrootte ingesteld op: {value}",
"ratio_usage": "Gebruik: /setratio 0.8",
"ratio_invalid": "Waarde moet tussen 0.5 en 1.0 zijn",
"ratio_set": "Minimale nieuwe ratio: {value:.2f}",
"sync_need_connect": "Gebruik eerst /connect",
"sync_done": "Liked tracks gesynchroniseerd: {count}",
"lang_choose": "Kies de interfacetaal:",
"lang_set": "Interfacetaal: {lang_name}",
"lang_invalid": "Alleen `ru`, `en`, `uk` en `nl` worden ondersteund.",
"unknown_action": "Ik begreep dat niet. Gebruik de knoppen hieronder of /help.",
"main_menu_hint": "Hoofdmenu",
"settings_menu_hint": "Instellingen",
"status_yes": "ja",
"status_no": "nee",
"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", "uk": "Українська", "nl": "Nederlands"}
SUPPORTED_LANGS = frozenset(LANG_NAMES.keys())
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), "en")
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 SUPPORTED_LANGS:
return cached
user = update.effective_user
tg_lang = (getattr(user, "language_code", None) or "").lower()
if tg_lang.startswith("uk") or tg_lang.startswith("ua"):
lang = "uk"
elif tg_lang.startswith("ru"):
lang = "ru"
elif tg_lang.startswith("nl"):
lang = "nl"
else:
lang = "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 SUPPORTED_LANGS:
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_lang_uk"), self._t(lang, "button_lang_nl")],
[self._t(lang, "button_back")],
]
return ReplyKeyboardMarkup(kb, resize_keyboard=True, is_persistent=True)
def _button_labels(self, key: str) -> set[str]:
return {I18N[code][key] for code in SUPPORTED_LANGS if key in I18N.get(code, {})}
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_lang_uk"):
await self._set_language_from_ui(update, "uk", reply_markup=self._settings_menu("uk"))
return
if text in self._button_labels("button_lang_nl"):
await self._set_language_from_ui(update, "nl", reply_markup=self._settings_menu("nl"))
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
if requested.startswith("uk") or requested.startswith("ua"):
await self._set_language_from_ui(update, "uk")
return
if requested.startswith("nl"):
await self._set_language_from_ui(update, "nl")
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)