From 40630c001488f34dff75830d31edfc68e49cbcac Mon Sep 17 00:00:00 2001 From: jellywx Date: Thu, 2 Sep 2021 23:38:12 +0100 Subject: [PATCH] restructured all the reminder creation stuff into builders --- Cargo.lock | 115 ++- Cargo.toml | 3 +- src/commands/info_cmds.rs | 6 +- src/commands/moderation_cmds.rs | 16 +- src/commands/reminder_cmds.rs | 810 +++----------------- src/commands/todo_cmds.rs | 4 +- src/framework.rs | 10 +- src/main.rs | 211 ++--- src/models/mod.rs | 32 +- src/models/reminder/builder.rs | 365 +++++++++ src/models/reminder/content.rs | 74 ++ src/models/reminder/errors.rs | 81 ++ src/models/reminder/helper.rs | 40 + src/models/reminder/look_flags.rs | 59 ++ src/models/{reminder.rs => reminder/mod.rs} | 169 +--- src/time_parser.rs | 2 + 16 files changed, 961 insertions(+), 1036 deletions(-) create mode 100644 src/models/reminder/builder.rs create mode 100644 src/models/reminder/content.rs create mode 100644 src/models/reminder/errors.rs create mode 100644 src/models/reminder/helper.rs create mode 100644 src/models/reminder/look_flags.rs rename src/models/{reminder.rs => reminder/mod.rs} (73%) diff --git a/Cargo.lock b/Cargo.lock index c125216..c593e45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,7 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 3 + [[package]] name = "Inflector" version = "0.11.4" @@ -55,18 +57,18 @@ dependencies = [ [[package]] name = "async-tungstenite" -version = "0.11.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7cc5408453d37e2b1c6f01d8078af1da58b6cfa6a80fa2ede3bd2b9a6ada9c4" +checksum = "07b30ef0ea5c20caaa54baea49514a206308989c68be7ecd86c7f956e4da6378" dependencies = [ "futures-io", "futures-util", "log", - "pin-project", + "pin-project-lite", "tokio", "tokio-rustls", "tungstenite", - "webpki-roots 0.20.0", + "webpki-roots", ] [[package]] @@ -101,12 +103,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" -[[package]] -name = "base64" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" - [[package]] name = "base64" version = "0.13.0" @@ -169,12 +165,6 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" -[[package]] -name = "bytes" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38" - [[package]] name = "bytes" version = "1.0.1" @@ -220,6 +210,7 @@ dependencies = [ [[package]] name = "command_attr" version = "0.3.7" +source = "git+https://github.com/serenity-rs/serenity?branch=next#4d431726f4eb2f29a040b83fb4a18a459427c1b2" dependencies = [ "proc-macro2", "quote", @@ -307,6 +298,7 @@ checksum = "e77a43b28d0668df09411cb0bc9a8c2adc40f9a048afe863e05fd43251e8e39c" dependencies = [ "cfg-if", "num_cpus", + "serde", ] [[package]] @@ -533,7 +525,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "825343c4eef0b63f541f8903f395dc5beb362a979b5799a84062527ef1e37726" dependencies = [ - "bytes 1.0.1", + "bytes", "fnv", "futures-core", "futures-sink", @@ -600,7 +592,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527e8c9ac747e28542699a951517aa9a6945af506cd1f2e1b53a576c17b6cc11" dependencies = [ - "bytes 1.0.1", + "bytes", "fnv", "itoa", ] @@ -611,7 +603,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60daa14be0e0786db0f03a9e57cb404c9d756eed2b6c62b9ea98ec5743ec75a9" dependencies = [ - "bytes 1.0.1", + "bytes", "http", "pin-project-lite", ] @@ -640,7 +632,7 @@ version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07d6baa1b441335f3ce5098ac421fb6547c46dda735ca1bc6d0153c838f9dd83" dependencies = [ - "bytes 1.0.1", + "bytes", "futures-channel", "futures-core", "futures-util", @@ -679,7 +671,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ - "bytes 1.0.1", + "bytes", "hyper", "native-tls", "tokio", @@ -709,11 +701,11 @@ dependencies = [ [[package]] name = "input_buffer" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a8a95243d5a0398cae618ec29477c6e3cb631152be5c19481f80bc71559754" +checksum = "f97967975f448f1a7ddb12b0bc41069d09ed6a1c161a92687e057325db35d413" dependencies = [ - "bytes 0.5.6", + "bytes", ] [[package]] @@ -1076,7 +1068,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd56cbd21fea48d0c440b41cd69c589faacade08c992d9a54e471b79d0fd13eb" dependencies = [ - "base64 0.13.0", + "base64", "once_cell", "regex", ] @@ -1087,26 +1079,6 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" -[[package]] -name = "pin-project" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7509cc106041c40a4518d2af7a61530e1eed0e6285296a3d8c5472806ccc4a4" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c950132583b500556b1efd71d45b319029f2b71518d979fcc208e16b42426f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pin-project-lite" version = "0.2.7" @@ -1288,7 +1260,7 @@ name = "reminder_rs" version = "1.5.1" dependencies = [ "Inflector", - "base64 0.13.0", + "base64", "chrono", "chrono-tz", "dashmap", @@ -1326,8 +1298,8 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246e9f61b9bb77df069a947682be06e31ac43ea37862e244a69f177694ea6d22" dependencies = [ - "base64 0.13.0", - "bytes 1.0.1", + "base64", + "bytes", "encoding_rs", "futures-core", "futures-util", @@ -1356,7 +1328,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 0.21.1", + "webpki-roots", "winreg", ] @@ -1401,7 +1373,7 @@ version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" dependencies = [ - "base64 0.13.0", + "base64", "log", "ring", "sct", @@ -1509,16 +1481,21 @@ dependencies = [ [[package]] name = "serenity" version = "0.10.8" +source = "git+https://github.com/serenity-rs/serenity?branch=next#4d431726f4eb2f29a040b83fb4a18a459427c1b2" dependencies = [ "async-trait", "async-tungstenite", - "base64 0.13.0", + "base64", "bitflags", - "bytes 1.0.1", + "bytes", "chrono", "command_attr", + "dashmap", "flate2", "futures", + "mime", + "mime_guess", + "parking_lot", "percent-encoding", "reqwest", "serde", @@ -1637,11 +1614,11 @@ checksum = "7f23af36748ec8ea8d49ef8499839907be41b0b1178a4e82b8cb45d29f531dc9" dependencies = [ "ahash", "atoi", - "base64 0.13.0", + "base64", "bigdecimal", "bitflags", "byteorder", - "bytes 1.0.1", + "bytes", "chrono", "crc", "crossbeam-channel", @@ -1676,7 +1653,7 @@ dependencies = [ "tokio-stream", "url", "webpki", - "webpki-roots 0.21.1", + "webpki-roots", "whoami", ] @@ -1837,7 +1814,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fb2ed024293bb19f7a5dc54fe83bf86532a44c12a2bb8ba40d64a4509395ca2" dependencies = [ "autocfg 1.0.1", - "bytes 1.0.1", + "bytes", "libc", "memchr", "mio", @@ -1899,7 +1876,7 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1caa0b0c8d94a049db56b5acf8cba99dc0623aab1b26d5b5f5e2d945846b3592" dependencies = [ - "bytes 1.0.1", + "bytes", "futures-core", "futures-sink", "log", @@ -1954,21 +1931,25 @@ checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" [[package]] name = "tungstenite" -version = "0.11.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0308d80d86700c5878b9ef6321f020f29b1bb9d5ff3cab25e75e23f3a492a23" +checksum = "5fe8dada8c1a3aeca77d6b51a4f1314e0f4b8e438b7b1b71e3ddaca8080e4093" dependencies = [ - "base64 0.12.3", + "base64", "byteorder", - "bytes 0.5.6", + "bytes", "http", "httparse", "input_buffer", "log", - "rand 0.7.3", + "rand 0.8.4", + "rustls", "sha-1", + "thiserror", "url", "utf-8", + "webpki", + "webpki-roots", ] [[package]] @@ -2044,6 +2025,7 @@ dependencies = [ "idna", "matches", "percent-encoding", + "serde", ] [[package]] @@ -2180,15 +2162,6 @@ dependencies = [ "untrusted", ] -[[package]] -name = "webpki-roots" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f20dea7535251981a9670857150d571846545088359b28e4951d350bdaf179f" -dependencies = [ - "webpki", -] - [[package]] name = "webpki-roots" version = "0.21.1" diff --git a/Cargo.toml b/Cargo.toml index c21577e..e0486d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,8 +22,7 @@ serde_json = "1.0" rand = "0.7" Inflector = "0.11" levenshtein = "1.0" -# serenity = { version = "0.10", features = ["collector"] } -serenity = { path = "/home/jude/serenity", features = ["collector", "unstable_discord_api"] } +serenity = { git = "https://github.com/serenity-rs/serenity", branch = "next", features = ["collector", "unstable_discord_api"] } sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]} ring = "0.16" base64 = "0.13.0" diff --git a/src/commands/info_cmds.rs b/src/commands/info_cmds.rs index 775dd6d..757854a 100644 --- a/src/commands/info_cmds.rs +++ b/src/commands/info_cmds.rs @@ -9,7 +9,7 @@ use crate::{ consts::DEFAULT_PREFIX, get_ctx_data, language_manager::LanguageManager, - models::{user_data::UserData, CtxGuildData}, + models::{user_data::UserData, CtxData}, FrameworkCtx, THEME_COLOR, }; @@ -35,7 +35,7 @@ async fn ping(ctx: &Context, msg: &Message, _args: String) { } async fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter { - let shard_count = ctx.cache.shard_count().await; + let shard_count = ctx.cache.shard_count(); let shard = ctx.shard_id; move |f| { @@ -145,7 +145,7 @@ async fn info(ctx: &Context, msg: &Message, _args: String) { let desc = lm .get(&language.await, "info") - .replacen("{user}", ¤t_user.await.name, 1) + .replacen("{user}", ¤t_user.name, 1) .replace("{default_prefix}", &*DEFAULT_PREFIX) .replace("{prefix}", &prefix.await); diff --git a/src/commands/moderation_cmds.rs b/src/commands/moderation_cmds.rs index b543ad9..2daac7d 100644 --- a/src/commands/moderation_cmds.rs +++ b/src/commands/moderation_cmds.rs @@ -7,7 +7,7 @@ use serenity::{ model::{ channel::Message, id::{ChannelId, MessageId, RoleId}, - interactions::ButtonStyle, + interactions::message_component::ButtonStyle, }, }; @@ -24,7 +24,7 @@ use crate::{ consts::{REGEX_ALIAS, REGEX_CHANNEL, REGEX_COMMANDS, REGEX_ROLE, THEME_COLOR}, framework::SendIterator, get_ctx_data, - models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData, CtxGuildData}, + models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData, CtxData}, FrameworkCtx, PopularTimezones, }; @@ -46,13 +46,11 @@ async fn blacklist(ctx: &Context, msg: &Message, args: String) { let (channel, local) = match capture_opt { Some(capture) => ( - ChannelId(capture.as_str().parse::().unwrap()) - .to_channel_cached(&ctx) - .await, + ChannelId(capture.as_str().parse::().unwrap()).to_channel_cached(&ctx), false, ), - None => (msg.channel(&ctx).await, true), + None => (msg.channel(&ctx).await.ok(), true), }; let mut channel_data = ChannelData::from_channel(channel.unwrap(), &pool) @@ -394,7 +392,7 @@ async fn restrict(ctx: &Context, msg: &Message, args: String) { let (pool, lm) = get_ctx_data(&ctx).await; let language = UserData::language_of(&msg.author, &pool).await; - let guild_data = GuildData::from_guild(msg.guild(&ctx).await.unwrap(), &pool) + let guild_data = GuildData::from_guild(msg.guild(&ctx).unwrap(), &pool) .await .unwrap(); @@ -411,7 +409,7 @@ async fn restrict(ctx: &Context, msg: &Message, args: String) { .unwrap(), ); - let role_opt = role_id.to_role_cached(&ctx).await; + let role_opt = role_id.to_role_cached(&ctx); if let Some(role) = role_opt { let _ = sqlx::query!( @@ -624,7 +622,7 @@ SELECT command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHER .get::().cloned().expect("Could not get FrameworkCtx from data"); let mut new_msg = msg.clone(); - new_msg.content = format!("<@{}> {}", &ctx.cache.current_user_id().await, row.command); + new_msg.content = format!("<@{}> {}", &ctx.cache.current_user_id(), row.command); new_msg.id = MessageId(0); framework.dispatch(ctx.clone(), new_msg).await; diff --git a/src/commands/reminder_cmds.rs b/src/commands/reminder_cmds.rs index a438ad6..76bcbc1 100644 --- a/src/commands/reminder_cmds.rs +++ b/src/commands/reminder_cmds.rs @@ -2,82 +2,39 @@ use regex_command_attr::command; use serenity::{ client::Context, - http::CacheHttp, - model::{ - channel::Message, - channel::{Channel, GuildChannel}, - guild::Guild, - id::{ChannelId, GuildId, UserId}, - interactions::ButtonStyle, - misc::Mentionable, - webhook::Webhook, - }, - Result as SerenityResult, + model::{channel::Channel, channel::Message}, }; use crate::{ check_subscription_on_message, command_help, consts::{ - CHARACTERS, MAX_TIME, MIN_INTERVAL, REGEX_CHANNEL_USER, REGEX_CONTENT_SUBSTITUTION, - REGEX_NATURAL_COMMAND_1, REGEX_NATURAL_COMMAND_2, REGEX_REMIND_COMMAND, THEME_COLOR, + REGEX_CHANNEL_USER, REGEX_NATURAL_COMMAND_1, REGEX_NATURAL_COMMAND_2, REGEX_REMIND_COMMAND, + THEME_COLOR, }, framework::SendIterator, get_ctx_data, models::{ channel_data::ChannelData, guild_data::GuildData, - reminder::{LookFlags, Reminder, ReminderAction}, + reminder::{builder::ReminderScope, content::Content, look_flags::LookFlags, Reminder}, timer::Timer, user_data::UserData, - CtxGuildData, + CtxData, }, time_parser::{natural_parser, TimeParser}, }; use chrono::NaiveDateTime; -use rand::{rngs::OsRng, seq::IteratorRandom}; - -use sqlx::MySqlPool; - use num_integer::Integer; +use crate::models::reminder::builder::MultiReminderBuilder; use std::{ - collections::HashSet, - convert::TryInto, default::Default, - env, - fmt::Display, string::ToString, time::{SystemTime, UNIX_EPOCH}, }; -use regex::Captures; - -async fn create_webhook( - ctx: impl CacheHttp, - channel: GuildChannel, - name: impl Display, -) -> SerenityResult { - channel - .create_webhook_with_avatar( - ctx.http(), - name, - ( - include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/", - env!( - "WEBHOOK_AVATAR", - "WEBHOOK_AVATAR not provided for compilation" - ) - )) as &[u8], - env!("WEBHOOK_AVATAR"), - ), - ) - .await -} - #[command] #[supports_dm(false)] #[permission_level(Restricted)] @@ -153,7 +110,7 @@ async fn offset(ctx: &Context, msg: &Message, args: String) { let parser = TimeParser::new(&args, user_data.timezone()); if let Ok(displacement) = parser.displacement() { - if let Some(guild) = msg.guild(&ctx).await { + if let Some(guild) = msg.guild(&ctx) { let guild_data = GuildData::from_guild(guild, &pool).await.unwrap(); sqlx::query!( @@ -263,7 +220,7 @@ async fn look(ctx: &Context, msg: &Message, args: String) { let flags = LookFlags::from_string(&args); - let channel_opt = msg.channel_id.to_channel_cached(&ctx).await; + let channel_opt = msg.channel_id.to_channel_cached(&ctx); let channel_id = if let Some(Channel::Guild(channel)) = channel_opt { if Some(channel.guild_id) == msg.guild_id { @@ -287,7 +244,7 @@ async fn look(ctx: &Context, msg: &Message, args: String) { let display = reminders .iter() - .map(|reminder| reminder.display(&flags, &inter)); + .map(|reminder| reminder.display(&flags, inter)); let _ = msg.channel_id.say_lines(&ctx, display).await; } @@ -545,308 +502,6 @@ enum RemindCommand { Interval, } -enum ReminderScope { - User(u64), - Channel(u64), -} - -impl ReminderScope { - fn mention(&self) -> String { - match self { - Self::User(id) => format!("<@{}>", id), - Self::Channel(id) => format!("<#{}>", id), - } - } -} - -#[derive(PartialEq, Eq, Hash, Debug)] -enum ReminderError { - LongInterval, - PastTime, - ShortInterval, - InvalidTag, - InvalidTime, - InvalidExpiration, - DiscordError(String), -} - -impl std::fmt::Display for ReminderError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.to_response()) - } -} - -impl std::error::Error for ReminderError {} - -trait ToResponse { - fn to_response(&self) -> &'static str; - - fn to_response_natural(&self) -> &'static str; -} - -impl ToResponse for ReminderError { - fn to_response(&self) -> &'static str { - match self { - Self::LongInterval => "interval/long_interval", - Self::PastTime => "remind/past_time", - Self::ShortInterval => "interval/short_interval", - Self::InvalidTag => "remind/invalid_tag", - Self::InvalidTime => "remind/invalid_time", - Self::InvalidExpiration => "interval/invalid_expiration", - Self::DiscordError(_) => "remind/generic_error", - } - } - - fn to_response_natural(&self) -> &'static str { - match self { - Self::InvalidTime => "natural/invalid_time", - _ => self.to_response(), - } - } -} - -impl ToResponse for Result { - fn to_response(&self) -> &'static str { - match self { - Ok(_) => "remind/success", - - Err(reminder_error) => reminder_error.to_response(), - } - } - - fn to_response_natural(&self) -> &'static str { - match self { - Ok(_) => "remind/success", - - Err(reminder_error) => reminder_error.to_response_natural(), - } - } -} - -fn generate_uid() -> String { - let mut generator: OsRng = Default::default(); - - (0..64) - .map(|_| { - CHARACTERS - .chars() - .choose(&mut generator) - .unwrap() - .to_owned() - .to_string() - }) - .collect::>() - .join("") -} - -#[derive(Debug)] -enum ContentError { - TooManyAttachments, - AttachmentTooLarge, - AttachmentDownloadFailed, -} - -impl ContentError { - fn to_response(&self) -> &'static str { - match self { - ContentError::TooManyAttachments => "remind/too_many_attachments", - ContentError::AttachmentTooLarge => "remind/attachment_too_large", - ContentError::AttachmentDownloadFailed => "remind/attachment_download_failed", - } - } -} - -impl std::fmt::Display for ContentError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self) - } -} - -impl std::error::Error for ContentError {} - -struct Content { - content: String, - tts: bool, - attachment: Option>, - attachment_name: Option, -} - -impl Content { - async fn build(content: S, message: &Message) -> Result { - if message.attachments.len() > 1 { - Err(ContentError::TooManyAttachments) - } else if let Some(attachment) = message.attachments.get(0) { - if attachment.size > 8_000_000 { - Err(ContentError::AttachmentTooLarge) - } else if let Ok(attachment_bytes) = attachment.download().await { - Ok(Self { - content: content.to_string(), - tts: false, - attachment: Some(attachment_bytes), - attachment_name: Some(attachment.filename.clone()), - }) - } else { - Err(ContentError::AttachmentDownloadFailed) - } - } else { - Ok(Self { - content: content.to_string(), - tts: false, - attachment: None, - attachment_name: None, - }) - } - } - - fn substitute(&mut self, guild: Guild) { - if self.content.starts_with("/tts ") { - self.tts = true; - self.content = self.content.split_off(5); - } - - self.content = REGEX_CONTENT_SUBSTITUTION - .replace(&self.content, |caps: &Captures| { - if let Some(user) = caps.name("user") { - format!("<@{}>", user.as_str()) - } else if let Some(role_name) = caps.name("role") { - if let Some(role) = guild.role_by_name(role_name.as_str()) { - role.mention().to_string() - } else { - format!("<<{}>>", role_name.as_str().to_string()) - } - } else { - String::new() - } - }) - .to_string() - .replace("<>", "@everyone") - .replace("<>", "@here"); - } -} - -#[command("countdown")] -#[permission_level(Managed)] -async fn countdown(ctx: &Context, msg: &Message, args: String) { - let (pool, lm) = get_ctx_data(&ctx).await; - let language = UserData::language_of(&msg.author, &pool).await; - - if check_subscription_on_message(&ctx, &msg).await { - let timezone = UserData::timezone_of(&msg.author, &pool).await; - - let split_args = args.splitn(3, ' ').collect::>(); - - if split_args.len() == 3 { - let time = split_args.get(0).unwrap(); - let interval = split_args.get(1).unwrap(); - let event_name = split_args.get(2).unwrap(); - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - let time_parser = TimeParser::new(*time, timezone); - let interval_parser = TimeParser::new(*interval, timezone); - - if let Ok(target_ts) = time_parser.timestamp() { - if let Ok(interval) = interval_parser.displacement() { - let mut first_time = target_ts; - - while first_time - interval > now as i64 { - first_time -= interval; - } - - let description = format!( - "**{}** occurs in **<>**", - event_name, target_ts - ); - - sqlx::query!( - " -INSERT INTO reminders ( - `uid`, - `name`, - `embed_title`, - `embed_description`, - `embed_color`, - `channel_id`, - `utc_time`, - `interval`, - `set_by`, - `expires` -) VALUES ( - ?, - 'Countdown', - ?, - ?, - ?, - ?, - ?, - ?, - (SELECT id FROM users WHERE user = ?), - FROM_UNIXTIME(?) -) - ", - generate_uid(), - event_name, - description, - *THEME_COLOR, - msg.channel_id.as_u64(), - first_time, - interval, - msg.author.id.as_u64(), - target_ts - ) - .execute(&pool) - .await - .unwrap(); - - let _ = msg.channel_id.send_message(&ctx, |m| { - m.embed(|e| { - e.title(lm.get(&language, "remind/success")).description( - "A new countdown reminder has been created on this channel", - ) - }) - }); - } else { - let _ = msg.channel_id.send_message(&ctx, |m| { - m.embed(|e| { - e.title(lm.get(&language, "remind/issue")) - .description(lm.get(&language, "interval/invalid_interval")) - }) - }); - } - } else { - let _ = msg.channel_id.send_message(&ctx, |m| { - m.embed(|e| { - e.title(lm.get(&language, "remind/issue")) - .description(lm.get(&language, "remind/invalid_time")) - }) - }); - } - } else { - command_help( - ctx, - msg, - lm, - &ctx.prefix(msg.guild_id).await, - &language, - "countdown", - ) - .await; - } - } else { - let _ = msg - .channel_id - .say( - &ctx, - lm.get(&language, "interval/donor") - .replace("{prefix}", &ctx.prefix(msg.guild_id).await), - ) - .await; - } -} - #[command("remind")] #[permission_level(Managed)] async fn remind(ctx: &Context, msg: &Message, args: String) { @@ -923,127 +578,63 @@ async fn remind_command(ctx: &Context, msg: &Message, args: String, command: Rem match content_res { Ok(mut content) => { - let mut ok_locations = vec![]; - let mut ok_reminders = vec![]; - let mut err_locations = vec![]; - let mut err_types = HashSet::new(); - - for scope in scopes { - let res = create_reminder( - &ctx, - &pool, - msg.author.id, - msg.guild_id, - &scope, - &time_parser, - expires_parser.as_ref(), - interval, - &mut content, - ) - .await; - - match res { - Err(e) => { - err_locations.push(scope); - err_types.insert(e); - } - - Ok(id) => { - ok_locations.push(scope); - ok_reminders.push(id); - } - } + if let Some(guild) = msg.guild(&ctx) { + content.substitute(guild); } - let success_part = match ok_locations.len() { + let user_data = ctx.user_data(&msg.author).await.unwrap(); + + let mut builder = MultiReminderBuilder::new(ctx, msg.guild_id) + .author(user_data) + .content(content) + .interval(interval) + .expires_parser(expires_parser) + .time_parser(time_parser.clone()); + + builder.set_scopes(scopes); + + let (errors, successes) = builder.build().await; + + let success_part = match successes.len() { 0 => "".to_string(), - 1 => lm - .get(&language, "remind/success") - .replace("{location}", &ok_locations[0].mention()) - .replace( - "{offset}", - &format!("", time_parser.timestamp().unwrap()), - ), - n => lm - .get(&language, "remind/success_bulk") - .replace("{number}", &n.to_string()) - .replace( - "{location}", - &ok_locations - .iter() - .map(|l| l.mention()) - .collect::>() - .join(", "), - ) - .replace( - "{offset}", - &format!("", time_parser.timestamp().unwrap()), - ), + n => format!( + "Reminder{s} for {locations} set for ", + s = if n > 1 { "s" } else { "" }, + locations = successes + .iter() + .map(|l| l.mention()) + .collect::>() + .join(", "), + offset = time_parser.timestamp().unwrap() + ), }; - let error_part = format!( - "{}\n{}", - match err_locations.len() { - 0 => "".to_string(), - 1 => lm - .get(&language, "remind/issue") - .replace("{location}", &err_locations[0].mention()), - n => lm - .get(&language, "remind/issue_bulk") - .replace("{number}", &n.to_string()) - .replace( - "{location}", - &err_locations - .iter() - .map(|l| l.mention()) - .collect::>() - .join(", "), - ), - }, - err_types - .iter() - .map(|err| match err { - ReminderError::DiscordError(s) => lm - .get(&language, err.to_response()) - .replace("{error}", &s), - - _ => lm - .get(&language, err.to_response()) - .replace("{min_interval}", &*MIN_INTERVAL.to_string()), - }) - .collect::>() - .join("\n") - ); + let error_part = match errors.len() { + 0 => "".to_string(), + n => format!( + "{n} reminder{s} failed to set:\n{errors}", + s = if n > 1 { "s" } else { "" }, + n = n, + errors = errors + .iter() + .map(|e| e.display(false)) + .collect::>() + .join("\n") + ), + }; let _ = msg .channel_id .send_message(&ctx, |m| { m.embed(|e| { - e.title( - lm.get(&language, "remind/title").replace( - "{number}", - &ok_locations.len().to_string(), - ), - ) + e.title(format!( + "{n} Reminder{s} Set", + n = successes.len(), + s = if successes.len() > 1 { "s" } else { "" } + )) .description(format!("{}\n\n{}", success_part, error_part)) .color(*THEME_COLOR) }) - .components(|c| { - if ok_locations.len() == 1 { - c.create_action_row(|r| { - r.create_button(|b| { - b.style(ButtonStyle::Danger) - .label("Delete") - .custom_id(ok_reminders[0].signed_action( - msg.author.id, - ReminderAction::Delete, - )) - }) - }); - } - - c - }) }) .await; } @@ -1053,12 +644,9 @@ async fn remind_command(ctx: &Context, msg: &Message, args: String, command: Rem .channel_id .send_message(ctx, |m| { m.embed(move |e| { - e.title( - lm.get(&language, "remind/title") - .replace("{number}", "0"), - ) - .description(lm.get(&language, content_error.to_response())) - .color(*THEME_COLOR) + e.title("0 Reminders Set") + .description(content_error.to_string()) + .color(*THEME_COLOR) }) }) .await; @@ -1163,94 +751,60 @@ async fn natural(ctx: &Context, msg: &Message, args: String) { match content_res { Ok(mut content) => { - let mut ok_locations = vec![]; - let mut err_locations = vec![]; - let mut err_types = HashSet::new(); - - for scope in location_ids { - let res = create_reminder( - &ctx, - &pool, - msg.author.id, - msg.guild_id, - &scope, - timestamp, - expires, - interval, - &mut content, - ) - .await; - - if let Err(e) = res { - err_locations.push(scope); - err_types.insert(e); - } else { - ok_locations.push(scope); - } + if let Some(guild) = msg.guild(&ctx) { + content.substitute(guild); } - let success_part = match ok_locations.len() { + let user_data = ctx.user_data(&msg.author).await.unwrap(); + + let mut builder = MultiReminderBuilder::new(ctx, msg.guild_id) + .author(user_data) + .content(content) + .interval(interval) + .expires(expires) + .time(timestamp); + + builder.set_scopes(location_ids); + + let (errors, successes) = builder.build().await; + + let success_part = match successes.len() { 0 => "".to_string(), - 1 => lm - .get(&user_data.language, "remind/success") - .replace("{location}", &ok_locations[0].mention()) - .replace("{offset}", &format!("", timestamp)), - n => lm - .get(&user_data.language, "remind/success_bulk") - .replace("{number}", &n.to_string()) - .replace( - "{location}", - &ok_locations - .iter() - .map(|l| l.mention()) - .collect::>() - .join(", "), - ) - .replace("{offset}", &format!("", timestamp)), + n => format!( + "Reminder{s} for {locations} set for ", + s = if n > 1 { "s" } else { "" }, + locations = successes + .iter() + .map(|l| l.mention()) + .collect::>() + .join(", "), + offset = timestamp + ), }; - let error_part = format!( - "{}\n{}", - match err_locations.len() { - 0 => "".to_string(), - 1 => lm - .get(&user_data.language, "remind/issue") - .replace("{location}", &err_locations[0].mention()), - n => lm - .get(&user_data.language, "remind/issue_bulk") - .replace("{number}", &n.to_string()) - .replace( - "{location}", - &err_locations - .iter() - .map(|l| l.mention()) - .collect::>() - .join(", "), - ), - }, - err_types - .iter() - .map(|err| match err { - ReminderError::DiscordError(s) => lm - .get(&user_data.language, err.to_response_natural()) - .replace("{error}", &s), - - _ => lm - .get(&user_data.language, err.to_response_natural()) - .to_string(), - }) - .collect::>() - .join("\n") - ); + let error_part = match errors.len() { + 0 => "".to_string(), + n => format!( + "{n} reminder{s} failed to set:\n{errors}", + s = if n > 1 { "s" } else { "" }, + n = n, + errors = errors + .iter() + .map(|e| e.display(true)) + .collect::>() + .join("\n") + ), + }; let _ = msg .channel_id .send_message(&ctx, |m| { m.embed(|e| { - e.title( - lm.get(&user_data.language, "remind/title") - .replace("{number}", &ok_locations.len().to_string()), - ) + e.title(format!( + "{n} Reminder{s} Set", + n = successes.len(), + s = if successes.len() > 1 { "s" } else { "" } + )) .description(format!("{}\n\n{}", success_part, error_part)) .color(*THEME_COLOR) }) @@ -1263,14 +817,9 @@ async fn natural(ctx: &Context, msg: &Message, args: String) { .channel_id .send_message(ctx, |m| { m.embed(move |e| { - e.title( - lm.get(&user_data.language, "remind/title") - .replace("{number}", "0"), - ) - .description( - lm.get(&user_data.language, content_error.to_response()), - ) - .color(*THEME_COLOR) + e.title("0 Reminders Set") + .description(content_error.to_string()) + .color(*THEME_COLOR) }) }) .await; @@ -1306,156 +855,3 @@ async fn natural(ctx: &Context, msg: &Message, args: String) { } } } - -async fn create_reminder<'a, U: Into, T: TryInto>( - ctx: &Context, - pool: &MySqlPool, - user_id: U, - guild_id: Option, - scope_id: &ReminderScope, - time_parser: T, - expires_parser: Option, - interval: Option, - content: &mut Content, -) -> Result { - let user_id = user_id.into(); - - if let Some(g_id) = guild_id { - if let Some(guild) = g_id.to_guild_cached(&ctx).await { - content.substitute(guild); - } - } - - let mut nudge = 0; - - let db_channel_id = match scope_id { - ReminderScope::User(user_id) => { - if let Ok(user) = UserId(*user_id).to_user(&ctx).await { - let user_data = UserData::from_user(&user, &ctx, &pool).await.unwrap(); - - if let Some(guild_id) = guild_id { - if guild_id.member(&ctx, user).await.is_err() { - return Err(ReminderError::InvalidTag); - } - } - - user_data.dm_channel - } else { - return Err(ReminderError::InvalidTag); - } - } - - ReminderScope::Channel(channel_id) => { - let channel = ChannelId(*channel_id).to_channel(&ctx).await.unwrap(); - - if channel.clone().guild().map(|gc| gc.guild_id) != guild_id { - return Err(ReminderError::InvalidTag); - } - - let mut channel_data = ChannelData::from_channel(channel.clone(), &pool) - .await - .unwrap(); - - nudge = channel_data.nudge; - - if let Some(guild_channel) = channel.guild() { - if channel_data.webhook_token.is_none() || channel_data.webhook_id.is_none() { - match create_webhook(&ctx, guild_channel, "Reminder").await { - Ok(webhook) => { - channel_data.webhook_id = Some(webhook.id.as_u64().to_owned()); - channel_data.webhook_token = webhook.token; - - channel_data.commit_changes(&pool).await; - } - - Err(e) => { - return Err(ReminderError::DiscordError(e.to_string())); - } - } - } - } - - channel_data.id - } - }; - - // validate time, channel - if interval.map_or(false, |inner| inner < *MIN_INTERVAL) { - Err(ReminderError::ShortInterval) - } else if interval.map_or(false, |inner| inner > *MAX_TIME) { - Err(ReminderError::LongInterval) - } else { - match time_parser.try_into() { - Ok(time_pre) => { - match expires_parser.map(|t| t.try_into()).transpose() { - Ok(expires) => { - let time = time_pre + nudge as i64; - - let unix_time = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - - if time >= unix_time - 10 { - let uid = generate_uid(); - - sqlx::query!( - " -INSERT INTO reminders ( - uid, - content, - tts, - attachment, - attachment_name, - channel_id, - `utc_time`, - expires, - `interval`, - set_by -) VALUES ( - ?, - ?, - ?, - ?, - ?, - ?, - DATE_ADD(FROM_UNIXTIME(0), INTERVAL ? SECOND), - DATE_ADD(FROM_UNIXTIME(0), INTERVAL ? SECOND), - ?, - (SELECT id FROM users WHERE user = ? LIMIT 1) -) - ", - uid, - content.content, - content.tts, - content.attachment, - content.attachment_name, - db_channel_id, - time, - expires, - interval, - user_id - ) - .execute(pool) - .await - .unwrap(); - - let reminder = Reminder::from_uid(ctx, uid).await.unwrap(); - - Ok(reminder) - } else if time < 0 { - // case required for if python returns -1 - Err(ReminderError::InvalidTime) - } else { - Err(ReminderError::PastTime) - } - } - - Err(_) => Err(ReminderError::InvalidExpiration), - } - } - - Err(_) => Err(ReminderError::InvalidTime), - } - } -} diff --git a/src/commands/todo_cmds.rs b/src/commands/todo_cmds.rs index 951b84e..413948a 100644 --- a/src/commands/todo_cmds.rs +++ b/src/commands/todo_cmds.rs @@ -14,7 +14,7 @@ use std::fmt; use crate::{ command_help, get_ctx_data, - models::{user_data::UserData, CtxGuildData}, + models::{user_data::UserData, CtxData}, }; use sqlx::MySqlPool; use std::convert::TryFrom; @@ -362,7 +362,7 @@ impl Execute for Result { if let Ok(subcommand) = self { target.execute(ctx, msg, subcommand, extra).await; } else { - show_help(&ctx, msg, Some(target)).await; + show_help(ctx, msg, Some(target)).await; } } } diff --git a/src/framework.rs b/src/framework.rs index b41f6ac..e3f8735 100644 --- a/src/framework.rs +++ b/src/framework.rs @@ -21,7 +21,7 @@ use std::{collections::HashMap, fmt}; use crate::{ language_manager::LanguageManager, - models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData, CtxGuildData}, + models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData, CtxData}, LimitExecutors, SQLPool, }; @@ -312,10 +312,10 @@ impl Framework for RegexFramework { guild: &Guild, channel: &GuildChannel, ) -> SerenityResult { - let user_id = ctx.cache.current_user_id().await; + let user_id = ctx.cache.current_user_id(); let guild_perms = guild.member_permissions(&ctx, user_id).await?; - let channel_perms = channel.permissions_for_user(ctx, user_id).await?; + let channel_perms = channel.permissions_for_user(ctx, user_id)?; let basic_perms = channel_perms.send_messages(); @@ -347,8 +347,8 @@ impl Framework for RegexFramework { if (msg.author.bot && self.ignore_bots) || msg.content.is_empty() { } else { // Guild Command - if let (Some(guild), Some(Channel::Guild(channel))) = - (msg.guild(&ctx).await, msg.channel(&ctx).await) + if let (Some(guild), Ok(Channel::Guild(channel))) = + (msg.guild(&ctx), msg.channel(&ctx).await) { let data = ctx.data.read().await; diff --git a/src/main.rs b/src/main.rs index 8c4c17c..3875582 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,9 @@ use serenity::{ channel::Message, guild::{Guild, GuildUnavailable}, id::{GuildId, UserId}, - interactions::{Interaction, InteractionData, InteractionType}, + interactions::{ + Interaction, InteractionApplicationCommandCallbackDataFlags, InteractionResponseType, + }, }, prelude::{Context, EventHandler, TypeMapKey}, utils::shard_id, @@ -36,7 +38,11 @@ use crate::{ consts::{CNC_GUILD, DEFAULT_PREFIX, SUBSCRIPTION_ROLES, THEME_COLOR}, framework::RegexFramework, language_manager::LanguageManager, - models::{guild_data::GuildData, user_data::UserData}, + models::{ + guild_data::GuildData, + reminder::{Reminder, ReminderAction}, + user_data::UserData, + }, }; use inflector::Inflector; @@ -46,12 +52,9 @@ use dashmap::DashMap; use tokio::sync::RwLock; -use crate::models::reminder::{Reminder, ReminderAction}; use chrono::Utc; + use chrono_tz::Tz; -use serenity::model::prelude::{ - InteractionApplicationCommandCallbackDataFlags, InteractionResponseType, -}; struct GuildDataCache; @@ -187,13 +190,12 @@ DELETE FROM channels WHERE channel = ? } if let Ok(token) = env::var("DISCORDBOTS_TOKEN") { - let shard_count = ctx.cache.shard_count().await; + let shard_count = ctx.cache.shard_count(); let current_shard_id = shard_id(guild_id, shard_count); let guild_count = ctx .cache .guilds() - .await .iter() .filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id) .count() as u64; @@ -215,7 +217,7 @@ DELETE FROM channels WHERE channel = ? .post( format!( "https://top.gg/api/bots/{}/stats", - ctx.cache.current_user_id().await.as_u64() + ctx.cache.current_user_id().as_u64() ) .as_str(), ) @@ -268,113 +270,117 @@ DELETE FROM guilds WHERE guild = ? async fn interaction_create(&self, ctx: Context, interaction: Interaction) { let (pool, lm) = get_ctx_data(&&ctx).await; - match interaction.kind { - InteractionType::ApplicationCommand => {} - InteractionType::MessageComponent => { - if let (Some(InteractionData::MessageComponent(data)), Some(member)) = - (interaction.clone().data, interaction.clone().member) - { - if data.custom_id.starts_with("timezone:") { - let mut user_data = UserData::from_user(&member.user, &ctx, &pool) - .await - .unwrap(); - let new_timezone = data.custom_id.replace("timezone:", "").parse::(); + match interaction { + Interaction::MessageComponent(component) => { + if component.data.custom_id.starts_with("timezone:") { + let mut user_data = UserData::from_user(&component.user, &ctx, &pool) + .await + .unwrap(); + let new_timezone = component + .data + .custom_id + .replace("timezone:", "") + .parse::(); - if let Ok(timezone) = new_timezone { - user_data.timezone = timezone.to_string(); - user_data.commit_changes(&pool).await; + if let Ok(timezone) = new_timezone { + user_data.timezone = timezone.to_string(); + user_data.commit_changes(&pool).await; - let _ = interaction.create_interaction_response(&ctx, |r| { - r.kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|d| { - let footer_text = lm.get(&user_data.language, "timezone/footer").replacen( - "{timezone}", - &user_data.timezone, + let _ = component.create_interaction_response(&ctx, |r| { + r.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|d| { + let footer_text = lm.get(&user_data.language, "timezone/footer").replacen( + "{timezone}", + &user_data.timezone, + 1, + ); + + let now = Utc::now().with_timezone(&user_data.timezone()); + + let content = lm + .get(&user_data.language, "timezone/set_p") + .replacen("{timezone}", &user_data.timezone, 1) + .replacen( + "{time}", + &now.format("%H:%M").to_string(), 1, ); - let now = Utc::now().with_timezone(&user_data.timezone()); + d.create_embed(|e| e.title(lm.get(&user_data.language, "timezone/set_p_title")) + .color(*THEME_COLOR) + .description(content) + .footer(|f| f.text(footer_text))) + .flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL); - let content = lm - .get(&user_data.language, "timezone/set_p") - .replacen("{timezone}", &user_data.timezone, 1) - .replacen( - "{time}", - &now.format("%H:%M").to_string(), - 1, - ); + d + }) + }).await; + } + } else if component.data.custom_id.starts_with("lang:") { + let mut user_data = UserData::from_user(&component.user, &ctx, &pool) + .await + .unwrap(); + let lang_code = component.data.custom_id.replace("lang:", ""); - d.create_embed(|e| e.title(lm.get(&user_data.language, "timezone/set_p_title")) + if let Some(lang) = lm.get_language(&lang_code) { + user_data.language = lang.to_string(); + user_data.commit_changes(&pool).await; + + let _ = component + .create_interaction_response(&ctx, |r| { + r.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|d| { + d.create_embed(|e| { + e.title( + lm.get(&user_data.language, "lang/set_p_title"), + ) .color(*THEME_COLOR) - .description(content) - .footer(|f| f.text(footer_text))) - .flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL); - - d + .description( + lm.get(&user_data.language, "lang/set_p"), + ) + }) + .flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL) }) - }).await; - } - } else if data.custom_id.starts_with("lang:") { - let mut user_data = UserData::from_user(&member.user, &ctx, &pool) - .await - .unwrap(); - let lang_code = data.custom_id.replace("lang:", ""); + }) + .await; + } + } else { + match Reminder::from_interaction( + &ctx, + component.user.id, + component.data.custom_id.clone(), + ) + .await + { + Ok((reminder, action)) => { + let response = match action { + ReminderAction::Delete => { + reminder.delete(&ctx).await; + "Reminder has been deleted" + } + }; - if let Some(lang) = lm.get_language(&lang_code) { - user_data.language = lang.to_string(); - user_data.commit_changes(&pool).await; - - let _ = interaction + let _ = component .create_interaction_response(&ctx, |r| { r.kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|d| { - d.create_embed(|e| { - e.title( - lm.get(&user_data.language, "lang/set_p_title"), - ) - .color(*THEME_COLOR) - .description( - lm.get(&user_data.language, "lang/set_p"), - ) - }) + .interaction_response_data(|d| d + .content(response) .flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL) - }) + ) }) .await; } - } else { - match Reminder::from_interaction(&ctx, member.user.id, data.custom_id).await - { - Ok((reminder, action)) => { - let response = match action { - ReminderAction::Delete => { - reminder.delete(&ctx).await; - "Reminder has been deleted" - } - }; - let _ = interaction - .create_interaction_response(&ctx, |r| { - r.kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|d| d - .content(response) - .flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL) - ) - }) - .await; - } - - Err(ie) => { - let _ = interaction - .create_interaction_response(&ctx, |r| { - r.kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|d| d - .content(ie.to_string()) - .flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL) - ) - }) - .await; - } + Err(ie) => { + let _ = component + .create_interaction_response(&ctx, |r| { + r.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|d| d + .content(ie.to_string()) + .flags(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL) + ) + }) + .await; } } } @@ -424,7 +430,6 @@ async fn main() -> Result<(), Box> { .add_command("natural", &reminder_cmds::NATURAL_COMMAND) .add_command("n", &reminder_cmds::NATURAL_COMMAND) .add_command("", &reminder_cmds::NATURAL_COMMAND) - .add_command("countdown", &reminder_cmds::COUNTDOWN_COMMAND) // management commands .add_command("look", &reminder_cmds::LOOK_COMMAND) .add_command("del", &reminder_cmds::DELETE_COMMAND) @@ -574,7 +579,7 @@ pub async fn check_subscription_on_message( msg: &Message, ) -> bool { check_subscription(&cache_http, &msg.author).await - || if let Some(guild) = msg.guild(&cache_http).await { + || if let Some(guild) = msg.guild(&cache_http) { check_subscription(&cache_http, guild.owner_id).await } else { false @@ -616,8 +621,8 @@ async fn command_help( m.embed(move |e| { e.title(format!("{} Help", command_name.to_title_case())) .description( - lm.get(&language, &format!("help/{}", command_name)) - .replace("{prefix}", &prefix), + lm.get(language, &format!("help/{}", command_name)) + .replace("{prefix}", prefix), ) .footer(|f| { f.text(concat!( diff --git a/src/models/mod.rs b/src/models/mod.rs index e5decbf..baff568 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -4,34 +4,46 @@ pub mod reminder; pub mod timer; pub mod user_data; -use serenity::{async_trait, model::id::GuildId, prelude::Context}; +use serenity::{ + async_trait, + model::id::{GuildId, UserId}, + prelude::Context, +}; use crate::{consts::DEFAULT_PREFIX, GuildDataCache, SQLPool}; use guild_data::GuildData; +use crate::models::user_data::UserData; + use std::sync::Arc; + use tokio::sync::RwLock; #[async_trait] -pub trait CtxGuildData { +pub trait CtxData { async fn guild_data + Send + Sync>( &self, guild_id: G, ) -> Result>, sqlx::Error>; + async fn user_data + Send + Sync>( + &self, + user_id: U, + ) -> Result>; + async fn prefix + Send + Sync>(&self, guild_id: Option) -> String; } #[async_trait] -impl CtxGuildData for Context { +impl CtxData for Context { async fn guild_data + Send + Sync>( &self, guild_id: G, ) -> Result>, sqlx::Error> { let guild_id = guild_id.into(); - let guild = guild_id.to_guild_cached(&self.cache).await.unwrap(); + let guild = guild_id.to_guild_cached(&self.cache).unwrap(); let guild_cache = self .data @@ -62,6 +74,18 @@ impl CtxGuildData for Context { x } + async fn user_data + Send + Sync>( + &self, + user_id: U, + ) -> Result> { + let user_id = user_id.into(); + let pool = self.data.read().await.get::().cloned().unwrap(); + + let user = user_id.to_user(self).await.unwrap(); + + UserData::from_user(&user, &self, &pool).await + } + async fn prefix + Send + Sync>(&self, guild_id: Option) -> String { if let Some(guild_id) = guild_id { self.guild_data(guild_id) diff --git a/src/models/reminder/builder.rs b/src/models/reminder/builder.rs new file mode 100644 index 0000000..61dbbe1 --- /dev/null +++ b/src/models/reminder/builder.rs @@ -0,0 +1,365 @@ +use serenity::{ + client::Context, + http::CacheHttp, + model::{ + channel::GuildChannel, + id::{ChannelId, GuildId, UserId}, + webhook::Webhook, + }, + Result as SerenityResult, +}; + +use chrono::{Duration, NaiveDateTime, Utc}; +use chrono_tz::Tz; + +use crate::{ + consts::{MAX_TIME, MIN_INTERVAL}, + models::{ + channel_data::ChannelData, + reminder::{content::Content, errors::ReminderError, helper::generate_uid, Reminder}, + user_data::UserData, + }, + time_parser::TimeParser, + SQLPool, +}; + +use sqlx::MySqlPool; + +use std::{collections::HashSet, fmt::Display}; + +async fn create_webhook( + ctx: impl CacheHttp, + channel: GuildChannel, + name: impl Display, +) -> SerenityResult { + channel + .create_webhook_with_avatar( + ctx.http(), + name, + ( + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/", + env!( + "WEBHOOK_AVATAR", + "WEBHOOK_AVATAR not provided for compilation" + ) + )) as &[u8], + env!("WEBHOOK_AVATAR"), + ), + ) + .await +} + +#[derive(Hash, PartialEq, Eq)] +pub enum ReminderScope { + User(u64), + Channel(u64), +} + +impl ReminderScope { + pub fn mention(&self) -> String { + match self { + Self::User(id) => format!("<@{}>", id), + Self::Channel(id) => format!("<#{}>", id), + } + } +} + +pub struct ReminderBuilder { + pool: MySqlPool, + uid: String, + channel: u32, + utc_time: NaiveDateTime, + timezone: String, + interval: Option, + expires: Option, + content: String, + tts: bool, + attachment_name: Option, + attachment: Option>, + set_by: Option, +} + +impl ReminderBuilder { + pub async fn build(self) -> Result { + let queried_time = sqlx::query!( + "SELECT DATE_ADD(?, INTERVAL (SELECT nudge FROM channels WHERE id = ?) SECOND) AS `utc_time`", + self.utc_time, + self.channel, + ) + .fetch_one(&self.pool) + .await + .unwrap(); + + match queried_time.utc_time { + Some(utc_time) => { + if utc_time < (Utc::now() + Duration::seconds(60)).naive_local() { + Err(ReminderError::PastTime) + } else { + sqlx::query!( + " +INSERT INTO reminders ( + `uid`, + `channel_id`, + `utc_time`, + `timezone`, + `interval`, + `expires`, + `content`, + `tts`, + `attachment_name`, + `attachment`, + `set_by` +) VALUES ( + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ? +) + ", + self.uid, + self.channel, + utc_time, + self.timezone, + self.interval, + self.expires, + self.content, + self.tts, + self.attachment_name, + self.attachment, + self.set_by + ) + .execute(&self.pool) + .await; + + Ok(Reminder::from_uid(&self.pool, self.uid).await.unwrap()) + } + } + + None => Err(ReminderError::LongTime), + } + } +} + +pub struct MultiReminderBuilder<'a> { + scopes: Vec, + utc_time: NaiveDateTime, + utc_time_parser: Option, + timezone: Tz, + interval: Option, + expires: Option, + expires_parser: Option, + content: Content, + set_by: Option, + ctx: &'a Context, + guild_id: Option, +} + +impl<'a> MultiReminderBuilder<'a> { + pub fn new(ctx: &'a Context, guild_id: Option) -> Self { + MultiReminderBuilder { + scopes: vec![], + utc_time: Utc::now().naive_utc(), + utc_time_parser: None, + timezone: Tz::UTC, + interval: None, + expires: None, + expires_parser: None, + content: Content::new(), + set_by: None, + ctx, + guild_id, + } + } + + pub fn content(mut self, content: Content) -> Self { + self.content = content; + + self + } + + pub fn time>(mut self, time: T) -> Self { + self.utc_time = NaiveDateTime::from_timestamp(time.into(), 0); + + self + } + + pub fn time_parser(mut self, parser: TimeParser) -> Self { + self.utc_time_parser = Some(parser); + + self + } + + pub fn expires>(mut self, time: Option) -> Self { + if let Some(t) = time { + self.expires = Some(NaiveDateTime::from_timestamp(t.into(), 0)); + } else { + self.expires = None; + } + + self + } + + pub fn expires_parser(mut self, parser: Option) -> Self { + self.expires_parser = parser; + + self + } + + pub fn author(mut self, user: UserData) -> Self { + self.set_by = Some(user.id); + self.timezone = user.timezone(); + + self + } + + pub fn interval(mut self, interval: Option) -> Self { + self.interval = interval; + + self + } + + pub fn set_scopes(&mut self, scopes: Vec) { + self.scopes = scopes; + } + + pub async fn build(mut self) -> (HashSet, HashSet) { + let pool = self + .ctx + .data + .read() + .await + .get::() + .cloned() + .unwrap(); + + let mut errors = HashSet::new(); + + let mut ok_locs = HashSet::new(); + + if let Some(expire_parser) = self.expires_parser { + if let Ok(expires) = expire_parser.timestamp() { + self.expires = Some(NaiveDateTime::from_timestamp(expires, 0)); + } else { + errors.insert(ReminderError::InvalidExpiration); + + return (errors, ok_locs); + } + } + + if let Some(time_parser) = self.utc_time_parser { + if let Ok(time) = time_parser.timestamp() { + self.utc_time = NaiveDateTime::from_timestamp(time, 0); + } else { + errors.insert(ReminderError::InvalidTime); + + return (errors, ok_locs); + } + } + + if self.interval.map_or(false, |i| (i as i64) < *MIN_INTERVAL) { + errors.insert(ReminderError::ShortInterval); + } else if self.interval.map_or(false, |i| (i as i64) > *MAX_TIME) { + errors.insert(ReminderError::LongInterval); + } else { + for scope in self.scopes { + let db_channel_id = match scope { + ReminderScope::User(user_id) => { + if let Ok(user) = UserId(user_id).to_user(&self.ctx).await { + let user_data = + UserData::from_user(&user, &self.ctx, &pool).await.unwrap(); + + if let Some(guild_id) = self.guild_id { + if guild_id.member(&self.ctx, user).await.is_err() { + Err(ReminderError::InvalidTag) + } else { + Ok(user_data.dm_channel) + } + } else { + Ok(user_data.dm_channel) + } + } else { + Err(ReminderError::InvalidTag) + } + } + ReminderScope::Channel(channel_id) => { + let channel = ChannelId(channel_id).to_channel(&self.ctx).await.unwrap(); + + if let Some(guild_channel) = channel.clone().guild() { + if Some(guild_channel.guild_id) != self.guild_id { + Err(ReminderError::InvalidTag) + } else { + let mut channel_data = + ChannelData::from_channel(channel, &pool).await.unwrap(); + + if channel_data.webhook_id.is_none() + || channel_data.webhook_token.is_none() + { + match create_webhook(&self.ctx, guild_channel, "Reminder").await + { + Ok(webhook) => { + channel_data.webhook_id = + Some(webhook.id.as_u64().to_owned()); + channel_data.webhook_token = webhook.token; + + channel_data.commit_changes(&pool).await; + + Ok(channel_data.id) + } + + Err(e) => Err(ReminderError::DiscordError(e.to_string())), + } + } else { + Ok(channel_data.id) + } + } + } else { + Err(ReminderError::InvalidTag) + } + } + }; + + match db_channel_id { + Ok(c) => { + let builder = ReminderBuilder { + pool: pool.clone(), + uid: generate_uid(), + channel: c, + utc_time: self.utc_time, + timezone: self.timezone.to_string(), + interval: self.interval, + expires: self.expires, + content: self.content.content.clone(), + tts: self.content.tts, + attachment_name: self.content.attachment_name.clone(), + attachment: self.content.attachment.clone(), + set_by: self.set_by, + }; + + match builder.build().await { + Ok(_) => { + ok_locs.insert(scope); + } + Err(e) => { + errors.insert(e); + } + } + } + Err(e) => { + errors.insert(e); + } + } + } + } + + (errors, ok_locs) + } +} diff --git a/src/models/reminder/content.rs b/src/models/reminder/content.rs new file mode 100644 index 0000000..3b41f1b --- /dev/null +++ b/src/models/reminder/content.rs @@ -0,0 +1,74 @@ +use serenity::model::{channel::Message, guild::Guild, misc::Mentionable}; + +use regex::Captures; + +use crate::{consts::REGEX_CONTENT_SUBSTITUTION, models::reminder::errors::ContentError}; + +pub struct Content { + pub content: String, + pub tts: bool, + pub attachment: Option>, + pub attachment_name: Option, +} + +impl Content { + pub fn new() -> Self { + Self { + content: "".to_string(), + tts: false, + attachment: None, + attachment_name: None, + } + } + + pub async fn build(content: S, message: &Message) -> Result { + if message.attachments.len() > 1 { + Err(ContentError::TooManyAttachments) + } else if let Some(attachment) = message.attachments.get(0) { + if attachment.size > 8_000_000 { + Err(ContentError::AttachmentTooLarge) + } else if let Ok(attachment_bytes) = attachment.download().await { + Ok(Self { + content: content.to_string(), + tts: false, + attachment: Some(attachment_bytes), + attachment_name: Some(attachment.filename.clone()), + }) + } else { + Err(ContentError::AttachmentDownloadFailed) + } + } else { + Ok(Self { + content: content.to_string(), + tts: false, + attachment: None, + attachment_name: None, + }) + } + } + + pub fn substitute(&mut self, guild: Guild) { + if self.content.starts_with("/tts ") { + self.tts = true; + self.content = self.content.split_off(5); + } + + self.content = REGEX_CONTENT_SUBSTITUTION + .replace(&self.content, |caps: &Captures| { + if let Some(user) = caps.name("user") { + format!("<@{}>", user.as_str()) + } else if let Some(role_name) = caps.name("role") { + if let Some(role) = guild.role_by_name(role_name.as_str()) { + role.mention().to_string() + } else { + format!("<<{}>>", role_name.as_str().to_string()) + } + } else { + String::new() + } + }) + .to_string() + .replace("<>", "@everyone") + .replace("<>", "@here"); + } +} diff --git a/src/models/reminder/errors.rs b/src/models/reminder/errors.rs new file mode 100644 index 0000000..3eabc8d --- /dev/null +++ b/src/models/reminder/errors.rs @@ -0,0 +1,81 @@ +use crate::consts::{MAX_TIME, MIN_INTERVAL}; + +#[derive(Debug)] +pub enum InteractionError { + InvalidFormat, + InvalidBase64, + InvalidSize, + NoReminder, + SignatureMismatch, + InvalidAction, +} + +impl ToString for InteractionError { + fn to_string(&self) -> String { + match self { + InteractionError::InvalidFormat => { + String::from("The interaction data was improperly formatted") + } + InteractionError::InvalidBase64 => String::from("The interaction data was invalid"), + InteractionError::InvalidSize => String::from("The interaction data was invalid"), + InteractionError::NoReminder => String::from("Reminder could not be found"), + InteractionError::SignatureMismatch => { + String::from("Only the user who did the command can use interactions") + } + InteractionError::InvalidAction => String::from("The action was invalid"), + } + } +} + +#[derive(PartialEq, Eq, Hash, Debug)] +pub enum ReminderError { + LongTime, + LongInterval, + PastTime, + ShortInterval, + InvalidTag, + InvalidTime, + InvalidExpiration, + DiscordError(String), +} + +impl ReminderError { + pub fn display(&self, is_natural: bool) -> String { + match self { + ReminderError::LongTime => "That time is too far in the future. Please specify a shorter time.".to_string(), + ReminderError::LongInterval => format!("Please ensure the interval specified is less than {max_time} days", max_time = *MAX_TIME / 86_400), + ReminderError::PastTime => "Please ensure the time provided is in the future. If the time should be in the future, please be more specific with the definition.".to_string(), + ReminderError::ShortInterval => format!("Please ensure the interval provided is longer than {min_interval} seconds", min_interval = *MIN_INTERVAL), + ReminderError::InvalidTag => "Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string(), + ReminderError::InvalidTime => if is_natural { + "Your time failed to process. Please make it as clear as possible, for example `\"16th of july\"` or `\"in 20 minutes\"`".to_string() + } else { + "Make sure the time you have provided is in the format of [num][s/m/h/d][num][s/m/h/d] etc. or `day/month/year-hour:minute:second`".to_string() + }, + ReminderError::InvalidExpiration => if is_natural { + "Your expiration time failed to process. Please make it as clear as possible, for example `\"16th of july\"` or `\"in 20 minutes\"`".to_string() + } else { + "Make sure the expiration time you have provided is in the format of [num][s/m/h/d][num][s/m/h/d] etc. or `day/month/year-hour:minute:second`".to_string() + }, + ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s) + } + } +} + +#[derive(Debug)] +pub enum ContentError { + TooManyAttachments, + AttachmentTooLarge, + AttachmentDownloadFailed, +} + +impl ToString for ContentError { + fn to_string(&self) -> String { + match self { + ContentError::TooManyAttachments => "remind/too_many_attachments", + ContentError::AttachmentTooLarge => "remind/attachment_too_large", + ContentError::AttachmentDownloadFailed => "remind/attachment_download_failed", + } + .to_string() + } +} diff --git a/src/models/reminder/helper.rs b/src/models/reminder/helper.rs new file mode 100644 index 0000000..b8e67f3 --- /dev/null +++ b/src/models/reminder/helper.rs @@ -0,0 +1,40 @@ +use crate::consts::{CHARACTERS, DAY, HOUR, MINUTE}; + +use num_integer::Integer; + +use rand::{rngs::OsRng, seq::IteratorRandom}; + +pub fn longhand_displacement(seconds: u64) -> String { + let (days, seconds) = seconds.div_rem(&DAY); + let (hours, seconds) = seconds.div_rem(&HOUR); + let (minutes, seconds) = seconds.div_rem(&MINUTE); + + let mut sections = vec![]; + + for (var, name) in [days, hours, minutes, seconds] + .iter() + .zip(["days", "hours", "minutes", "seconds"].iter()) + { + if *var > 0 { + sections.push(format!("{} {}", var, name)); + } + } + + sections.join(", ") +} + +pub fn generate_uid() -> String { + let mut generator: OsRng = Default::default(); + + (0..64) + .map(|_| { + CHARACTERS + .chars() + .choose(&mut generator) + .unwrap() + .to_owned() + .to_string() + }) + .collect::>() + .join("") +} diff --git a/src/models/reminder/look_flags.rs b/src/models/reminder/look_flags.rs new file mode 100644 index 0000000..576f364 --- /dev/null +++ b/src/models/reminder/look_flags.rs @@ -0,0 +1,59 @@ +use serenity::model::id::ChannelId; + +use crate::consts::REGEX_CHANNEL; + +pub enum TimeDisplayType { + Absolute, + Relative, +} + +pub struct LookFlags { + pub limit: u16, + pub show_disabled: bool, + pub channel_id: Option, + pub time_display: TimeDisplayType, +} + +impl Default for LookFlags { + fn default() -> Self { + Self { + limit: u16::MAX, + show_disabled: true, + channel_id: None, + time_display: TimeDisplayType::Relative, + } + } +} + +impl LookFlags { + pub fn from_string(args: &str) -> Self { + let mut new_flags: Self = Default::default(); + + for arg in args.split(' ') { + match arg { + "enabled" => { + new_flags.show_disabled = false; + } + + "time" => { + new_flags.time_display = TimeDisplayType::Absolute; + } + + param => { + if let Ok(val) = param.parse::() { + new_flags.limit = val; + } else if let Some(channel) = REGEX_CHANNEL + .captures(arg) + .map(|cap| cap.get(1)) + .flatten() + .map(|c| c.as_str().parse::().unwrap()) + { + new_flags.channel_id = Some(ChannelId(channel)); + } + } + } + } + + new_flags + } +} diff --git a/src/models/reminder.rs b/src/models/reminder/mod.rs similarity index 73% rename from src/models/reminder.rs rename to src/models/reminder/mod.rs index f80784e..b81dc97 100644 --- a/src/models/reminder.rs +++ b/src/models/reminder/mod.rs @@ -1,3 +1,9 @@ +pub mod builder; +pub mod content; +pub mod errors; +mod helper; +pub mod look_flags; + use serenity::{ client::Context, model::id::{ChannelId, GuildId, UserId}, @@ -6,32 +12,45 @@ use serenity::{ use chrono::NaiveDateTime; use crate::{ - consts::{DAY, HOUR, MINUTE, REGEX_CHANNEL}, + models::reminder::{ + errors::InteractionError, + helper::longhand_displacement, + look_flags::{LookFlags, TimeDisplayType}, + }, SQLPool, }; -use num_integer::Integer; use ring::hmac; -use std::convert::{TryFrom, TryInto}; -use std::env; -fn longhand_displacement(seconds: u64) -> String { - let (days, seconds) = seconds.div_rem(&DAY); - let (hours, seconds) = seconds.div_rem(&HOUR); - let (minutes, seconds) = seconds.div_rem(&MINUTE); +use sqlx::MySqlPool; +use std::{ + convert::{TryFrom, TryInto}, + env, +}; - let mut sections = vec![]; +#[derive(Clone, Copy)] +pub enum ReminderAction { + Delete, +} - for (var, name) in [days, hours, minutes, seconds] - .iter() - .zip(["days", "hours", "minutes", "seconds"].iter()) - { - if *var > 0 { - sections.push(format!("{} {}", var, name)); +impl ToString for ReminderAction { + fn to_string(&self) -> String { + match self { + Self::Delete => String::from("del"), } } +} - sections.join(", ") +impl TryFrom<&str> for ReminderAction { + type Error = (); + + fn try_from(value: &str) -> Result { + match value { + "del" => Ok(Self::Delete), + + _ => Err(()), + } + } } #[derive(Debug)] @@ -49,9 +68,7 @@ pub struct Reminder { } impl Reminder { - pub async fn from_uid(ctx: &Context, uid: String) -> Option { - let pool = ctx.data.read().await.get::().cloned().unwrap(); - + pub async fn from_uid(pool: &MySqlPool, uid: String) -> Option { sqlx::query_as_unchecked!( Self, " @@ -81,7 +98,7 @@ WHERE ", uid ) - .fetch_one(&pool) + .fetch_one(pool) .await .ok() } @@ -178,7 +195,7 @@ LIMIT let pool = ctx.data.read().await.get::().cloned().unwrap(); if let Some(guild_id) = guild_id { - let guild_opt = guild_id.to_guild_cached(&ctx).await; + let guild_opt = guild_id.to_guild_cached(&ctx); if let Some(guild) = guild_opt { let channels = guild @@ -333,7 +350,7 @@ WHERE member_id: U, payload: String, ) -> Result<(Self, ReminderAction), InteractionError> { - let sections = payload.split(".").collect::>(); + let sections = payload.split('.').collect::>(); if sections.len() != 3 { Err(InteractionError::InvalidFormat) @@ -397,111 +414,3 @@ DELETE FROM reminders WHERE id = ? .unwrap(); } } - -#[derive(Debug)] -pub enum InteractionError { - InvalidFormat, - InvalidBase64, - InvalidSize, - NoReminder, - SignatureMismatch, - InvalidAction, -} - -impl ToString for InteractionError { - fn to_string(&self) -> String { - match self { - InteractionError::InvalidFormat => { - String::from("The interaction data was improperly formatted") - } - InteractionError::InvalidBase64 => String::from("The interaction data was invalid"), - InteractionError::InvalidSize => String::from("The interaction data was invalid"), - InteractionError::NoReminder => String::from("Reminder could not be found"), - InteractionError::SignatureMismatch => { - String::from("Only the user who did the command can use interactions") - } - InteractionError::InvalidAction => String::from("The action was invalid"), - } - } -} - -#[derive(Clone, Copy)] -pub enum ReminderAction { - Delete, -} - -impl ToString for ReminderAction { - fn to_string(&self) -> String { - match self { - Self::Delete => String::from("del"), - } - } -} - -impl TryFrom<&str> for ReminderAction { - type Error = (); - - fn try_from(value: &str) -> Result { - match value { - "del" => Ok(Self::Delete), - - _ => Err(()), - } - } -} - -enum TimeDisplayType { - Absolute, - Relative, -} - -pub struct LookFlags { - pub limit: u16, - pub show_disabled: bool, - pub channel_id: Option, - time_display: TimeDisplayType, -} - -impl Default for LookFlags { - fn default() -> Self { - Self { - limit: u16::MAX, - show_disabled: true, - channel_id: None, - time_display: TimeDisplayType::Relative, - } - } -} - -impl LookFlags { - pub fn from_string(args: &str) -> Self { - let mut new_flags: Self = Default::default(); - - for arg in args.split(' ') { - match arg { - "enabled" => { - new_flags.show_disabled = false; - } - - "time" => { - new_flags.time_display = TimeDisplayType::Absolute; - } - - param => { - if let Ok(val) = param.parse::() { - new_flags.limit = val; - } else if let Some(channel) = REGEX_CHANNEL - .captures(&arg) - .map(|cap| cap.get(1)) - .flatten() - .map(|c| c.as_str().parse::().unwrap()) - { - new_flags.channel_id = Some(ChannelId(channel)); - } - } - } - } - - new_flags - } -} diff --git a/src/time_parser.rs b/src/time_parser.rs index fabba8e..f910a06 100644 --- a/src/time_parser.rs +++ b/src/time_parser.rs @@ -26,11 +26,13 @@ impl Display for InvalidTime { impl std::error::Error for InvalidTime {} +#[derive(Copy, Clone)] enum ParseType { Explicit, Displacement, } +#[derive(Clone)] pub struct TimeParser { timezone: Tz, inverted: bool,