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 2a245d0..4d63cc0 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 _; @@ -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,21 +367,27 @@ 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?; - } - - 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::debug!( + %user_id, + "Spotify refresh token is invalid, continuing with unauthenticated client" + ); + + return Ok(TokenState::Invalid); } let token = instance @@ -392,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 { @@ -410,7 +419,12 @@ impl Manager { } } - pub async fn for_user(&self, db: &DbConn, user_id: &str) -> anyhow::Result { + #[tracing::instrument(skip_all, fields(%user_id))] + 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?; @@ -421,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 {