From 9c48e39363c9a6015d3c8d5b6713fd79090e643a Mon Sep 17 00:00:00 2001 From: Vitaly Date: Mon, 20 Jul 2026 13:56:10 +0300 Subject: [PATCH 1/3] Keep bot usable when Spotify refresh token becomes invalid Previously token_refresh returned an error on invalid_grant, which made user_state construction fail and every Telegram update die before any reply was sent, locking the user out of the bot entirely. Now only the in-memory token is cleared (the spotify_auth row is kept), so handlers treat the user as unauthenticated and prompt them to /login again. --- src/spotify/mod.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/spotify/mod.rs b/src/spotify/mod.rs index 2a245d0..1c38dee 100644 --- a/src/spotify/mod.rs +++ b/src/spotify/mod.rs @@ -5,7 +5,7 @@ use std::borrow::Cow; use std::ops::Deref; use std::sync::Arc; -use anyhow::{Context as _, anyhow}; +use anyhow::Context as _; use auth::SpotifyAuthService; use chrono::{Duration, NaiveDate}; use deadpool_redis::redis::AsyncCommands as _; @@ -378,7 +378,21 @@ impl Manager { txn.commit().await?; } - return Err(anyhow!("Token is invalid")); + // Keep the spotify_auth row in the database, but drop the in-memory + // token so the state behaves as unauthenticated and handlers prompt + // the user to /login instead of failing before any reply is sent + *instance + .get_token() + .lock() + .await + .expect("Cannot acquire lock") = None; + + tracing::warn!( + %user_id, + "Spotify refresh token is invalid, continuing with unauthenticated client" + ); + + return Ok(()); } let token = instance @@ -410,6 +424,7 @@ impl Manager { } } + #[tracing::instrument(skip_all, fields(%user_id))] pub async fn for_user(&self, db: &DbConn, user_id: &str) -> anyhow::Result { let mut instance = self.spotify.clone(); instance.token = Arc::default(); From f2a13ff8ce7153a1f3874b26c14f1944a5f525a8 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Tue, 21 Jul 2026 17:46:01 +0300 Subject: [PATCH 2/3] Notify user once when Spotify refresh token becomes invalid The invalid-token alert previously depended on the background tick being the first to hit the dead token; if the user interacted with the bot first, the status flipped silently and the alert was never sent. Now user_state detects the transition itself, marks the user and sends the localized /login prompt directly, whichever path discovers it first. The spotify layer no longer touches user status and only reports TokenState. --- src/app.rs | 19 +++++++++++++++++-- src/spotify/mod.rs | 35 +++++++++++++++++------------------ src/telegram/mod.rs | 27 +++++++++++++++++++++++++++ src/tick/user.rs | 4 ++++ 4 files changed, 65 insertions(+), 20 deletions(-) diff --git a/src/app.rs b/src/app.rs index ea166d9..0214790 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,12 +13,14 @@ use teloxide::dispatching::dialogue::RedisStorage as TeloxideRedisStorage; use teloxide::dispatching::dialogue::serializer::Bincode; use teloxide::requests::RequesterExt as _; +use crate::entity::prelude::UserStatus; use crate::metrics::influx::InfluxClient; use crate::metrics::prometheus::PrometheusClient; use crate::queue::QueueManager; use crate::services::{AISlopDetectionService, SongLinkService, UserService}; +use crate::spotify::TokenState; use crate::user::UserState; -use crate::{lyrics, profanity, spotify}; +use crate::{lyrics, profanity, spotify, telegram}; pub struct App { spotify_manager: spotify::Manager, @@ -372,8 +374,21 @@ impl App { } pub async fn user_state(&'static self, user_id: &str) -> anyhow::Result { - let spotify = self.spotify_manager.for_user(&self.db, user_id).await?; let (user, newly_created) = UserService::upsert_by_id(self.db(), user_id).await?; + let (spotify, token_state) = self.spotify_manager.for_user(&self.db, user_id).await?; + + if matches!(token_state, TokenState::Invalid) + && user.status != UserStatus::SpotifyTokenInvalid + { + UserService::set_status(self.db(), user_id, UserStatus::SpotifyTokenInvalid).await?; + + // NOTE: Yes, it's a hack. I don't know how to handle invalid Spotify token in the right way + // with error_handler module + if let Err(err) = telegram::notify_token_invalid(self, &user).await { + tracing::error!(err = ?err, %user_id, "Failed to notify about invalid Spotify token"); + } + } + let state = UserState::new(user, newly_created, spotify); Ok(state) diff --git a/src/spotify/mod.rs b/src/spotify/mod.rs index 1c38dee..641e2e4 100644 --- a/src/spotify/mod.rs +++ b/src/spotify/mod.rs @@ -24,11 +24,9 @@ use rspotify::model::{ TrackId, }; use rspotify::{AuthCodeSpotify, ClientError, ClientResult, Token, scopes}; -use sea_orm::{DbConn, TransactionTrait as _}; +use sea_orm::DbConn; use teloxide::utils::html; -use crate::entity::prelude::*; -use crate::services::UserService; use crate::user::UserState; pub struct ShortPlaylist { @@ -300,6 +298,11 @@ impl From for CurrentlyPlaying { } } +pub enum TokenState { + Valid, + Invalid, +} + pub struct Manager { spotify: AuthCodeSpotify, } @@ -354,7 +357,7 @@ impl Manager { db: &DbConn, user_id: &str, instance: &AuthCodeSpotify, - ) -> anyhow::Result<()> { + ) -> anyhow::Result { let should_reauth = instance .get_token() .lock() @@ -364,20 +367,12 @@ impl Manager { .is_some_and(Token::is_expired); if !should_reauth { - return Ok(()); + return Ok(TokenState::Valid); } let res = instance.refresh_token().await; if !Self::is_token_valid(res).await? { - { - let txn = db.begin().await?; - - UserService::set_status(&txn, user_id, UserStatus::SpotifyTokenInvalid).await?; - - txn.commit().await?; - } - // Keep the spotify_auth row in the database, but drop the in-memory // token so the state behaves as unauthenticated and handlers prompt // the user to /login instead of failing before any reply is sent @@ -392,7 +387,7 @@ impl Manager { "Spotify refresh token is invalid, continuing with unauthenticated client" ); - return Ok(()); + return Ok(TokenState::Invalid); } let token = instance @@ -406,7 +401,7 @@ impl Manager { SpotifyAuthService::set_token(db, user_id, token).await?; } - Ok(()) + Ok(TokenState::Valid) } async fn is_token_valid(mut res: ClientResult<()>) -> anyhow::Result { @@ -425,7 +420,11 @@ impl Manager { } #[tracing::instrument(skip_all, fields(%user_id))] - pub async fn for_user(&self, db: &DbConn, user_id: &str) -> anyhow::Result { + pub async fn for_user( + &self, + db: &DbConn, + user_id: &str, + ) -> anyhow::Result<(AuthCodeSpotify, TokenState)> { let mut instance = self.spotify.clone(); instance.token = Arc::default(); let token = SpotifyAuthService::get_token(db, user_id).await?; @@ -436,9 +435,9 @@ impl Manager { .await .expect("Cannot acquire lock") = token; - Self::token_refresh(db, user_id, &instance).await?; + let token_state = Self::token_refresh(db, user_id, &instance).await?; - Ok(instance) + Ok((instance, token_state)) } pub async fn get_authorize_url(&self, state: &UserState) -> anyhow::Result { diff --git a/src/telegram/mod.rs b/src/telegram/mod.rs index c61f31a..fe7dec3 100644 --- a/src/telegram/mod.rs +++ b/src/telegram/mod.rs @@ -8,4 +8,31 @@ pub mod inline_buttons_admin; pub mod keyboards; pub mod utils; +use teloxide::payloads::SendMessageSetters as _; +use teloxide::prelude::Requester as _; +use teloxide::types::ChatId; + +use crate::app::App; +use crate::entity::prelude::UserModel; +use crate::telegram::commands::UserCommandDisplay; +use crate::telegram::keyboards::StartKeyboard; + pub const MESSAGE_MAX_LEN: usize = 4096; + +// TODO: Find a better place for this function +#[tracing::instrument(skip_all, fields(user_id = %user.id))] +pub async fn notify_token_invalid(app: &App, user: &UserModel) -> anyhow::Result<()> { + app.bot() + .send_message( + ChatId(user.id.parse()?), + t!( + "error.spotify-invalid-token", + locale = user.locale.as_ref(), + command = UserCommandDisplay::Login, + ), + ) + .reply_markup(StartKeyboard::markup(user.locale.as_ref())) + .await?; + + Ok(()) +} diff --git a/src/tick/user.rs b/src/tick/user.rs index b60a468..05786e4 100644 --- a/src/tick/user.rs +++ b/src/tick/user.rs @@ -29,6 +29,10 @@ pub async fn check(app: &'static App, user_id: &str) -> anyhow::Result state, }; + if !state.is_spotify_authed().await { + return Ok(CheckUserResult::Complete); + } + let playing = state.spotify().await.current_playing_wrapped().await; let (track, context) = match playing { From 0daefe5e0b6a04ecb6374a6702025340c826a260 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Tue, 21 Jul 2026 17:46:25 +0300 Subject: [PATCH 3/3] debug --- src/spotify/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spotify/mod.rs b/src/spotify/mod.rs index 641e2e4..4d63cc0 100644 --- a/src/spotify/mod.rs +++ b/src/spotify/mod.rs @@ -382,7 +382,7 @@ impl Manager { .await .expect("Cannot acquire lock") = None; - tracing::warn!( + tracing::debug!( %user_id, "Spotify refresh token is invalid, continuing with unauthenticated client" );