diff --git a/.env.example b/.env.example index e74f904..0c02d28 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,16 @@ -# Change this -DISCORD_TOKEN = "Bot Token" -GUILD_ID = "Server ID" +DISCORD_TOKEN="Bot token" +GUILD_ID="Server ID" +BOT_APIKEY="API key for secure communication" -# Config -# Channel Ids -CHANNEL_DAILY = "1219703076944871616" -CHANNEL_SUGGEST = "824695624665923594" -CHANNEL_SHOWCASE = "Channel id for Showcase" -TEMPORAL_WAIT = "" -TEMPORAL_CATEGORY = "" -TEMPORAL_LOGS = "" +CHANNEL_DAILY="Channel ID for daily challenges" +CHANNEL_SUGGEST="Channel ID for suggestions" +CHANNEL_SHOWCASE="Channel ID for showcase forum" -# Keep this for development -BOT_API_PORT = 8080 -BOT_API_ADDR = 0.0.0.0 -BOT_APIKEY = "PermitidoHacerCosas123" +SHOWCASE_CACHE_PATH="showcase_cache.json" -# AI features (remove to disable) -GEMINI_KEY = " " \ No newline at end of file +TEMPORAL_WAIT="Temporal wait channel ID" +TEMPORAL_CATEGORY="Temporal category ID" +TEMPORAL_LOGS="Temporal logs channel ID" + +DISCORD_PREFIX="&" +GEMINI_KEY="" \ No newline at end of file diff --git a/doc/VARIABLES.md b/doc/VARIABLES.md index 06f5a95..dc6b0bd 100644 --- a/doc/VARIABLES.md +++ b/doc/VARIABLES.md @@ -16,6 +16,8 @@ Para ejecutar el bot, crea un archivo `.env` con las siguientes variables: - `STATIC_ROOT`: corresponde a la ubicacion del contenido `static`, por defecto es `./static` +- `SHOWCASE_CACHE_PATH`: ruta del archivo usado para guardar el cache de sincronización de showcases. Por defecto es `showcase_cache.json`. + #### Formato para `.env` ```toml @@ -25,4 +27,5 @@ BOT_APIKEY = "API key for secure communication" CHANNEL_DAILY = "Channel ID for daily challenges" CHANNEL_SUGGEST = "Channel ID for suggestions" CHANNEL_SHOWCASE = "Channel ID for showcase forum" +SHOWCASE_CACHE_PATH = "showcase_cache.json" ``` \ No newline at end of file diff --git a/src/api/mod.rs b/src/api/mod.rs index ae31d96..9791f26 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -22,6 +22,7 @@ pub fn build_router(secrets: &CangrebotSecrets, ctx: Arc) -> Router { ) .route("/send_message", axum::routing::post(routes::send_message)) .route("/showcase", axum::routing::post(routes::showcase)) + .route("/showcase/sync", axum::routing::post(routes::showcase_sync)) .layer(axum::middleware::from_fn_with_state( secrets.clone(), auth::middleware, diff --git a/src/api/routes/mod.rs b/src/api/routes/mod.rs index 55eb37e..5e48e3e 100644 --- a/src/api/routes/mod.rs +++ b/src/api/routes/mod.rs @@ -9,6 +9,9 @@ pub use send_message::send_message; mod showcase; pub use showcase::showcase; +mod showcase_sync; +pub use showcase_sync::showcase_sync; + pub mod send_stats; pub async fn healthcheck() -> impl IntoResponse { diff --git a/src/api/routes/showcase_sync.rs b/src/api/routes/showcase_sync.rs new file mode 100644 index 0000000..c3e5c48 --- /dev/null +++ b/src/api/routes/showcase_sync.rs @@ -0,0 +1,183 @@ +use std::collections::HashMap; +use std::fs; +use std::io::ErrorKind; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use serde::{Deserialize, Serialize}; +use tracing::info; + +use crate::api::RouteState; +use crate::serenity::builder::{CreateForumPost, CreateMessage}; +use crate::serenity::model::prelude::ChannelId; + +#[derive(Deserialize, Serialize)] +pub struct ShowcaseSyncRequest { + projects: Vec, +} + +#[derive(Deserialize, Serialize)] +pub struct ShowcaseSyncProject { + key: String, + name: String, + desc: String, + url: String, + #[serde(default)] + tags: Vec, +} + +#[derive(Serialize)] +pub struct ShowcaseSyncResponse { + created: Vec, + skipped: Vec, + failed: Vec, +} + +#[derive(Serialize)] +pub struct ShowcaseSyncFailure { + key: String, + reason: String, +} + +pub async fn showcase_sync( + State((secrets, ctx)): State, + Json(ShowcaseSyncRequest { projects }): Json, +) -> impl IntoResponse { + info!("Running showcase sync from API"); + + let mut cache = match load_showcase_cache(&secrets.showcase_cache_path) { + Ok(cache) => cache, + Err(reason) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ShowcaseSyncResponse { + created: Vec::new(), + skipped: Vec::new(), + failed: vec![ShowcaseSyncFailure { + key: "showcase_cache".to_string(), + reason, + }], + }), + ); + } + }; + + let msg_channel = ChannelId::new(secrets.channel_showcase); + + let Ok(channel) = msg_channel.to_channel(&ctx).await else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ShowcaseSyncResponse { + created: Vec::new(), + skipped: Vec::new(), + failed: vec![ShowcaseSyncFailure { + key: "showcase_channel".to_string(), + reason: "Cannot convert to channel".to_string(), + }], + }), + ); + }; + + let Some(forum) = channel.guild() else { + return ( + StatusCode::NOT_FOUND, + Json(ShowcaseSyncResponse { + created: Vec::new(), + skipped: Vec::new(), + failed: vec![ShowcaseSyncFailure { + key: "showcase_channel".to_string(), + reason: "Guild channel not found".to_string(), + }], + }), + ); + }; + + let mut created = Vec::new(); + let mut skipped = Vec::new(); + let mut failed = Vec::new(); + + for project in projects { + if cache.contains_key(&project.key) { + skipped.push(project.key); + continue; + } + + let mut forum_post = CreateForumPost::new( + project.name, + CreateMessage::new().content(format!("{}\n\n{}", project.desc, project.url)), + ); + + let mut missing_tag = None; + + for tag_name in project.tags { + let Some(tag) = forum.available_tags.iter().find(|tag| tag.name == tag_name) else { + missing_tag = Some(tag_name); + break; + }; + + forum_post = forum_post.add_applied_tag(tag.id); + } + + if let Some(tag_name) = missing_tag { + failed.push(ShowcaseSyncFailure { + key: project.key, + reason: format!("Tag '{tag_name}' not found"), + }); + continue; + } + + match msg_channel.create_forum_post(&ctx, forum_post).await { + Ok(post) => { + cache.insert(project.key.clone(), post.id.to_string()); + created.push(project.key); + } + Err(err) => { + tracing::error!("Cannot create showcase forum post: {err:?}"); + failed.push(ShowcaseSyncFailure { + key: project.key, + reason: "Cannot create showcase forum post".to_string(), + }); + } + } + } + + if let Err(reason) = save_showcase_cache(&secrets.showcase_cache_path, &cache) { + failed.push(ShowcaseSyncFailure { + key: "showcase_cache".to_string(), + reason, + }); + } + + let status = if failed.is_empty() { + StatusCode::OK + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + + ( + status, + Json(ShowcaseSyncResponse { + created, + skipped, + failed, + }), + ) +} + +fn load_showcase_cache(path: &str) -> Result, String> { + match fs::read_to_string(path) { + Ok(content) => serde_json::from_str(&content) + .map_err(|err| format!("Cannot parse showcase cache: {err}")), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(HashMap::new()), + Err(err) => Err(format!("Cannot read showcase cache: {err}")), + } +} + +fn save_showcase_cache(path: &str, cache: &HashMap) -> Result<(), String> { + let content = serde_json::to_string_pretty(cache) + .map_err(|err| format!("Cannot serialize showcase cache: {err}"))?; + + fs::write(path, content).map_err(|err| format!("Cannot write showcase cache: {err}")) +} diff --git a/src/secrets.rs b/src/secrets.rs index a89210f..927abe2 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -11,6 +11,8 @@ pub struct CangrebotSecrets { pub channel_suggest: u64, /// Channel id for Showcase pub channel_showcase: u64, + /// Path for showcase sync cache file + pub showcase_cache_path: String, /// Waiting channel id for temporal voice chats pub temporal_wait: u64, /// Category id for temporal voice chats @@ -47,6 +49,10 @@ impl CangrebotSecrets { .expect("'CHANNEL_SHOWCASE' was not found") .parse() .expect("Cannot parse 'CHANNEL_SHOWCASE'"), + showcase_cache_path: secrets("SHOWCASE_CACHE_PATH").unwrap_or_else(|_| { + warn!("'SHOWCASE_CACHE_PATH' was not found. Defaults to \"showcase_cache.json\""); + "showcase_cache.json".to_owned() + }), temporal_wait: secrets("TEMPORAL_WAIT") .expect("'TEMPORAL_WAIT' was not found") .parse()