diff --git a/Cargo.lock b/Cargo.lock index aea1abb..b846c1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1599,7 +1599,7 @@ dependencies = [ [[package]] name = "squad-quest" -version = "0.9.0" +version = "0.10.0" dependencies = [ "serde", "toml", @@ -1607,7 +1607,7 @@ dependencies = [ [[package]] name = "squad-quest-cli" -version = "0.9.0" +version = "0.10.0" dependencies = [ "chrono", "clap", @@ -1618,8 +1618,9 @@ dependencies = [ [[package]] name = "squad-quest-discord" -version = "0.9.0" +version = "0.10.0" dependencies = [ + "chrono", "clap", "dotenvy", "poise", diff --git a/Cargo.toml b/Cargo.toml index bb709d8..5f06182 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = ["cli", "discord"] [workspace.package] -version = "0.9.0" +version = "0.10.0" edition = "2024" repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" license = "MIT" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2006986..1db73ec 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,5 +9,5 @@ license.workspace = true chrono = "0.4.42" clap = { version = "4.5.53", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } -squad-quest = { version = "0.9.0", path = ".." } +squad-quest = { version = "0.10.0", path = ".." } toml = "0.9.8" diff --git a/discord/Cargo.toml b/discord/Cargo.toml index 399606a..6853c25 100644 --- a/discord/Cargo.toml +++ b/discord/Cargo.toml @@ -6,10 +6,11 @@ repository.workspace = true license.workspace = true [dependencies] +chrono = "0.4.42" clap = { version = "4.5.53", features = ["derive"] } dotenvy = "0.15.7" poise = "0.6.1" serde = "1.0.228" -squad-quest = { version = "0.9.0", path = ".." } +squad-quest = { version = "0.10.0", path = ".." } tokio = { version = "1.48.0", features = ["rt-multi-thread"] } toml = "0.9.8" diff --git a/discord/src/commands/init.rs b/discord/src/commands/init.rs index bfd7998..e85d79a 100644 --- a/discord/src/commands/init.rs +++ b/discord/src/commands/init.rs @@ -1,9 +1,10 @@ -use std::path::Path; +use std::{path::Path, str::FromStr}; -use poise::serenity_prelude::{ChannelId}; +use poise::{CreateReply, serenity_prelude::ChannelId}; use squad_quest::SquadObject; +use toml::value::Time; -use crate::{Context, Error}; +use crate::{Context, Error, timer::DailyTimer}; /// Set channels to post quests and answers to #[poise::command( @@ -25,7 +26,7 @@ pub async fn init( #[description_localized("ru", "Канал для публикации ответов на проверку")] answers_channel: ChannelId, ) -> Result<(), Error> { - let dc = ctx.data().discord.clone(); +let dc = ctx.data().discord.clone(); { let mut guard = dc.lock().expect("shouldn't be locked"); let guild = ctx.guild_id().unwrap(); @@ -43,3 +44,61 @@ pub async fn init( Ok(()) } + +#[derive(serde::Deserialize)] +struct TimeWrapper { + time: Time, +} + +impl FromStr for TimeWrapper { + type Err = toml::de::Error; + fn from_str(s: &str) -> Result { + let toml_str = format!("time = {s}"); + let wrapper: Self = toml::from_str(&toml_str)?; + Ok(wrapper) + } +} + +impl From for Time { + fn from(value: TimeWrapper) -> Self { + value.time + } +} + +fn seconds(time: Time) -> u64 { + time.hour as u64 * 3600 + time.minute as u64 * 60 + time.second as u64 +} + +/// Enable publication timer on given UTC time +#[poise::command( + prefix_command, + slash_command, + required_permissions = "ADMINISTRATOR", + guild_only, + name_localized("ru", "таймер"), + description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК-3)"), +)] +pub async fn timer( + ctx: Context<'_>, + time: TimeWrapper, +) -> Result<(), Error> { + if ctx.data().has_timer() { + return Err(Error::TimerSet); + } + + let time = Time::from(time); + let start_time = seconds(time); + let timer = DailyTimer::new(start_time); + + let strings = &ctx.data().strings; + let formatter = strings.formatter().value(time); + + let content = formatter.fmt(&strings.timer_reply); + let builder = CreateReply::default().ephemeral(true).content(content); + ctx.send(builder).await?; + + ctx.data().timer(); + + timer.start(ctx).await; + Ok(()) +} diff --git a/discord/src/commands/mod.rs b/discord/src/commands/mod.rs index 4a0ba13..85d3ee8 100644 --- a/discord/src/commands/mod.rs +++ b/discord/src/commands/mod.rs @@ -46,7 +46,7 @@ pub async fn error_handler(error: poise::FrameworkError<'_, Data, Error>) { } } -fn print_error_recursively(error: &impl StdError) { +pub fn print_error_recursively(error: &impl StdError) { eprintln!("{error}"); if let Some(source) = error.source() { eprintln!("source:"); diff --git a/discord/src/commands/quest.rs b/discord/src/commands/quest.rs index ad61e1a..bf90fdd 100644 --- a/discord/src/commands/quest.rs +++ b/discord/src/commands/quest.rs @@ -310,6 +310,27 @@ pub async fn update( Ok(()) } +pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Error> { + quest.public = true; + + let quests_path = ctx.data().config.full_quests_path(); + quest.save(quests_path)?; + + let content = make_quest_message_content(ctx, &quest); + + let builder = CreateMessage::new() + .content(content); + + let dc = ctx.data().discord.clone(); + let channel = { + let guard = dc.lock().expect("shouldn't be locked"); + guard.quests_channel + }; + + channel.send_message(ctx, builder).await?; + Ok(()) +} + /// Mark quest as public and send its message in quests channel #[poise::command( prefix_command, @@ -335,24 +356,8 @@ pub async fn publish( if quest.public { return Err(Error::QuestIsPublic(id)); } - - quest.public = true; - let content = make_quest_message_content(ctx, &quest); - - let builder = CreateMessage::new() - .content(content); - - let dc = ctx.data().discord.clone(); - let channel = { - let guard = dc.lock().expect("shouldn't be locked"); - guard.quests_channel - }; - - channel.send_message(ctx, builder).await?; - - let quests_path = ctx.data().config.full_quests_path(); - quest.save(quests_path)?; + publish_inner(ctx, quest).await?; let strings = &ctx.data().strings; let formatter = strings.formatter().quest(&quest); diff --git a/discord/src/error.rs b/discord/src/error.rs index e5f57c8..8d8b442 100644 --- a/discord/src/error.rs +++ b/discord/src/error.rs @@ -20,6 +20,7 @@ pub enum Error { RoomNotFound(u16), RoomAlreadyUnlocked(u16), CannotReach(u16), + TimerSet, } impl From for Error { @@ -63,6 +64,7 @@ impl Display for Error { Self::RoomNotFound(id) => write!(f, "room #{id} not found"), Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"), Self::CannotReach(id) => write!(f, "user cannot reach room #{id}"), + Self::TimerSet => write!(f, "timer is already set"), } } } @@ -81,7 +83,8 @@ impl std::error::Error for Error { Self::InsufficientFunds(_) | Self::RoomNotFound(_) | Self::RoomAlreadyUnlocked(_) | - Self::CannotReach(_) => None, + Self::CannotReach(_) | + Self::TimerSet => None, Self::SerenityError(error) => Some(error), Self::SquadQuestError(error) => Some(error), } diff --git a/discord/src/main.rs b/discord/src/main.rs index f65b4fc..cfb68c7 100644 --- a/discord/src/main.rs +++ b/discord/src/main.rs @@ -1,4 +1,4 @@ -use std::sync::{Arc, Mutex}; +use std::{sync::{Arc, Mutex}}; use clap::Parser; use dotenvy::dotenv; @@ -13,16 +13,42 @@ mod config; mod account; mod error; mod strings; +mod timer; const CONFIG_PATH: &str = "cfg/config.toml"; const DISCORD_TOKEN: &str = "DISCORD_TOKEN"; +#[derive(Debug)] +struct InnerBool { + pub value: bool, +} + #[derive(Debug)] struct Data { pub config: Config, pub discord: Arc>, pub strings: Strings, + pub timer_set: Arc>, } + +impl Data { + pub fn timer(&self) { + let tm = self.timer_set.clone(); + { + let mut guard = tm.lock().unwrap(); + guard.value = true; + } + } + + pub fn has_timer(&self) -> bool { + let tm = self.timer_set.clone(); + { + let guard = tm.lock().unwrap(); + guard.value + } + } +} + type Context<'a> = poise::Context<'a, Data, Error>; #[tokio::main] @@ -43,10 +69,11 @@ async fn main() { .options(poise::FrameworkOptions { on_error: |err| Box::pin(error_handler(err)), commands: vec![ - //commands::register(), + commands::register(), commands::quest::quest(), commands::info(), commands::init::init(), + commands::init::timer(), commands::answer::answer(), commands::social::social(), commands::account::scoreboard(), @@ -57,12 +84,13 @@ async fn main() { ], ..Default::default() }) - .setup(|ctx, _ready, _framework| { + .setup(|_ctx, _ready, _framework| { Box::pin(async move { //poise::builtins::register_globally(ctx, &framework.options().commands).await?; Ok(Data { config, discord: Arc::new(Mutex::new(discord)), + timer_set: Arc::new(Mutex::new(InnerBool { value: false })), strings, }) }) diff --git a/discord/src/strings.rs b/discord/src/strings.rs index 711afc9..a3d485f 100644 --- a/discord/src/strings.rs +++ b/discord/src/strings.rs @@ -125,6 +125,7 @@ pub struct Strings { pub points: String, pub info: String, pub init_reply: String, + pub timer_reply: String, pub account: AccountReplies, pub answer: Answer, pub difficulty: Difficulty, @@ -142,6 +143,7 @@ impl Default for Strings { info: "SquadQuest version {v}\ {n}Find the map here: {url}".to_string(), init_reply: "Updated linked channels and guild.".to_string(), + timer_reply: "Set daily timer on {value}.".to_string(), answer: Answer::default(), difficulty: Difficulty::default(), scoreboard: Scoreboard::default(), diff --git a/discord/src/timer.rs b/discord/src/timer.rs new file mode 100644 index 0000000..3930492 --- /dev/null +++ b/discord/src/timer.rs @@ -0,0 +1,56 @@ +use std::time::Duration; + +use chrono::{Datelike, Timelike, Utc}; +use tokio::time::sleep; +use toml::value::Date as TomlDate; + +use crate::{Context, commands::{print_error_recursively, quest::publish_inner}}; + +const DAY_IN_SECONDS: u64 = 24 * 60 * 60; + +#[derive(Debug)] +pub struct DailyTimer { + start_time: u64, +} + +impl DailyTimer { + pub fn new(start_time: u64) -> Self { + Self { start_time } + } + + fn get_countdown(&self) -> u64 { + let current_time = Utc::now().time(); + let seconds = current_time.num_seconds_from_midnight() as u64; + let result = if seconds > self.start_time { + DAY_IN_SECONDS + self.start_time - seconds + } else { + self.start_time - seconds + }; + if result == 0 { + return DAY_IN_SECONDS - 1; + } + result + } + + pub async fn start(&self, ctx: Context<'_>) { + loop { + let countdown = self.get_countdown(); + println!("Daily timer: sleeping for {countdown} seconds."); + sleep(Duration::from_secs(countdown)).await; + let now = Utc::now().date_naive(); + let date = TomlDate { + year: now.year() as u16, + month: now.month() as u8, + day: now.day() as u8, + }; + let conf = &ctx.data().config; + let quests = conf.load_quests().into_iter().filter(|q| !q.public && q.available_on.is_some_and(|d| d <= date)); + for mut quest in quests { + if let Err(error) = publish_inner(ctx, &mut quest).await { + eprintln!("ERROR in timer:"); + print_error_recursively(&error); + } + } + } + } +}