feat: add load question

This commit is contained in:
Max batleforc 2025-06-02 21:35:36 +02:00
parent ace28c402f
commit 38ea1f0632
No known key found for this signature in database
GPG Key ID: 25D243AB4B6AC9E7
10 changed files with 288 additions and 17 deletions

1
Cargo.lock generated
View File

@ -460,6 +460,7 @@ dependencies = [
"clickhouse", "clickhouse",
"clickhouse_pool", "clickhouse_pool",
"config", "config",
"croner",
"database", "database",
"poise", "poise",
"serde", "serde",

View File

@ -25,6 +25,7 @@ serde_json = '1.0'
clickhouse = { version = '0.13', features = ['native-tls', 'uuid', 'chrono'] } clickhouse = { version = '0.13', features = ['native-tls', 'uuid', 'chrono'] }
uuid = { version = '1.16', features = ['serde', 'v4'] } uuid = { version = '1.16', features = ['serde', 'v4'] }
chrono = { version = '0.4.41', features = ['serde'] } chrono = { version = '0.4.41', features = ['serde'] }
croner = { version = "*" }
[profile.release] [profile.release]
lto = true lto = true

View File

@ -23,12 +23,12 @@
- [x] [ADMIN] trivial config : Modification des paramétre de base d'un event - [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 add-question : Ajout de question + réponse ou X réponse
- [ ] [ADMIN] trivial del-question : Suppression de question - [ ] [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 scoreboard : Scoreboard
- [ ] trivial score : Score d'une personne par défaut le sien - [ ] trivial score : Score d'une personne par défaut le sien
- [ ] trivial top : Top X par défaut 3 - [ ] trivial top : Top X par défaut 3
- [ ] [ADMIN] trivial balance : Modifie le score d'une personne +/-/= - [ ] [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 #### TODO

View File

@ -13,5 +13,6 @@ clickhouse = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
config = { path = "../../libs/config" } config = { path = "../../libs/config" }
database = { path = "../../libs/database" } database = { path = "../../libs/database" }
croner = { workspace = true }
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@ -1,6 +1,7 @@
use crate::helper::is_user_admin_right; use crate::helper::is_user_admin_right;
use crate::{Context, Error}; use crate::{Context, Error};
use clickhouse_pool::traits::Model; use clickhouse_pool::traits::Model;
use croner::Cron;
use database::trivial::{Trivial, TrivialRewardKind}; use database::trivial::{Trivial, TrivialRewardKind};
use poise::serenity_prelude::{Channel, Role}; use poise::serenity_prelude::{Channel, Role};
use tracing::{debug, info, instrument}; use tracing::{debug, info, instrument};
@ -26,6 +27,10 @@ pub async fn config(
#[description = "Reward amount for the trivia game"] reward_amount: Option<u64>, #[description = "Reward amount for the trivia game"] reward_amount: Option<u64>,
#[description = "Whether to send an ephemeral message when the answer is taken into account"] #[description = "Whether to send an ephemeral message when the answer is taken into account"]
taken_into_account: Option<bool>, taken_into_account: Option<bool>,
#[description = "When to ask the question, in cron format (e.g. '0 0 * * *' for daily)"]
cron: Option<String>,
#[description = "When to answer the question, in cron format (e.g. '0 0 * * *' for daily)"]
cron_answer: Option<String>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let guild_id = match ctx.guild_id() { let guild_id = match ctx.guild_id() {
Some(id) => id.get(), Some(id) => id.get(),
@ -43,7 +48,7 @@ pub async fn config(
} }
}; };
let manager = ctx.data().datalake_config.clone(); let manager = ctx.data().datalake_config.clone();
let seach_query = Trivial::build_select_query( let search_query = Trivial::build_select_query(
Some(&format!( Some(&format!(
"channel_id = {} and guild_id = {}", "channel_id = {} and guild_id = {}",
channel_id, guild_id channel_id, guild_id
@ -52,7 +57,7 @@ pub async fn config(
None, None,
); );
let mut trivia_exists: Trivial = match manager let mut trivia_exists: Trivial = match manager
.execute_select_with_retry::<Trivial>(&seach_query) .execute_select_with_retry::<Trivial>(&search_query)
.await .await
{ {
Ok(trivia) => { Ok(trivia) => {
@ -111,6 +116,27 @@ pub async fn config(
if let Some(taken_into_account) = taken_into_account { if let Some(taken_into_account) = taken_into_account {
trivia_exists.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.updated_at = chrono::Utc::now();
trivia_exists.updater_id = ctx.author().id.get(); trivia_exists.updater_id = ctx.author().id.get();
debug!("Updated trivia game: {:?}", trivia_exists); debug!("Updated trivia game: {:?}", trivia_exists);

199
libs/bot/src/trivia/load.rs Normal file
View File

@ -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<Channel>,
#[description = "Whether or not the file has an header row"] has_header: Option<bool>,
) -> 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<Trivial> = 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::<Vec<&str>>().into_iter()
} else {
content.lines().collect::<Vec<&str>>().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::<Vec<(String, String)>>();
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<TrivialQuestion> =
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<String> = 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>(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(())
}

View File

@ -1,12 +1,14 @@
pub mod config; pub mod config;
pub mod create; pub mod create;
pub mod list; pub mod list;
pub mod load;
pub mod status; pub mod status;
use crate::{Context, Error}; use crate::{Context, Error};
use config::config; use config::config;
use create::create; use create::create;
use list::list; use list::list;
use load::load;
use status::status; use status::status;
use tracing::instrument; use tracing::instrument;
@ -16,7 +18,7 @@ use tracing::instrument;
slash_command, slash_command,
prefix_command, prefix_command,
category = "Trivia", category = "Trivia",
subcommands("create", "list", "config", "status"), subcommands("create", "list", "config", "status", "load"),
guild_only = true guild_only = true
)] )]
pub async fn trivia(ctx: Context<'_>) -> Result<(), Error> { pub async fn trivia(ctx: Context<'_>) -> Result<(), Error> {

View File

@ -78,6 +78,9 @@ pub struct Trivial {
pub taken_into_account: bool, pub taken_into_account: bool,
pub status: TrivialStatus, pub status: TrivialStatus,
pub sign: i8, pub sign: i8,
pub question_asked: String,
pub answer_given: String,
} }
impl Default for Trivial { impl Default for Trivial {
@ -100,6 +103,8 @@ impl Default for Trivial {
taken_into_account: true, taken_into_account: true,
status: TrivialStatus::Init, status: TrivialStatus::Init,
sign: 1, sign: 1,
question_asked: "".to_string(),
answer_given: "".to_string(),
} }
} }
} }
@ -154,7 +159,9 @@ impl Model for Trivial {
reward_amount UInt64, reward_amount UInt64,
taken_into_account Bool, taken_into_account Bool,
status Enum8('Init' = 0, 'Started' = 1, 'Finished' = 2, 'Paused' = 3), status Enum8('Init' = 0, 'Started' = 1, 'Finished' = 2, 'Paused' = 3),
sign Int8 sign Int8,
question_asked String,
answer_given String
) ENGINE = CollapsingMergeTree(sign) ) ENGINE = CollapsingMergeTree(sign)
PRIMARY KEY (guild_id, id) PRIMARY KEY (guild_id, id)
ORDER BY (guild_id, id) ORDER BY (guild_id, id)
@ -180,6 +187,8 @@ impl Model for Trivial {
"taken_into_account", "taken_into_account",
"status", "status",
"sign", "sign",
"question_asked",
"answer_given",
] ]
} }
@ -204,6 +213,8 @@ impl Model for Trivial {
self.taken_into_account.to_string(), self.taken_into_account.to_string(),
format!("'{:?}'", serde_json::to_string(&self.status)), format!("'{:?}'", serde_json::to_string(&self.status)),
self.sign.to_string(), self.sign.to_string(),
format!("'{}'", self.question_asked),
format!("'{}'", self.answer_given),
], ],
) )
} }

