From 38ea1f0632cde3b0fbde081482558938406126f2 Mon Sep 17 00:00:00 2001 From: Max batleforc Date: Mon, 2 Jun 2025 21:35:36 +0200 Subject: [PATCH] feat: add load question --- Cargo.lock | 1 + Cargo.toml | 23 +-- Readme.md | 4 +- libs/bot/Cargo.toml | 1 + libs/bot/src/trivia/config.rs | 30 +++- libs/bot/src/trivia/load.rs | 199 ++++++++++++++++++++++++++ libs/bot/src/trivia/mod.rs | 4 +- libs/database/src/trivial.rs | 13 +- libs/database/src/trivial_question.rs | 26 ++++ resources/template_question.csv | 4 + 10 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 libs/bot/src/trivia/load.rs create mode 100644 resources/template_question.csv diff --git a/Cargo.lock b/Cargo.lock index 5cf29d0..eb59f8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -460,6 +460,7 @@ dependencies = [ "clickhouse", "clickhouse_pool", "config", + "croner", "database", "poise", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4b8344f..3bf20c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,22 +2,22 @@ [workspace] resolver = '2' members = [ - 'apps/cli', - 'libs/tool_tracing', - 'libs/clickhouse_pool', - 'libs/database', - 'libs/bot', - 'libs/config', - 'libs/cron_scheduler', - 'libs/api', + 'apps/cli', + 'libs/tool_tracing', + 'libs/clickhouse_pool', + 'libs/database', + 'libs/bot', + 'libs/config', + 'libs/cron_scheduler', + 'libs/api', ] [workspace.dependencies] poise = { git = 'https://github.com/serenity-rs/poise', branch = 'current' } tokio = { version = '1.45.0', features = [ - 'macros', - 'rt-multi-thread', - 'io-std', + 'macros', + 'rt-multi-thread', + 'io-std', ] } serde = '1.0' tracing = '0.1' @@ -25,6 +25,7 @@ serde_json = '1.0' clickhouse = { version = '0.13', features = ['native-tls', 'uuid', 'chrono'] } uuid = { version = '1.16', features = ['serde', 'v4'] } chrono = { version = '0.4.41', features = ['serde'] } +croner = { version = "*" } [profile.release] lto = true diff --git a/Readme.md b/Readme.md index a678f5f..55a68be 100644 --- a/Readme.md +++ b/Readme.md @@ -23,12 +23,12 @@ - [x] [ADMIN] trivial config : Modification des paramétre de base d'un event - [ ] [ADMIN] trivial add-question : Ajout de question + réponse ou X réponse - [ ] [ADMIN] trivial del-question : Suppression de question -- [ ] [ADMIN] trivial load-question : Charge un fichier/lien de question +- [x] [ADMIN] trivial load-question : Charge un fichier/lien de question - [ ] trivial scoreboard : Scoreboard - [ ] trivial score : Score d'une personne par défaut le sien - [ ] trivial top : Top X par défaut 3 - [ ] [ADMIN] trivial balance : Modifie le score d'une personne +/-/= -- [ ] [ADMIN] trivial status : Return trvial status or change trivial status +- [x] [ADMIN] trivial status : Return trvial status or change trivial status #### TODO diff --git a/libs/bot/Cargo.toml b/libs/bot/Cargo.toml index 9951c55..618d09b 100644 --- a/libs/bot/Cargo.toml +++ b/libs/bot/Cargo.toml @@ -13,5 +13,6 @@ clickhouse = { workspace = true } chrono = { workspace = true } config = { path = "../../libs/config" } database = { path = "../../libs/database" } +croner = { workspace = true } # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/libs/bot/src/trivia/config.rs b/libs/bot/src/trivia/config.rs index dfb670c..6816e6a 100644 --- a/libs/bot/src/trivia/config.rs +++ b/libs/bot/src/trivia/config.rs @@ -1,6 +1,7 @@ use crate::helper::is_user_admin_right; use crate::{Context, Error}; use clickhouse_pool::traits::Model; +use croner::Cron; use database::trivial::{Trivial, TrivialRewardKind}; use poise::serenity_prelude::{Channel, Role}; use tracing::{debug, info, instrument}; @@ -26,6 +27,10 @@ pub async fn config( #[description = "Reward amount for the trivia game"] reward_amount: Option, #[description = "Whether to send an ephemeral message when the answer is taken into account"] taken_into_account: Option, + #[description = "When to ask the question, in cron format (e.g. '0 0 * * *' for daily)"] + cron: Option, + #[description = "When to answer the question, in cron format (e.g. '0 0 * * *' for daily)"] + cron_answer: Option, ) -> Result<(), Error> { let guild_id = match ctx.guild_id() { Some(id) => id.get(), @@ -43,7 +48,7 @@ pub async fn config( } }; let manager = ctx.data().datalake_config.clone(); - let seach_query = Trivial::build_select_query( + let search_query = Trivial::build_select_query( Some(&format!( "channel_id = {} and guild_id = {}", channel_id, guild_id @@ -52,7 +57,7 @@ pub async fn config( None, ); let mut trivia_exists: Trivial = match manager - .execute_select_with_retry::(&seach_query) + .execute_select_with_retry::(&search_query) .await { Ok(trivia) => { @@ -111,6 +116,27 @@ pub async fn config( if let Some(taken_into_account) = taken_into_account { trivia_exists.taken_into_account = taken_into_account; } + if let Some(cron) = cron { + match Cron::new(&cron).parse() { + Ok(_) => {} + Err(e) => { + ctx.say(format!("Invalid cron format: {}", e)).await?; + return Ok(()); + } + }; + trivia_exists.question_asked = cron; + } + if let Some(cron_answer) = cron_answer { + match Cron::new(&cron_answer).parse() { + Ok(_) => {} + Err(e) => { + ctx.say(format!("Invalid cron answer format: {}", e)) + .await?; + return Ok(()); + } + }; + trivia_exists.answer_given = cron_answer; + } trivia_exists.updated_at = chrono::Utc::now(); trivia_exists.updater_id = ctx.author().id.get(); debug!("Updated trivia game: {:?}", trivia_exists); diff --git a/libs/bot/src/trivia/load.rs b/libs/bot/src/trivia/load.rs new file mode 100644 index 0000000..72aeb58 --- /dev/null +++ b/libs/bot/src/trivia/load.rs @@ -0,0 +1,199 @@ +use crate::helper::is_user_admin_right; +use crate::{Context, Error}; +use clickhouse_pool::traits::Model; +use database::trivial::Trivial; +use database::trivial_question::TrivialQuestion; +use poise::serenity_prelude; +use poise::serenity_prelude::Channel; +use tracing::{info, instrument, warn}; + +/// Load a csv file containing trivia questions and answers into the database. +#[instrument(name = "trivia_load", skip(ctx), level = "info", fields(channel = ctx.channel_id().get(), guild = ?ctx.guild_id().unwrap().get()))] +#[poise::command( + prefix_command, + slash_command, + guild_only, + category = "Trivia", + check = "is_user_admin_right" +)] +pub async fn load( + ctx: Context<'_>, + #[description = "Fichier au format CSV contenant questions, réponses"] + file: serenity_prelude::Attachment, + #[description = "Channel of the trivia game, or current"] channel_id: Option, + #[description = "Whether or not the file has an header row"] has_header: Option, +) -> Result<(), Error> { + let guild_id = match ctx.guild_id() { + Some(id) => id.get(), + None => { + ctx.say("This command can only be used in a server.") + .await?; + return Ok(()); + } + }; + let channel_id = match channel_id { + Some(c) => c.id().get(), + None => { + ctx.say("No channel specified, using current channel.") + .await?; + ctx.channel_id().get() + } + }; + let manager_pool = ctx.data().datalake_config.clone(); + // Check if the trivia game already exists in the channel + let trivia = Trivial::build_select_query( + Some(&format!( + "channel_id = {} and guild_id = {}", + channel_id.clone(), + guild_id.clone() + )), + None, + None, + ); + let trivia_exists: Vec = match manager_pool.execute_select_with_retry(&trivia).await { + Ok(trivia) => trivia, + Err(e) => { + ctx.say(format!("Failed to fetch trivia game: {}", e)) + .await?; + return Ok(()); + } + }; + if trivia_exists.is_empty() { + ctx.say(format!("No trivia game exists in this channel.",)) + .await?; + return Ok(()); + } + let trivia = trivia_exists.first().unwrap(); + let has_header = has_header.unwrap_or(true); + let file_content = match file.download().await { + Ok(content) => content, + Err(e) => { + ctx.say(format!("Failed to download file: {}", e)).await?; + return Ok(()); + } + }; + // Assuming the file is a CSV, we can parse it here. + let content = String::from_utf8_lossy(&file_content); + let content_split = if has_header { + // Skip the first line if it is a header + let mut lines = content.lines(); + lines.next(); // Skip header + lines.collect::>().into_iter() + } else { + content.lines().collect::>().into_iter() + }; + // Split each line by comma and process + + let parsed_content = content_split + .map(|line| { + let parts: Vec<&str> = line.split(',').collect(); + if parts.len() < 2 || parts[0].trim().is_empty() || parts[1].trim().is_empty() { + warn!("Invalid line format: {}", line); + return ("".to_string(), "".to_string()); + } + let question = parts[0].trim().to_string(); + let answer = parts[1].trim().to_string(); + (question, answer) + }) + .filter(|(q, a)| !q.is_empty() && !a.is_empty()) + .collect::>(); + if parsed_content.is_empty() { + ctx.say("No valid questions found in the file.").await?; + return Ok(()); + } + + let get_trivia = TrivialQuestion::build_select_query( + Some(&format!( + "channel_id = {} and guild_id = {}", + channel_id, guild_id + )), + None, + None, + ); + let trivial_questions: Vec = + match manager_pool.execute_select_with_retry(&get_trivia).await { + Ok(questions) => questions, + Err(e) => { + ctx.say(format!("Failed to fetch trivia questions: {}", e)) + .await?; + return Ok(()); + } + }; + let parsed_content = if !trivial_questions.is_empty() { + // Check for duplicate questions + let existing_questions: Vec = trivial_questions + .iter() + .map(|q| q.question.clone()) + .collect(); + let no_duplicates: Vec<(String, String)> = parsed_content + .iter() + .filter(|(q, _)| !existing_questions.contains(q)) + .cloned() + .collect(); + if no_duplicates.is_empty() { + ctx.say("Questions already exist in the database, no new questions to add.") + .await?; + return Ok(()); + } + no_duplicates + } else { + // No existing questions, all are new + parsed_content + }; + let mut inserter = match manager_pool + .get_insert::(TrivialQuestion::table_name()) + .await + { + Ok(inserter) => inserter, + Err(e) => { + ctx.say(format!( + "Internal Server error, please contact Bot Developer: {}", + e + )) + .await?; + warn!("Failed to create inserter for TrivialQuestion: {}", e); + return Ok(()); + } + }; + for (question, answer) in parsed_content { + let trivial_question = TrivialQuestion { + trivial_id: trivia.id, + guild_id: guild_id, + channel_id: channel_id, + question: question, + answer: answer, + creator_id: ctx.author().id.get(), + updater_id: ctx.author().id.get(), + ..Default::default() + }; + if let Err(e) = inserter.write(&trivial_question).await { + warn!("Failed to insert trivial question: {}", e); + ctx.say(format!("Failed to insert question: {}", e)).await?; + return Ok(()); + } + } + match inserter.end().await { + Ok(_) => { + info!("Successfully loaded trivia questions into the database."); + ctx.say("Trivia questions loaded successfully.").await?; + } + Err(e) => { + warn!("Failed to finalize insert operation: {}", e); + ctx.say(format!("Failed to load trivia questions: {}", e)) + .await?; + return Ok(()); + } + } + + let optimize = TrivialQuestion::optimize_table_sql(); + if let Err(e) = manager_pool.execute_with_retry(optimize).await { + warn!("Failed to optimize trivial_question table: {}", e); + ctx.say(format!("Failed to optimize trivia questions: {}", e)) + .await?; + return Ok(()); + } + info!("Successfully optimized trivial_question table."); + ctx.say("Trivia questions loaded and table optimized successfully.") + .await?; + Ok(()) +} diff --git a/libs/bot/src/trivia/mod.rs b/libs/bot/src/trivia/mod.rs index 739f186..604807c 100644 --- a/libs/bot/src/trivia/mod.rs +++ b/libs/bot/src/trivia/mod.rs @@ -1,12 +1,14 @@ pub mod config; pub mod create; pub mod list; +pub mod load; pub mod status; use crate::{Context, Error}; use config::config; use create::create; use list::list; +use load::load; use status::status; use tracing::instrument; @@ -16,7 +18,7 @@ use tracing::instrument; slash_command, prefix_command, category = "Trivia", - subcommands("create", "list", "config", "status"), + subcommands("create", "list", "config", "status", "load"), guild_only = true )] pub async fn trivia(ctx: Context<'_>) -> Result<(), Error> { diff --git a/libs/database/src/trivial.rs b/libs/database/src/trivial.rs index 4a472df..25cac01 100644 --- a/libs/database/src/trivial.rs +++ b/libs/database/src/trivial.rs @@ -78,6 +78,9 @@ pub struct Trivial { pub taken_into_account: bool, pub status: TrivialStatus, pub sign: i8, + + pub question_asked: String, + pub answer_given: String, } impl Default for Trivial { @@ -100,6 +103,8 @@ impl Default for Trivial { taken_into_account: true, status: TrivialStatus::Init, sign: 1, + question_asked: "".to_string(), + answer_given: "".to_string(), } } } @@ -154,7 +159,9 @@ impl Model for Trivial { reward_amount UInt64, taken_into_account Bool, status Enum8('Init' = 0, 'Started' = 1, 'Finished' = 2, 'Paused' = 3), - sign Int8 + sign Int8, + question_asked String, + answer_given String ) ENGINE = CollapsingMergeTree(sign) PRIMARY KEY (guild_id, id) ORDER BY (guild_id, id) @@ -180,6 +187,8 @@ impl Model for Trivial { "taken_into_account", "status", "sign", + "question_asked", + "answer_given", ] } @@ -204,6 +213,8 @@ impl Model for Trivial { self.taken_into_account.to_string(), format!("'{:?}'", serde_json::to_string(&self.status)), self.sign.to_string(), + format!("'{}'", self.question_asked), + format!("'{}'", self.answer_given), ], ) } diff --git a/libs/database/src/trivial_question.rs b/libs/database/src/trivial_question.rs index 4cedf5a..adec879 100644 --- a/libs/database/src/trivial_question.rs +++ b/libs/database/src/trivial_question.rs @@ -11,6 +11,9 @@ pub struct TrivialQuestion { #[serde(with = "clickhouse::serde::uuid")] pub trivial_id: Uuid, + pub guild_id: u64, + pub channel_id: u64, + pub question: String, pub answer: String, @@ -23,6 +26,23 @@ pub struct TrivialQuestion { pub updater_id: u64, } +impl Default for TrivialQuestion { + fn default() -> Self { + Self { + id: Uuid::new_v4(), + trivial_id: Uuid::new_v4(), + guild_id: 0, + channel_id: 0, + question: String::new(), + answer: String::new(), + created_at: Utc::now(), + updated_at: Utc::now(), + creator_id: 0, + updater_id: 0, + } + } +} + impl Model for TrivialQuestion { type T = TrivialQuestion; @@ -42,6 +62,8 @@ impl Model for TrivialQuestion { CREATE TABLE IF NOT EXISTS trivial_question ( id UUID PRIMARY KEY, trivial_id UUID, + guild_id UInt64, + channel_id UInt64, question String, answer String, created_at DateTime64(3), @@ -58,6 +80,8 @@ impl Model for TrivialQuestion { vec![ "id", "trivial_id", + "guild_id", + "channel_id", "question", "answer", "created_at", @@ -73,6 +97,8 @@ impl Model for TrivialQuestion { vec![ self.id.to_string(), self.trivial_id.to_string(), + self.guild_id.to_string(), + self.channel_id.to_string(), self.question.clone(), self.answer.clone(), self.created_at.to_string(), diff --git a/resources/template_question.csv b/resources/template_question.csv new file mode 100644 index 0000000..bc166df --- /dev/null +++ b/resources/template_question.csv @@ -0,0 +1,4 @@ +question;response +De qu'elle couleur est le cheval blanc d'henry IV?;blanc +Joseph ?;joestar +Rick ?;morty \ No newline at end of file