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

564 lines
26 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_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)