From 76a286076bf8a1a57ec23240e72938b945dfeb9a Mon Sep 17 00:00:00 2001 From: jude Date: Sun, 18 Feb 2024 13:24:37 +0000 Subject: [PATCH] Link all top-level commands with macro recording/replaying logic --- Cargo.lock | 13 +- Cargo.toml | 7 +- {extract_macro => extract_derive}/Cargo.lock | 0 {extract_macro => extract_derive}/Cargo.toml | 2 +- {extract_macro => extract_derive}/src/lib.rs | 1 + recordable_derive/Cargo.toml | 11 ++ recordable_derive/src/lib.rs | 42 ++++++ src/commands/clock.rs | 34 +++-- src/commands/command_macro/run_macro.rs | 9 +- src/commands/dashboard.rs | 34 ++--- src/commands/delete.rs | 27 ++-- src/commands/donate.rs | 16 ++- src/commands/help.rs | 38 ++--- src/commands/info.rs | 38 ++--- src/commands/look.rs | 143 ++++++++++--------- src/commands/multiline.rs | 79 +++++----- src/commands/nudge.rs | 43 ++++-- src/commands/offset.rs | 87 +++++------ src/commands/pause.rs | 94 ++++++------ src/commands/remind.rs | 39 ++--- src/commands/timezone.rs | 111 +++++++------- src/commands/webhook.rs | 49 ++++--- src/hooks.rs | 43 +++--- src/models/command_macro.rs | 59 ++++++-- src/utils.rs | 10 +- 25 files changed, 619 insertions(+), 410 deletions(-) rename {extract_macro => extract_derive}/Cargo.lock (100%) rename {extract_macro => extract_derive}/Cargo.toml (86%) rename {extract_macro => extract_derive}/src/lib.rs (95%) create mode 100644 recordable_derive/Cargo.toml create mode 100644 recordable_derive/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 479f575..a8b53e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -774,7 +774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] -name = "extract_macro" +name = "extract_derive" version = "0.1.0" dependencies = [ "quote", @@ -2235,6 +2235,14 @@ dependencies = [ "getrandom", ] +[[package]] +name = "recordable_derive" +version = "0.1.0" +dependencies = [ + "quote", + "syn 2.0.49", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2317,7 +2325,7 @@ dependencies = [ "chrono-tz", "dotenv", "env_logger", - "extract_macro", + "extract_derive", "lazy-regex", "lazy_static", "levenshtein", @@ -2326,6 +2334,7 @@ dependencies = [ "poise", "postman", "rand", + "recordable_derive", "regex", "reminder_web", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 1da88c0..5c709b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,8 +35,11 @@ path = "postman" [dependencies.reminder_web] path = "web" -[dependencies.extract_macro] -path = "extract_macro" +[dependencies.extract_derive] +path = "extract_derive" + +[dependencies.recordable_derive] +path = "recordable_derive" [package.metadata.deb] depends = "$auto, python3-dateparser (>= 1.0.0)" diff --git a/extract_macro/Cargo.lock b/extract_derive/Cargo.lock similarity index 100% rename from extract_macro/Cargo.lock rename to extract_derive/Cargo.lock diff --git a/extract_macro/Cargo.toml b/extract_derive/Cargo.toml similarity index 86% rename from extract_macro/Cargo.toml rename to extract_derive/Cargo.toml index 77b3315..ec25bbe 100644 --- a/extract_macro/Cargo.toml +++ b/extract_derive/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "extract_macro" +name = "extract_derive" version = "0.1.0" edition = "2021" diff --git a/extract_macro/src/lib.rs b/extract_derive/src/lib.rs similarity index 95% rename from extract_macro/src/lib.rs rename to extract_derive/src/lib.rs index bca3857..389b5a8 100644 --- a/extract_macro/src/lib.rs +++ b/extract_derive/src/lib.rs @@ -12,6 +12,7 @@ fn impl_extract(ast: &syn::DeriveInput) -> TokenStream { let name = &ast.ident; match &ast.data { + // Dispatch over struct: extract args directly from context Data::Struct(st) => match &st.fields { Fields::Named(fields) => { let extracted = fields.named.iter().map(|field| { diff --git a/recordable_derive/Cargo.toml b/recordable_derive/Cargo.toml new file mode 100644 index 0000000..58bbd9c --- /dev/null +++ b/recordable_derive/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "recordable_derive" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +quote = "1.0.35" +syn = { version = "2.0.49", features = ["full"] } diff --git a/recordable_derive/src/lib.rs b/recordable_derive/src/lib.rs new file mode 100644 index 0000000..9ab877c --- /dev/null +++ b/recordable_derive/src/lib.rs @@ -0,0 +1,42 @@ +use proc_macro::TokenStream; +use syn::{spanned::Spanned, Data}; + +/// Macro to allow Recordable to be implemented on an enum by dispatching over each variant. +#[proc_macro_derive(Recordable)] +pub fn extract(input: TokenStream) -> TokenStream { + let ast = syn::parse_macro_input!(input); + + impl_recordable(&ast) +} + +fn impl_recordable(ast: &syn::DeriveInput) -> TokenStream { + let name = &ast.ident; + + match &ast.data { + Data::Enum(en) => { + let extracted = en.variants.iter().map(|var| { + let ident = &var.ident; + + quote::quote_spanned! {var.span()=> + Self::#ident (opt) => opt.run(ctx).await? + } + }); + + TokenStream::from(quote::quote! { + impl Recordable for #name { + async fn run(self, ctx: crate::Context<'_>) -> Result<(), crate::Error> { + match self { + #(#extracted,)* + } + + Ok(()) + } + } + }) + } + + _ => { + panic!("Only enums can derive Recordable"); + } + } +} diff --git a/src/commands/clock.rs b/src/commands/clock.rs index 9709271..aab4eea 100644 --- a/src/commands/clock.rs +++ b/src/commands/clock.rs @@ -2,29 +2,35 @@ use chrono::Utc; use poise::CreateReply; use serde::{Deserialize, Serialize}; -use crate::{models::CtxData, utils::Extract, Context, Error}; +use crate::{ + models::CtxData, + utils::{Extract, Recordable}, + Context, Error, +}; #[derive(Serialize, Deserialize, Extract)] pub struct Options; -pub async fn clock(ctx: Context<'_>, _options: Options) -> Result<(), Error> { - ctx.defer_ephemeral().await?; +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + ctx.defer_ephemeral().await?; - let tz = ctx.timezone().await; - let now = Utc::now().with_timezone(&tz); + let tz = ctx.timezone().await; + let now = Utc::now().with_timezone(&tz); - ctx.send(CreateReply::default().ephemeral(true).content(format!( - "Time in **{}**: `{}`", - tz, - now.format("%H:%M") - ))) - .await?; + ctx.send(CreateReply::default().ephemeral(true).content(format!( + "Time in **{}**: `{}`", + tz, + now.format("%H:%M") + ))) + .await?; - Ok(()) + Ok(()) + } } /// View the current time in your selected timezone -#[poise::command(slash_command, rename = "clock")] +#[poise::command(slash_command, rename = "clock", identifying_name = "clock")] pub async fn command(ctx: Context<'_>) -> Result<(), Error> { - clock(ctx, Options {}).await + (Options {}).run(ctx).await } diff --git a/src/commands/command_macro/run_macro.rs b/src/commands/command_macro/run_macro.rs index 030ae16..5b9ff7b 100644 --- a/src/commands/command_macro/run_macro.rs +++ b/src/commands/command_macro/run_macro.rs @@ -1,7 +1,10 @@ use poise::{serenity_prelude::CreateEmbed, CreateReply}; use super::super::autocomplete::macro_name_autocomplete; -use crate::{models::command_macro::guild_command_macro, Context, Data, Error, THEME_COLOR}; +use crate::{ + models::command_macro::guild_command_macro, utils::Recordable, Context, Data, Error, + THEME_COLOR, +}; /// Run a recorded macro #[poise::command( @@ -32,7 +35,9 @@ pub async fn run_macro( .await?; for command in command_macro.commands { - command.execute(poise::ApplicationContext { ..ctx }).await?; + command + .run(poise::Context::Application(poise::ApplicationContext { ..ctx })) + .await?; } } diff --git a/src/commands/dashboard.rs b/src/commands/dashboard.rs index 7a65500..9c970f5 100644 --- a/src/commands/dashboard.rs +++ b/src/commands/dashboard.rs @@ -3,32 +3,34 @@ use serde::{Deserialize, Serialize}; use crate::{ consts::THEME_COLOR, - utils::{footer, Extract}, + utils::{footer, Extract, Recordable}, Context, Error, }; #[derive(Serialize, Deserialize, Extract)] pub struct Options; -pub async fn dashboard(ctx: Context<'_>, _options: Options) -> Result<(), Error> { - let footer = footer(ctx); +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + let footer = footer(ctx); - ctx.send( - CreateReply::default().ephemeral(true).embed( - CreateEmbed::new() - .title("Dashboard") - .description("**https://beta.reminder-bot.com/dashboard**") - .footer(footer) - .color(*THEME_COLOR), - ), - ) - .await?; + ctx.send( + CreateReply::default().ephemeral(true).embed( + CreateEmbed::new() + .title("Dashboard") + .description("**https://beta.reminder-bot.com/dashboard**") + .footer(footer) + .color(*THEME_COLOR), + ), + ) + .await?; - Ok(()) + Ok(()) + } } /// Get the link to the web dashboard -#[poise::command(slash_command, rename = "dashboard")] +#[poise::command(slash_command, rename = "dashboard", identifying_name = "dashboard")] pub async fn command(ctx: Context<'_>) -> Result<(), Error> { - dashboard(ctx, Options {}).await + (Options {}).run(ctx).await } diff --git a/src/commands/delete.rs b/src/commands/delete.rs index 3c2c561..f5212ce 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -15,7 +15,7 @@ use crate::{ }, consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR}, models::{reminder::Reminder, CtxData}, - utils::Extract, + utils::{Extract, Recordable}, Context, Error, }; @@ -140,21 +140,28 @@ pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> Cr #[derive(Serialize, Deserialize, Extract)] pub struct Options; -pub async fn delete(ctx: Context<'_>, _options: Options) -> Result<(), Error> { - let timezone = ctx.timezone().await; +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + let timezone = ctx.timezone().await; - let reminders = - Reminder::from_guild(&ctx, &ctx.data().database, ctx.guild_id(), ctx.author().id).await; + let reminders = + Reminder::from_guild(&ctx, &ctx.data().database, ctx.guild_id(), ctx.author().id).await; - let resp = show_delete_page(&reminders, 0, timezone); + let resp = show_delete_page(&reminders, 0, timezone); - ctx.send(resp).await?; + ctx.send(resp).await?; - Ok(()) + Ok(()) + } } /// Delete reminders -#[poise::command(slash_command, rename = "del", default_member_permissions = "MANAGE_GUILD")] +#[poise::command( + slash_command, + rename = "delete", + identifying_name = "delete", + default_member_permissions = "MANAGE_GUILD" +)] pub async fn command(ctx: Context<'_>) -> Result<(), Error> { - delete(ctx, Options {}).await + (Options {}).run(ctx).await } diff --git a/src/commands/donate.rs b/src/commands/donate.rs index 9459803..3f49261 100644 --- a/src/commands/donate.rs +++ b/src/commands/donate.rs @@ -3,17 +3,18 @@ use serde::{Deserialize, Serialize}; use crate::{ consts::THEME_COLOR, - utils::{footer, Extract}, + utils::{footer, Extract, Recordable}, Context, Error, }; #[derive(Serialize, Deserialize, Extract)] pub struct Options; -pub async fn donate(ctx: Context<'_>, _options: Options) -> Result<(), Error> { - let footer = footer(ctx); +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + let footer = footer(ctx); - ctx.send(CreateReply::default().embed(CreateEmbed::new().title("Donate") + ctx.send(CreateReply::default().embed(CreateEmbed::new().title("Donate") .description("Thinking of adding a monthly contribution? Click below for my Patreon and official bot server :) @@ -36,11 +37,12 @@ Just $2 USD/month! ) .await?; - Ok(()) + Ok(()) + } } /// Details on supporting the bot and Patreon benefits -#[poise::command(slash_command, rename = "patreon")] +#[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")] pub async fn command(ctx: Context<'_>) -> Result<(), Error> { - donate(ctx, Options {}).await + (Options {}).run(ctx).await } diff --git a/src/commands/help.rs b/src/commands/help.rs index 47d4c3c..7494d47 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -3,23 +3,24 @@ use serde::{Deserialize, Serialize}; use crate::{ consts::THEME_COLOR, - utils::{footer, Extract}, + utils::{footer, Extract, Recordable}, Context, Error, }; #[derive(Serialize, Deserialize, Extract)] pub struct Options; -pub async fn help(ctx: Context<'_>, _options: Options) -> Result<(), Error> { - let footer = footer(ctx); +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + let footer = footer(ctx); - ctx.send( - CreateReply::default().ephemeral(true).embed( - CreateEmbed::new() - .title("Help") - .color(*THEME_COLOR) - .description( - "__Info Commands__ + ctx.send( + CreateReply::default().ephemeral(true).embed( + CreateEmbed::new() + .title("Help") + .color(*THEME_COLOR) + .description( + "__Info Commands__ `/help` `/info` `/donate` `/dashboard` `/clock` *run these commands with no options* @@ -44,17 +45,18 @@ __Setup Commands__ __Advanced Commands__ `/macro` - Record and replay command sequences ", - ) - .footer(footer), - ), - ) - .await?; + ) + .footer(footer), + ), + ) + .await?; - Ok(()) + Ok(()) + } } /// Get an overview of bot commands -#[poise::command(slash_command, rename = "help")] +#[poise::command(slash_command, rename = "help", identifying_name = "help")] pub async fn command(ctx: Context<'_>) -> Result<(), Error> { - help(ctx, Options {}).await + (Options {}).run(ctx).await } diff --git a/src/commands/info.rs b/src/commands/info.rs index 2bc22fc..fd822e4 100644 --- a/src/commands/info.rs +++ b/src/commands/info.rs @@ -3,22 +3,23 @@ use serde::{Deserialize, Serialize}; use crate::{ consts::THEME_COLOR, - utils::{footer, Extract}, + utils::{footer, Extract, Recordable}, Context, Error, }; #[derive(Serialize, Deserialize, Extract)] pub struct Options; -pub async fn info(ctx: Context<'_>, _options: Options) -> Result<(), Error> { - let footer = footer(ctx); +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + let footer = footer(ctx); - ctx.send( - CreateReply::default().ephemeral(true).embed( - CreateEmbed::new() - .title("Info") - .description( - "Help: `/help` + ctx.send( + CreateReply::default().ephemeral(true).embed( + CreateEmbed::new() + .title("Info") + .description( + "Help: `/help` **Welcome to Reminder Bot!** Developer: <@203532103185465344> @@ -27,18 +28,19 @@ Find me on https://discord.jellywx.com and on https://github.com/JellyWX :) Invite the bot: https://invite.reminder-bot.com/ Use our dashboard: https://reminder-bot.com/", - ) - .footer(footer) - .color(*THEME_COLOR), - ), - ) - .await?; + ) + .footer(footer) + .color(*THEME_COLOR), + ), + ) + .await?; - Ok(()) + Ok(()) + } } /// Get information about the bot -#[poise::command(slash_command, rename = "info")] +#[poise::command(slash_command, rename = "info", identifying_name = "info")] pub async fn command(ctx: Context<'_>) -> Result<(), Error> { - info(ctx, Options {}).await + (Options {}).run(ctx).await } diff --git a/src/commands/look.rs b/src/commands/look.rs index bb4a01e..1363967 100644 --- a/src/commands/look.rs +++ b/src/commands/look.rs @@ -9,7 +9,7 @@ use crate::{ component_models::pager::{LookPager, Pager}, consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, models::{reminder::Reminder, CtxData}, - utils::Extract, + utils::{Extract, Recordable}, Context, Error, }; @@ -40,88 +40,95 @@ pub struct Options { relative: Option, } -pub async fn look(ctx: Context<'_>, options: Options) -> Result<(), Error> { - let timezone = ctx.timezone().await; +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + let timezone = ctx.timezone().await; - let flags = LookFlags { - show_disabled: options.disabled.unwrap_or(true), - channel_id: options.channel.map(|c| c.id), - time_display: options.relative.map_or(TimeDisplayType::Relative, |b| { - if b { - TimeDisplayType::Relative + let flags = LookFlags { + show_disabled: self.disabled.unwrap_or(true), + channel_id: self.channel.map(|c| c.id), + time_display: self.relative.map_or(TimeDisplayType::Relative, |b| { + if b { + TimeDisplayType::Relative + } else { + TimeDisplayType::Absolute + } + }), + }; + + let channel_id = if let Some(channel) = ctx.channel_id().to_channel_cached(&ctx.cache()) { + if Some(channel.guild_id) == ctx.guild_id() { + flags.channel_id.unwrap_or_else(|| ctx.channel_id()) } else { - TimeDisplayType::Absolute + ctx.channel_id() } - }), - }; - - let channel_id = if let Some(channel) = ctx.channel_id().to_channel_cached(&ctx.cache()) { - if Some(channel.guild_id) == ctx.guild_id() { - flags.channel_id.unwrap_or_else(|| ctx.channel_id()) } else { ctx.channel_id() + }; + + let channel_name = + channel_id.to_channel_cached(&ctx.cache()).map(|channel| channel.name.clone()); + + let reminders = Reminder::from_channel(&ctx.data().database, channel_id, &flags).await; + + if reminders.is_empty() { + let _ = ctx.say("No reminders on specified channel").await; + } else { + let mut char_count = 0; + + let display = reminders + .iter() + .map(|reminder| reminder.display(&flags, &timezone)) + .take_while(|p| { + char_count += p.len(); + + char_count < EMBED_DESCRIPTION_MAX_LENGTH + }) + .collect::>() + .join(""); + + let pages = reminders + .iter() + .map(|reminder| reminder.display(&flags, &timezone)) + .fold(0, |t, r| t + r.len()) + .div_ceil(EMBED_DESCRIPTION_MAX_LENGTH); + + let pager = LookPager::new(flags, timezone); + + ctx.send( + CreateReply::default() + .ephemeral(true) + .embed( + CreateEmbed::new() + .title(format!( + "Reminders{}", + channel_name.map_or(String::new(), |n| format!(" on #{}", n)) + )) + .description(display) + .footer(CreateEmbedFooter::new(format!("Page {} of {}", 1, pages))) + .color(*THEME_COLOR), + ) + .components(vec![pager.create_button_row(pages)]), + ) + .await?; } - } else { - ctx.channel_id() - }; - let channel_name = - channel_id.to_channel_cached(&ctx.cache()).map(|channel| channel.name.clone()); - - let reminders = Reminder::from_channel(&ctx.data().database, channel_id, &flags).await; - - if reminders.is_empty() { - let _ = ctx.say("No reminders on specified channel").await; - } else { - let mut char_count = 0; - - let display = reminders - .iter() - .map(|reminder| reminder.display(&flags, &timezone)) - .take_while(|p| { - char_count += p.len(); - - char_count < EMBED_DESCRIPTION_MAX_LENGTH - }) - .collect::>() - .join(""); - - let pages = reminders - .iter() - .map(|reminder| reminder.display(&flags, &timezone)) - .fold(0, |t, r| t + r.len()) - .div_ceil(EMBED_DESCRIPTION_MAX_LENGTH); - - let pager = LookPager::new(flags, timezone); - - ctx.send( - CreateReply::default() - .ephemeral(true) - .embed( - CreateEmbed::new() - .title(format!( - "Reminders{}", - channel_name.map_or(String::new(), |n| format!(" on #{}", n)) - )) - .description(display) - .footer(CreateEmbedFooter::new(format!("Page {} of {}", 1, pages))) - .color(*THEME_COLOR), - ) - .components(vec![pager.create_button_row(pages)]), - ) - .await?; + Ok(()) } - - Ok(()) } /// View reminders on a specific channel -#[poise::command(slash_command, rename = "look", default_member_permissions = "MANAGE_GUILD")] +#[poise::command( + slash_command, + rename = "look", + identifying_name = "look", + default_member_permissions = "MANAGE_GUILD" +)] pub async fn command( ctx: Context<'_>, #[description = "Channel to view reminders on"] channel: Option, #[description = "Whether to show disabled reminders or not"] disabled: Option, #[description = "Whether to display times as relative or exact times"] relative: Option, ) -> Result<(), Error> { - look(ctx, Options { channel, disabled, relative }).await + (Options { channel, disabled, relative }).run(ctx).await } diff --git a/src/commands/multiline.rs b/src/commands/multiline.rs index 36bb7fd..9776e6e 100644 --- a/src/commands/multiline.rs +++ b/src/commands/multiline.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::{ commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete}, models::reminder::create_reminder, - utils::Extract, + utils::{Extract, Recordable}, Context, Error, }; @@ -30,49 +30,58 @@ pub struct Options { timezone: Option, } -pub async fn multiline(ctx: Context<'_>, options: Options) -> Result<(), Error> { - match ctx { - Context::Application(app_ctx) => { - let tz = options.timezone.map(|t| t.parse::().ok()).flatten(); - let data_opt = ContentModal::execute(app_ctx).await?; +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + match ctx { + Context::Application(app_ctx) => { + let tz = self.timezone.map(|t| t.parse::().ok()).flatten(); + let data_opt = ContentModal::execute(app_ctx).await?; - match data_opt { - Some(data) => { - create_reminder( - ctx, - options.time, - data.content, - options.channels, - options.interval, - options.expires, - options.tts, - tz, - ) - .await - } - - None => { - warn!("Unexpected None encountered in /multiline"); - Ok(ctx - .send(CreateReply::default().content("Unexpected error.").ephemeral(true)) + match data_opt { + Some(data) => { + create_reminder( + ctx, + self.time, + data.content, + self.channels, + self.interval, + self.expires, + self.tts, + tz, + ) .await - .map(|_| ())?) + } + + None => { + warn!("Unexpected None encountered in /multiline"); + Ok(ctx + .send( + CreateReply::default().content("Unexpected error.").ephemeral(true), + ) + .await + .map(|_| ())?) + } } } - } - _ => { - warn!("Shouldn't be here"); - Ok(ctx - .send(CreateReply::default().content("Unexpected error.").ephemeral(true)) - .await - .map(|_| ())?) + _ => { + warn!("Shouldn't be here"); + Ok(ctx + .send(CreateReply::default().content("Unexpected error.").ephemeral(true)) + .await + .map(|_| ())?) + } } } } /// Create a reminder with multi-line content. Press "+4 more" for other options. -#[poise::command(slash_command, rename = "multiline", default_member_permissions = "MANAGE_GUILD")] +#[poise::command( + slash_command, + rename = "multiline", + identifying_name = "multiline", + default_member_permissions = "MANAGE_GUILD" +)] pub async fn command( ctx: Context<'_>, #[description = "A description of the time to set the reminder for"] @@ -89,5 +98,5 @@ pub async fn command( #[autocomplete = "timezone_autocomplete"] timezone: Option, ) -> Result<(), Error> { - multiline(ctx, Options { time, channels, interval, expires, tts, timezone }).await + (Options { time, channels, interval, expires, tts, timezone }).run(ctx).await } diff --git a/src/commands/nudge.rs b/src/commands/nudge.rs index 2c23999..857b786 100644 --- a/src/commands/nudge.rs +++ b/src/commands/nudge.rs @@ -1,6 +1,11 @@ use serde::{Deserialize, Serialize}; -use crate::{consts::MINUTE, models::CtxData, utils::Extract, Context, Error}; +use crate::{ + consts::MINUTE, + models::CtxData, + utils::{Extract, Recordable}, + Context, Error, +}; #[derive(Serialize, Deserialize, Extract)] pub struct Options { @@ -8,30 +13,38 @@ pub struct Options { seconds: Option, } -pub async fn nudge(ctx: Context<'_>, options: Options) -> Result<(), Error> { - let combined_time = - options.minutes.map_or(0, |m| m * MINUTE as i64) + options.seconds.map_or(0, |s| s); +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + let combined_time = + self.minutes.map_or(0, |m| m * MINUTE as i64) + self.seconds.map_or(0, |s| s); - if combined_time < i16::MIN as i64 || combined_time > i16::MAX as i64 { - ctx.say("Nudge times must be less than 500 minutes").await?; - } else { - let mut channel_data = ctx.channel_data().await.unwrap(); + if combined_time < i16::MIN as i64 || combined_time > i16::MAX as i64 { + ctx.say("Nudge times must be less than 500 minutes").await?; + } else { + let mut channel_data = ctx.channel_data().await.unwrap(); - channel_data.nudge = combined_time as i16; - channel_data.commit_changes(&ctx.data().database).await; + channel_data.nudge = combined_time as i16; + channel_data.commit_changes(&ctx.data().database).await; - ctx.say(format!("Future reminders will be nudged by {} seconds", combined_time)).await?; + ctx.say(format!("Future reminders will be nudged by {} seconds", combined_time)) + .await?; + } + + Ok(()) } - - Ok(()) } /// Nudge all future reminders on this channel by a certain amount (don't use for DST! See `/offset`) -#[poise::command(slash_command, rename = "nudge", default_member_permissions = "MANAGE_GUILD")] +#[poise::command( + slash_command, + rename = "nudge", + identifying_name = "nudge", + default_member_permissions = "MANAGE_GUILD" +)] pub async fn command( ctx: Context<'_>, #[description = "Number of minutes to nudge new reminders by"] minutes: Option, #[description = "Number of seconds to nudge new reminders by"] seconds: Option, ) -> Result<(), Error> { - nudge(ctx, Options { minutes, seconds }).await + (Options { minutes, seconds }).run(ctx).await } diff --git a/src/commands/offset.rs b/src/commands/offset.rs index b1bd9bb..6618d41 100644 --- a/src/commands/offset.rs +++ b/src/commands/offset.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::{ consts::{HOUR, MINUTE}, - utils::Extract, + utils::{Extract, Recordable}, Context, Error, }; @@ -13,69 +13,76 @@ pub struct Options { seconds: Option, } -async fn offset(ctx: Context<'_>, options: Options) -> Result<(), Error> { - ctx.defer().await?; +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + ctx.defer().await?; - let combined_time = options.hours.map_or(0, |h| h * HOUR as i64) - + options.minutes.map_or(0, |m| m * MINUTE as i64) - + options.seconds.map_or(0, |s| s); + let combined_time = self.hours.map_or(0, |h| h * HOUR as i64) + + self.minutes.map_or(0, |m| m * MINUTE as i64) + + self.seconds.map_or(0, |s| s); - if combined_time == 0 { - ctx.say("Please specify one of `hours`, `minutes` or `seconds`").await?; - } else { - if let Some(channels) = ctx.guild().map(|guild| { - guild - .channels - .iter() - .filter(|(_, channel)| channel.is_text_based()) - .map(|(id, _)| id.get().to_string()) - .collect::>() - .join(",") - }) { - sqlx::query!( - " + if combined_time == 0 { + ctx.say("Please specify one of `hours`, `minutes` or `seconds`").await?; + } else { + if let Some(channels) = ctx.guild().map(|guild| { + guild + .channels + .iter() + .filter(|(_, channel)| channel.is_text_based()) + .map(|(id, _)| id.get().to_string()) + .collect::>() + .join(",") + }) { + sqlx::query!( + " UPDATE reminders INNER JOIN `channels` ON `channels`.id = reminders.channel_id SET reminders.`utc_time` = DATE_ADD(reminders.`utc_time`, INTERVAL ? SECOND) WHERE FIND_IN_SET(channels.`channel`, ?) ", - combined_time as i64, - channels - ) - .execute(&ctx.data().database) - .await - .unwrap(); - } else { - sqlx::query!( - " + combined_time as i64, + channels + ) + .execute(&ctx.data().database) + .await + .unwrap(); + } else { + sqlx::query!( + " UPDATE reminders INNER JOIN `channels` ON `channels`.id = reminders.channel_id SET reminders.`utc_time` = reminders.`utc_time` + ? WHERE channels.`channel` = ? ", - combined_time as i64, - ctx.channel_id().get() - ) - .execute(&ctx.data().database) - .await - .unwrap(); + combined_time as i64, + ctx.channel_id().get() + ) + .execute(&ctx.data().database) + .await + .unwrap(); + } + + ctx.say(format!("All reminders offset by {} seconds", combined_time)).await?; } - ctx.say(format!("All reminders offset by {} seconds", combined_time)).await?; + Ok(()) } - - Ok(()) } /// Move all reminders in the current server by a certain amount of time. Times get added together -#[poise::command(slash_command, rename = "offset", default_member_permissions = "MANAGE_GUILD")] +#[poise::command( + slash_command, + rename = "offset", + identifying_name = "offset", + default_member_permissions = "MANAGE_GUILD" +)] pub async fn command( ctx: Context<'_>, #[description = "Number of hours to offset by"] hours: Option, #[description = "Number of minutes to offset by"] minutes: Option, #[description = "Number of seconds to offset by"] seconds: Option, ) -> Result<(), Error> { - offset(ctx, Options { hours, minutes, seconds }).await + (Options { hours, minutes, seconds }).run(ctx).await } diff --git a/src/commands/pause.rs b/src/commands/pause.rs index c8b65ea..5662c26 100644 --- a/src/commands/pause.rs +++ b/src/commands/pause.rs @@ -1,73 +1,85 @@ use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; -use crate::{models::CtxData, time_parser::natural_parser, utils::Extract, Context, Error}; +use crate::{ + models::CtxData, + time_parser::natural_parser, + utils::{Extract, Recordable}, + Context, Error, +}; #[derive(Serialize, Deserialize, Extract)] pub struct Options { until: Option, } -pub async fn pause(ctx: Context<'_>, options: Options) -> Result<(), Error> { - let timezone = ctx.timezone().await; +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + let timezone = ctx.timezone().await; - let mut channel = ctx.channel_data().await.unwrap(); + let mut channel = ctx.channel_data().await.unwrap(); - match options.until { - Some(until) => { - let parsed = natural_parser(&until, &timezone.to_string()).await; + match self.until { + Some(until) => { + let parsed = natural_parser(&until, &timezone.to_string()).await; - if let Some(timestamp) = parsed { - match NaiveDateTime::from_timestamp_opt(timestamp, 0) { - Some(dt) => { - channel.paused = true; - channel.paused_until = Some(dt); + if let Some(timestamp) = parsed { + match NaiveDateTime::from_timestamp_opt(timestamp, 0) { + Some(dt) => { + channel.paused = true; + channel.paused_until = Some(dt); - channel.commit_changes(&ctx.data().database).await; + channel.commit_changes(&ctx.data().database).await; - ctx.say(format!( - "Reminders in this channel have been silenced until ****", - timestamp - )) - .await?; - } + ctx.say(format!( + "Reminders in this channel have been silenced until ****", + timestamp + )) + .await?; + } - None => { - ctx.say( + None => { + ctx.say( "Time processed could not be interpreted as `DateTime`. Please write the time as clearly as possible", ) .await?; + } } + } else { + ctx.say( + "Time could not be processed. Please write the time as clearly as possible", + ) + .await?; + } + } + _ => { + channel.paused = !channel.paused; + channel.paused_until = None; + + channel.commit_changes(&ctx.data().database).await; + + if channel.paused { + ctx.say("Reminders in this channel have been silenced indefinitely").await?; + } else { + ctx.say("Reminders in this channel have been unsilenced").await?; } - } else { - ctx.say( - "Time could not be processed. Please write the time as clearly as possible", - ) - .await?; } } - _ => { - channel.paused = !channel.paused; - channel.paused_until = None; - channel.commit_changes(&ctx.data().database).await; - - if channel.paused { - ctx.say("Reminders in this channel have been silenced indefinitely").await?; - } else { - ctx.say("Reminders in this channel have been unsilenced").await?; - } - } + Ok(()) } - - Ok(()) } /// Pause all reminders on the current channel until a certain time or indefinitely -#[poise::command(slash_command, rename = "pause", default_member_permissions = "MANAGE_GUILD")] +#[poise::command( + slash_command, + rename = "pause", + identifying_name = "pause", + default_member_permissions = "MANAGE_GUILD" +)] pub async fn command( ctx: Context<'_>, #[description = "When to pause until"] until: Option, ) -> Result<(), Error> { - pause(ctx, Options { until }).await + (Options { until }).run(ctx).await } diff --git a/src/commands/remind.rs b/src/commands/remind.rs index a88b8f5..3e69132 100644 --- a/src/commands/remind.rs +++ b/src/commands/remind.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::{ commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete}, models::reminder::create_reminder, - utils::Extract, + utils::{Extract, Recordable}, Context, Error, }; @@ -19,24 +19,31 @@ pub struct Options { timezone: Option, } -pub async fn remind(ctx: Context<'_>, options: Options) -> Result<(), Error> { - let tz = options.timezone.map(|t| t.parse::().ok()).flatten(); +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + let tz = self.timezone.map(|t| t.parse::().ok()).flatten(); - create_reminder( - ctx, - options.time, - options.content, - options.channels, - options.interval, - options.expires, - options.tts, - tz, - ) - .await + create_reminder( + ctx, + self.time, + self.content, + self.channels, + self.interval, + self.expires, + self.tts, + tz, + ) + .await + } } /// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content. -#[poise::command(slash_command, rename = "remind", default_member_permissions = "MANAGE_GUILD")] +#[poise::command( + slash_command, + rename = "remind", + default_member_permissions = "MANAGE_GUILD", + identifying_name = "remind" +)] pub async fn command( ctx: Context<'_>, #[description = "The time (and optionally date) to set the reminder for"] @@ -54,5 +61,5 @@ pub async fn command( #[autocomplete = "timezone_autocomplete"] timezone: Option, ) -> Result<(), Error> { - remind(ctx, Options { time, content, channels, interval, expires, tts, timezone }).await + (Options { time, content, channels, interval, expires, tts, timezone }).run(ctx).await } diff --git a/src/commands/timezone.rs b/src/commands/timezone.rs index d2a46de..a71d799 100644 --- a/src/commands/timezone.rs +++ b/src/commands/timezone.rs @@ -8,8 +8,11 @@ use poise::{ use serde::{Deserialize, Serialize}; use crate::{ - commands::autocomplete::timezone_autocomplete, consts::THEME_COLOR, models::CtxData, - utils::Extract, Context, Error, + commands::autocomplete::timezone_autocomplete, + consts::THEME_COLOR, + models::CtxData, + utils::{Extract, Recordable}, + Context, Error, }; #[derive(Serialize, Deserialize, Extract)] @@ -17,55 +20,56 @@ pub struct Options { pub timezone: Option, } -pub async fn timezone_fn(ctx: Context<'_>, options: Options) -> Result<(), Error> { - let mut user_data = ctx.author_data().await.unwrap(); +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + let mut user_data = ctx.author_data().await.unwrap(); - let footer_text = format!("Current timezone: {}", user_data.timezone); + let footer_text = format!("Current timezone: {}", user_data.timezone); - if let Some(timezone) = options.timezone { - match timezone.parse::() { - Ok(tz) => { - user_data.timezone = timezone.clone(); - user_data.commit_changes(&ctx.data().database).await; + if let Some(timezone) = self.timezone { + match timezone.parse::() { + Ok(tz) => { + user_data.timezone = timezone.clone(); + user_data.commit_changes(&ctx.data().database).await; - let now = Utc::now().with_timezone(&tz); + let now = Utc::now().with_timezone(&tz); - ctx.send( - CreateReply::default().embed( - CreateEmbed::new() - .title("Timezone Set") - .description(format!( + ctx.send( + CreateReply::default().embed( + CreateEmbed::new() + .title("Timezone Set") + .description(format!( "Timezone has been set to **{}**. Your current time should be `{}`", timezone, now.format("%H:%M") )) - .color(*THEME_COLOR), - ), - ) - .await?; - } - - Err(_) => { - let filtered_tz = TZ_VARIANTS - .iter() - .filter(|tz| { - timezone.contains(&tz.to_string()) - || tz.to_string().contains(&timezone) - || levenshtein(&tz.to_string(), &timezone) < 4 - }) - .take(25) - .map(|t| t.to_owned()) - .collect::>(); - - let fields = filtered_tz.iter().map(|tz| { - ( - tz.to_string(), - format!("🕗 `{}`", Utc::now().with_timezone(tz).format("%H:%M")), - true, + .color(*THEME_COLOR), + ), ) - }); + .await?; + } - ctx.send( + Err(_) => { + let filtered_tz = TZ_VARIANTS + .iter() + .filter(|tz| { + timezone.contains(&tz.to_string()) + || tz.to_string().contains(&timezone) + || levenshtein(&tz.to_string(), &timezone) < 4 + }) + .take(25) + .map(|t| t.to_owned()) + .collect::>(); + + let fields = filtered_tz.iter().map(|tz| { + ( + tz.to_string(), + format!("🕗 `{}`", Utc::now().with_timezone(tz).format("%H:%M")), + true, + ) + }); + + ctx.send( CreateReply::default().embed( CreateEmbed::new() .title("Timezone Not Recognized") @@ -83,14 +87,18 @@ pub async fn timezone_fn(ctx: Context<'_>, options: Options) -> Result<(), Error ), ) .await?; + } } - } - } else { - let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| { - (t.to_string(), format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M")), true) - }); + } else { + let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| { + ( + t.to_string(), + format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M")), + true, + ) + }); - ctx.send( + ctx.send( CreateReply::default().embed( CreateEmbed::new() .title("Timezone Usage") @@ -110,18 +118,19 @@ You may want to use one of the popular timezones below, otherwise click [here](h ), ) .await?; - } + } - Ok(()) + Ok(()) + } } /// Select your timezone -#[poise::command(slash_command, rename = "timezone")] +#[poise::command(slash_command, rename = "timezone", identifying_name = "timezone")] pub async fn command( ctx: Context<'_>, #[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"] #[autocomplete = "timezone_autocomplete"] timezone: Option, ) -> Result<(), Error> { - timezone_fn(ctx, Options { timezone }).await + (Options { timezone }).run(ctx).await } diff --git a/src/commands/webhook.rs b/src/commands/webhook.rs index 800835a..878f322 100644 --- a/src/commands/webhook.rs +++ b/src/commands/webhook.rs @@ -2,39 +2,50 @@ use log::warn; use poise::CreateReply; use serde::{Deserialize, Serialize}; -use crate::{models::CtxData, utils::Extract, Context, Error}; +use crate::{ + models::CtxData, + utils::{Extract, Recordable}, + Context, Error, +}; #[derive(Serialize, Deserialize, Extract)] pub struct Options; -pub async fn webhook(ctx: Context<'_>, _options: Options) -> Result<(), Error> { - match ctx.channel_data().await { - Ok(data) => { - if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) { - ctx.send(CreateReply::default().ephemeral(true).content(format!( - "**Warning!** +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + match ctx.channel_data().await { + Ok(data) => { + if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) { + ctx.send(CreateReply::default().ephemeral(true).content(format!( + "**Warning!** This link can be used by users to anonymously send messages, with or without permissions. Do not share it! || https://discord.com/api/webhooks/{}/{} ||", - id, token, - ))) - .await?; - } else { + id, token, + ))) + .await?; + } else { + ctx.say("No webhook configured on this channel.").await?; + } + } + Err(e) => { + warn!("Error fetching channel data: {:?}", e); + ctx.say("No webhook configured on this channel.").await?; } } - Err(e) => { - warn!("Error fetching channel data: {:?}", e); - ctx.say("No webhook configured on this channel.").await?; - } + Ok(()) } - - Ok(()) } /// View the webhook being used to send reminders to this channel -#[poise::command(slash_command, rename = "webhook", required_permissions = "ADMINISTRATOR")] +#[poise::command( + slash_command, + rename = "webhook", + identifying_name = "webhook", + required_permissions = "ADMINISTRATOR" +)] pub async fn command(ctx: Context<'_>) -> Result<(), Error> { - webhook(ctx, Options {}).await + (Options {}).run(ctx).await } diff --git a/src/hooks.rs b/src/hooks.rs index 7ec0fc5..e74430f 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -13,18 +13,6 @@ async fn macro_check(ctx: Context<'_>) -> bool { let mut lock = ctx.data().recording_macros.write().await; if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) { - if ctx.command().identifying_name != "remind" { - let _ = ctx - .send( - CreateReply::default() - .ephemeral(true) - .content("Macro recording only supports `/remind`. Please stop recording with `/macro finish` before using other commands.") - ) - .await; - - return false; - } - if command_macro.commands.len() >= MACRO_MAX_COMMANDS { let _ = ctx .send( @@ -34,16 +22,29 @@ async fn macro_check(ctx: Context<'_>) -> bool { ) .await; } else { - let recorded = RecordedCommand::from_context(app_ctx).unwrap(); - command_macro.commands.push(recorded); + match RecordedCommand::from_context(app_ctx) { + Some(recorded) => { + command_macro.commands.push(recorded); - let _ = ctx - .send( - CreateReply::default() - .ephemeral(true) - .content("Command recorded to macro"), - ) - .await; + let _ = ctx + .send( + CreateReply::default() + .ephemeral(true) + .content("Command recorded to macro"), + ) + .await; + } + + None => { + let _ = ctx + .send( + CreateReply::default().ephemeral(true).content( + "This command is not supported in macros yet.", + ), + ) + .await; + } + } } return false; diff --git a/src/models/command_macro.rs b/src/models/command_macro.rs index d385f3b..6095299 100644 --- a/src/models/command_macro.rs +++ b/src/models/command_macro.rs @@ -2,29 +2,64 @@ use poise::serenity_prelude::model::id::GuildId; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::{commands::remind, utils::Extract, ApplicationContext, Context, Error}; +use crate::{ + utils::{Extract, Recordable}, + ApplicationContext, Context, +}; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Recordable)] #[serde(tag = "command_name")] pub enum RecordedCommand { - Remind(remind::Options), + #[serde(rename = "clock")] + Clock(crate::commands::clock::Options), + #[serde(rename = "dashboard")] + Dashboard(crate::commands::dashboard::Options), + #[serde(rename = "delete")] + Delete(crate::commands::delete::Options), + #[serde(rename = "donate")] + Donate(crate::commands::donate::Options), + #[serde(rename = "help")] + Help(crate::commands::help::Options), + #[serde(rename = "info")] + Info(crate::commands::info::Options), + #[serde(rename = "look")] + Look(crate::commands::look::Options), + #[serde(rename = "multiline")] + Multiline(crate::commands::multiline::Options), + #[serde(rename = "nudge")] + Nudge(crate::commands::nudge::Options), + #[serde(rename = "offset")] + Offset(crate::commands::offset::Options), + #[serde(rename = "pause")] + Pause(crate::commands::pause::Options), + #[serde(rename = "remind")] + Remind(crate::commands::remind::Options), + #[serde(rename = "timezone")] + Timezone(crate::commands::timezone::Options), + #[serde(rename = "webhook")] + Webhook(crate::commands::webhook::Options), } impl RecordedCommand { pub fn from_context(ctx: ApplicationContext) -> Option { match ctx.command().identifying_name.as_str() { - "remind" => Some(Self::Remind(remind::Options::extract(ctx))), + "clock" => Some(Self::Clock(crate::commands::clock::Options::extract(ctx))), + "dashboard" => Some(Self::Dashboard(crate::commands::dashboard::Options::extract(ctx))), + "delete" => Some(Self::Delete(crate::commands::delete::Options::extract(ctx))), + "donate" => Some(Self::Donate(crate::commands::donate::Options::extract(ctx))), + "help" => Some(Self::Help(crate::commands::help::Options::extract(ctx))), + "info" => Some(Self::Info(crate::commands::info::Options::extract(ctx))), + "look" => Some(Self::Look(crate::commands::look::Options::extract(ctx))), + "multiline" => Some(Self::Multiline(crate::commands::multiline::Options::extract(ctx))), + "nudge" => Some(Self::Nudge(crate::commands::nudge::Options::extract(ctx))), + "offset" => Some(Self::Offset(crate::commands::offset::Options::extract(ctx))), + "pause" => Some(Self::Pause(crate::commands::pause::Options::extract(ctx))), + "remind" => Some(Self::Remind(crate::commands::remind::Options::extract(ctx))), + "timezone" => Some(Self::Timezone(crate::commands::timezone::Options::extract(ctx))), + "webhook" => Some(Self::Webhook(crate::commands::webhook::Options::extract(ctx))), _ => None, } } - - pub async fn execute(self, ctx: ApplicationContext<'_>) -> Result<(), Error> { - match self { - RecordedCommand::Remind(options) => { - remind::remind(Context::Application(ctx), options).await - } - } - } } pub struct CommandMacro { diff --git a/src/utils.rs b/src/utils.rs index 682d68b..faf2f8b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -9,7 +9,7 @@ use poise::{ use crate::{ consts::{CNC_GUILD, SUBSCRIPTION_ROLES}, - ApplicationContext, Context, + ApplicationContext, Context, Error, }; pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into) -> bool { @@ -65,11 +65,17 @@ pub fn footer(ctx: Context<'_>) -> CreateEmbedFooter { )) } +pub trait Recordable { + async fn run(self, ctx: Context<'_>) -> Result<(), Error>; +} + +pub use recordable_derive::Recordable; + pub trait Extract { fn extract(ctx: ApplicationContext) -> Self; } -pub use extract_macro::Extract; +pub use extract_derive::Extract; macro_rules! extract_arg { ($ctx:ident, $name:ident, String) => {