diff --git a/.env.example b/.env.example index 2c4cf7b..8da76e8 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,9 @@ GITHUB_CLIENT_SECRET= # Discord DISCORD_WEBHOOK_URL= +DISCORD_BOT_TOKEN= +DISCORD_GUILD_ID= +DISCORD_CHANNEL_ID= # Config diff --git a/src/config.rs b/src/config.rs index ac75c16..8132223 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,6 +5,7 @@ pub struct AppData { front_url: String, github: GitHubClientData, webhook_url: String, + discord: DiscordForumData, disable_downloads: bool, max_download_mb: u32, port: u16, @@ -17,6 +18,13 @@ pub struct GitHubClientData { client_secret: String, } +#[derive(Clone)] +pub struct DiscordForumData { + guild_id: u64, + channel_id: u64, + bot_token: String, +} + pub async fn build_config() -> anyhow::Result { let env_url = dotenvy::var("DATABASE_URL")?; @@ -31,6 +39,15 @@ pub async fn build_config() -> anyhow::Result { let github_client = dotenvy::var("GITHUB_CLIENT_ID").unwrap_or("".to_string()); let github_secret = dotenvy::var("GITHUB_CLIENT_SECRET").unwrap_or("".to_string()); let webhook_url = dotenvy::var("DISCORD_WEBHOOK_URL").unwrap_or("".to_string()); + let guild_id = dotenvy::var("DISCORD_GUILD_ID") + .unwrap_or("0".to_string()) + .parse::() + .unwrap_or(0); + let channel_id = dotenvy::var("DISCORD_CHANNEL_ID") + .unwrap_or("0".to_string()) + .parse::() + .unwrap_or(0); + let bot_token = dotenvy::var("DISCORD_BOT_TOKEN").unwrap_or("".to_string()); let disable_downloads = dotenvy::var("DISABLE_DOWNLOAD_COUNTS").unwrap_or("0".to_string()) == "1"; let max_download_mb = dotenvy::var("MAX_MOD_FILESIZE_MB") @@ -47,6 +64,11 @@ pub async fn build_config() -> anyhow::Result { client_secret: github_secret, }, webhook_url, + discord: DiscordForumData { + guild_id, + channel_id, + bot_token, + }, disable_downloads, max_download_mb, port, @@ -64,6 +86,24 @@ impl GitHubClientData { } } +impl DiscordForumData { + pub fn is_valid(&self) -> bool { + self.guild_id != 0 && self.channel_id != 0 && !self.bot_token.is_empty() + } + + pub fn guild_id(&self) -> u64 { + self.guild_id + } + + pub fn channel_id(&self) -> u64 { + self.channel_id + } + + pub fn bot_auth(&self) -> String { + format!("Bot {}", self.bot_token) + } +} + impl AppData { pub fn db(&self) -> &sqlx::postgres::PgPool { &self.db @@ -85,6 +125,10 @@ impl AppData { &self.webhook_url } + pub fn discord(&self) -> &DiscordForumData { + &self.discord + } + pub fn disable_downloads(&self) -> bool { self.disable_downloads } diff --git a/src/endpoints/mod_versions.rs b/src/endpoints/mod_versions.rs index cb1e076..5006c49 100644 --- a/src/endpoints/mod_versions.rs +++ b/src/endpoints/mod_versions.rs @@ -18,6 +18,7 @@ use crate::types::models; use crate::webhook::discord::DiscordWebhook; use crate::{ extractors::auth::Auth, + forum, types::{ api::ApiResponse, mod_json::{split_version_and_compare, ModJson}, @@ -355,6 +356,8 @@ pub async fn create_version( .collect(), ); + let json_version = json.version.clone(); + if make_accepted { if let Some(links) = json.links.clone() { mod_links::upsert( @@ -397,6 +400,17 @@ pub async fn create_version( version.modify_metadata(data.app_url(), false); + if !make_accepted { + forum::discord::create_or_update_thread( + data.discord().clone(), + id, + json_version, + "".to_string(), + data.app_url().to_string(), + pool, + ); + } + Ok(HttpResponse::Created().json(ApiResponse { error: "".into(), payload: version, @@ -497,6 +511,8 @@ pub async fn update_version( tx.commit().await?; + let display_name = dev.display_name.clone(); + if payload.status == ModVersionStatusEnum::Accepted { let is_update = approved_count > 0; @@ -531,5 +547,16 @@ pub async fn update_version( } } + if payload.status == ModVersionStatusEnum::Accepted || payload.status == ModVersionStatusEnum::Rejected { + forum::discord::create_or_update_thread( + data.discord().clone(), + path.id.clone(), + path.version.clone(), + display_name, + data.app_url().to_string(), + pool + ); + } + Ok(HttpResponse::NoContent()) } diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index 862bc95..f58b0b3 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -10,6 +10,7 @@ use crate::database::repository::mods; use crate::endpoints::ApiError; use crate::events::mod_feature::ModFeaturedEvent; use crate::extractors::auth::Auth; +use crate::forum; use crate::mod_zip; use crate::types::api::{create_download_link, ApiResponse}; use crate::types::mod_json::ModJson; @@ -220,6 +221,15 @@ pub async fn create( i.modify_metadata(data.app_url(), false); } + forum::discord::create_or_update_thread( + data.discord().clone(), + json.id, + json.version, + "".to_string(), + data.app_url().to_string(), + pool + ); + Ok(HttpResponse::Created().json(ApiResponse { error: "".into(), payload: the_mod, diff --git a/src/forum/discord.rs b/src/forum/discord.rs new file mode 100644 index 0000000..fed6eb1 --- /dev/null +++ b/src/forum/discord.rs @@ -0,0 +1,341 @@ +use serde_json::{json, to_string, Value}; + +use crate::config::DiscordForumData; +use crate::types::models::mod_entity::Mod; +use crate::types::models::mod_version::ModVersion; +use crate::types::models::mod_version_status::ModVersionStatusEnum; + +fn mod_embed(m: &Mod, v: &ModVersion, base_url: &str) -> Value { + json!({ + "title": if m.featured { + format!("⭐️ {}", v.name) + } else { + v.name.clone() + }, + "description": v.description, + "url": format!("https://geode-sdk.org/mods/{}?version={}", m.id, v.version), + "thumbnail": { + "url": format!("{}/v1/mods/{}/logo", base_url, m.id) + }, + "fields": [ + { + "name": "ID", + "value": m.id, + "inline": true + }, + { + "name": "Version", + "value": v.version, + "inline": true + }, + { + "name": "Geode", + "value": v.geode, + "inline": true + }, + { + "name": "Early Load", + "value": v.early_load, + "inline": true + }, + { + "name": "API", + "value": v.api, + "inline": true + }, + { + "name": "Developers", + "value": m.developers.clone().into_iter().map(|d| { + if d.is_owner { + format!("**[{}](https://geode-sdk.org/developers/{})**", d.display_name, d.id) + } else { + format!("[{}](https://geode-sdk.org/developers/{})", d.display_name, d.id) + } + }).collect::>().join(", "), + "inline": true + }, + { + "name": "Geometry Dash", + "value": format!( + "Windows: {}\nAndroid (64-bit): {}\nAndroid (32-bit): {}\nmacOS (ARM): {}\nmacOS (Intel): {}\niOS: {}", + v.gd.win.map(|x| to_string(&x).ok()).flatten().unwrap_or("N/A".to_string()).replace('"', ""), + v.gd.android64.map(|x| to_string(&x).ok()).flatten().unwrap_or("N/A".to_string()).replace('"', ""), + v.gd.android32.map(|x| to_string(&x).ok()).flatten().unwrap_or("N/A".to_string()).replace('"', ""), + v.gd.mac_arm.map(|x| to_string(&x).ok()).flatten().unwrap_or("N/A".to_string()).replace('"', ""), + v.gd.mac_intel.map(|x| to_string(&x).ok()).flatten().unwrap_or("N/A".to_string()).replace('"', ""), + v.gd.ios.map(|x| to_string(&x).ok()).flatten().unwrap_or("N/A".to_string()).replace('"', "") + ), + "inline": false + }, + { + "name": "Dependencies", + "value": v.dependencies.clone().map(|x| { + if !x.is_empty() { + x.into_iter().map(|d| { + format!("`{} {} ({})`", d.mod_id, d.version, to_string(&d.importance) + .unwrap_or("unknown".to_string()).replace('"', "")) + }).collect::>().join("\n") + } else { + "None".to_string() + } + }).unwrap_or("None".to_string()), + "inline": false + }, + { + "name": "Incompatibilities", + "value": v.incompatibilities.clone().map(|x| { + if !x.is_empty() { + x.into_iter().map(|i| { + format!("`{} {} ({})`", i.mod_id, i.version, to_string(&i.importance) + .unwrap_or("unknown".to_string()).replace('"', "")) + }).collect::>().join("\n") + } else { + "None".to_string() + } + }).unwrap_or("None".to_string()), + "inline": false + }, + { + "name": "Source", + "value": m.links.clone().map(|l| l.source).flatten() + .unwrap_or(m.repository.clone().unwrap_or("N/A".to_string())), + "inline": true + }, + { + "name": "Community", + "value": m.links.clone().map(|l| l.community).flatten().unwrap_or("N/A".to_string()), + "inline": true + }, + { + "name": "Homepage", + "value": m.links.clone().map(|l| l.homepage).flatten().unwrap_or("N/A".to_string()), + "inline": true + }, + { + "name": "Hash", + "value": format!("`{}`", v.hash), + "inline": true + }, + { + "name": "Download", + "value": v.download_link, + "inline": true + }, + { + "name": "Tags", + "value": v.tags.clone().map(|x| { + if !x.is_empty() { + x.into_iter().map(|t| format!("`{}`", t)).collect::>().join(", ") + } else { + "None".to_string() + } + }).unwrap_or("None".to_string()), + "inline": true + } + ] + }) +} + +pub async fn get_threads(data: &DiscordForumData) -> Vec { + let client = reqwest::Client::new(); + let res = client + .get(format!("https://discord.com/api/v10/guilds/{}/threads/active", data.guild_id())) + .header("Authorization", data.bot_auth()) + .send() + .await; + if res.is_err() { + return vec![]; + } + let res = res.unwrap(); + if !res.status().is_success() { + return vec![]; + } + let res = res.json::().await; + if res.is_err() { + return vec![]; + } + let res = res.unwrap()["threads"].clone(); + if !res.is_array() { + return vec![]; + } + + let channel_id = data.channel_id(); + let vec1 = res.as_array().unwrap().clone().into_iter() + .filter(|t| t["parent_id"].as_str().unwrap_or("").to_string().parse::().unwrap_or(0) == channel_id) + .collect::>(); + + let res2 = client + .get(format!("https://discord.com/api/v10/channels/{}/threads/archived/public", channel_id)) + .header("Authorization", data.bot_auth()) + .send() + .await; + if res2.is_err() { + return vec1; + } + let res2 = res2.unwrap(); + if !res2.status().is_success() { + return vec1; + } + let res2 = res2.json::().await; + if res2.is_err() { + return vec1; + } + let res2 = res2.unwrap()["threads"].clone(); + if !res2.is_array() { + return vec1; + } + + let vec2 = res2.as_array().unwrap().clone(); + + [vec1, vec2].concat().into_iter() + .filter(|t| t["thread_metadata"]["locked"].is_boolean() && !t["thread_metadata"]["locked"].as_bool().unwrap()) + .collect::>() +} + +pub fn create_or_update_thread( + data: DiscordForumData, + id: String, + ver: String, + admin: String, + base_url: String, + mut pool: sqlx::pool::PoolConnection, +) { + tokio::spawn(async move { + if !data.is_valid() { + log::error!("Discord configuration is not set up. Not creating forum threads."); + return; + } + + let mod_res = Mod::get_one(&id, false, &mut pool).await.ok().flatten(); + if mod_res.is_none() { + return; + } + let version_res = ModVersion::get_one(&id, &ver, true, false, &mut pool).await.ok().flatten(); + if version_res.is_none() { + return; + } + create_or_update_thread_internal( + None, + &data, + &mod_res.unwrap(), + &version_res.unwrap(), + &admin, + &base_url + ).await; + }); +} + +pub async fn create_or_update_thread_internal( + threads: Option>, + data: &DiscordForumData, + m: &Mod, + v: &ModVersion, + admin: &str, + base_url: &str +) { + if !data.is_valid() { + log::error!("Discord configuration is not set up. Not creating forum threads."); + return; + } + + let thread_vec = if threads.is_some() { + threads.unwrap() + } else { + get_threads(data).await + }; + + let thread = thread_vec.iter().find(|t| { + t["name"].as_str().unwrap_or("").contains(format!("({})", m.id).as_str()) + }); + + let client = reqwest::Client::new(); + if thread.is_none() { + if v.status != ModVersionStatusEnum::Pending { + return; + } + + let _ = client + .post(format!("https://discord.com/api/v10/channels/{}/threads", data.channel_id())) + .header("Authorization", data.bot_auth()) + .json(&json!({ + "name": format!("🕓 {} ({}) {}", v.name, m.id, v.version), + "message": { + "embeds": [mod_embed(m, v, base_url)] + } + })) + .send() + .await; + return; + } + + let thread_id = thread.unwrap()["id"].as_str().unwrap_or(""); + if thread_id.is_empty() { + return; + } + + if thread.unwrap()["name"].as_str().unwrap_or("").ends_with(format!("{} ({}) {}", v.name, m.id, v.version).as_str()) { + if v.status == ModVersionStatusEnum::Pending { + return; + } + + let _ = client + .post(format!("https://discord.com/api/v10/channels/{}/messages", thread_id)) + .header("Authorization", data.bot_auth()) + .json(&json!({ + "content": format!("{}{}{}", match v.status { + ModVersionStatusEnum::Accepted => "Accepted", + ModVersionStatusEnum::Rejected => "Rejected", + _ => "", + }, if !admin.is_empty() { + format!(" by {}", admin) + } else { + "".to_string() + }, if v.info.is_some() && !v.info.clone().unwrap().is_empty() { + format!("\n```\n{}\n```", v.info.clone().unwrap()) + } else { + "".to_string() + }), + "message_reference": { + "message_id": thread_id, + "fail_if_not_exists": false + } + })) + .send() + .await; + + let _ = client + .patch(format!("https://discord.com/api/v10/channels/{}", thread_id)) + .header("Authorization", data.bot_auth()) + .json(&json!({ + "name": match v.status { + ModVersionStatusEnum::Accepted => format!("✅ {} ({}) {}", v.name, m.id, v.version), + ModVersionStatusEnum::Rejected => format!("❌ {} ({}) {}", v.name, m.id, v.version), + _ => format!("🕓 {} ({}) {}", v.name, m.id, v.version), + }, + "locked": true, + "archived": true + })) + .send() + .await; + + return; + } + + let _ = client + .patch(format!("https://discord.com/api/v10/channels/{}", thread_id)) + .header("Authorization", data.bot_auth()) + .json(&json!({ + "name": format!("🕓 {} ({}) {}", v.name, m.id, v.version) + })) + .send() + .await; + + let _ = client + .patch(format!("https://discord.com/api/v10/channels/{}/messages/{}", thread_id, thread_id)) + .header("Authorization", data.bot_auth()) + .json(&json!({ + "embeds": [mod_embed(m, v, base_url)] + })) + .send() + .await; +} diff --git a/src/forum/mod.rs b/src/forum/mod.rs new file mode 100644 index 0000000..3228a62 --- /dev/null +++ b/src/forum/mod.rs @@ -0,0 +1 @@ +pub mod discord; diff --git a/src/main.rs b/src/main.rs index 4359cef..bee1147 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,8 @@ use actix_web::{ web::{self, QueryConfig}, App, HttpServer, }; +use endpoints::mods::{IndexQueryParams, IndexSortType}; +use types::models::{mod_entity::Mod, mod_version::ModVersion, mod_version_status::ModVersionStatusEnum}; use crate::types::api; mod auth; @@ -16,6 +18,7 @@ mod extractors; mod jobs; mod mod_zip; mod types; +mod forum; mod webhook; #[tokio::main] @@ -34,6 +37,7 @@ async fn main() -> anyhow::Result<()> { let port = app_data.port(); let debug = app_data.debug(); + let data = app_data.clone(); log::info!("Starting server on 0.0.0.0:{}", port); let server = HttpServer::new(move || { @@ -86,6 +90,69 @@ async fn main() -> anyhow::Result<()> { }) .bind(("0.0.0.0", port))?; + tokio::spawn(async move { + if !data.discord().is_valid() { + log::error!("Discord configuration is not set up. Not creating forum threads."); + return; + } + + log::info!("Starting forum thread creation job"); + let pool_res = data.db().acquire().await; + if pool_res.is_err() { + return; + } + let mut pool = pool_res.unwrap(); + let query = IndexQueryParams { + page: None, + per_page: Some(100), + query: None, + gd: None, + platforms: None, + sort: IndexSortType::Downloads, + geode: None, + developer: None, + tags: None, + featured: None, + status: Some(ModVersionStatusEnum::Pending), + }; + let results = Mod::get_index(&mut pool, query).await; + if results.is_err() { + return; + } + + let threads = forum::discord::get_threads(&data.discord()).await; + let threads_res = Some(threads); + let mut mods = results.unwrap().data; + mods.sort_by(|a, b| { + let a = &a.versions[0]; + let b = &b.versions[0]; + a.created_at.cmp(&b.created_at) + }); + for i in 0..mods.len() { + let m = &mods[i]; + let version_res = ModVersion::get_one(&m.id, &m.versions[0].version, true, false, &mut pool).await.ok().flatten(); + if version_res.is_none() { + continue; + } + + if i != 0 && i % 10 == 0 { + log::info!("Created {i} threads, sleeping for 10 seconds"); + tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; + } + + forum::discord::create_or_update_thread_internal( + threads_res.clone(), + &data.discord(), + m, + &version_res.unwrap(), + "", + &data.app_url() + ).await; + } + + log::info!("Finished creating forum threads"); + }); + if debug { log::info!("Running in debug mode, using 1 thread."); server.workers(1).run().await?;