Move all commands to their own files

This commit is contained in:
jude 2024-02-17 18:55:16 +00:00
parent eb92eacb90
commit 4823754955
51 changed files with 1757 additions and 1699 deletions

3
Cargo.lock generated
View File

@ -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",
]

View File

@ -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"] }

View 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(())
}

View 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(())
}

View 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
View 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(())
}

View 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(())
}

View File

@ -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(())
}

View File

@ -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(())
}

View File

@ -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
View 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
View 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
View 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
View 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
View 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(())
}

View File

@ -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
View 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(())
}

View File

@ -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;

View File

@ -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
View 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
View 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
View 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
View 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
View 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
}

View File

@ -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>>()
}

View 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(())
}

View File

@ -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(())
}

View File

@ -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(())
}

View 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(())
}

View File

@ -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(())
}

View File

@ -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
View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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
View 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(),
},
)),
])
}
}

View 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(())
}

View 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(())
}

View 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(())
}

View File

@ -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
View 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(())
}

View File

@ -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},

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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 }
}
}

View File

@ -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(())
}

View File

@ -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,
))
}