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