Move all commands to their own files
This commit is contained in:
parent
eb92eacb90
commit
4823754955
3
Cargo.lock
generated
3
Cargo.lock
generated
@ -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",
|
||||
]
|
||||
|
||||
|
@ -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"] }
|
||||
|
10
src/commands/allowed_dm/mod.rs
Normal file
10
src/commands/allowed_dm/mod.rs
Normal file
@ -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(())
|
||||
}
|
23
src/commands/allowed_dm/set_allowed_dm.rs
Normal file
23
src/commands/allowed_dm/set_allowed_dm.rs
Normal file
@ -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(())
|
||||
}
|
25
src/commands/allowed_dm/unset_allowed_dm.rs
Normal file
25
src/commands/allowed_dm/unset_allowed_dm.rs
Normal file
@ -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(())
|
||||
}
|
22
src/commands/clock.rs
Normal file
22
src/commands/clock.rs
Normal file
@ -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(())
|
||||
}
|
27
src/commands/clock_context_menu.rs
Normal file
27
src/commands/clock_context_menu.rs
Normal file
@ -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(())
|
||||
}
|
@ -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(())
|
||||
}
|
@ -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(())
|
||||
}
|
||||
|
@ -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<T: Serialize + DeserializeOwned, R> {
|
||||
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::<Punctuated<Box<Pat>, 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<T> {
|
||||
args: T
|
||||
func: Func<T>
|
||||
}
|
||||
|
||||
impl Execute<T> for RecordableCommand<T> {
|
||||
fn execute() {
|
||||
// Unpack self.args into self.func
|
||||
}
|
||||
}
|
||||
|
||||
struct command_args {
|
||||
...args
|
||||
}
|
||||
|
||||
fn command_fn(...args) {
|
||||
...block
|
||||
}
|
||||
|
||||
*/
|
22
src/commands/dashboard.rs
Normal file
22
src/commands/dashboard.rs
Normal file
@ -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(())
|
||||
}
|
156
src/commands/delete.rs
Normal file
156
src/commands/delete.rs
Normal file
@ -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<String>) = 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::<String>()
|
||||
)
|
||||
} 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)])
|
||||
}
|
34
src/commands/donate.rs
Normal file
34
src/commands/donate.rs
Normal file
@ -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(())
|
||||
}
|
48
src/commands/help.rs
Normal file
48
src/commands/help.rs
Normal file
@ -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(())
|
||||
}
|
33
src/commands/info.rs
Normal file
33
src/commands/info.rs
Normal file
@ -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(())
|
||||
}
|
@ -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(())
|
||||
}
|
119
src/commands/look.rs
Normal file
119
src/commands/look.rs
Normal file
@ -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<ChannelId>,
|
||||
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<Channel>,
|
||||
#[description = "Whether to show disabled reminders or not"] disabled: Option<bool>,
|
||||
#[description = "Whether to display times as relative or exact times"] relative: Option<bool>,
|
||||
) -> 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::<Vec<String>>()
|
||||
.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(())
|
||||
}
|
@ -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;
|
||||
|
@ -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<String>,
|
||||
) -> 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::<Tz>() {
|
||||
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::<Vec<Tz>>();
|
||||
|
||||
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(())
|
||||
}
|
69
src/commands/multiline.rs
Normal file
69
src/commands/multiline.rs
Normal file
@ -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<String>,
|
||||
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
|
||||
interval: Option<String>,
|
||||
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"]
|
||||
expires: Option<String>,
|
||||
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
|
||||
tts: Option<bool>,
|
||||
#[description = "Set a timezone override for this reminder only"]
|
||||
#[autocomplete = "timezone_autocomplete"]
|
||||
timezone: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
let tz = timezone.map(|t| t.parse::<Tz>().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(|_| ())?)
|
||||
}
|
||||
}
|
||||
}
|
28
src/commands/nudge.rs
Normal file
28
src/commands/nudge.rs
Normal file
@ -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<isize>,
|
||||
#[description = "Number of seconds to nudge new reminders by"] seconds: Option<isize>,
|
||||
) -> 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(())
|
||||
}
|
71
src/commands/offset.rs
Normal file
71
src/commands/offset.rs
Normal file
@ -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<isize>,
|
||||
#[description = "Number of minutes to offset by"] minutes: Option<isize>,
|
||||
#[description = "Number of seconds to offset by"] seconds: Option<isize>,
|
||||
) -> 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::<Vec<String>>()
|
||||
.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(())
|
||||
}
|
67
src/commands/pause.rs
Normal file
67
src/commands/pause.rs
Normal file
@ -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<String>,
|
||||
) -> 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 **<t:{}:D>**",
|
||||
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(())
|
||||
}
|
48
src/commands/remind.rs
Normal file
48
src/commands/remind.rs
Normal file
@ -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<String>,
|
||||
pub interval: Option<String>,
|
||||
pub expires: Option<String>,
|
||||
pub tts: Option<bool>,
|
||||
pub timezone: Option<Tz>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
|
||||
interval: Option<String>,
|
||||
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"]
|
||||
expires: Option<String>,
|
||||
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
|
||||
tts: Option<bool>,
|
||||
#[description = "Set a timezone override for this reminder only"]
|
||||
#[autocomplete = "timezone_autocomplete"]
|
||||
timezone: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
|
||||
|
||||
create_reminder(Context::Application(ctx), time, content, channels, interval, expires, tts, tz)
|
||||
.await
|
||||
}
|
@ -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<String>,
|
||||
) -> 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 **<t:{}:D>**",
|
||||
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<isize>,
|
||||
#[description = "Number of minutes to offset by"] minutes: Option<isize>,
|
||||
#[description = "Number of seconds to offset by"] seconds: Option<isize>,
|
||||
) -> 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::<Vec<String>>()
|
||||
.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<isize>,
|
||||
#[description = "Number of seconds to nudge new reminders by"] seconds: Option<isize>,
|
||||
) -> 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<Channel>,
|
||||
#[description = "Whether to show disabled reminders or not"] disabled: Option<bool>,
|
||||
#[description = "Whether to display times as relative or exact times"] relative: Option<bool>,
|
||||
) -> 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::<Vec<String>>()
|
||||
.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<String>) = 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::<String>()
|
||||
)
|
||||
} 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<String>,
|
||||
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
|
||||
interval: Option<String>,
|
||||
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"]
|
||||
expires: Option<String>,
|
||||
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
|
||||
tts: Option<bool>,
|
||||
#[description = "Set a timezone override for this reminder only"]
|
||||
#[autocomplete = "timezone_autocomplete"]
|
||||
timezone: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
let tz = timezone.map(|t| t.parse::<Tz>().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<String>,
|
||||
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
|
||||
interval: Option<String>,
|
||||
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"]
|
||||
expires: Option<String>,
|
||||
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
|
||||
tts: Option<bool>,
|
||||
#[description = "Set a timezone override for this reminder only"]
|
||||
#[autocomplete = "timezone_autocomplete"]
|
||||
timezone: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
let tz = timezone.map(|t| t.parse::<Tz>().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<String>,
|
||||
interval: Option<String>,
|
||||
expires: Option<String>,
|
||||
tts: Option<bool>,
|
||||
timezone: Option<Tz>,
|
||||
) -> 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<ReminderError>,
|
||||
time: i64,
|
||||
) -> CreateEmbed {
|
||||
let success_part = match successes.len() {
|
||||
0 => "".to_string(),
|
||||
n => format!(
|
||||
"Reminder{s} for {locations} set for <t:{offset}:R>",
|
||||
s = if n > 1 { "s" } else { "" },
|
||||
locations =
|
||||
successes.iter().map(|(_, l)| l.mention()).collect::<Vec<String>>().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::<Vec<String>>().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<ReminderScope> {
|
||||
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::<u64>().unwrap();
|
||||
|
||||
if pref == "#" {
|
||||
ReminderScope::Channel(id)
|
||||
} else {
|
||||
ReminderScope::User(id)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<ReminderScope>>()
|
||||
}
|
15
src/commands/settings/ephemeral_confirmations/mod.rs
Normal file
15
src/commands/settings/ephemeral_confirmations/mod.rs
Normal file
@ -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(())
|
||||
}
|
@ -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(())
|
||||
}
|
@ -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(())
|
||||
}
|
14
src/commands/settings/mod.rs
Normal file
14
src/commands/settings/mod.rs
Normal file
@ -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(())
|
||||
}
|
@ -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(())
|
||||
}
|
||||
|
@ -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(())
|
||||
}
|
117
src/commands/timezone.rs
Normal file
117
src/commands/timezone.rs
Normal file
@ -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<String>,
|
||||
) -> 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::<Tz>() {
|
||||
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::<Vec<Tz>>();
|
||||
|
||||
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(())
|
||||
}
|
38
src/commands/todo/channel/add.rs
Normal file
38
src/commands/todo/channel/add.rs
Normal file
@ -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(())
|
||||
}
|
16
src/commands/todo/channel/mod.rs
Normal file
16
src/commands/todo/channel/mod.rs
Normal file
@ -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(())
|
||||
}
|
38
src/commands/todo/channel/view.rs
Normal file
38
src/commands/todo/channel/view.rs
Normal file
@ -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::<Vec<(usize, String)>>();
|
||||
|
||||
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(())
|
||||
}
|
31
src/commands/todo/guild/add.rs
Normal file
31
src/commands/todo/guild/add.rs
Normal file
@ -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(())
|
||||
}
|
15
src/commands/todo/guild/mod.rs
Normal file
15
src/commands/todo/guild/mod.rs
Normal file
@ -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(())
|
||||
}
|
32
src/commands/todo/guild/view.rs
Normal file
32
src/commands/todo/guild/view.rs
Normal file
@ -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::<Vec<(usize, String)>>();
|
||||
|
||||
let resp = show_todo_page(&values, 0, None, None, ctx.guild_id().map(|g| g.get()));
|
||||
|
||||
ctx.send(resp).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
157
src/commands/todo/mod.rs
Normal file
157
src/commands/todo/mod.rs
Normal file
@ -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<u64>,
|
||||
channel_id: Option<u64>,
|
||||
guild_id: Option<u64>,
|
||||
) -> 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<usize>, Vec<String>) = 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::<String>())
|
||||
} else {
|
||||
c.to_string()
|
||||
};
|
||||
|
||||
CreateSelectMenuOption::new(
|
||||
format!("Mark {} complete", count + first_num),
|
||||
id.to_string(),
|
||||
)
|
||||
.description(description)
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
)),
|
||||
])
|
||||
}
|
||||
}
|
27
src/commands/todo/user/add.rs
Normal file
27
src/commands/todo/user/add.rs
Normal file
@ -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(())
|
||||
}
|
10
src/commands/todo/user/mod.rs
Normal file
10
src/commands/todo/user/mod.rs
Normal file
@ -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(())
|
||||
}
|
26
src/commands/todo/user/view.rs
Normal file
26
src/commands/todo/user/view.rs
Normal file
@ -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::<Vec<(usize, String)>>();
|
||||
|
||||
let resp = show_todo_page(&values, 0, Some(ctx.author().id.get()), None, None);
|
||||
|
||||
ctx.send(resp).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
@ -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::<Vec<(usize, String)>>();
|
||||
|
||||
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::<Vec<(usize, String)>>();
|
||||
|
||||
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::<Vec<(usize, String)>>();
|
||||
|
||||
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<u64>,
|
||||
channel_id: Option<u64>,
|
||||
guild_id: Option<u64>,
|
||||
) -> 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<usize>, Vec<String>) = 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::<String>())
|
||||
} else {
|
||||
c.to_string()
|
||||
};
|
||||
|
||||
CreateSelectMenuOption::new(
|
||||
format!("Mark {} complete", count + first_num),
|
||||
id.to_string(),
|
||||
)
|
||||
.description(description)
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
)),
|
||||
])
|
||||
}
|
||||
}
|
36
src/commands/webhook.rs
Normal file
36
src/commands/webhook.rs
Normal file
@ -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(())
|
||||
}
|
@ -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},
|
||||
|
@ -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;
|
||||
|
74
src/main.rs
74
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<dyn StdError + Send + Sync>> {
|
||||
|
||||
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<dyn StdError + Send + Sync>> {
|
||||
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,
|
||||
|
@ -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<String>,
|
||||
interval: Option<String>,
|
||||
expires: Option<String>,
|
||||
tts: Option<bool>,
|
||||
timezone: Option<Tz>,
|
||||
}
|
||||
|
||||
pub struct CommandMacro {
|
||||
pub guild_id: GuildId,
|
||||
pub name: String,
|
||||
|
@ -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<ChannelId>,
|
||||
pub time_display: TimeDisplayType,
|
||||
}
|
||||
|
||||
impl Default for LookFlags {
|
||||
fn default() -> Self {
|
||||
Self { show_disabled: true, channel_id: None, time_display: TimeDisplayType::Relative }
|
||||
}
|
||||
}
|
@ -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::{
|
||||
use poise::{
|
||||
serenity_prelude::{
|
||||
model::id::{ChannelId, GuildId, UserId},
|
||||
Cache,
|
||||
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<String>,
|
||||
interval: Option<String>,
|
||||
expires: Option<String>,
|
||||
tts: Option<bool>,
|
||||
timezone: Option<Tz>,
|
||||
) -> Result<(), Error> {
|
||||
fn parse_mention_list(mentions: &str) -> Vec<ReminderScope> {
|
||||
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::<u64>().unwrap();
|
||||
|
||||
if pref == "#" {
|
||||
ReminderScope::Channel(id)
|
||||
} else {
|
||||
ReminderScope::User(id)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<ReminderScope>>()
|
||||
}
|
||||
|
||||
fn create_response(
|
||||
successes: &HashSet<(Reminder, ReminderScope)>,
|
||||
errors: &HashSet<ReminderError>,
|
||||
time: i64,
|
||||
) -> CreateEmbed {
|
||||
let success_part = match successes.len() {
|
||||
0 => "".to_string(),
|
||||
n => format!(
|
||||
"Reminder{s} for {locations} set for <t:{offset}:R>",
|
||||
s = if n > 1 { "s" } else { "" },
|
||||
locations =
|
||||
successes.iter().map(|(_, l)| l.mention()).collect::<Vec<String>>().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::<Vec<String>>().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(())
|
||||
}
|
||||
|
19
src/utils.rs
19
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<UserId>) -> 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,
|
||||
))
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user