View File

@ -11,6 +11,9 @@ pub struct TrivialQuestion {
#[serde(with = "clickhouse::serde::uuid")] #[serde(with = "clickhouse::serde::uuid")]
pub trivial_id: Uuid, pub trivial_id: Uuid,
pub guild_id: u64,
pub channel_id: u64,
pub question: String, pub question: String,
pub answer: String, pub answer: String,
@ -23,6 +26,23 @@ pub struct TrivialQuestion {
pub updater_id: u64, 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 { impl Model for TrivialQuestion {
type T = TrivialQuestion; type T = TrivialQuestion;
@ -42,6 +62,8 @@ impl Model for TrivialQuestion {
CREATE TABLE IF NOT EXISTS trivial_question ( CREATE TABLE IF NOT EXISTS trivial_question (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
trivial_id UUID, trivial_id UUID,
guild_id UInt64,
channel_id UInt64,
question String, question String,
answer String, answer String,
created_at DateTime64(3), created_at DateTime64(3),
@ -58,6 +80,8 @@ impl Model for TrivialQuestion {
vec![ vec![
"id", "id",
"trivial_id", "trivial_id",
"guild_id",
"channel_id",
"question", "question",
"answer", "answer",
"created_at", "created_at",
@ -73,6 +97,8 @@ impl Model for TrivialQuestion {
vec![ vec![
self.id.to_string(), self.id.to_string(),
self.trivial_id.to_string(), self.trivial_id.to_string(),
self.guild_id.to_string(),
self.channel_id.to_string(),
self.question.clone(), self.question.clone(),
self.answer.clone(), self.answer.clone(),
self.created_at.to_string(), self.created_at.to_string(),

View File

@ -0,0 +1,4 @@
question;response
De qu'elle couleur est le cheval blanc d'henry IV?;blanc
Joseph ?;joestar
Rick ?;morty
1 question response
2 De qu'elle couleur est le cheval blanc d'henry IV? blanc
3 Joseph ? joestar
4 Rick ? morty