diff --git a/Cargo.lock b/Cargo.lock index ad08103..6d77c57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1502,6 +1502,7 @@ dependencies = [ "mellow_models", "mellow_util", "once_cell", + "rand", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 995b627..c4d8d71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ chrono = { version = "0.4.38", features = ["serde"] } dashmap = { version = "6.0.1", features = ["inline"] } sqlx = { version = "0.7.4", features = ["uuid", "json", "chrono", "macros", "postgres", "tls-native-tls", "rust_decimal", "runtime-tokio"] } tokio = { version = "1.38.0", features = ["full"] } +rand = "0.8.5" reqwest = { version = "0.12.5", features = ["json"] } serde = { version = "1.0.203", features = ["derive"] } serde_json = "1.0.120" diff --git a/crates/mellow/Cargo.toml b/crates/mellow/Cargo.toml index 7b86416..6fb4bbe 100644 --- a/crates/mellow/Cargo.toml +++ b/crates/mellow/Cargo.toml @@ -26,6 +26,7 @@ mellow_macros.path = "../mellow_macros" mellow_models.path = "../mellow_models" mellow_util.path = "../mellow_util" once_cell = "1.19.0" +rand.workspace = true reqwest.workspace = true sha2 = "0.10.8" serde.workspace = true diff --git a/crates/mellow/src/commands/syncing.rs b/crates/mellow/src/commands/syncing.rs index 7fb6273..ea12aad 100644 --- a/crates/mellow/src/commands/syncing.rs +++ b/crates/mellow/src/commands/syncing.rs @@ -58,32 +58,48 @@ pub async fn sync_with_token(guild_id: Id, user_id: HakuId Your Server Preferences <:external_link:1225472071417729065>]() • [<:personraisedhand:1219234152709095424> Get Support]()", if result.profile_changed { - format!("## <:check2circle:1219235152580837419> {determiner} server profile has been updated.\n{}```diff\n{}```", - if has_assigned_role && has_retracted_role { - format!("{pronoun} been assigned and retracted roles, ...equality! o(>ω<)o") - } else if has_assigned_role { - format!("{pronoun} been assigned new roles, {}", - if is_forced { "yippee!" } else { "hold them dearly to your heart! ♡(>ᴗ•)" } + let content = format!("{}\n[<:gear_fill:1224667889592700950> Your Server Preferences <:external_link:1225472071417729065>]() • [<:personraisedhand:1219234152709095424> Get Support]()", + if result.profile_changed { + format!("## {}\n```diff\n{}```", + if result.issues.is_empty() { + format!("<:check2circle:1219235152580837419> {determiner} server profile has been updated.\n{}", + if has_assigned_role && has_retracted_role { + format!("{pronoun} been assigned and retracted roles, ...equality! o(>ω<)o") + } else if has_assigned_role { + format!("{pronoun} been assigned new roles, {}", + if is_forced { "yippee!" } else { "hold them dearly to your heart! ♡(>ᴗ•)" } + ) + } else { + format!("{pronoun} been retracted some roles, that's either a good thing, or a bad thing! ┐(︶▽︶)┌") + } ) } else { - format!("{pronoun} been retracted some roles, that's either a good thing, or a bad thing! ┐(︶▽︶)┌") + format!("There was an issue while syncing your profile.\n{}", + SyncingIssue::format_many(&result.issues, guild_id, user_id, &website_token) + .await? + ) }, - result.role_changes.iter().map(|x| match x.kind { - RoleChangeKind::Added => format!("+ {}", x.display_name), - RoleChangeKind::Removed => format!("- {}", x.display_name) - }).collect::>().join("\n") + result.role_changes + .iter() + .map(|x| match x.kind { + RoleChangeKind::Added => format!("+ {}", x.display_name), + RoleChangeKind::Removed => format!("- {}", x.display_name) + }) + .collect::>() + .join("\n") ) - } else { - format!("## <:mellow_squircled:1225413361777508393> {determiner} server profile is already up to par!\nAccording to my simulated brain, there's nothing to change here, {contraction} all set!\nIf you were expecting a *different* result, you may need to try again in a few minutes, apologies!\n") - }, if !result.issues.is_empty() { - format!("\n### There were issues with your syncing request\n{}\n", - SyncingIssue::format_many(&result.issues, guild_id, &website_token) + } else if !result.issues.is_empty() { + format!("## There was an issue while syncing your profile.\n{}\n", + SyncingIssue::format_many(&result.issues, guild_id, user_id, &website_token) .await? ) - } else { "".into() }, guild_id))) + } else { + format!("## <:mellow_squircled:1225413361777508393> {determiner} server profile is already up to par!\nAccording to my simulated brain, there's nothing to change here, {contraction} all set!\nIf you were expecting a *different* result, you may need to try again in a few minutes, apologies!\n") + } + ); + DISCORD_INTERACTION_CLIENT + .update_response(interaction_token) + .content(Some(&content)) .await?; let mut server_logs: Vec = vec![]; diff --git a/crates/mellow/src/error.rs b/crates/mellow/src/error.rs index ed6d41e..72b3e1f 100644 --- a/crates/mellow/src/error.rs +++ b/crates/mellow/src/error.rs @@ -8,6 +8,9 @@ pub enum Error { #[error("Model: {0}")] Model(#[from] mellow_models::Error), + + #[error("There is no patreon campaign connected")] + PatreonCampaignNotConnected, #[error("Reqwest Error: {0}")] Reqwest(#[from] reqwest::Error), diff --git a/crates/mellow/src/syncing/mod.rs b/crates/mellow/src/syncing/mod.rs index 4ac8b48..7963550 100644 --- a/crates/mellow/src/syncing/mod.rs +++ b/crates/mellow/src/syncing/mod.rs @@ -17,10 +17,15 @@ use mellow_util::{ marker::{ ConnectionMarker, DocumentMarker, SyncActionMarker, UserMarker as HakuUserMarker }, HakuId }, - DISCORD_CLIENT + DISCORD_CLIENT, + PG_POOL }; +use rand::{ distributions::Alphanumeric, Rng }; use serde::{ Serialize, Deserialize }; -use std::collections::HashMap; +use std::{ + collections::HashMap, + pin::Pin +}; use twilight_http::request::AuditLogReason; use twilight_model::id::{ marker::{ GuildMarker, RoleMarker, UserMarker }, @@ -87,21 +92,41 @@ pub enum SyncingIssue { } impl SyncingIssue { - pub async fn format_many(items: &[Self], guild_id: Id, website_token: &str) -> Result { + pub async fn format_many(items: &[Self], guild_id: Id, user_id: HakuId, website_token: &str) -> Result { let mut strings = Vec::with_capacity(items.len()); for item in items { - strings.push(item.display(guild_id, website_token).await?); + strings.push(item.display(guild_id, user_id, website_token).await?); } Ok(strings.join("\n")) } - pub async fn display(&self, guild_id: Id, website_token: &str) -> Result { + pub async fn display(&self, guild_id: Id, user_id: HakuId, website_token: &str) -> Result { Ok(match self { Self::MissingConnections => format!("You haven't given this server access to all connections yet, fix that [here]()!"), - Self::MissingOAuthAuthorisation(connection_kind) => - format!("Missing authorisation for your {connection_kind:?} connection.") + Self::MissingOAuthAuthorisation(connection_kind) => { + let token: String = rand::thread_rng() + .sample_iter(Alphanumeric) + .take(24) + .map(char::from) + .collect(); + sqlx::query!( + " + INSERT INTO mellow_connection_requests (server_id, user_id, token) + VALUES ($1, $2, $3) + ON CONFLICT (user_id) + DO UPDATE SET token = $3 + ", + guild_id.get() as i64, + user_id.value, + &token + ) + .execute(&*Pin::static_ref(&PG_POOL).await) + .await?; + + format!("Your {connection_kind:?} connection was invalidated, please [reconnect it]().") + } }) } } @@ -212,7 +237,7 @@ pub async fn get_connection_metadata(guild_id: Id, user_ids: &Vec A }, ElementKind::GetLinkedPatreonCampaign => { let guild_id = variables.read().await.get("guild_id").cast_id(); - let oauth_authorisations = CACHE - .mellow - .server_oauth_authorisations(guild_id) - .await?; - let patreon_authorisation = CACHE - .mellow - .oauth_authorisation(oauth_authorisations.into_iter().next().unwrap()) - .unwrap() - .clone(); let campaign = CACHE .patreon - .campaign(patreon_authorisation) + .campaign(guild_id) .await? + .ok_or(Error::PatreonCampaignNotConnected)? .clone(); variables .write() diff --git a/crates/mellow_cache/src/error.rs b/crates/mellow_cache/src/error.rs index e8c8ec3..7ffaa80 100644 --- a/crates/mellow_cache/src/error.rs +++ b/crates/mellow_cache/src/error.rs @@ -1,5 +1,3 @@ -use mellow_models::hakumi::user::connection::ConnectionKind; - #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Model: {0}")] @@ -14,17 +12,14 @@ pub enum Error { #[error("Serde JSON: {0}")] SerdeJson(#[from] serde_json::Error), + #[error("OAuth authorisation refresh failed")] + OAuthAuthorisationRefresh, + #[error("SIMD JSON: {0}")] SimdJson(#[from] simd_json::Error), #[error("SQLx: {0}")] - Sqlx(#[from] sqlx::Error), - - #[error("Your {0:?} connection appears to be invalid, please reconnect it [here]().")] - UserConnectionInvalid(ConnectionKind), - - #[error("Failed to refresh user connection")] - UserConnectionRefresh + Sqlx(#[from] sqlx::Error) } pub type Result = core::result::Result; \ No newline at end of file diff --git a/crates/mellow_cache/src/mellow.rs b/crates/mellow_cache/src/mellow.rs index 4542a55..1e9c931 100644 --- a/crates/mellow_cache/src/mellow.rs +++ b/crates/mellow_cache/src/mellow.rs @@ -31,7 +31,7 @@ use crate::{ CACHE, Result }; #[derive(Default)] pub struct MellowCache { commands: DashMap, CommandModel>, - oauth_authorisations: DashMap, + pub oauth_authorisations: DashMap, pub servers: DashMap, ServerModel>, server_oauth_authorisations: DashMap, DashSet>, server_sync_actions: DashMap, DashSet>>, diff --git a/crates/mellow_cache/src/patreon.rs b/crates/mellow_cache/src/patreon.rs index d7aebe8..f950dee 100644 --- a/crates/mellow_cache/src/patreon.rs +++ b/crates/mellow_cache/src/patreon.rs @@ -3,15 +3,9 @@ use dashmap::{ mapref::one::Ref, DashMap }; -use mellow_models::{ - hakumi::{ - user::connection::ConnectionKind, - OAuthAuthorisationModel - }, - patreon::{ - campaign::{ GetCampaign, Tier }, - CampaignModel, UserIdentityModel - } +use mellow_models::patreon::{ + campaign::{ GetCampaign, Tier }, + CampaignModel, UserIdentityModel }; use mellow_util::{ hakuid::{ @@ -28,6 +22,10 @@ use std::{ collections::HashMap, pin::Pin }; +use twilight_model::id::{ + marker::GuildMarker, + Id +}; use crate::{ CACHE, Error, Result }; @@ -44,23 +42,78 @@ pub struct PatreonRefreshResult { #[derive(Default)] pub struct PatreonCache { - campaigns: DashMap, + campaigns: DashMap, CampaignModel>, user_identities: DashMap, UserIdentityModel> } impl PatreonCache { - pub async fn campaign(&self, oauth_authorisation: OAuthAuthorisationModel) -> Result> { - let access_token = oauth_authorisation.access_token; - Ok(match self.campaigns.get(&access_token) { - Some(model) => model, - None => { + pub async fn campaign(&self, guild_id: Id) -> Result, CampaignModel>>> { + Ok(match self.campaigns.get(&guild_id) { + Some(model) => Some(model), + None => if + let Some(authorisation_id) = CACHE + .mellow + .server_oauth_authorisations(guild_id) + .await? + .into_iter() + .next() && + let Some(authorisation) = CACHE.mellow.oauth_authorisation(authorisation_id) + { + let auth_header = if Utc::now() > authorisation.expires_at { + let response = HTTP + .post("https://www.patreon.com/api/oauth2/token") + .form(&HashMap::from([ + ("client_id", PATREON_CLIENT_ID), + ("client_secret", PATREON_CLIENT_SECRET), + ("grant_type", "refresh_token"), + ("refresh_token", &authorisation.refresh_token) + ])) + .send() + .await?; + let status_code = response.status(); + if status_code.is_success() { + let result: PatreonRefreshResult = response + .json() + .await?; + let expires_at = Utc::now().checked_add_signed(TimeDelta::seconds(result.expires_in)).unwrap(); + sqlx::query!( + " + UPDATE user_connection_oauth_authorisations + SET expires_at = $2, token_type = $3, access_token = $4, refresh_token = $5 + WHERE id = $1 + ", + authorisation.id as i64, + expires_at, + result.token_type, + result.access_token, + result.refresh_token + ) + .execute(&*Pin::static_ref(&PG_POOL).await) + .await?; + + let auth_header = format!("{} {}", result.token_type, result.access_token); + if let Some(mut authorisation) = CACHE.mellow.oauth_authorisations.get_mut(&authorisation_id) { + authorisation.expires_at = expires_at; + authorisation.token_type = result.token_type; + authorisation.access_token = result.access_token; + authorisation.refresh_token = result.refresh_token; + } + + auth_header + } else if status_code == StatusCode::UNAUTHORIZED { + return Ok(None); + } else { + return Err(Error::OAuthAuthorisationRefresh); + } + } else { format!("{} {}", authorisation.token_type, authorisation.access_token) }; + let campaign: GetCampaign = get_json("https://www.patreon.com/api/oauth2/v2/campaigns?include=tiers&fields%5Btier%5D=patron_count") - .header("authorization", format!("{} {}", oauth_authorisation.token_type, access_token)) + .header("authorization", auth_header) .await?; let Some(included) = campaign.included else { return Err(Error::ModelNotFound); }; - self.campaigns.entry(access_token) + Some(self.campaigns.entry(guild_id) .insert(CampaignModel { tiers: included .into_iter() @@ -70,7 +123,8 @@ impl PatreonCache { .collect() }) .downgrade() - } + ) + } else { None } }) } @@ -129,9 +183,9 @@ impl PatreonCache { authorisation = authorisation2.clone(); } } else if status_code == StatusCode::UNAUTHORIZED { - return Err(Error::UserConnectionInvalid(ConnectionKind::Patreon)); + return Ok(None); } else { - return Err(Error::UserConnectionRefresh); + return Err(Error::OAuthAuthorisationRefresh); } } diff --git a/crates/mellow_util/Cargo.toml b/crates/mellow_util/Cargo.toml index 299ae94..21d6576 100644 --- a/crates/mellow_util/Cargo.toml +++ b/crates/mellow_util/Cargo.toml @@ -17,7 +17,7 @@ async-once-cell.workspace = true chrono.workspace = true http = "1.1.0" once_cell.workspace = true -rand = "0.8.5" +rand.workspace = true reqwest.workspace = true serde.workspace = true sqlx.workspace = true