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]]
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",

View file

@ -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"

View file

@ -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"

View file

@ -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"

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

View file

@ -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,
@ -336,23 +357,7 @@ pub async fn publish(
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);

View file

@ -20,6 +20,7 @@ pub enum Error {
RoomNotFound(u16),
RoomAlreadyUnlocked(u16),
CannotReach(u16),
TimerSet,
}
impl From<serenity::Error> 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),
}

View file

@ -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<Mutex<DiscordConfig>>,
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>;
#[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,
})
})

View file

@ -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(),

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