743 lines
36 KiB
Python
743 lines
36 KiB
Python
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"
|
||
"Делаю плейлисты по вашему 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|uk|nl] - сменить язык"
|
||
),
|
||
"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|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 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|uk|nl] - 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|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"
|
||
"Створюю плейлисти з вашого 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|uk|nl] - змінити мову"
|
||
),
|
||
"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|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 recent listening + liked tracks en probeer ze fris te houden met minder herhalingen.\n\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 - liked tracks uit Spotify synchroniseren\n"
|
||
"/lang [ru|en|uk|nl] - taal wijzigen"
|
||
),
|
||
"help_text": (
|
||
"Ik ben een Spotify daily vibe playlist-bot.\n"
|
||
"Ik maak playlists op basis van je recent listening + liked tracks en probeer ze fris te houden met minder herhalingen.\n\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 - liked tracks 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)
|