feat: Implemented daily timer

- Bump version to 0.10.0
- Added /timer command
This commit is contained in:
Alexey 2025-12-18 15:58:18 +03:00
commit cc916c06ce
11 changed files with 187 additions and 32 deletions

7
Cargo.lock generated
View file

@ -1599,7 +1599,7 @@ dependencies = [
[[package]] [[package]]
name = "squad-quest" name = "squad-quest"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"serde", "serde",
"toml", "toml",
@ -1607,7 +1607,7 @@ dependencies = [
[[package]] [[package]]
name = "squad-quest-cli" name = "squad-quest-cli"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
@ -1618,8 +1618,9 @@ dependencies = [
[[package]] [[package]]
name = "squad-quest-discord" name = "squad-quest-discord"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"chrono",
"clap", "clap",
"dotenvy", "dotenvy",
"poise", "poise",

View file

@ -2,7 +2,7 @@
members = ["cli", "discord"] members = ["cli", "discord"]
[workspace.package] [workspace.package]
version = "0.9.0" version = "0.10.0"
edition = "2024" edition = "2024"
repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest"
license = "MIT" license = "MIT"

View file

@ -9,5 +9,5 @@ license.workspace = true
chrono = "0.4.42" chrono = "0.4.42"
clap = { version = "4.5.53", features = ["derive"] } clap = { version = "4.5.53", features = ["derive"] }
serde = { version = "1.0.228", 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" toml = "0.9.8"

View file

@ -6,10 +6,11 @@ repository.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
chrono = "0.4.42"
clap = { version = "4.5.53", features = ["derive"] } clap = { version = "4.5.53", features = ["derive"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
poise = "0.6.1" poise = "0.6.1"
serde = "1.0.228" 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"] } tokio = { version = "1.48.0", features = ["rt-multi-thread"] }
toml = "0.9.8" toml = "0.9.8"

View file

@ -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 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 /// Set channels to post quests and answers to
#[poise::command( #[poise::command(
@ -25,7 +26,7 @@ pub async fn init(
#[description_localized("ru", "Канал для публикации ответов на проверку")] #[description_localized("ru", "Канал для публикации ответов на проверку")]
answers_channel: ChannelId, answers_channel: ChannelId,
) -> Result<(), Error> { ) -> 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 mut guard = dc.lock().expect("shouldn't be locked");
let guild = ctx.guild_id().unwrap(); let guild = ctx.guild_id().unwrap();
@ -43,3 +44,61 @@ pub async fn init(
Ok(()) Ok(())
} }
#[derive(serde::Deserialize)]
struct TimeWrapper {
time: Time,
}
impl FromStr for TimeWrapper {
type Err = toml::de::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let toml_str = format!("time = {s}");
let wrapper: Self = toml::from_str(&toml_str)?;
Ok(wrapper)
}
}
impl From<TimeWrapper> 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(())
}

View file

@ -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}"); eprintln!("{error}");
if let Some(source) = error.source() { if let Some(source) = error.source() {
eprintln!("source:"); eprintln!("source:");

View file

@ -310,6 +310,27 @@ pub async fn update(
Ok(()) 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 /// Mark quest as public and send its message in quests channel
#[poise::command( #[poise::command(
prefix_command, prefix_command,
@ -336,23 +357,7 @@ pub async fn publish(
return Err(Error::QuestIsPublic(id)); return Err(Error::QuestIsPublic(id));
} }
quest.public = true; publish_inner(ctx, quest).await?;
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)?;
let strings = &ctx.data().strings; let strings = &ctx.data().strings;
let formatter = strings.formatter().quest(&quest); let formatter = strings.formatter().quest(&quest);

View file

@ -20,6 +20,7 @@ pub enum Error {
RoomNotFound(u16), RoomNotFound(u16),
RoomAlreadyUnlocked(u16), RoomAlreadyUnlocked(u16),
CannotReach(u16), CannotReach(u16),
TimerSet,
} }
impl From<serenity::Error> for Error { impl From<serenity::Error> for Error {
@ -63,6 +64,7 @@ impl Display for Error {
Self::RoomNotFound(id) => write!(f, "room #{id} not found"), Self::RoomNotFound(id) => write!(f, "room #{id} not found"),
Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"), Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"),
Self::CannotReach(id) => write!(f, "user cannot reach room #{id}"), 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::InsufficientFunds(_) |
Self::RoomNotFound(_) | Self::RoomNotFound(_) |
Self::RoomAlreadyUnlocked(_) | Self::RoomAlreadyUnlocked(_) |
Self::CannotReach(_) => None, Self::CannotReach(_) |
Self::TimerSet => None,
Self::SerenityError(error) => Some(error), Self::SerenityError(error) => Some(error),
Self::SquadQuestError(error) => Some(error), Self::SquadQuestError(error) => Some(error),
} }

View file

@ -1,4 +1,4 @@
use std::sync::{Arc, Mutex}; use std::{sync::{Arc, Mutex}};
use clap::Parser; use clap::Parser;
use dotenvy::dotenv; use dotenvy::dotenv;
@ -13,16 +13,42 @@ mod config;
mod account; mod account;
mod error; mod error;
mod strings; mod strings;
mod timer;
const CONFIG_PATH: &str = "cfg/config.toml"; const CONFIG_PATH: &str = "cfg/config.toml";
const DISCORD_TOKEN: &str = "DISCORD_TOKEN"; const DISCORD_TOKEN: &str = "DISCORD_TOKEN";
#[derive(Debug)]
struct InnerBool {
pub value: bool,
}
#[derive(Debug)] #[derive(Debug)]
struct Data { struct Data {
pub config: Config, pub config: Config,
pub discord: Arc<Mutex<DiscordConfig>>, pub discord: Arc<Mutex<DiscordConfig>>,
pub strings: Strings, pub strings: Strings,
pub timer_set: Arc<Mutex<InnerBool>>,
} }
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>; type Context<'a> = poise::Context<'a, Data, Error>;
#[tokio::main] #[tokio::main]
@ -43,10 +69,11 @@ async fn main() {
.options(poise::FrameworkOptions { .options(poise::FrameworkOptions {
on_error: |err| Box::pin(error_handler(err)), on_error: |err| Box::pin(error_handler(err)),
commands: vec![ commands: vec![
//commands::register(), commands::register(),
commands::quest::quest(), commands::quest::quest(),
commands::info(), commands::info(),
commands::init::init(), commands::init::init(),
commands::init::timer(),
commands::answer::answer(), commands::answer::answer(),
commands::social::social(), commands::social::social(),
commands::account::scoreboard(), commands::account::scoreboard(),
@ -57,12 +84,13 @@ async fn main() {
], ],
..Default::default() ..Default::default()
}) })
.setup(|ctx, _ready, _framework| { .setup(|_ctx, _ready, _framework| {
Box::pin(async move { Box::pin(async move {
//poise::builtins::register_globally(ctx, &framework.options().commands).await?; //poise::builtins::register_globally(ctx, &framework.options().commands).await?;
Ok(Data { Ok(Data {
config, config,
discord: Arc::new(Mutex::new(discord)), discord: Arc::new(Mutex::new(discord)),
timer_set: Arc::new(Mutex::new(InnerBool { value: false })),
strings, strings,
}) })
}) })

View file

@ -125,6 +125,7 @@ pub struct Strings {
pub points: String, pub points: String,
pub info: String, pub info: String,
pub init_reply: String, pub init_reply: String,
pub timer_reply: String,
pub account: AccountReplies, pub account: AccountReplies,
pub answer: Answer, pub answer: Answer,
pub difficulty: Difficulty, pub difficulty: Difficulty,
@ -142,6 +143,7 @@ impl Default for Strings {
info: "SquadQuest version {v}\ info: "SquadQuest version {v}\
{n}Find the map here: {url}".to_string(), {n}Find the map here: {url}".to_string(),
init_reply: "Updated linked channels and guild.".to_string(), init_reply: "Updated linked channels and guild.".to_string(),
timer_reply: "Set daily timer on {value}.".to_string(),
answer: Answer::default(), answer: Answer::default(),
difficulty: Difficulty::default(), difficulty: Difficulty::default(),
scoreboard: Scoreboard::default(), scoreboard: Scoreboard::default(),

56
discord/src/timer.rs Normal file
View file

@ -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);
}
}
}
}
}