feat: Implemented daily timer
- Bump version to 0.10.0 - Added /timer command
This commit is contained in:
parent
60aa5fcb34
commit
cc916c06ce
11 changed files with 187 additions and 32 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
56
discord/src/timer.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue