From 482375495560114ad86b440079b085e9d196cac0 Mon Sep 17 00:00:00 2001 From: jude Date: Sat, 17 Feb 2024 18:55:16 +0000 Subject: [PATCH] Move all commands to their own files --- Cargo.lock | 3 - Cargo.toml | 3 - src/commands/allowed_dm/mod.rs | 10 + src/commands/allowed_dm/set_allowed_dm.rs | 23 + src/commands/allowed_dm/unset_allowed_dm.rs | 25 + src/commands/clock.rs | 22 + src/commands/clock_context_menu.rs | 27 + src/commands/command_macro/macro_base.rs | 13 - src/commands/command_macro/mod.rs | 14 +- src/commands/command_proc/mod.rs | 80 -- src/commands/dashboard.rs | 22 + src/commands/delete.rs | 156 ++++ src/commands/donate.rs | 34 + src/commands/help.rs | 48 ++ src/commands/info.rs | 33 + src/commands/info_cmds.rs | 183 ----- src/commands/look.rs | 119 +++ src/commands/mod.rs | 23 +- src/commands/moderation_cmds.rs | 256 ------- src/commands/multiline.rs | 69 ++ src/commands/nudge.rs | 28 + src/commands/offset.rs | 71 ++ src/commands/pause.rs | 67 ++ src/commands/remind.rs | 48 ++ src/commands/reminder_cmds.rs | 702 ------------------ .../settings/ephemeral_confirmations/mod.rs | 15 + .../set_ephemeral_confirmations.rs | 31 + .../unset_ephemeral_confirmations.rs | 31 + src/commands/settings/mod.rs | 14 + src/commands/timer/mod.rs | 14 +- src/commands/timer/timer_base.rs | 12 - src/commands/timezone.rs | 117 +++ src/commands/todo/channel/add.rs | 38 + src/commands/todo/channel/mod.rs | 16 + src/commands/todo/channel/view.rs | 38 + src/commands/todo/guild/add.rs | 31 + src/commands/todo/guild/mod.rs | 15 + src/commands/todo/guild/view.rs | 32 + src/commands/todo/mod.rs | 157 ++++ src/commands/todo/user/add.rs | 27 + src/commands/todo/user/mod.rs | 10 + src/commands/todo/user/view.rs | 26 + src/commands/todo_cmds.rs | 355 --------- src/commands/webhook.rs | 36 + src/component_models/mod.rs | 4 +- src/component_models/pager.rs | 2 +- src/main.rs | 74 +- src/models/command_macro.rs | 16 +- src/models/reminder/look_flags.rs | 23 - src/models/reminder/mod.rs | 224 +++++- src/utils.rs | 19 +- 51 files changed, 1757 insertions(+), 1699 deletions(-) create mode 100644 src/commands/allowed_dm/mod.rs create mode 100644 src/commands/allowed_dm/set_allowed_dm.rs create mode 100644 src/commands/allowed_dm/unset_allowed_dm.rs create mode 100644 src/commands/clock.rs create mode 100644 src/commands/clock_context_menu.rs delete mode 100644 src/commands/command_macro/macro_base.rs delete mode 100644 src/commands/command_proc/mod.rs create mode 100644 src/commands/dashboard.rs create mode 100644 src/commands/delete.rs create mode 100644 src/commands/donate.rs create mode 100644 src/commands/help.rs create mode 100644 src/commands/info.rs delete mode 100644 src/commands/info_cmds.rs create mode 100644 src/commands/look.rs delete mode 100644 src/commands/moderation_cmds.rs create mode 100644 src/commands/multiline.rs create mode 100644 src/commands/nudge.rs create mode 100644 src/commands/offset.rs create mode 100644 src/commands/pause.rs create mode 100644 src/commands/remind.rs delete mode 100644 src/commands/reminder_cmds.rs create mode 100644 src/commands/settings/ephemeral_confirmations/mod.rs create mode 100644 src/commands/settings/ephemeral_confirmations/set_ephemeral_confirmations.rs create mode 100644 src/commands/settings/ephemeral_confirmations/unset_ephemeral_confirmations.rs create mode 100644 src/commands/settings/mod.rs delete mode 100644 src/commands/timer/timer_base.rs create mode 100644 src/commands/timezone.rs create mode 100644 src/commands/todo/channel/add.rs create mode 100644 src/commands/todo/channel/mod.rs create mode 100644 src/commands/todo/channel/view.rs create mode 100644 src/commands/todo/guild/add.rs create mode 100644 src/commands/todo/guild/mod.rs create mode 100644 src/commands/todo/guild/view.rs create mode 100644 src/commands/todo/mod.rs create mode 100644 src/commands/todo/user/add.rs create mode 100644 src/commands/todo/user/mod.rs create mode 100644 src/commands/todo/user/view.rs delete mode 100644 src/commands/todo_cmds.rs create mode 100644 src/commands/webhook.rs delete mode 100644 src/models/reminder/look_flags.rs diff --git a/Cargo.lock b/Cargo.lock index cb27f75..6a7bd16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2316,8 +2316,6 @@ dependencies = [ "num-integer", "poise", "postman", - "proc-macro2", - "quote", "rand", "regex", "reminder_web", @@ -2328,7 +2326,6 @@ dependencies = [ "serde_json", "serde_repr", "sqlx", - "syn 2.0.49", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 987c79c..27cec1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,6 @@ license = "AGPL-3.0 only" description = "Reminder Bot for Discord, now in Rust" [dependencies] -quote = "1.0.35" -proc-macro2 = "1.0.78" -syn = { version = "2.0.49", features = ["full"] } poise = "0.6.1" dotenv = "0.15" tokio = { version = "1", features = ["process", "full"] } diff --git a/src/commands/allowed_dm/mod.rs b/src/commands/allowed_dm/mod.rs new file mode 100644 index 0000000..6fed3ea --- /dev/null +++ b/src/commands/allowed_dm/mod.rs @@ -0,0 +1,10 @@ +pub mod set_allowed_dm; +pub mod unset_allowed_dm; + +use crate::{Context, Error}; + +/// Configure whether other users can set reminders to your direct messages +#[poise::command(slash_command, rename = "dm")] +pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} diff --git a/src/commands/allowed_dm/set_allowed_dm.rs b/src/commands/allowed_dm/set_allowed_dm.rs new file mode 100644 index 0000000..4fd463b --- /dev/null +++ b/src/commands/allowed_dm/set_allowed_dm.rs @@ -0,0 +1,23 @@ +use poise::{serenity_prelude::CreateEmbed, CreateReply}; + +use crate::{consts::THEME_COLOR, models::CtxData, Context, Error}; + +/// Allow other users to set reminders in your direct messages +#[poise::command(slash_command, rename = "allow", identifying_name = "set_allowed_dm")] +pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { + let mut user_data = ctx.author_data().await?; + user_data.allowed_dm = true; + user_data.commit_changes(&ctx.data().database).await; + + ctx.send( + CreateReply::default().ephemeral(true).embed( + CreateEmbed::new() + .title("DMs permitted") + .description("You will receive a message if a user sets a DM reminder for you.") + .color(*THEME_COLOR), + ), + ) + .await?; + + Ok(()) +} diff --git a/src/commands/allowed_dm/unset_allowed_dm.rs b/src/commands/allowed_dm/unset_allowed_dm.rs new file mode 100644 index 0000000..76cd523 --- /dev/null +++ b/src/commands/allowed_dm/unset_allowed_dm.rs @@ -0,0 +1,25 @@ +use poise::{serenity_prelude::CreateEmbed, CreateReply}; + +use crate::{consts::THEME_COLOR, models::CtxData, Context, Error}; + +/// Block other users from setting reminders in your direct messages +#[poise::command(slash_command, rename = "block", identifying_name = "unset_allowed_dm")] +pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { + let mut user_data = ctx.author_data().await?; + user_data.allowed_dm = false; + user_data.commit_changes(&ctx.data().database).await; + + ctx.send( + CreateReply::default().ephemeral(true).embed( + CreateEmbed::new() + .title("DMs blocked") + .description( + "You can still set DM reminders for yourself or for users with DMs enabled.", + ) + .color(*THEME_COLOR), + ), + ) + .await?; + + Ok(()) +} diff --git a/src/commands/clock.rs b/src/commands/clock.rs new file mode 100644 index 0000000..6e13ebb --- /dev/null +++ b/src/commands/clock.rs @@ -0,0 +1,22 @@ +use chrono::Utc; +use poise::CreateReply; + +use crate::{models::CtxData, Context, Error}; + +/// View the current time in your selected timezone +#[poise::command(slash_command)] +pub async fn clock(ctx: Context<'_>) -> Result<(), Error> { + ctx.defer_ephemeral().await?; + + 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?; + + Ok(()) +} diff --git a/src/commands/clock_context_menu.rs b/src/commands/clock_context_menu.rs new file mode 100644 index 0000000..8eac70b --- /dev/null +++ b/src/commands/clock_context_menu.rs @@ -0,0 +1,27 @@ +use chrono::Utc; +use poise::{ + serenity_prelude::{Mentionable, User}, + CreateReply, +}; + +use crate::{models::CtxData, Context, Error}; + +/// View the current time in a user's selected timezone +#[poise::command(context_menu_command = "View Local Time")] +pub async fn clock_context_menu(ctx: Context<'_>, user: User) -> Result<(), Error> { + ctx.defer_ephemeral().await?; + + let user_data = ctx.user_data(user.id).await?; + let tz = user_data.timezone(); + + let now = Utc::now().with_timezone(&tz); + + ctx.send(CreateReply::default().ephemeral(true).content(format!( + "Time in {}'s timezone: `{}`", + user.mention(), + now.format("%H:%M") + ))) + .await?; + + Ok(()) +} diff --git a/src/commands/command_macro/macro_base.rs b/src/commands/command_macro/macro_base.rs deleted file mode 100644 index 733199a..0000000 --- a/src/commands/command_macro/macro_base.rs +++ /dev/null @@ -1,13 +0,0 @@ -use crate::{Context, Error}; - -/// Record and replay command sequences -#[poise::command( - slash_command, - rename = "macro", - guild_only = true, - default_member_permissions = "MANAGE_GUILD", - identifying_name = "macro_base" -)] -pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> { - Ok(()) -} diff --git a/src/commands/command_macro/mod.rs b/src/commands/command_macro/mod.rs index ecaddf0..0be6810 100644 --- a/src/commands/command_macro/mod.rs +++ b/src/commands/command_macro/mod.rs @@ -1,6 +1,18 @@ pub mod delete_macro; pub mod finish_macro; pub mod list_macro; -pub mod macro_base; pub mod record_macro; pub mod run_macro; +use crate::{Context, Error}; + +/// Record and replay command sequences +#[poise::command( + slash_command, + rename = "macro", + guild_only = true, + default_member_permissions = "MANAGE_GUILD", + identifying_name = "macro_base" +)] +pub async fn command_macro(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} diff --git a/src/commands/command_proc/mod.rs b/src/commands/command_proc/mod.rs deleted file mode 100644 index 373e380..0000000 --- a/src/commands/command_proc/mod.rs +++ /dev/null @@ -1,80 +0,0 @@ -use proc_macro2::{Ident, Span, TokenStream}; -use serde::{de::DeserializeOwned, Serialize}; -use syn::{punctuated::Punctuated, token::Comma, FnArg, Pat}; - -struct RecordableCommand { - args: T, - func: dyn Fn(T) -> R, -} - -/// Takes a function and produces a serializable struct of its args. -pub fn arg_struct(mut function: syn::ItemFn) -> TokenStream { - let struct_name = Ident::new(&format!("{}_args", function.sig.ident), Span::call_site()); - let wrapped_fn_name = Ident::new(&format!("{}_fn", function.sig.ident), Span::call_site()); - - let fn_name = &function.sig.ident; - let fn_generics = &function.sig.generics; - let fn_inputs = &function.sig.inputs; - let fn_args = &function - .sig - .inputs - .iter() - .map(|arg| match arg { - FnArg::Receiver(_) => { - panic!("Can't accept Receiver arg") - } - FnArg::Typed(p) => p.pat.clone(), - }) - .collect::, Comma>>(); - let fn_retval = &function.sig.output; - let fn_body = &function.block; - - quote::quote! { - pub async fn #fn_name #fn_generics(#fn_inputs) -> #fn_retval { - #wrapped_fn_name(#fn_args).await - } - - pub async fn #wrapped_fn_name #fn_generics(#fn_inputs) -> #fn_retval { - #fn_body - } - - } -} - -/* - -#[poise] -#[wrapper] -pub async fn command(...args) { - ...block -} - - ... becomes ... - -#[poise] -fn command(...args) { - command_fn( - ...args - ) -} - -struct RecordableCommand { - args: T - func: Func -} - -impl Execute for RecordableCommand { - fn execute() { - // Unpack self.args into self.func - } -} - -struct command_args { - ...args -} - -fn command_fn(...args) { - ...block -} - - */ diff --git a/src/commands/dashboard.rs b/src/commands/dashboard.rs new file mode 100644 index 0000000..15a5987 --- /dev/null +++ b/src/commands/dashboard.rs @@ -0,0 +1,22 @@ +use poise::{serenity_prelude::CreateEmbed, CreateReply}; + +use crate::{consts::THEME_COLOR, utils::footer, Context, Error}; + +/// Get the link to the online dashboard +#[poise::command(slash_command)] +pub async fn dashboard(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?; + + Ok(()) +} diff --git a/src/commands/delete.rs b/src/commands/delete.rs new file mode 100644 index 0000000..3555909 --- /dev/null +++ b/src/commands/delete.rs @@ -0,0 +1,156 @@ +use chrono_tz::Tz; +use poise::{ + serenity_prelude::{ + CreateActionRow, CreateEmbed, CreateEmbedFooter, CreateSelectMenu, CreateSelectMenuKind, + CreateSelectMenuOption, + }, + CreateReply, +}; + +use crate::{ + component_models::{ + pager::{DelPager, Pager}, + ComponentDataModel, DelSelector, + }, + consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR}, + models::{reminder::Reminder, CtxData}, + Context, Error, +}; + +/// Delete reminders +#[poise::command( + slash_command, + rename = "del", + identifying_name = "delete", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn delete(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 resp = show_delete_page(&reminders, 0, timezone); + + ctx.send(resp).await?; + + Ok(()) +} + +pub fn max_delete_page(reminders: &[Reminder], timezone: &Tz) -> usize { + let mut rows = 0; + let mut char_count = 0; + + reminders + .iter() + .enumerate() + .map(|(count, reminder)| reminder.display_del(count, timezone)) + .fold(1, |mut pages, reminder| { + rows += 1; + char_count += reminder.len(); + + if char_count > EMBED_DESCRIPTION_MAX_LENGTH || rows > SELECT_MAX_ENTRIES { + rows = 1; + char_count = reminder.len(); + pages += 1; + } + + pages + }) +} + +pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> CreateReply { + let pager = DelPager::new(page, timezone); + + if reminders.is_empty() { + let embed = CreateEmbed::new() + .title("Delete Reminders") + .description("No Reminders") + .color(*THEME_COLOR); + + return CreateReply::default().embed(embed).components(vec![pager.create_button_row(0)]); + } + + let pages = max_delete_page(reminders, &timezone); + + let mut page = page; + if page >= pages { + page = pages - 1; + } + + let mut char_count = 0; + let mut rows = 0; + let mut skipped_rows = 0; + let mut skipped_char_count = 0; + let mut first_num = 0; + + let mut skipped_pages = 0; + + let (shown_reminders, display_vec): (Vec<&Reminder>, Vec) = reminders + .iter() + .enumerate() + .map(|(count, reminder)| (reminder, reminder.display_del(count, &timezone))) + .skip_while(|(_, p)| { + first_num += 1; + skipped_rows += 1; + skipped_char_count += p.len(); + + if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH + || skipped_rows > SELECT_MAX_ENTRIES + { + skipped_rows = 1; + skipped_char_count = p.len(); + skipped_pages += 1; + } + + skipped_pages < page + }) + .take_while(|(_, p)| { + rows += 1; + char_count += p.len(); + + char_count < EMBED_DESCRIPTION_MAX_LENGTH && rows <= SELECT_MAX_ENTRIES + }) + .unzip(); + + let display = display_vec.join("\n"); + + let del_selector = ComponentDataModel::DelSelector(DelSelector { page, timezone }); + + let embed = CreateEmbed::new() + .title("Delete Reminders") + .description(display) + .footer(CreateEmbedFooter::new(format!("Page {} of {}", page + 1, pages))) + .color(*THEME_COLOR); + + let select_menu = CreateSelectMenu::new( + del_selector.to_custom_id(), + CreateSelectMenuKind::String { + options: shown_reminders + .iter() + .enumerate() + .map(|(count, reminder)| { + let c = reminder.display_content(); + let description = if c.len() > 100 { + format!( + "{}...", + reminder.display_content().chars().take(97).collect::() + ) + } else { + c.to_string() + }; + + CreateSelectMenuOption::new( + (count + first_num).to_string(), + reminder.id.to_string(), + ) + .description(description) + }) + .collect(), + }, + ); + + CreateReply::default() + .embed(embed) + .components(vec![pager.create_button_row(pages), CreateActionRow::SelectMenu(select_menu)]) +} diff --git a/src/commands/donate.rs b/src/commands/donate.rs new file mode 100644 index 0000000..9005453 --- /dev/null +++ b/src/commands/donate.rs @@ -0,0 +1,34 @@ +use poise::{serenity_prelude::CreateEmbed, CreateReply}; + +use crate::{consts::THEME_COLOR, utils::footer, Context, Error}; + +/// Details on supporting the bot and Patreon benefits +#[poise::command(slash_command)] +pub async fn donate(ctx: Context<'_>) -> Result<(), Error> { + let footer = footer(ctx); + + 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 :) + +**https://www.patreon.com/jellywx/** +**https://discord.jellywx.com/** + +When you subscribe, Patreon will automatically give you a role on the Discord server (make sure you link your Patreon and Discord accounts!) +With your new rank, you'll be able to: +• Set repeating reminders with `/remind` or the dashboard +• Use unlimited uploads on SoundFX + +(Also, members of servers you __own__ will be able to set repeating reminders via commands) + +Just $2 USD/month! + +*Please note, you must be in the JellyWX Discord server to receive Patreon features*") + .footer(footer) + .color(*THEME_COLOR) + ), + ) + .await?; + + Ok(()) +} diff --git a/src/commands/help.rs b/src/commands/help.rs new file mode 100644 index 0000000..bb76eac --- /dev/null +++ b/src/commands/help.rs @@ -0,0 +1,48 @@ +use poise::{serenity_prelude::CreateEmbed, CreateReply}; + +use crate::{consts::THEME_COLOR, utils::footer, Context, Error}; + +/// Get an overview of bot commands +#[poise::command(slash_command)] +pub async fn help(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__ +`/help` `/info` `/donate` `/dashboard` `/clock` +*run these commands with no options* + +__Reminder Commands__ +`/remind` - Create a new reminder that will send a message at a certain time +`/timer` - Start a timer from now, that will count time passed. Also used to view and remove timers + +__Reminder Management__ +`/del` - Delete reminders +`/look` - View reminders +`/pause` - Pause all reminders on the channel +`/offset` - Move all reminders by a certain time +`/nudge` - Move all new reminders on this channel by a certain time + +__Todo Commands__ +`/todo` - Add, view and manage the server, channel or user todo lists + +__Setup Commands__ +`/timezone` - Set your timezone (necessary for `/remind` to work properly) +`/dm allow/block` - Change your DM settings for reminders. + +__Advanced Commands__ +`/macro` - Record and replay command sequences + ", + ) + .footer(footer), + ), + ) + .await?; + + Ok(()) +} diff --git a/src/commands/info.rs b/src/commands/info.rs new file mode 100644 index 0000000..03870e9 --- /dev/null +++ b/src/commands/info.rs @@ -0,0 +1,33 @@ +use poise::{serenity_prelude::CreateEmbed, CreateReply}; + +use crate::{consts::THEME_COLOR, utils::footer, Context, Error}; + +/// Get information about the bot +#[poise::command(slash_command)] +pub async fn info(ctx: Context<'_>) -> Result<(), Error> { + let footer = footer(ctx); + + let _ = ctx + .send( + CreateReply::default().ephemeral(true).embed( + CreateEmbed::new() + .title("Info") + .description( + "Help: `/help` + +**Welcome to Reminder Bot!** +Developer: <@203532103185465344> +Icon: <@253202252821430272> +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; + + Ok(()) +} diff --git a/src/commands/info_cmds.rs b/src/commands/info_cmds.rs deleted file mode 100644 index b5bb369..0000000 --- a/src/commands/info_cmds.rs +++ /dev/null @@ -1,183 +0,0 @@ -use chrono::offset::Utc; -use poise::{ - serenity_prelude as serenity, - serenity_prelude::{CreateEmbed, CreateEmbedFooter, Mentionable}, - CreateReply, -}; - -use crate::{models::CtxData, Context, Error, THEME_COLOR}; - -fn footer(ctx: Context<'_>) -> CreateEmbedFooter { - let shard_count = ctx.serenity_context().cache.shard_count(); - let shard = ctx.serenity_context().shard_id; - - CreateEmbedFooter::new(format!( - "{}\nshard {} of {}", - concat!(env!("CARGO_PKG_NAME"), " ver ", env!("CARGO_PKG_VERSION")), - shard, - shard_count, - )) -} - -/// Get an overview of bot commands -#[poise::command(slash_command)] -pub async fn help(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__ -`/help` `/info` `/donate` `/dashboard` `/clock` -*run these commands with no options* - -__Reminder Commands__ -`/remind` - Create a new reminder that will send a message at a certain time -`/timer` - Start a timer from now, that will count time passed. Also used to view and remove timers - -__Reminder Management__ -`/del` - Delete reminders -`/look` - View reminders -`/pause` - Pause all reminders on the channel -`/offset` - Move all reminders by a certain time -`/nudge` - Move all new reminders on this channel by a certain time - -__Todo Commands__ -`/todo` - Add, view and manage the server, channel or user todo lists - -__Setup Commands__ -`/timezone` - Set your timezone (necessary for `/remind` to work properly) -`/dm allow/block` - Change your DM settings for reminders. - -__Advanced Commands__ -`/macro` - Record and replay command sequences - ", - ) - .footer(footer), - ), - ) - .await?; - - Ok(()) -} - -/// Get information about the bot -#[poise::command(slash_command)] -pub async fn info(ctx: Context<'_>) -> Result<(), Error> { - let footer = footer(ctx); - - let _ = ctx - .send( - CreateReply::default().ephemeral(true).embed( - CreateEmbed::new() - .title("Info") - .description( - "Help: `/help` - -**Welcome to Reminder Bot!** -Developer: <@203532103185465344> -Icon: <@253202252821430272> -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; - - Ok(()) -} - -/// Details on supporting the bot and Patreon benefits -#[poise::command(slash_command)] -pub async fn donate(ctx: Context<'_>) -> Result<(), Error> { - let footer = footer(ctx); - - 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 :) - -**https://www.patreon.com/jellywx/** -**https://discord.jellywx.com/** - -When you subscribe, Patreon will automatically rank you up on our Discord server (make sure you link your Patreon and Discord accounts!) -With your new rank, you'll be able to: -• Set repeating reminders with `interval`, `natural` or the dashboard -• Use unlimited uploads on SoundFX - -(Also, members of servers you __own__ will be able to set repeating reminders via commands) - -Just $2 USD/month! - -*Please note, you must be in the JellyWX Discord server to receive Patreon features*") - .footer(footer) - .color(*THEME_COLOR) - ), - ) - .await?; - - Ok(()) -} - -/// Get the link to the online dashboard -#[poise::command(slash_command)] -pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> { - let footer = footer(ctx); - - ctx.send( - CreateReply::default().ephemeral(true).embed( - CreateEmbed::new() - .title("Dashboard") - .description("**https://reminder-bot.com/dashboard**") - .footer(footer) - .color(*THEME_COLOR), - ), - ) - .await?; - - Ok(()) -} - -/// View the current time in your selected timezone -#[poise::command(slash_command)] -pub async fn clock(ctx: Context<'_>) -> Result<(), Error> { - ctx.defer_ephemeral().await?; - - 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?; - - Ok(()) -} - -/// View the current time in a user's selected timezone -#[poise::command(context_menu_command = "View Local Time")] -pub async fn clock_context_menu(ctx: Context<'_>, user: serenity::User) -> Result<(), Error> { - ctx.defer_ephemeral().await?; - - let user_data = ctx.user_data(user.id).await?; - let tz = user_data.timezone(); - - let now = Utc::now().with_timezone(&tz); - - ctx.send(CreateReply::default().ephemeral(true).content(format!( - "Time in {}'s timezone: `{}`", - user.mention(), - now.format("%H:%M") - ))) - .await?; - - Ok(()) -} diff --git a/src/commands/look.rs b/src/commands/look.rs new file mode 100644 index 0000000..4fbbcf3 --- /dev/null +++ b/src/commands/look.rs @@ -0,0 +1,119 @@ +use poise::{ + serenity_prelude::{model::id::ChannelId, Channel, CreateEmbed, CreateEmbedFooter}, + CreateReply, +}; +use serde::{Deserialize, Serialize}; +use serde_repr::*; + +use crate::{ + component_models::pager::{LookPager, Pager}, + consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, + models::{reminder::Reminder, CtxData}, + Context, Error, +}; + +#[derive(Serialize_repr, Deserialize_repr, Copy, Clone, Debug)] +#[repr(u8)] +pub enum TimeDisplayType { + Absolute = 0, + Relative = 1, +} + +#[derive(Serialize, Deserialize, Copy, Clone, Debug)] +pub struct LookFlags { + pub show_disabled: bool, + pub channel_id: Option, + pub time_display: TimeDisplayType, +} + +impl Default for LookFlags { + fn default() -> Self { + Self { show_disabled: true, channel_id: None, time_display: TimeDisplayType::Relative } + } +} + +/// View reminders on a specific channel +#[poise::command( + slash_command, + identifying_name = "look", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn look( + 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> { + let timezone = ctx.timezone().await; + + let flags = LookFlags { + show_disabled: disabled.unwrap_or(true), + channel_id: channel.map(|c| c.id()), + time_display: 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 { + 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?; + } + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9db7d75..6c2ef00 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,8 +1,21 @@ +pub mod allowed_dm; mod autocomplete; +pub mod clock; +pub mod clock_context_menu; pub mod command_macro; -mod command_proc; -pub mod info_cmds; -pub mod moderation_cmds; -pub mod reminder_cmds; +pub mod dashboard; +pub mod delete; +pub mod donate; +pub mod help; +pub mod info; +pub mod look; +pub mod multiline; +pub mod nudge; +pub mod offset; +pub mod pause; +pub mod remind; +pub mod settings; pub mod timer; -pub mod todo_cmds; +pub mod timezone; +pub mod todo; +pub mod webhook; diff --git a/src/commands/moderation_cmds.rs b/src/commands/moderation_cmds.rs deleted file mode 100644 index 9c9a521..0000000 --- a/src/commands/moderation_cmds.rs +++ /dev/null @@ -1,256 +0,0 @@ -use chrono::offset::Utc; -use chrono_tz::{Tz, TZ_VARIANTS}; -use levenshtein::levenshtein; -use log::warn; -use poise::{ - serenity_prelude::{CreateEmbed, CreateEmbedFooter}, - CreateReply, -}; - -use super::autocomplete::timezone_autocomplete; -use crate::{consts::THEME_COLOR, models::CtxData, Context, Error}; - -/// Select your timezone -#[poise::command(slash_command, identifying_name = "timezone")] -pub async fn timezone( - ctx: Context<'_>, - #[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"] - #[autocomplete = "timezone_autocomplete"] - timezone: Option, -) -> Result<(), Error> { - let mut user_data = ctx.author_data().await.unwrap(); - - let footer_text = format!("Current timezone: {}", user_data.timezone); - - if let Some(timezone) = 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); - - 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, - ) - }); - - ctx.send(CreateReply::default().embed(CreateEmbed::new() - .title("Timezone Not Recognized") - .description("Possibly you meant one of the following timezones, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):") - .color(*THEME_COLOR) - .fields(fields) - .footer(CreateEmbedFooter::new(footer_text)) - .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee") - ) - ) - .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) - }); - - ctx.send( - CreateReply::default().embed( - CreateEmbed::new() - .title("Timezone Usage") - .description( - "**Usage:** -`/timezone Name` - -**Example:** -`/timezone Europe/London` - -You may want to use one of the popular timezones below, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):", - ) - .color(*THEME_COLOR) - .fields(popular_timezones_iter) - .footer(CreateEmbedFooter::new(footer_text)) - .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"), - ), - ) - .await?; - } - - Ok(()) -} - -/// Configure server settings -#[poise::command( - slash_command, - rename = "settings", - identifying_name = "settings", - guild_only = true -)] -pub async fn settings(_ctx: Context<'_>) -> Result<(), Error> { - Ok(()) -} - -/// Configure ephemeral setup -#[poise::command( - slash_command, - rename = "ephemeral", - identifying_name = "ephemeral_confirmations", - guild_only = true -)] -pub async fn ephemeral_confirmations(_ctx: Context<'_>) -> Result<(), Error> { - Ok(()) -} - -/// Set reminder confirmations to be sent "ephemerally" (private and cleared automatically) -#[poise::command( - slash_command, - rename = "on", - identifying_name = "set_ephemeral_confirmations", - guild_only = true -)] -pub async fn set_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> { - let mut guild_data = ctx.guild_data().await.unwrap()?; - guild_data.ephemeral_confirmations = true; - guild_data.commit_changes(&ctx.data().database).await; - - ctx.send(CreateReply::default().ephemeral(true).embed(CreateEmbed::new().title("Confirmations ephemeral") - .description("Reminder confirmations will be sent privately, and removed when your client restarts.") - .color(*THEME_COLOR) - ) - ) - .await?; - - Ok(()) -} - -/// Set reminder confirmations to persist indefinitely -#[poise::command( - slash_command, - rename = "off", - identifying_name = "unset_ephemeral_confirmations", - guild_only = true -)] -pub async fn unset_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> { - let mut guild_data = ctx.guild_data().await.unwrap()?; - guild_data.ephemeral_confirmations = false; - guild_data.commit_changes(&ctx.data().database).await; - - ctx.send(CreateReply::default().ephemeral(true).embed(CreateEmbed::new().title("Confirmations public") - .description( - "Reminder confirmations will be sent as regular messages, and won't be removed automatically.", - ) - .color(*THEME_COLOR) - ) - ) - .await?; - - Ok(()) -} - -/// Configure whether other users can set reminders to your direct messages -#[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")] -pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> { - Ok(()) -} - -/// Allow other users to set reminders in your direct messages -#[poise::command(slash_command, rename = "allow", identifying_name = "set_allowed_dm")] -pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { - let mut user_data = ctx.author_data().await?; - user_data.allowed_dm = true; - user_data.commit_changes(&ctx.data().database).await; - - ctx.send( - CreateReply::default().ephemeral(true).embed( - CreateEmbed::new() - .title("DMs permitted") - .description("You will receive a message if a user sets a DM reminder for you.") - .color(*THEME_COLOR), - ), - ) - .await?; - - Ok(()) -} - -/// Block other users from setting reminders in your direct messages -#[poise::command(slash_command, rename = "block", identifying_name = "unset_allowed_dm")] -pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { - let mut user_data = ctx.author_data().await?; - user_data.allowed_dm = false; - user_data.commit_changes(&ctx.data().database).await; - - ctx.send( - CreateReply::default().ephemeral(true).embed( - CreateEmbed::new() - .title("DMs blocked") - .description( - "You can still set DM reminders for yourself or for users with DMs enabled.", - ) - .color(*THEME_COLOR), - ), - ) - .await?; - - Ok(()) -} - -/// View the webhook being used to send reminders to this channel -#[poise::command( - slash_command, - identifying_name = "webhook_url", - required_permissions = "ADMINISTRATOR" -)] -pub async fn webhook(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 { - 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(()) -} diff --git a/src/commands/multiline.rs b/src/commands/multiline.rs new file mode 100644 index 0000000..f4c0fba --- /dev/null +++ b/src/commands/multiline.rs @@ -0,0 +1,69 @@ +use chrono_tz::Tz; +use log::warn; +use poise::{CreateReply, Modal}; + +use crate::{ + commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete}, + models::reminder::create_reminder, + ApplicationContext, Context, Error, +}; + +#[derive(poise::Modal)] +#[name = "Reminder"] +struct ContentModal { + #[name = "Content"] + #[placeholder = "Message..."] + #[paragraph] + #[max_length = 2000] + content: String, +} + +/// Create a reminder with multi-line content. Press "+4 more" for other options. +#[poise::command( + slash_command, + identifying_name = "multiline", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn multiline( + ctx: ApplicationContext<'_>, + #[description = "A description of the time to set the reminder for"] + #[autocomplete = "time_hint_autocomplete"] + time: String, + #[description = "Channel or user mentions to set the reminder for"] channels: Option, + #[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"] + interval: Option, + #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"] + expires: Option, + #[description = "Set the TTS flag on the reminder message, similar to the /tts command"] + tts: Option, + #[description = "Set a timezone override for this reminder only"] + #[autocomplete = "timezone_autocomplete"] + timezone: Option, +) -> Result<(), Error> { + let tz = timezone.map(|t| t.parse::().ok()).flatten(); + let data_opt = ContentModal::execute(ctx).await?; + + match data_opt { + Some(data) => { + create_reminder( + Context::Application(ctx), + time, + data.content, + channels, + interval, + expires, + tts, + tz, + ) + .await + } + + None => { + warn!("Unexpected None encountered in /multiline"); + Ok(Context::Application(ctx) + .send(CreateReply::default().content("Unexpected error.").ephemeral(true)) + .await + .map(|_| ())?) + } + } +} diff --git a/src/commands/nudge.rs b/src/commands/nudge.rs new file mode 100644 index 0000000..f6816a5 --- /dev/null +++ b/src/commands/nudge.rs @@ -0,0 +1,28 @@ +use crate::{consts::MINUTE, models::CtxData, Context, Error}; + +/// Nudge all future reminders on this channel by a certain amount (don't use for DST! See `/offset`) +#[poise::command( + slash_command, + identifying_name = "nudge", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn nudge( + 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> { + let combined_time = minutes.map_or(0, |m| m * MINUTE as isize) + seconds.map_or(0, |s| s); + + if combined_time < i16::MIN as isize || combined_time > i16::MAX as isize { + 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; + + ctx.say(format!("Future reminders will be nudged by {} seconds", combined_time)).await?; + } + + Ok(()) +} diff --git a/src/commands/offset.rs b/src/commands/offset.rs new file mode 100644 index 0000000..b359dfb --- /dev/null +++ b/src/commands/offset.rs @@ -0,0 +1,71 @@ +use crate::{ + consts::{HOUR, MINUTE}, + Context, Error, +}; + +/// Move all reminders in the current server by a certain amount of time. Times get added together +#[poise::command( + slash_command, + identifying_name = "offset", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn offset( + 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> { + ctx.defer().await?; + + let combined_time = hours.map_or(0, |h| h * HOUR as isize) + + minutes.map_or(0, |m| m * MINUTE as isize) + + 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!( + " + 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!( + " + 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(); + } + + ctx.say(format!("All reminders offset by {} seconds", combined_time)).await?; + } + + Ok(()) +} diff --git a/src/commands/pause.rs b/src/commands/pause.rs new file mode 100644 index 0000000..158a387 --- /dev/null +++ b/src/commands/pause.rs @@ -0,0 +1,67 @@ +use chrono::NaiveDateTime; + +use crate::{models::CtxData, time_parser::natural_parser, Context, Error}; + +/// Pause all reminders on the current channel until a certain time or indefinitely +#[poise::command( + slash_command, + identifying_name = "pause", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn pause( + ctx: Context<'_>, + #[description = "When to pause until"] until: Option, +) -> Result<(), Error> { + let timezone = ctx.timezone().await; + + let mut channel = ctx.channel_data().await.unwrap(); + + match 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); + + channel.commit_changes(&ctx.data().database).await; + + ctx.say(format!( + "Reminders in this channel have been silenced until ****", + timestamp + )) + .await?; + } + + 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?; + } + } + } + + Ok(()) +} diff --git a/src/commands/remind.rs b/src/commands/remind.rs new file mode 100644 index 0000000..8141986 --- /dev/null +++ b/src/commands/remind.rs @@ -0,0 +1,48 @@ +use chrono_tz::Tz; +use serde::{Deserialize, Serialize}; + +use crate::{ + commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete}, + models::reminder::create_reminder, + ApplicationContext, Context, Error, +}; + +#[derive(Serialize, Deserialize, Default)] +pub struct RemindOptions { + pub time: String, + pub content: String, + pub channels: Option, + pub interval: Option, + pub expires: Option, + pub tts: Option, + pub timezone: Option, +} + +/// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content. +#[poise::command( + slash_command, + identifying_name = "remind", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn remind( + ctx: ApplicationContext<'_>, + #[description = "The time (and optionally date) to set the reminder for"] + #[autocomplete = "time_hint_autocomplete"] + time: String, + #[description = "The message content to send"] content: String, + #[description = "Channel or user mentions to set the reminder for"] channels: Option, + #[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"] + interval: Option, + #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"] + expires: Option, + #[description = "Set the TTS flag on the reminder message, similar to the /tts command"] + tts: Option, + #[description = "Set a timezone override for this reminder only"] + #[autocomplete = "timezone_autocomplete"] + timezone: Option, +) -> Result<(), Error> { + let tz = timezone.map(|t| t.parse::().ok()).flatten(); + + create_reminder(Context::Application(ctx), time, content, channels, interval, expires, tts, tz) + .await +} diff --git a/src/commands/reminder_cmds.rs b/src/commands/reminder_cmds.rs deleted file mode 100644 index 4239a62..0000000 --- a/src/commands/reminder_cmds.rs +++ /dev/null @@ -1,702 +0,0 @@ -use std::{collections::HashSet, string::ToString}; - -use chrono::NaiveDateTime; -use chrono_tz::Tz; -use log::warn; -use poise::{ - serenity_prelude::{ - builder::CreateEmbed, model::channel::Channel, ButtonStyle, CreateActionRow, CreateButton, - CreateEmbedFooter, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, - ReactionType, - }, - CreateReply, Modal, -}; - -use crate::{ - commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete}, - component_models::{ - pager::{DelPager, LookPager, Pager}, - ComponentDataModel, DelSelector, UndoReminder, - }, - consts::{ - EMBED_DESCRIPTION_MAX_LENGTH, HOUR, MINUTE, REGEX_CHANNEL_USER, SELECT_MAX_ENTRIES, - THEME_COLOR, - }, - interval_parser::parse_duration, - models::{ - reminder::{ - builder::{MultiReminderBuilder, ReminderScope}, - content::Content, - errors::ReminderError, - look_flags::{LookFlags, TimeDisplayType}, - Reminder, - }, - CtxData, - }, - time_parser::natural_parser, - utils::{check_guild_subscription, check_subscription}, - ApplicationContext, Context, Error, -}; - -/// Pause all reminders on the current channel until a certain time or indefinitely -#[poise::command( - slash_command, - identifying_name = "pause", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn pause( - ctx: Context<'_>, - #[description = "When to pause until"] until: Option, -) -> Result<(), Error> { - let timezone = ctx.timezone().await; - - let mut channel = ctx.channel_data().await.unwrap(); - - match 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); - - channel.commit_changes(&ctx.data().database).await; - - ctx.say(format!( - "Reminders in this channel have been silenced until ****", - timestamp - )) - .await?; - } - - None => { - ctx.say(format!( - "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?; - } - } - } - - Ok(()) -} - -/// Move all reminders in the current server by a certain amount of time. Times get added together -#[poise::command( - slash_command, - identifying_name = "offset", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn offset( - 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> { - ctx.defer().await?; - - let combined_time = hours.map_or(0, |h| h * HOUR as isize) - + minutes.map_or(0, |m| m * MINUTE as isize) - + 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!( - " - 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!( - " - 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(); - } - - ctx.say(format!("All reminders offset by {} seconds", combined_time)).await?; - } - - Ok(()) -} - -/// Nudge all future reminders on this channel by a certain amount (don't use for DST! See `/offset`) -#[poise::command( - slash_command, - identifying_name = "nudge", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn nudge( - 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> { - let combined_time = minutes.map_or(0, |m| m * MINUTE as isize) + seconds.map_or(0, |s| s); - - if combined_time < i16::MIN as isize || combined_time > i16::MAX as isize { - 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; - - ctx.say(format!("Future reminders will be nudged by {} seconds", combined_time)).await?; - } - - Ok(()) -} - -/// View reminders on a specific channel -#[poise::command( - slash_command, - identifying_name = "look", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn look( - 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> { - let timezone = ctx.timezone().await; - - let flags = LookFlags { - show_disabled: disabled.unwrap_or(true), - channel_id: channel.map(|c| c.id()), - time_display: 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 { - 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?; - } - - Ok(()) -} - -/// Delete reminders -#[poise::command( - slash_command, - rename = "del", - identifying_name = "delete", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn delete(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 resp = show_delete_page(&reminders, 0, timezone); - - ctx.send(resp).await?; - - Ok(()) -} - -pub fn max_delete_page(reminders: &[Reminder], timezone: &Tz) -> usize { - let mut rows = 0; - let mut char_count = 0; - - reminders - .iter() - .enumerate() - .map(|(count, reminder)| reminder.display_del(count, timezone)) - .fold(1, |mut pages, reminder| { - rows += 1; - char_count += reminder.len(); - - if char_count > EMBED_DESCRIPTION_MAX_LENGTH || rows > SELECT_MAX_ENTRIES { - rows = 1; - char_count = reminder.len(); - pages += 1; - } - - pages - }) -} - -pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> CreateReply { - let pager = DelPager::new(page, timezone); - - if reminders.is_empty() { - let embed = CreateEmbed::new() - .title("Delete Reminders") - .description("No Reminders") - .color(*THEME_COLOR); - - return CreateReply::default().embed(embed).components(vec![pager.create_button_row(0)]); - } - - let pages = max_delete_page(reminders, &timezone); - - let mut page = page; - if page >= pages { - page = pages - 1; - } - - let mut char_count = 0; - let mut rows = 0; - let mut skipped_rows = 0; - let mut skipped_char_count = 0; - let mut first_num = 0; - - let mut skipped_pages = 0; - - let (shown_reminders, display_vec): (Vec<&Reminder>, Vec) = reminders - .iter() - .enumerate() - .map(|(count, reminder)| (reminder, reminder.display_del(count, &timezone))) - .skip_while(|(_, p)| { - first_num += 1; - skipped_rows += 1; - skipped_char_count += p.len(); - - if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH - || skipped_rows > SELECT_MAX_ENTRIES - { - skipped_rows = 1; - skipped_char_count = p.len(); - skipped_pages += 1; - } - - skipped_pages < page - }) - .take_while(|(_, p)| { - rows += 1; - char_count += p.len(); - - char_count < EMBED_DESCRIPTION_MAX_LENGTH && rows <= SELECT_MAX_ENTRIES - }) - .unzip(); - - let display = display_vec.join("\n"); - - let del_selector = ComponentDataModel::DelSelector(DelSelector { page, timezone }); - - let embed = CreateEmbed::new() - .title("Delete Reminders") - .description(display) - .footer(CreateEmbedFooter::new(format!("Page {} of {}", page + 1, pages))) - .color(*THEME_COLOR); - - let select_menu = CreateSelectMenu::new( - del_selector.to_custom_id(), - CreateSelectMenuKind::String { - options: shown_reminders - .iter() - .enumerate() - .map(|(count, reminder)| { - let c = reminder.display_content(); - let description = if c.len() > 100 { - format!( - "{}...", - reminder.display_content().chars().take(97).collect::() - ) - } else { - c.to_string() - }; - - CreateSelectMenuOption::new( - (count + first_num).to_string(), - reminder.id.to_string(), - ) - .description(description) - }) - .collect(), - }, - ); - - CreateReply::default() - .embed(embed) - .components(vec![pager.create_button_row(pages), CreateActionRow::SelectMenu(select_menu)]) -} - -#[derive(poise::Modal)] -#[name = "Reminder"] -struct ContentModal { - #[name = "Content"] - #[placeholder = "Message..."] - #[paragraph] - #[max_length = 2000] - content: String, -} - -/// Create a reminder with multi-line content. Press "+4 more" for other options. -#[poise::command( - slash_command, - identifying_name = "multiline", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn multiline( - ctx: ApplicationContext<'_>, - #[description = "A description of the time to set the reminder for"] - #[autocomplete = "time_hint_autocomplete"] - time: String, - #[description = "Channel or user mentions to set the reminder for"] channels: Option, - #[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"] - interval: Option, - #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"] - expires: Option, - #[description = "Set the TTS flag on the reminder message, similar to the /tts command"] - tts: Option, - #[description = "Set a timezone override for this reminder only"] - #[autocomplete = "timezone_autocomplete"] - timezone: Option, -) -> Result<(), Error> { - let tz = timezone.map(|t| t.parse::().ok()).flatten(); - let data_opt = ContentModal::execute(ctx).await?; - - match data_opt { - Some(data) => { - create_reminder( - Context::Application(ctx), - time, - data.content, - channels, - interval, - expires, - tts, - tz, - ) - .await - } - - None => { - warn!("Unexpected None encountered in /multiline"); - Ok(Context::Application(ctx) - .send(CreateReply::default().content("Unexpected error.").ephemeral(true)) - .await - .map(|_| ())?) - } - } -} - -/// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content. -#[poise::command( - slash_command, - identifying_name = "remind", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn remind( - ctx: ApplicationContext<'_>, - #[description = "The time (and optionally date) to set the reminder for"] - #[autocomplete = "time_hint_autocomplete"] - time: String, - #[description = "The message content to send"] content: String, - #[description = "Channel or user mentions to set the reminder for"] channels: Option, - #[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"] - interval: Option, - #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"] - expires: Option, - #[description = "Set the TTS flag on the reminder message, similar to the /tts command"] - tts: Option, - #[description = "Set a timezone override for this reminder only"] - #[autocomplete = "timezone_autocomplete"] - timezone: Option, -) -> Result<(), Error> { - let tz = timezone.map(|t| t.parse::().ok()).flatten(); - - create_reminder(Context::Application(ctx), time, content, channels, interval, expires, tts, tz) - .await -} - -pub async fn create_reminder( - ctx: Context<'_>, - time: String, - content: String, - channels: Option, - interval: Option, - expires: Option, - tts: Option, - timezone: Option, -) -> Result<(), Error> { - if interval.is_none() && expires.is_some() { - ctx.say("`expires` can only be used with `interval`").await?; - - return Ok(()); - } - - let ephemeral = - ctx.guild_data().await.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations)); - if ephemeral { - ctx.defer_ephemeral().await?; - } else { - ctx.defer().await?; - } - - let user_data = ctx.author_data().await.unwrap(); - let timezone = timezone.unwrap_or(ctx.timezone().await); - - let time = natural_parser(&time, &timezone.to_string()).await; - - match time { - Some(time) => { - let content = { - let tts = tts.unwrap_or(false); - - Content { content, tts, attachment: None, attachment_name: None } - }; - - let scopes = { - let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default(); - - if list.is_empty() { - if ctx.guild_id().is_some() { - vec![ReminderScope::Channel(ctx.channel_id().get())] - } else { - vec![ReminderScope::User(ctx.author().id.get())] - } - } else { - list - } - }; - - let (processed_interval, processed_expires) = if let Some(repeat) = &interval { - if check_subscription(&ctx, ctx.author().id).await - || (ctx.guild_id().is_some() - && check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await) - { - ( - parse_duration(repeat) - .or_else(|_| parse_duration(&format!("1 {}", repeat))) - .ok(), - { - if let Some(arg) = &expires { - natural_parser(arg, &timezone.to_string()).await - } else { - None - } - }, - ) - } else { - ctx.send(CreateReply::default().content( - "`repeat` is only available to Patreon subscribers or self-hosted users", - )) - .await?; - - return Ok(()); - } - } else { - (None, None) - }; - - if processed_interval.is_none() && interval.is_some() { - ctx.send(CreateReply::default().content( - "Repeat interval could not be processed. Try similar to `1 hour` or `4 days`", - )) - .await?; - } else if processed_expires.is_none() && expires.is_some() { - ctx.send( - CreateReply::default().ephemeral(true).content( - "Expiry time failed to process. Please make it as clear as possible", - ), - ) - .await?; - } else { - let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id()) - .author(user_data) - .content(content) - .time(time) - .timezone(timezone) - .expires(processed_expires) - .interval(processed_interval); - - builder.set_scopes(scopes); - - let (errors, successes) = builder.build().await; - - let embed = create_response(&successes, &errors, time); - - if successes.len() == 1 { - let reminder = successes.iter().next().map(|(r, _)| r.id).unwrap(); - let undo_button = ComponentDataModel::UndoReminder(UndoReminder { - user_id: ctx.author().id, - reminder_id: reminder, - }); - - ctx.send(CreateReply::default().embed(embed).components(vec![ - CreateActionRow::Buttons(vec![ - CreateButton::new(undo_button.to_custom_id()) - .emoji(ReactionType::Unicode("🔕".to_string())) - .label("Cancel") - .style(ButtonStyle::Danger), - CreateButton::new_link("https://beta.reminder-bot.com/dashboard") - .emoji(ReactionType::Unicode("📝".to_string())) - .label("Edit"), - ]), - ])) - .await?; - } else { - ctx.send(CreateReply::default().embed(embed)).await?; - } - } - } - - None => { - ctx.say("Time could not be processed").await?; - } - } - - Ok(()) -} - -fn create_response( - successes: &HashSet<(Reminder, ReminderScope)>, - errors: &HashSet, - time: i64, -) -> CreateEmbed { - let success_part = match successes.len() { - 0 => "".to_string(), - 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 - ), - }; - - 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.to_string()).collect::>().join("\n") - ), - }; - - CreateEmbed::default() - .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) -} - -fn parse_mention_list(mentions: &str) -> Vec { - REGEX_CHANNEL_USER - .captures_iter(mentions) - .map(|i| { - let pref = i.get(1).unwrap().as_str(); - let id = i.get(2).unwrap().as_str().parse::().unwrap(); - - if pref == "#" { - ReminderScope::Channel(id) - } else { - ReminderScope::User(id) - } - }) - .collect::>() -} diff --git a/src/commands/settings/ephemeral_confirmations/mod.rs b/src/commands/settings/ephemeral_confirmations/mod.rs new file mode 100644 index 0000000..74a23ce --- /dev/null +++ b/src/commands/settings/ephemeral_confirmations/mod.rs @@ -0,0 +1,15 @@ +use crate::{Context, Error}; + +pub mod set_ephemeral_confirmations; +pub mod unset_ephemeral_confirmations; + +/// Configure ephemeral setup +#[poise::command( + slash_command, + rename = "ephemeral", + identifying_name = "ephemeral_confirmations", + guild_only = true +)] +pub async fn ephemeral_confirmations(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} diff --git a/src/commands/settings/ephemeral_confirmations/set_ephemeral_confirmations.rs b/src/commands/settings/ephemeral_confirmations/set_ephemeral_confirmations.rs new file mode 100644 index 0000000..cd21966 --- /dev/null +++ b/src/commands/settings/ephemeral_confirmations/set_ephemeral_confirmations.rs @@ -0,0 +1,31 @@ +use poise::{serenity_prelude::CreateEmbed, CreateReply}; + +use crate::{consts::THEME_COLOR, models::CtxData, Context, Error}; + +/// Set reminder confirmations to be sent "ephemerally" (private and cleared automatically) +#[poise::command( + slash_command, + rename = "on", + identifying_name = "set_ephemeral_confirmations", + guild_only = true +)] +pub async fn set_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> { + let mut guild_data = ctx.guild_data().await.unwrap()?; + guild_data.ephemeral_confirmations = true; + guild_data.commit_changes(&ctx.data().database).await; + + ctx.send( + CreateReply::default().ephemeral(true).embed( + CreateEmbed::new() + .title("Confirmations ephemeral") + .description(concat!( + "Reminder confirmations will be sent privately, and removed when your client", + " restarts." + )) + .color(*THEME_COLOR), + ), + ) + .await?; + + Ok(()) +} diff --git a/src/commands/settings/ephemeral_confirmations/unset_ephemeral_confirmations.rs b/src/commands/settings/ephemeral_confirmations/unset_ephemeral_confirmations.rs new file mode 100644 index 0000000..7331962 --- /dev/null +++ b/src/commands/settings/ephemeral_confirmations/unset_ephemeral_confirmations.rs @@ -0,0 +1,31 @@ +use poise::{serenity_prelude::CreateEmbed, CreateReply}; + +use crate::{consts::THEME_COLOR, models::CtxData, Context, Error}; + +/// Set reminder confirmations to persist indefinitely +#[poise::command( + slash_command, + rename = "off", + identifying_name = "unset_ephemeral_confirmations", + guild_only = true +)] +pub async fn unset_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> { + let mut guild_data = ctx.guild_data().await.unwrap()?; + guild_data.ephemeral_confirmations = false; + guild_data.commit_changes(&ctx.data().database).await; + + ctx.send( + CreateReply::default().ephemeral(true).embed( + CreateEmbed::new() + .title("Confirmations public") + .description(concat!( + "Reminder confirmations will be sent as regular messages, and won't be ", + "removed automatically." + )) + .color(*THEME_COLOR), + ), + ) + .await?; + + Ok(()) +} diff --git a/src/commands/settings/mod.rs b/src/commands/settings/mod.rs new file mode 100644 index 0000000..c5989ae --- /dev/null +++ b/src/commands/settings/mod.rs @@ -0,0 +1,14 @@ +use crate::{Context, Error}; + +pub mod ephemeral_confirmations; + +/// Configure server settings +#[poise::command( + slash_command, + rename = "settings", + identifying_name = "settings", + guild_only = true +)] +pub async fn settings(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} diff --git a/src/commands/timer/mod.rs b/src/commands/timer/mod.rs index 40128bb..799d412 100644 --- a/src/commands/timer/mod.rs +++ b/src/commands/timer/mod.rs @@ -1,4 +1,16 @@ pub mod delete_timer; pub mod list_timer; pub mod start_timer; -pub mod timer_base; + +use crate::{Context, Error}; + +/// Manage timers +#[poise::command( + slash_command, + rename = "timer", + identifying_name = "timer_base", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn timer(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} diff --git a/src/commands/timer/timer_base.rs b/src/commands/timer/timer_base.rs deleted file mode 100644 index 9baf15f..0000000 --- a/src/commands/timer/timer_base.rs +++ /dev/null @@ -1,12 +0,0 @@ -use crate::{Context, Error}; - -/// Manage timers -#[poise::command( - slash_command, - rename = "timer", - identifying_name = "timer_base", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn timer_base(_ctx: Context<'_>) -> Result<(), Error> { - Ok(()) -} diff --git a/src/commands/timezone.rs b/src/commands/timezone.rs new file mode 100644 index 0000000..452fa50 --- /dev/null +++ b/src/commands/timezone.rs @@ -0,0 +1,117 @@ +use chrono::Utc; +use chrono_tz::{Tz, TZ_VARIANTS}; +use levenshtein::levenshtein; +use poise::{ + serenity_prelude::{CreateEmbed, CreateEmbedFooter}, + CreateReply, +}; + +use crate::{ + commands::autocomplete::timezone_autocomplete, consts::THEME_COLOR, models::CtxData, Context, + Error, +}; + +/// Select your timezone +#[poise::command(slash_command, identifying_name = "timezone")] +pub async fn timezone( + ctx: Context<'_>, + #[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"] + #[autocomplete = "timezone_autocomplete"] + timezone: Option, +) -> Result<(), Error> { + let mut user_data = ctx.author_data().await.unwrap(); + + let footer_text = format!("Current timezone: {}", user_data.timezone); + + if let Some(timezone) = 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); + + 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, + ) + }); + + ctx.send( + CreateReply::default().embed( + CreateEmbed::new() + .title("Timezone Not Recognized") + .description(concat!( + "Possibly you meant one of the following timezones,", + " otherwise click [here](https://gist.github.com/JellyWX/", + "913dfc8b63d45192ad6cb54c829324ee):" + )) + .color(*THEME_COLOR) + .fields(fields) + .footer(CreateEmbedFooter::new(footer_text)) + .url( + "https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee", + ), + ), + ) + .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) + }); + + ctx.send( + CreateReply::default().embed( + CreateEmbed::new() + .title("Timezone Usage") + .description( + "**Usage:** +`/timezone Name` + +**Example:** +`/timezone Europe/London` + +You may want to use one of the popular timezones below, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):", + ) + .color(*THEME_COLOR) + .fields(popular_timezones_iter) + .footer(CreateEmbedFooter::new(footer_text)) + .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"), + ), + ) + .await?; + } + + Ok(()) +} diff --git a/src/commands/todo/channel/add.rs b/src/commands/todo/channel/add.rs new file mode 100644 index 0000000..0e31de5 --- /dev/null +++ b/src/commands/todo/channel/add.rs @@ -0,0 +1,38 @@ +use crate::{models::CtxData, Context, Error}; + +/// Add an item to the channel todo list +#[poise::command( + slash_command, + rename = "add", + guild_only = true, + identifying_name = "todo_channel_add", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn add( + ctx: Context<'_>, + #[description = "The task to add to the todo list"] task: String, +) -> Result<(), Error> { + // ensure channel is cached + let _ = ctx.channel_data().await; + + sqlx::query!( + " + INSERT INTO todos (guild_id, channel_id, value) + VALUES ( + (SELECT id FROM guilds WHERE guild = ?), + (SELECT id FROM channels WHERE channel = ?), + ? + ) + ", + ctx.guild_id().unwrap().get(), + ctx.channel_id().get(), + task + ) + .execute(&ctx.data().database) + .await + .unwrap(); + + ctx.say("Item added to todo list").await?; + + Ok(()) +} diff --git a/src/commands/todo/channel/mod.rs b/src/commands/todo/channel/mod.rs new file mode 100644 index 0000000..2563c1f --- /dev/null +++ b/src/commands/todo/channel/mod.rs @@ -0,0 +1,16 @@ +pub mod add; +pub mod view; + +use crate::{Context, Error}; + +/// Manage the channel todo list +#[poise::command( + slash_command, + rename = "channel", + guild_only = true, + identifying_name = "todo_channel_base", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn channel(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} diff --git a/src/commands/todo/channel/view.rs b/src/commands/todo/channel/view.rs new file mode 100644 index 0000000..3680912 --- /dev/null +++ b/src/commands/todo/channel/view.rs @@ -0,0 +1,38 @@ +use crate::{commands::todo::show_todo_page, Context, Error}; + +/// View and remove from the channel todo list +#[poise::command( + slash_command, + rename = "view", + guild_only = true, + identifying_name = "todo_channel_view", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn view(ctx: Context<'_>) -> Result<(), Error> { + let values = sqlx::query!( + " + SELECT todos.id, value FROM todos + INNER JOIN channels ON todos.channel_id = channels.id + WHERE channels.channel = ? + ", + ctx.channel_id().get(), + ) + .fetch_all(&ctx.data().database) + .await + .unwrap() + .iter() + .map(|row| (row.id as usize, row.value.clone())) + .collect::>(); + + let resp = show_todo_page( + &values, + 0, + None, + Some(ctx.channel_id().get()), + ctx.guild_id().map(|g| g.get()), + ); + + ctx.send(resp).await?; + + Ok(()) +} diff --git a/src/commands/todo/guild/add.rs b/src/commands/todo/guild/add.rs new file mode 100644 index 0000000..97cd262 --- /dev/null +++ b/src/commands/todo/guild/add.rs @@ -0,0 +1,31 @@ +use crate::{Context, Error}; + +/// Add an item to the server todo list +#[poise::command( + slash_command, + rename = "add", + guild_only = true, + identifying_name = "todo_guild_add", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn add( + ctx: Context<'_>, + #[description = "The task to add to the todo list"] task: String, +) -> Result<(), Error> { + sqlx::query!( + " + INSERT INTO todos (guild_id, value) + VALUES ( + (SELECT id FROM guilds WHERE guild = ?), ? + )", + ctx.guild_id().unwrap().get(), + task + ) + .execute(&ctx.data().database) + .await + .unwrap(); + + ctx.say("Item added to todo list").await?; + + Ok(()) +} diff --git a/src/commands/todo/guild/mod.rs b/src/commands/todo/guild/mod.rs new file mode 100644 index 0000000..a7b577f --- /dev/null +++ b/src/commands/todo/guild/mod.rs @@ -0,0 +1,15 @@ +pub mod add; +pub mod view; + +use crate::{Context, Error}; + +/// Manage the server todo list +#[poise::command( + slash_command, + rename = "server", + guild_only = true, + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn guild(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} diff --git a/src/commands/todo/guild/view.rs b/src/commands/todo/guild/view.rs new file mode 100644 index 0000000..f555abc --- /dev/null +++ b/src/commands/todo/guild/view.rs @@ -0,0 +1,32 @@ +use crate::{commands::todo::show_todo_page, Context, Error}; + +/// View and remove from the server todo list +#[poise::command( + slash_command, + rename = "view", + guild_only = true, + identifying_name = "todo_guild_view", + default_member_permissions = "MANAGE_GUILD" +)] +pub async fn view(ctx: Context<'_>) -> Result<(), Error> { + let values = sqlx::query!( + " + SELECT todos.id, value FROM todos + INNER JOIN guilds ON todos.guild_id = guilds.id + WHERE guilds.guild = ? + ", + ctx.guild_id().unwrap().get(), + ) + .fetch_all(&ctx.data().database) + .await + .unwrap() + .iter() + .map(|row| (row.id as usize, row.value.clone())) + .collect::>(); + + let resp = show_todo_page(&values, 0, None, None, ctx.guild_id().map(|g| g.get())); + + ctx.send(resp).await?; + + Ok(()) +} diff --git a/src/commands/todo/mod.rs b/src/commands/todo/mod.rs new file mode 100644 index 0000000..ab80b34 --- /dev/null +++ b/src/commands/todo/mod.rs @@ -0,0 +1,157 @@ +use poise::{ + serenity_prelude::{ + CreateActionRow, CreateEmbed, CreateEmbedFooter, CreateSelectMenu, CreateSelectMenuKind, + CreateSelectMenuOption, + }, + CreateReply, +}; + +use crate::{ + component_models::{ + pager::{Pager, TodoPager}, + ComponentDataModel, TodoSelector, + }, + consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR}, + Context, Error, +}; + +pub mod channel; +pub mod guild; +pub mod user; + +/// Manage todo lists +#[poise::command(slash_command, default_member_permissions = "MANAGE_GUILD")] +pub async fn todo(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} + +pub fn max_todo_page(todo_values: &[(usize, String)]) -> usize { + let mut rows = 0; + let mut char_count = 0; + + todo_values.iter().enumerate().map(|(c, (_, v))| format!("{}: {}", c, v)).fold( + 1, + |mut pages, text| { + rows += 1; + char_count += text.len(); + + if char_count > EMBED_DESCRIPTION_MAX_LENGTH || rows > SELECT_MAX_ENTRIES { + rows = 1; + char_count = text.len(); + pages += 1; + } + + pages + }, + ) +} + +pub fn show_todo_page( + todo_values: &[(usize, String)], + page: usize, + user_id: Option, + channel_id: Option, + guild_id: Option, +) -> CreateReply { + let pager = TodoPager::new(page, user_id, channel_id, guild_id); + + let pages = max_todo_page(todo_values); + let mut page = page; + if page >= pages { + page = pages - 1; + } + + let mut char_count = 0; + let mut rows = 0; + let mut skipped_rows = 0; + let mut skipped_char_count = 0; + let mut first_num = 0; + + let mut skipped_pages = 0; + + let (todo_ids, display_vec): (Vec, Vec) = todo_values + .iter() + .enumerate() + .map(|(c, (i, v))| (i, format!("`{}`: {}", c + 1, v))) + .skip_while(|(_, p)| { + first_num += 1; + skipped_rows += 1; + skipped_char_count += p.len(); + + if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH + || skipped_rows > SELECT_MAX_ENTRIES + { + skipped_rows = 1; + skipped_char_count = p.len(); + skipped_pages += 1; + } + + skipped_pages < page + }) + .take_while(|(_, p)| { + rows += 1; + char_count += p.len(); + + char_count < EMBED_DESCRIPTION_MAX_LENGTH && rows <= SELECT_MAX_ENTRIES + }) + .unzip(); + + let display = display_vec.join("\n"); + + let title = if user_id.is_some() { + "Your" + } else if channel_id.is_some() { + "Channel" + } else { + "Server" + }; + + if todo_ids.is_empty() { + CreateReply::default().embed( + CreateEmbed::new() + .title(format!("{} Todo List", title)) + .description("Todo List Empty!") + .color(*THEME_COLOR) + .footer(CreateEmbedFooter::new(format!("Page {} of {}", page + 1, pages))), + ) + } else { + let todo_selector = + ComponentDataModel::TodoSelector(TodoSelector { page, user_id, channel_id, guild_id }); + + CreateReply::default() + .embed( + CreateEmbed::new() + .title(format!("{} Todo List", title)) + .description(display) + .color(*THEME_COLOR) + .footer(CreateEmbedFooter::new(format!("Page {} of {}", page + 1, pages))), + ) + .components(vec![ + pager.create_button_row(pages), + CreateActionRow::SelectMenu(CreateSelectMenu::new( + todo_selector.to_custom_id(), + CreateSelectMenuKind::String { + options: todo_ids + .iter() + .zip(&display_vec) + .enumerate() + .map(|(count, (id, disp))| { + let c = disp.split_once(' ').unwrap_or(("", "")).1; + let description = if c.len() > 100 { + format!("{}...", c.chars().take(97).collect::()) + } else { + c.to_string() + }; + + CreateSelectMenuOption::new( + format!("Mark {} complete", count + first_num), + id.to_string(), + ) + .description(description) + }) + .collect(), + }, + )), + ]) + } +} diff --git a/src/commands/todo/user/add.rs b/src/commands/todo/user/add.rs new file mode 100644 index 0000000..6c4bb25 --- /dev/null +++ b/src/commands/todo/user/add.rs @@ -0,0 +1,27 @@ +use crate::{Context, Error}; + +/// Add an item to your personal todo list +#[poise::command(slash_command, rename = "add", identifying_name = "todo_user_add")] +pub async fn add( + ctx: Context<'_>, + #[description = "The task to add to the todo list"] task: String, +) -> Result<(), Error> { + sqlx::query!( + " + INSERT INTO todos (user_id, value) + VALUES ( + (SELECT id FROM users WHERE user = ?), + ? + ) + ", + ctx.author().id.get(), + task + ) + .execute(&ctx.data().database) + .await + .unwrap(); + + ctx.say("Item added to todo list").await?; + + Ok(()) +} diff --git a/src/commands/todo/user/mod.rs b/src/commands/todo/user/mod.rs new file mode 100644 index 0000000..8b0c250 --- /dev/null +++ b/src/commands/todo/user/mod.rs @@ -0,0 +1,10 @@ +pub mod add; +pub mod view; + +use crate::{Context, Error}; + +/// Manage your personal todo list +#[poise::command(slash_command, rename = "user", identifying_name = "todo_user_base")] +pub async fn user(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} diff --git a/src/commands/todo/user/view.rs b/src/commands/todo/user/view.rs new file mode 100644 index 0000000..088f201 --- /dev/null +++ b/src/commands/todo/user/view.rs @@ -0,0 +1,26 @@ +use crate::{commands::todo::show_todo_page, Context, Error}; + +/// View and remove from your personal todo list +#[poise::command(slash_command, rename = "view", identifying_name = "todo_user_view")] +pub async fn view(ctx: Context<'_>) -> Result<(), Error> { + let values = sqlx::query!( + " + SELECT todos.id, value FROM todos + INNER JOIN users ON todos.user_id = users.id + WHERE users.user = ? + ", + ctx.author().id.get(), + ) + .fetch_all(&ctx.data().database) + .await + .unwrap() + .iter() + .map(|row| (row.id as usize, row.value.clone())) + .collect::>(); + + let resp = show_todo_page(&values, 0, Some(ctx.author().id.get()), None, None); + + ctx.send(resp).await?; + + Ok(()) +} diff --git a/src/commands/todo_cmds.rs b/src/commands/todo_cmds.rs deleted file mode 100644 index a98dd02..0000000 --- a/src/commands/todo_cmds.rs +++ /dev/null @@ -1,355 +0,0 @@ -use poise::{ - serenity_prelude::{ - CreateActionRow, CreateEmbed, CreateEmbedFooter, CreateSelectMenu, CreateSelectMenuKind, - CreateSelectMenuOption, - }, - CreateReply, -}; - -use crate::{ - component_models::{ - pager::{Pager, TodoPager}, - ComponentDataModel, TodoSelector, - }, - consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR}, - models::CtxData, - Context, Error, -}; - -/// Manage todo lists -#[poise::command( - slash_command, - rename = "todo", - identifying_name = "todo_base", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn todo_base(_ctx: Context<'_>) -> Result<(), Error> { - Ok(()) -} - -/// Manage the server todo list -#[poise::command( - slash_command, - rename = "server", - guild_only = true, - identifying_name = "todo_guild_base", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn todo_guild_base(_ctx: Context<'_>) -> Result<(), Error> { - Ok(()) -} - -/// Add an item to the server todo list -#[poise::command( - slash_command, - rename = "add", - guild_only = true, - identifying_name = "todo_guild_add", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn todo_guild_add( - ctx: Context<'_>, - #[description = "The task to add to the todo list"] task: String, -) -> Result<(), Error> { - sqlx::query!( - "INSERT INTO todos (guild_id, value) -VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)", - ctx.guild_id().unwrap().get(), - task - ) - .execute(&ctx.data().database) - .await - .unwrap(); - - ctx.say("Item added to todo list").await?; - - Ok(()) -} - -/// View and remove from the server todo list -#[poise::command( - slash_command, - rename = "view", - guild_only = true, - identifying_name = "todo_guild_view", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> { - let values = sqlx::query!( - "SELECT todos.id, value FROM todos -INNER JOIN guilds ON todos.guild_id = guilds.id -WHERE guilds.guild = ?", - ctx.guild_id().unwrap().get(), - ) - .fetch_all(&ctx.data().database) - .await - .unwrap() - .iter() - .map(|row| (row.id as usize, row.value.clone())) - .collect::>(); - - let resp = show_todo_page(&values, 0, None, None, ctx.guild_id().map(|g| g.get())); - - ctx.send(resp).await?; - - Ok(()) -} - -/// Manage the channel todo list -#[poise::command( - slash_command, - rename = "channel", - guild_only = true, - identifying_name = "todo_channel_base", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn todo_channel_base(_ctx: Context<'_>) -> Result<(), Error> { - Ok(()) -} - -/// Add an item to the channel todo list -#[poise::command( - slash_command, - rename = "add", - guild_only = true, - identifying_name = "todo_channel_add", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn todo_channel_add( - ctx: Context<'_>, - #[description = "The task to add to the todo list"] task: String, -) -> Result<(), Error> { - // ensure channel is cached - let _ = ctx.channel_data().await; - - sqlx::query!( - "INSERT INTO todos (guild_id, channel_id, value) -VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)", - ctx.guild_id().unwrap().get(), - ctx.channel_id().get(), - task - ) - .execute(&ctx.data().database) - .await - .unwrap(); - - ctx.say("Item added to todo list").await?; - - Ok(()) -} - -/// View and remove from the channel todo list -#[poise::command( - slash_command, - rename = "view", - guild_only = true, - identifying_name = "todo_channel_view", - default_member_permissions = "MANAGE_GUILD" -)] -pub async fn todo_channel_view(ctx: Context<'_>) -> Result<(), Error> { - let values = sqlx::query!( - "SELECT todos.id, value FROM todos -INNER JOIN channels ON todos.channel_id = channels.id -WHERE channels.channel = ?", - ctx.channel_id().get(), - ) - .fetch_all(&ctx.data().database) - .await - .unwrap() - .iter() - .map(|row| (row.id as usize, row.value.clone())) - .collect::>(); - - let resp = show_todo_page( - &values, - 0, - None, - Some(ctx.channel_id().get()), - ctx.guild_id().map(|g| g.get()), - ); - - ctx.send(resp).await?; - - Ok(()) -} - -/// Manage your personal todo list -#[poise::command(slash_command, rename = "user", identifying_name = "todo_user_base")] -pub async fn todo_user_base(_ctx: Context<'_>) -> Result<(), Error> { - Ok(()) -} - -/// Add an item to your personal todo list -#[poise::command(slash_command, rename = "add", identifying_name = "todo_user_add")] -pub async fn todo_user_add( - ctx: Context<'_>, - #[description = "The task to add to the todo list"] task: String, -) -> Result<(), Error> { - sqlx::query!( - "INSERT INTO todos (user_id, value) -VALUES ((SELECT id FROM users WHERE user = ?), ?)", - ctx.author().id.get(), - task - ) - .execute(&ctx.data().database) - .await - .unwrap(); - - ctx.say("Item added to todo list").await?; - - Ok(()) -} - -/// View and remove from your personal todo list -#[poise::command(slash_command, rename = "view", identifying_name = "todo_user_view")] -pub async fn todo_user_view(ctx: Context<'_>) -> Result<(), Error> { - let values = sqlx::query!( - "SELECT todos.id, value FROM todos -INNER JOIN users ON todos.user_id = users.id -WHERE users.user = ?", - ctx.author().id.get(), - ) - .fetch_all(&ctx.data().database) - .await - .unwrap() - .iter() - .map(|row| (row.id as usize, row.value.clone())) - .collect::>(); - - let resp = show_todo_page(&values, 0, Some(ctx.author().id.get()), None, None); - - ctx.send(resp).await?; - - Ok(()) -} - -pub fn max_todo_page(todo_values: &[(usize, String)]) -> usize { - let mut rows = 0; - let mut char_count = 0; - - todo_values.iter().enumerate().map(|(c, (_, v))| format!("{}: {}", c, v)).fold( - 1, - |mut pages, text| { - rows += 1; - char_count += text.len(); - - if char_count > EMBED_DESCRIPTION_MAX_LENGTH || rows > SELECT_MAX_ENTRIES { - rows = 1; - char_count = text.len(); - pages += 1; - } - - pages - }, - ) -} - -pub fn show_todo_page( - todo_values: &[(usize, String)], - page: usize, - user_id: Option, - channel_id: Option, - guild_id: Option, -) -> CreateReply { - let pager = TodoPager::new(page, user_id, channel_id, guild_id); - - let pages = max_todo_page(todo_values); - let mut page = page; - if page >= pages { - page = pages - 1; - } - - let mut char_count = 0; - let mut rows = 0; - let mut skipped_rows = 0; - let mut skipped_char_count = 0; - let mut first_num = 0; - - let mut skipped_pages = 0; - - let (todo_ids, display_vec): (Vec, Vec) = todo_values - .iter() - .enumerate() - .map(|(c, (i, v))| (i, format!("`{}`: {}", c + 1, v))) - .skip_while(|(_, p)| { - first_num += 1; - skipped_rows += 1; - skipped_char_count += p.len(); - - if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH - || skipped_rows > SELECT_MAX_ENTRIES - { - skipped_rows = 1; - skipped_char_count = p.len(); - skipped_pages += 1; - } - - skipped_pages < page - }) - .take_while(|(_, p)| { - rows += 1; - char_count += p.len(); - - char_count < EMBED_DESCRIPTION_MAX_LENGTH && rows <= SELECT_MAX_ENTRIES - }) - .unzip(); - - let display = display_vec.join("\n"); - - let title = if user_id.is_some() { - "Your" - } else if channel_id.is_some() { - "Channel" - } else { - "Server" - }; - - if todo_ids.is_empty() { - CreateReply::default().embed( - CreateEmbed::new() - .title(format!("{} Todo List", title)) - .description("Todo List Empty!") - .color(*THEME_COLOR) - .footer(CreateEmbedFooter::new(format!("Page {} of {}", page + 1, pages))), - ) - } else { - let todo_selector = - ComponentDataModel::TodoSelector(TodoSelector { page, user_id, channel_id, guild_id }); - - CreateReply::default() - .embed( - CreateEmbed::new() - .title(format!("{} Todo List", title)) - .description(display) - .color(*THEME_COLOR) - .footer(CreateEmbedFooter::new(format!("Page {} of {}", page + 1, pages))), - ) - .components(vec![ - pager.create_button_row(pages), - CreateActionRow::SelectMenu(CreateSelectMenu::new( - todo_selector.to_custom_id(), - CreateSelectMenuKind::String { - options: todo_ids - .iter() - .zip(&display_vec) - .enumerate() - .map(|(count, (id, disp))| { - let c = disp.split_once(' ').unwrap_or(("", "")).1; - let description = if c.len() > 100 { - format!("{}...", c.chars().take(97).collect::()) - } else { - c.to_string() - }; - - CreateSelectMenuOption::new( - format!("Mark {} complete", count + first_num), - id.to_string(), - ) - .description(description) - }) - .collect(), - }, - )), - ]) - } -} diff --git a/src/commands/webhook.rs b/src/commands/webhook.rs new file mode 100644 index 0000000..904b0d0 --- /dev/null +++ b/src/commands/webhook.rs @@ -0,0 +1,36 @@ +use log::warn; +use poise::CreateReply; + +use crate::{models::CtxData, Context, Error}; + +/// View the webhook being used to send reminders to this channel +#[poise::command( + slash_command, + identifying_name = "webhook_url", + required_permissions = "ADMINISTRATOR" +)] +pub async fn webhook(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 { + 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(()) +} diff --git a/src/component_models/mod.rs b/src/component_models/mod.rs index c27693a..0164b53 100644 --- a/src/component_models/mod.rs +++ b/src/component_models/mod.rs @@ -18,8 +18,8 @@ use serde::{Deserialize, Serialize}; use crate::{ commands::{ command_macro::list_macro::{max_macro_page, show_macro_page}, - reminder_cmds::{max_delete_page, show_delete_page}, - todo_cmds::{max_todo_page, show_todo_page}, + delete::{max_delete_page, show_delete_page}, + todo::{max_todo_page, show_todo_page}, }, component_models::pager::{DelPager, LookPager, MacroPager, Pager, TodoPager}, consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, diff --git a/src/component_models/pager.rs b/src/component_models/pager.rs index 76b4824..b894fe9 100644 --- a/src/component_models/pager.rs +++ b/src/component_models/pager.rs @@ -4,7 +4,7 @@ use poise::serenity_prelude::{ButtonStyle, CreateActionRow, CreateButton}; use serde::{Deserialize, Serialize}; use serde_repr::*; -use crate::{component_models::ComponentDataModel, models::reminder::look_flags::LookFlags}; +use crate::{commands::look::LookFlags, component_models::ComponentDataModel}; pub trait Pager { fn next_page(&self, max_pages: usize) -> usize; diff --git a/src/main.rs b/src/main.rs index 3c46809..f9dc616 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,11 @@ use sqlx::{MySql, Pool}; use tokio::sync::{broadcast, broadcast::Sender, RwLock}; use crate::{ - commands::{command_macro, info_cmds, moderation_cmds, reminder_cmds, timer, todo_cmds}, + commands::{ + allowed_dm, clock::clock, clock_context_menu::clock_context_menu, command_macro, + dashboard::dashboard, delete, donate::donate, help::help, info::info, look, multiline, + nudge, offset, pause, remind, settings, timer, timezone::timezone, todo, webhook::webhook, + }, consts::THEME_COLOR, event_handlers::listener, hooks::all_checks, @@ -99,31 +103,31 @@ async fn _main(tx: Sender<()>) -> Result<(), Box> { let options = poise::FrameworkOptions { commands: vec![ - info_cmds::help(), - info_cmds::info(), - info_cmds::donate(), - info_cmds::clock(), - info_cmds::clock_context_menu(), - info_cmds::dashboard(), - moderation_cmds::timezone(), + help(), + info(), + donate(), + clock(), + clock_context_menu(), + dashboard(), + timezone(), poise::Command { subcommands: vec![ - moderation_cmds::set_allowed_dm(), - moderation_cmds::unset_allowed_dm(), + allowed_dm::set_allowed_dm::set_allowed_dm(), + allowed_dm::unset_allowed_dm::unset_allowed_dm(), ], - ..moderation_cmds::allowed_dm() + ..allowed_dm::allowed_dm() }, poise::Command { subcommands: vec![poise::Command { subcommands: vec![ - moderation_cmds::set_ephemeral_confirmations(), - moderation_cmds::unset_ephemeral_confirmations(), + settings::ephemeral_confirmations::set_ephemeral_confirmations::set_ephemeral_confirmations(), + settings::ephemeral_confirmations::unset_ephemeral_confirmations::unset_ephemeral_confirmations(), ], - ..moderation_cmds::ephemeral_confirmations() + ..settings::ephemeral_confirmations::ephemeral_confirmations() }], - ..moderation_cmds::settings() + ..settings::settings() }, - moderation_cmds::webhook(), + webhook(), poise::Command { subcommands: vec![ command_macro::delete_macro::delete_macro(), @@ -132,45 +136,39 @@ async fn _main(tx: Sender<()>) -> Result<(), Box> { command_macro::record_macro::record_macro(), command_macro::run_macro::run_macro(), ], - ..command_macro::macro_base::macro_base() + ..command_macro::command_macro() }, - reminder_cmds::pause(), - reminder_cmds::offset(), - reminder_cmds::nudge(), - reminder_cmds::look(), - reminder_cmds::delete(), + pause::pause(), + offset::offset(), + nudge::nudge(), + look::look(), + delete::delete(), poise::Command { subcommands: vec![ timer::list_timer::list_timer(), timer::start_timer::start_timer(), timer::delete_timer::delete_timer(), ], - ..timer::timer_base::timer_base() + ..timer::timer() }, - reminder_cmds::multiline(), - reminder_cmds::remind(), + multiline::multiline(), + remind::remind(), poise::Command { subcommands: vec![ poise::Command { - subcommands: vec![ - todo_cmds::todo_guild_add(), - todo_cmds::todo_guild_view(), - ], - ..todo_cmds::todo_guild_base() + subcommands: vec![todo::guild::add::add(), todo::guild::view::view()], + ..todo::guild::guild() }, poise::Command { - subcommands: vec![ - todo_cmds::todo_channel_add(), - todo_cmds::todo_channel_view(), - ], - ..todo_cmds::todo_channel_base() + subcommands: vec![todo::channel::add::add(), todo::channel::view::view()], + ..todo::channel::channel() }, poise::Command { - subcommands: vec![todo_cmds::todo_user_add(), todo_cmds::todo_user_view()], - ..todo_cmds::todo_user_base() + subcommands: vec![todo::user::add::add(), todo::user::view::view()], + ..todo::user::user() }, ], - ..todo_cmds::todo_base() + ..todo::todo() }, ], allowed_mentions: None, diff --git a/src/models/command_macro.rs b/src/models/command_macro.rs index 7027fcb..5f82fff 100644 --- a/src/models/command_macro.rs +++ b/src/models/command_macro.rs @@ -3,7 +3,10 @@ use poise::serenity_prelude::{model::id::GuildId, ResolvedValue}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::{commands::reminder_cmds::create_reminder, ApplicationContext, Context, Error}; +use crate::{ + commands::remind::RemindOptions, models::reminder::create_reminder, ApplicationContext, + Context, Error, +}; #[derive(Serialize, Deserialize)] #[serde(tag = "command_name")] @@ -81,17 +84,6 @@ impl RecordedCommand { } } -#[derive(Serialize, Deserialize, Default)] -pub struct RemindOptions { - time: String, - content: String, - channels: Option, - interval: Option, - expires: Option, - tts: Option, - timezone: Option, -} - pub struct CommandMacro { pub guild_id: GuildId, pub name: String, diff --git a/src/models/reminder/look_flags.rs b/src/models/reminder/look_flags.rs deleted file mode 100644 index 9960f33..0000000 --- a/src/models/reminder/look_flags.rs +++ /dev/null @@ -1,23 +0,0 @@ -use poise::serenity_prelude::model::id::ChannelId; -use serde::{Deserialize, Serialize}; -use serde_repr::*; - -#[derive(Serialize_repr, Deserialize_repr, Copy, Clone, Debug)] -#[repr(u8)] -pub enum TimeDisplayType { - Absolute = 0, - Relative = 1, -} - -#[derive(Serialize, Deserialize, Copy, Clone, Debug)] -pub struct LookFlags { - pub show_disabled: bool, - pub channel_id: Option, - pub time_display: TimeDisplayType, -} - -impl Default for LookFlags { - fn default() -> Self { - Self { show_disabled: true, channel_id: None, time_display: TimeDisplayType::Relative } - } -} diff --git a/src/models/reminder/mod.rs b/src/models/reminder/mod.rs index a822e53..21418ac 100644 --- a/src/models/reminder/mod.rs +++ b/src/models/reminder/mod.rs @@ -2,21 +2,39 @@ pub mod builder; pub mod content; pub mod errors; mod helper; -pub mod look_flags; -use std::hash::{Hash, Hasher}; +use std::{ + collections::HashSet, + hash::{Hash, Hasher}, +}; use chrono::{DateTime, NaiveDateTime, Utc}; use chrono_tz::Tz; -use poise::serenity_prelude::{ - model::id::{ChannelId, GuildId, UserId}, - Cache, +use poise::{ + serenity_prelude::{ + model::id::{ChannelId, GuildId, UserId}, + ButtonStyle, Cache, CreateActionRow, CreateButton, CreateEmbed, ReactionType, + }, + CreateReply, }; use sqlx::Executor; use crate::{ - models::reminder::look_flags::{LookFlags, TimeDisplayType}, - Database, + commands::look::{LookFlags, TimeDisplayType}, + component_models::{ComponentDataModel, UndoReminder}, + consts::{REGEX_CHANNEL_USER, THEME_COLOR}, + interval_parser::parse_duration, + models::{ + reminder::{ + builder::{MultiReminderBuilder, ReminderScope}, + content::Content, + errors::ReminderError, + }, + CtxData, + }, + time_parser::natural_parser, + utils::{check_guild_subscription, check_subscription}, + Context, Database, Error, }; #[derive(Debug, Clone)] @@ -369,3 +387,195 @@ impl Reminder { } } } + +pub async fn create_reminder( + ctx: Context<'_>, + time: String, + content: String, + channels: Option, + interval: Option, + expires: Option, + tts: Option, + timezone: Option, +) -> Result<(), Error> { + fn parse_mention_list(mentions: &str) -> Vec { + REGEX_CHANNEL_USER + .captures_iter(mentions) + .map(|i| { + let pref = i.get(1).unwrap().as_str(); + let id = i.get(2).unwrap().as_str().parse::().unwrap(); + + if pref == "#" { + ReminderScope::Channel(id) + } else { + ReminderScope::User(id) + } + }) + .collect::>() + } + + fn create_response( + successes: &HashSet<(Reminder, ReminderScope)>, + errors: &HashSet, + time: i64, + ) -> CreateEmbed { + let success_part = match successes.len() { + 0 => "".to_string(), + 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 + ), + }; + + 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.to_string()).collect::>().join("\n") + ), + }; + + CreateEmbed::default() + .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) + } + + if interval.is_none() && expires.is_some() { + ctx.say("`expires` can only be used with `interval`").await?; + + return Ok(()); + } + + let ephemeral = + ctx.guild_data().await.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations)); + if ephemeral { + ctx.defer_ephemeral().await?; + } else { + ctx.defer().await?; + } + + let user_data = ctx.author_data().await.unwrap(); + let timezone = timezone.unwrap_or(ctx.timezone().await); + + let time = natural_parser(&time, &timezone.to_string()).await; + + match time { + Some(time) => { + let content = { + let tts = tts.unwrap_or(false); + + Content { content, tts, attachment: None, attachment_name: None } + }; + + let scopes = { + let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default(); + + if list.is_empty() { + if ctx.guild_id().is_some() { + vec![ReminderScope::Channel(ctx.channel_id().get())] + } else { + vec![ReminderScope::User(ctx.author().id.get())] + } + } else { + list + } + }; + + let (processed_interval, processed_expires) = if let Some(repeat) = &interval { + if check_subscription(&ctx, ctx.author().id).await + || (ctx.guild_id().is_some() + && check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await) + { + ( + parse_duration(repeat) + .or_else(|_| parse_duration(&format!("1 {}", repeat))) + .ok(), + { + if let Some(arg) = &expires { + natural_parser(arg, &timezone.to_string()).await + } else { + None + } + }, + ) + } else { + ctx.send(CreateReply::default().content( + "`repeat` is only available to Patreon subscribers or self-hosted users", + )) + .await?; + + return Ok(()); + } + } else { + (None, None) + }; + + if processed_interval.is_none() && interval.is_some() { + ctx.send(CreateReply::default().content( + "Repeat interval could not be processed. Try similar to `1 hour` or `4 days`", + )) + .await?; + } else if processed_expires.is_none() && expires.is_some() { + ctx.send( + CreateReply::default().ephemeral(true).content( + "Expiry time failed to process. Please make it as clear as possible", + ), + ) + .await?; + } else { + let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id()) + .author(user_data) + .content(content) + .time(time) + .timezone(timezone) + .expires(processed_expires) + .interval(processed_interval); + + builder.set_scopes(scopes); + + let (errors, successes) = builder.build().await; + + let embed = create_response(&successes, &errors, time); + + if successes.len() == 1 { + let reminder = successes.iter().next().map(|(r, _)| r.id).unwrap(); + let undo_button = ComponentDataModel::UndoReminder(UndoReminder { + user_id: ctx.author().id, + reminder_id: reminder, + }); + + ctx.send(CreateReply::default().embed(embed).components(vec![ + CreateActionRow::Buttons(vec![ + CreateButton::new(undo_button.to_custom_id()) + .emoji(ReactionType::Unicode("🔕".to_string())) + .label("Cancel") + .style(ButtonStyle::Danger), + CreateButton::new_link("https://beta.reminder-bot.com/dashboard") + .emoji(ReactionType::Unicode("📝".to_string())) + .label("Edit"), + ]), + ])) + .await?; + } else { + ctx.send(CreateReply::default().embed(embed)).await?; + } + } + } + + None => { + ctx.say("Time could not be processed").await?; + } + } + + Ok(()) +} diff --git a/src/utils.rs b/src/utils.rs index c27bf47..f2ff8a0 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2,12 +2,15 @@ use poise::{ serenity_prelude::{ http::CacheHttp, model::id::{GuildId, UserId}, - CreateInteractionResponseMessage, + CreateEmbedFooter, CreateInteractionResponseMessage, }, CreateReply, }; -use crate::consts::{CNC_GUILD, SUBSCRIPTION_ROLES}; +use crate::{ + consts::{CNC_GUILD, SUBSCRIPTION_ROLES}, + Context, +}; pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into) -> bool { if let Some(subscription_guild) = *CNC_GUILD { @@ -49,3 +52,15 @@ pub fn reply_to_interaction_response_message( builder } + +pub fn footer(ctx: Context<'_>) -> CreateEmbedFooter { + let shard_count = ctx.serenity_context().cache.shard_count(); + let shard = ctx.serenity_context().shard_id; + + CreateEmbedFooter::new(format!( + "{}\nshard {} of {}", + concat!(env!("CARGO_PKG_NAME"), " ver ", env!("CARGO_PKG_VERSION")), + shard, + shard_count, + )) +}