diff --git a/Cargo.lock b/Cargo.lock index 80e1c73..347f0ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,6 +690,7 @@ dependencies = [ "actix-cors", "actix-web", "chrono", + "cron", "dotenvy", "once_cell", "openssl", @@ -709,6 +710,7 @@ dependencies = [ "surrealdb", "time", "tokio", + "tokio-cron", "tokio-cron-scheduler", "toml", "tracing", @@ -4277,6 +4279,19 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "tokio-cron" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b29e8f172aedef409e518b9b88ea7aeb47fd6248cf7673f0a8991c0913601a55" +dependencies = [ + "chrono", + "cron", + "futures", + "tokio", + "tracing", +] + [[package]] name = "tokio-cron-scheduler" version = "0.10.2" diff --git a/Cargo.toml b/Cargo.toml index 0ce854b..2e992bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,8 @@ tokio-cron-scheduler = { version = "0.10", features = [ "signal", ] } serde_with = "3.8.1" +tokio-cron = "0.1.3" +cron = "0.12.1" [[bin]] diff --git a/src/botv2/cmd/concour/period.rs b/src/botv2/cmd/concour/period.rs index 9ea9883..a2facfb 100644 --- a/src/botv2/cmd/concour/period.rs +++ b/src/botv2/cmd/concour/period.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use crate::botv2::{ domain::concour::{ check_if_allowed::check_if_allowed, @@ -5,11 +7,13 @@ use crate::botv2::{ }, init::{Context, Error}, }; +use chrono::{DateTime, Utc}; +use cron::Schedule; use poise::{ serenity_prelude::{model::colour, CreateEmbed, CreateEmbedFooter, Mentionable, RoleId}, CreateReply, }; -use tracing::{info, instrument}; +use tracing::{info, instrument, warn}; /// Update the step duration of a concour (only for admin) #[instrument(skip(ctx), level = "info", fields(channel = ctx.channel_id().get(), guild = ?ctx.guild_id().unwrap().get()))] @@ -21,7 +25,8 @@ use tracing::{info, instrument}; )] pub async fn period( ctx: Context<'_>, - #[description = "Dureer d'une étape du concours"] step: u64, + #[description = "Dureer d'une étape du concours au format CRON (min: 6H d'interval)"] + cron_schedule: String, ) -> Result<(), Error> { let guild = match ctx.guild_id() { Some(guild) => guild, @@ -59,12 +64,11 @@ pub async fn period( return Ok(()); } }; - let duration = match step.try_into() { - Ok(val) => time::Duration::new(val, 0), + let schedule = match Schedule::from_str(&cron_schedule) { + Ok(schedule) => schedule, Err(_) => { - info!("Invalid Duration input"); let embed = CreateEmbed::new() - .title("Invalid duration input") + .title("Invalid CRON format") .color(colour::Color::RED) .footer(footer); if let Err(why) = ctx @@ -76,7 +80,37 @@ pub async fn period( return Ok(()); } }; - let concour = match set_periode(guild.get(), ctx.channel_id().get(), duration).await { + let next_to_come = schedule.upcoming(Utc).take(10); + // Check if the next_to_come has at least 6 hours of interval + let mut last: Option> = None; + for next in next_to_come { + if let Some(last) = last { + if next.timestamp() - last.timestamp() < 6 * 60 * 60 { + let embed = CreateEmbed::new() + .title("Interval between steps is too short") + .description("Please provide a CRON schedule with at least 6 hours of interval") + .color(colour::Color::RED) + .footer(footer); + if let Err(why) = ctx + .send(CreateReply::default().embed(embed).ephemeral(true)) + .await + { + tracing::error!("Error sending message: {:?}", why); + } + warn!( + cron_schedule = cron_schedule, + "Interval between steps is too short" + ); + return Ok(()); + } + } + last = Some(next); + } + info!( + cron_schedule = cron_schedule.as_str(), + "Setting concour periode" + ); + let concour = match set_periode(guild.get(), ctx.channel_id().get(), cron_schedule).await { Ok(concour) => { if concour.is_none() { CreateEmbed::new() diff --git a/src/botv2/cmd/concour/start.rs b/src/botv2/cmd/concour/start.rs index 59f8112..23523d7 100644 --- a/src/botv2/cmd/concour/start.rs +++ b/src/botv2/cmd/concour/start.rs @@ -57,7 +57,6 @@ pub async fn start(ctx: Context<'_>) -> Result<(), Error> { return Ok(()); } }; - let (concour, success) = match start_concour( guild.get(), ctx.channel_id().get(), @@ -85,11 +84,11 @@ pub async fn start(ctx: Context<'_>) -> Result<(), Error> { concour.index_keyword + 1 )) .description(concour.description) - .field("Mot du jours ", keyword.to_string(), false) + .field("Word of the day ", keyword.to_string(), false) .field("Good luck !", "", false) .field( - "Vous avez jusqu'a demain 17h", - "HARD CODED FOR THE MOMENT", + "Please see when the concour end in the concour get command", + "", false, ) .color(colour::Color::DARK_GREEN); @@ -110,6 +109,9 @@ pub async fn start(ctx: Context<'_>) -> Result<(), Error> { StartConcourError::FinishedKeyWordList => CreateEmbed::new() .title("Finished keyword list, add new one") .color(colour::Color::RED), + StartConcourError::RoleRecompenseEmpty => CreateEmbed::new() + .title("Role recompense not defined") + .color(colour::Color::RED), _ => CreateEmbed::new() .title("Error while creating concour") .field("Please contact your administrator", "", false) diff --git a/src/botv2/domain/concour/set_periode.rs b/src/botv2/domain/concour/set_periode.rs index 46bdfdf..739be9c 100644 --- a/src/botv2/domain/concour/set_periode.rs +++ b/src/botv2/domain/concour/set_periode.rs @@ -14,7 +14,7 @@ pub enum SetPeriodeConcourError { pub async fn set_periode( server_id: u64, channel_id: u64, - periode: time::Duration, + periode: String, ) -> Result, SetPeriodeConcourError> { let concour = match Concour::find_by_server_id_channel_id(&server_id, &channel_id).await { Ok(list_concour) => list_concour, diff --git a/src/botv2/domain/concour/start_concour.rs b/src/botv2/domain/concour/start_concour.rs index 82ff35e..49ba78a 100644 --- a/src/botv2/domain/concour/start_concour.rs +++ b/src/botv2/domain/concour/start_concour.rs @@ -13,6 +13,7 @@ pub enum StartConcourError { DoesntExist, KeyWordListEmpty, FinishedKeyWordList, + RoleRecompenseEmpty, FindError(String), UnknownError(String), } @@ -39,6 +40,10 @@ pub async fn start_concour( return Err(StartConcourError::DoesntExist); } let mut concour = concour.unwrap(); + if concour.role_recompense == 0 { + tracing::warn!("Role recompense is empty"); + return Err(StartConcourError::RoleRecompenseEmpty); + } if concour.keywords.is_empty() { tracing::warn!("Keyword list is empty"); return Err(StartConcourError::KeyWordListEmpty); @@ -53,7 +58,7 @@ pub async fn start_concour( return Err(StartConcourError::AlreadyOnGoing); } match cron_scheduler - .add_concour_cron_job(server_id, channel_id, "*/1 * * * * *".to_string(), http) + .add_concour_cron_job(server_id, channel_id, concour.periode.clone(), http) .await { Ok(_) => {} diff --git a/src/botv2/handler.rs b/src/botv2/handler.rs index f58fc65..a46c05c 100644 --- a/src/botv2/handler.rs +++ b/src/botv2/handler.rs @@ -4,8 +4,9 @@ use poise::serenity_prelude as serenity; use rand::Rng; use serenity::all::{CreateAttachment, CreateMessage}; use tokio::fs::File; -use tracing::info; +use tracing::{info, instrument}; +#[instrument(skip(ctx, _framework, data), err, level = "trace")] pub async fn event_handler( ctx: &serenity::Context, event: &serenity::FullEvent, diff --git a/src/db/concour.rs b/src/db/concour.rs index d3f8261..ea80fae 100644 --- a/src/db/concour.rs +++ b/src/db/concour.rs @@ -1,6 +1,5 @@ use super::init::DB; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::fmt; use surrealdb::opt::Resource; use surrealdb::sql::Thing; @@ -34,6 +33,7 @@ pub struct ConcourRecord { pub struct ConcourWinner { pub user_id: u64, pub date: chrono::DateTime, + pub keyword: String, } #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] @@ -43,13 +43,13 @@ pub struct Concour { pub title: String, pub description: String, pub start_date: chrono::DateTime, - pub periode: time::Duration, + pub periode: String, pub role_recompense: u64, pub keywords: Vec, pub banner: Option, pub index_keyword: u64, pub status: ConcourStatus, - pub winner: HashMap, + pub winner: Vec, pub last_message_id: Option, } @@ -61,13 +61,13 @@ impl Default for Concour { title: String::new(), description: String::new(), start_date: chrono::Utc::now(), - periode: time::Duration::days(1), + periode: "0 0 17 * * * *".to_string(), role_recompense: 0, keywords: Vec::new(), banner: None, index_keyword: 0, status: ConcourStatus::Created, - winner: HashMap::new(), + winner: Vec::new(), last_message_id: None, } } @@ -81,13 +81,13 @@ impl Concour { title: String::new(), description: String::new(), start_date: chrono::Utc::now(), - periode: time::Duration::days(1), + periode: "0 0 17 * * * *".to_string(), role_recompense: 0, keywords: Vec::new(), banner: None, index_keyword: 0, status: ConcourStatus::Created, - winner: HashMap::new(), + winner: Vec::new(), last_message_id: None, }) } diff --git a/src/event/concour/mod.rs b/src/event/concour/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/src/event/concour/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/event/mod.rs b/src/event/mod.rs index d7dc481..e82d0d4 100644 --- a/src/event/mod.rs +++ b/src/event/mod.rs @@ -1,2 +1 @@ -pub mod concour; pub mod schedule_job; diff --git a/src/event/schedule_job.rs b/src/event/schedule_job.rs index 76e8e11..43a6c6a 100644 --- a/src/event/schedule_job.rs +++ b/src/event/schedule_job.rs @@ -1,4 +1,6 @@ -use poise::serenity_prelude::Http; +use poise::serenity_prelude::{ + Color, CreateEmbed, CreateMessage, Http, Mentionable, MessagePagination, +}; use std::{ collections::HashMap, fmt::{self, Display}, @@ -6,10 +8,10 @@ use std::{ }; use tokio::sync::RwLock; use tokio_cron_scheduler::{Job, JobScheduler}; -use tracing::{error, info, instrument}; +use tracing::{error, info, info_span, instrument, Instrument}; use uuid::Uuid; -use crate::db::concour::{Concour, ConcourStatus}; +use crate::db::concour::{Concour, ConcourStatus, ConcourWinner}; #[derive(Debug)] pub enum StopScheduleJob { @@ -23,12 +25,20 @@ impl Display for StopScheduleJob { } } -#[derive(Clone)] pub struct ScheduleJob { pub job_id: Arc>>, pub scheduler: JobScheduler, } +impl Clone for ScheduleJob { + fn clone(&self) -> Self { + ScheduleJob { + job_id: self.job_id.clone(), + scheduler: self.scheduler.clone(), + } + } +} + impl ScheduleJob { #[instrument(level = "info")] pub async fn start_cron_scheduler() -> Result { @@ -72,12 +82,7 @@ impl ScheduleJob { }; for concour in concours { match self - .add_concour_cron_job( - concour.server_id, - concour.channel_id, - "0 0 17 * * *".to_string(), - http, - ) + .add_concour_cron_job(concour.server_id, concour.channel_id, concour.periode, http) .await { Ok(_) => { @@ -124,30 +129,293 @@ impl ScheduleJob { } } - #[instrument(skip(self, _http), level = "info")] + #[instrument(skip(self, http), level = "info")] pub async fn add_concour_cron_job( &mut self, server_id: u64, channel_id: u64, cron_expression: String, - _http: &Http, + http: &Http, ) -> Result { - let job = match Job::new_async(cron_expression.as_str(), |uuid, _l| { - Box::pin(async move { - // Send the message to announce the end of the concour - // Get concour data - // Get All message since the announcement - // filter out the bot's message - // count the number of reactions per message - // get the user comment with the highest reaction - // announce the winner - // Give the winner the role reward - // update concour with the winner and increment the index - // Announce the next concour - // Or not if there is no more keyword - info!("Cron job fired: {:?}", uuid); - }) - }) { + let http = Arc::new(Http::new(http.token())); + let job = match Job::new_cron_job_async_tz( + cron_expression.as_str(), + chrono::Local, + move |uuid, _l| { + Box::pin( + { + let http = http.clone(); + Box::pin(async move { + info!(id = uuid.to_string(), "Cron job fired"); + // Get concour data + let concour = match Concour::find_by_server_id_channel_id( + &server_id, + &channel_id, + ) + .await + { + Ok(concour) => concour, + Err(e) => { + error!("Error getting concour: {:?}", e); + // Disable the concour ? + return; + } + }; + if concour.is_none() { + error!("Concour not found"); + return; + } + let mut concour = concour.unwrap(); + // Send the message to announce the end of the concour + let current_keyword = concour + .keywords + .get(concour.index_keyword as usize) + .unwrap(); + let embed = CreateEmbed::default() + .title(format!( + "Concour has ended Day {} has ended", + concour.index_keyword + 1 + )) + .description("Processing the results...") + .field("Title", concour.title.clone(), false) + .field("Description", concour.description.clone(), false) + .field("Word of the day", current_keyword, false) + .color(Color::DARK_GREEN); + let reply = CreateMessage::default().embed(embed); + let last_id = + match http.send_message(channel_id.into(), vec![], &reply).await { + Ok(message) => message.id, + Err(e) => { + error!("Error sending message: {:?}", e); + return; + } + }; + // Check if the concour is still enabledl + if concour.status != ConcourStatus::OnGoing + || concour.last_message_id.is_none() + { + info!("Concour is not enabled"); + return; + } + // Get All message since the announcement + let message_pagination = + MessagePagination::After(concour.last_message_id.unwrap().into()); + let mut messages = match http + .get_messages(channel_id.into(), Some(message_pagination), None) + .await + { + Ok(messages) => messages, + Err(e) => { + error!("Error getting messages: {:?}", e); + return; + } + }; + if messages.is_empty() { + error!("No message found"); + let embed: CreateEmbed = CreateEmbed::default() + .title("An error has occured while fetching the messages") + .color(Color::DARK_RED); + let reply = CreateMessage::default().embed(embed); + if let Err(err) = + http.send_message(channel_id.into(), vec![], &reply).await + { + error!("Error sending message: {:?}", err); + } + return; + } + if messages.last().unwrap().id != last_id { + info!("Fetching more messages because last one is not the last id"); + loop { + let message_pagination = MessagePagination::After( + messages.last().unwrap().id.into(), + ); + let mut new_messages = match http + .get_messages( + channel_id.into(), + Some(message_pagination), + None, + ) + .await + { + Ok(messages) => messages, + Err(e) => { + error!("Error getting messages: {:?}", e); + return; + } + }; + if new_messages.is_empty() { + info!("No more messages found"); + break; + } + messages.append(&mut new_messages); + if messages.last().unwrap().id == last_id { + info!("Last message found"); + break; + } + } + } + // filter out the bot's message + messages.retain(|message| !message.author.bot); + info!( + nbr_message = messages.len(), + "{} messages found", + messages.len() + ); + // count the number of reactions per message + let mut max_reaction = 0; + let mut max_winner = None; + messages.into_iter().for_each(|msg| { + // test + let count = msg + .reactions + .into_iter() + .fold(0, |acc, reaction| acc + reaction.count); + if count > max_reaction { + max_reaction = count; + max_winner = Some(msg.author); + } + }); + // announce the winner + let winner = match max_winner { + Some(winner) => winner, + None => { + let embed = CreateEmbed::default() + .title("No winner found, What happened ?") + .color(Color::DARK_RED); + let reply = CreateMessage::default().embed(embed); + if let Err(err) = + http.send_message(channel_id.into(), vec![], &reply).await + { + error!("Error sending message: {:?}", err); + } + error!("No winner found"); + return; + } + }; + let embed = CreateEmbed::default() + .title("Winner") + .description(format!("The winner is {}", winner.mention())) + .color(Color::DARK_GREEN); + let reply = CreateMessage::default().embed(embed); + if let Err(err) = + http.send_message(channel_id.into(), vec![], &reply).await + { + error!("Error sending message: {:?}", err); + } + let (add, previous) = match concour.winner.last() { + Some(previous_winner) => ( + previous_winner.user_id != winner.id.get(), + previous_winner.user_id, + ), + None => (true, 0), + }; + // Remove the role from the previous winner + // Give the winner the role reward + if add { + if previous != 0 { + match http + .remove_member_role( + server_id.into(), + previous.into(), + concour.role_recompense.into(), + None, + ) + .await + { + Ok(_) => { + info!("Role removed from the previous winner"); + } + Err(e) => { + error!( + "Error removing role from the previous winner: {:?}", + e + ); + } + } + } + match http + .add_member_role( + server_id.into(), + winner.id, + concour.role_recompense.into(), + None, + ) + .await + { + Ok(_) => { + info!("Role added to the winner"); + } + Err(e) => { + error!("Error adding role to the winner: {:?}", e); + } + } + } + + // update concour with the winner and increment the index + concour.winner.push(ConcourWinner { + user_id: winner.id.get(), + date: chrono::Utc::now(), + keyword: current_keyword.to_string(), + }); + concour.index_keyword += 1; + if concour.index_keyword as usize >= concour.keywords.len() { + concour.status = ConcourStatus::Finished; + let embed = CreateEmbed::default() + .title("Concour has ended") + .description("The concour has ended, no more keyword") + .color(Color::DARK_GREEN); + let reply = CreateMessage::default().embed(embed); + if let Err(err) = + http.send_message(channel_id.into(), vec![], &reply).await + { + error!("Error sending message: {:?}", err); + } + info!("Concour has ended, no more keyword"); + return; + } + let next_keyword = concour + .keywords + .get(concour.index_keyword as usize) + .unwrap(); + let output = CreateEmbed::new() + .title(format!( + "Concour: {} Jour : {}", + concour.title.clone(), + concour.index_keyword + 1 + )) + .description(concour.description.clone()) + .field("Word of the day ", next_keyword.to_string(), false) + .field("Good luck !", "", false) + .field( + "Please see when the concour end in the concour get command", + "", + false, + ) + .color(Color::DARK_GREEN); + let reply = CreateMessage::default().embed(output); + let last_id = + match http.send_message(channel_id.into(), vec![], &reply).await { + Ok(message) => message.id, + Err(e) => { + error!("Error sending message: {:?}", e); + return; + } + }; + concour.last_message_id = Some(last_id.get()); + match concour.update().await { + Ok(_) => { + info!("Concour updated"); + } + Err(e) => { + error!("Error updating concour: {:?}", e); + } + } + }) + } + .instrument(info_span!("ConcourJob", id = uuid.to_string())), + ) + }, + ) { Ok(job) => job, Err(e) => { error!("Error creating cron job: {:?}", e);