Compare commits

...

49 Commits

Author SHA1 Message Date
jude 2d1668a63a new global models 2022-05-20 21:13:15 +01:00
jude 6f7d0f67b3 mentions 2022-05-15 12:14:07 +01:00
jude bfc2d71ca0 patreon 2022-05-14 12:02:46 +01:00
jude 8eb46f1f23 delete reminders when the user cannot be direct messaged 2022-05-14 10:56:03 +01:00
jude c4087bf569 pos mysql 2022-05-14 08:12:50 +01:00
jude f25cfed8d7 edit button 2022-05-13 23:30:01 +01:00
jude d2a8bd1982 update readme with better build instructions 2022-05-13 23:13:39 +01:00
jude 437ee6b446 ver bump 2022-05-13 23:10:29 +01:00
jude 7d43aa5918 cleared up all unwraps from the reminder sender. cleared up clippy lints. added undo button 2022-05-13 23:08:52 +01:00
jude 8bad95510d configure playing status 2022-05-13 12:43:27 +01:00
Jude Southworth d7a0b727fb Merge pull request #7 from reminder-bot/poise-2
Poise 2
2022-05-13 09:00:26 +01:00
jude 1c1f5662d3 removed guild only hook. permissions on commands. fix for macro command count. 2022-05-13 08:59:46 +01:00
jude ded750aa2d update dependencies 2022-04-19 15:23:27 +01:00
jude 4c4f0927f1 fix attachments. remove webhook sending for speedup 2022-04-09 12:21:28 +01:00
jude 0f05018cab removed remainder of old personal dashboard code. fixed big lighthouse issues. 2022-04-07 21:41:24 +01:00
jude 85d27c5bba fields are now json and work. fix for intervals. moved some code together 2022-04-07 17:13:02 +01:00
jude d946ef1dca process intervals. inlining fields 2022-04-03 21:53:28 +01:00
jude f21d522435 mobile appearence 2022-03-27 19:17:30 +01:00
jude 3add718cdf interval field client processing 2022-03-27 18:03:42 +01:00
jude f4ef7afea0 show newly created reminders. fix color rendering. 2022-03-27 14:15:01 +01:00
jude f8547bba70 upload attachments 2022-03-26 20:03:58 +00:00
jude 08fd88ce54 styling interval inputs 2022-03-24 22:40:05 +00:00
jude abfe492192 put a plain background on images 2022-03-24 21:36:22 +00:00
jude afb2fbe4ff patch reminders 2022-03-22 22:21:47 +00:00
jude 878ea11502 graceful shutdown 2022-03-21 23:11:52 +00:00
jude 93da746bdc support articles 2022-03-20 21:41:38 +00:00
jude 9e6a387f82 support articles 2022-03-20 21:04:24 +00:00
jude af9d8bea62 collapse/expand elements. moved the embed color picker 2022-03-20 18:29:27 +00:00
jude 318be1fa5e prettier for javascript formatting. sorting 2022-03-20 15:46:22 +00:00
jude 3b6e02e16e working on editing reminders 2022-03-20 00:10:19 +00:00
jude a56f84f659 timezone help. moved javascript to separate file 2022-03-19 23:47:40 +00:00
jude 3e4dd0fa48 channel selection shows properly. loader 2022-03-19 21:28:11 +00:00
jude d0d2d50966 create reminders :) 2022-03-19 17:41:34 +00:00
jude e2e5b022a0 create reminder route. formatting on frontend 2022-03-05 19:43:02 +00:00
jude 6ae2353c92 add distinct identifying names. log errors in run_macro 2022-02-20 12:19:39 +00:00
jude 06c4deeaa9 component models 2022-02-19 22:11:21 +00:00
jude afc376c44f everything except component model actions 2022-02-19 18:21:11 +00:00
jude 84ee7e77c5 2nd attempt at doing poise stuff 2022-02-19 14:32:03 +00:00
jude 620f054703 extracted event handler. removed custom sharding code. extracted util functions 2022-02-19 13:28:24 +00:00
jude cb471c52f3 optionally dont run web/postman 2022-02-19 12:45:33 +00:00
jude 37420b2b1f ..... 2022-02-11 20:03:53 +00:00
jude 49974b7153 moved dashboard crate into here 2022-02-11 17:44:08 +00:00
jude a3844dde9e moved postman into separate crate 2022-02-06 15:47:59 +00:00
jude d62c8c95c2 support months in sender 2022-02-01 23:41:28 +00:00
jude 05606dfec1 update lock 2022-02-01 23:05:14 +00:00
jude 68ee42f244 Merge remote-tracking branch 'origin/next' into next
# Conflicts:
#	Cargo.lock
2022-02-01 23:04:44 +00:00
jude fad28faabb interval months/interval seconds 2022-02-01 23:04:31 +00:00
jude e5ab99f67b removed some log messages. rustfmt 2021-12-21 13:46:10 +00:00
jude e47715917e integrate reminder sender 2021-12-20 13:48:18 +00:00
166 changed files with 80949 additions and 4432 deletions
+2
View File
@@ -0,0 +1,2 @@
printWidth = 90
tabWidth = 4
Generated
+1936 -493
View File
File diff suppressed because it is too large Load Diff
+7 -21
View File
@@ -1,12 +1,13 @@
[package]
name = "reminder_rs"
version = "1.6.0-beta2"
version = "1.6.0"
authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018"
workspaces = [".", "postman", "web", "entity", "migration"]
[dependencies]
poise = "0.2"
dotenv = "0.15"
humantime = "2.1"
tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11"
regex = "1.4"
@@ -22,25 +23,10 @@ serde_repr = "0.1"
rmp-serde = "0.15"
rand = "0.7"
levenshtein = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
base64 = "0.13.0"
[dependencies.regex_command_attr]
path = "command_attributes"
[dependencies.postman]
path = "postman"
[dependencies.serenity]
git = "https://github.com/serenity-rs/serenity"
branch = "next"
default-features = false
features = [
"builder",
"client",
"cache",
"gateway",
"http",
"model",
"utils",
"rustls_backend",
"collector",
"unstable_discord_api"
]
[dependencies.reminder_web]
path = "web"
+9 -7
View File
@@ -2,13 +2,20 @@
Reminder Bot for Discord.
## How do I use it?
We offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating
I offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating
reminders are paid on the hosted version of the bot. Keep reading if you want to host it yourself.
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust)
### Compiling
Reminder Bot can be built by running `cargo build --release` in the top level directory. It is necessary to create a folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of dimensions 128x128px to be used as the webhook avatar.
Install build requirements:
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential`
Install Rust from https://rustup.rs
Reminder Bot can then be built by running `cargo build --release` in the top level directory. It is necessary to create a
folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of
dimensions 128x128px to be used as the webhook avatar.
#### Compilation environment variables
These environment variables must be provided when compiling the bot
@@ -30,15 +37,10 @@ __Other Variables__
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
* `IGNORE_BOTS` - default `1`, if `1`, Reminder Bot will ignore all other bots
* `PYTHON_LOCATION` - default `venv/bin/python3`. Can be changed if your Python executable is located somewhere else
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds
* `SHARD_COUNT` - default `None`, accepts the number of shards that are being ran
* `SHARD_RANGE` - default `None`, if `SHARD_COUNT` is specified, specifies what range of shards to start on this process
* `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages
### Todo List
* Convert aliases to macros
* Help command
* Test everything
+28
View File
@@ -0,0 +1,28 @@
[default]
address = "0.0.0.0"
port = 5000
template_dir = "web/templates"
limits = { json = "10MiB" }
[debug]
secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="
[debug.tls]
certs = "web/private/rsa_sha256_cert.pem"
key = "web/private/rsa_sha256_key.pem"
[rsa_sha256.tls]
certs = "web/private/rsa_sha256_cert.pem"
key = "web/private/rsa_sha256_key.pem"
[ecdsa_nistp256_sha256.tls]
certs = "web/private/ecdsa_nistp256_sha256_cert.pem"
key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem"
[ecdsa_nistp384_sha384.tls]
certs = "web/private/ecdsa_nistp384_sha384_cert.pem"
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
[ed25519.tls]
certs = "web/private/ed25519_cert.pem"
key = "eb/private/ed25519_key.pem"
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "regex_command_attr"
version = "0.3.6"
authors = ["acdenisSK <acdenissk69@gmail.com>", "jellywx <judesouthworth@pm.me>"]
edition = "2018"
description = "Procedural macros for command creation for the Serenity library."
license = "ISC"
[lib]
proc-macro = true
[dependencies]
quote = "^1.0"
syn = { version = "^1.0", features = ["full", "derive", "extra-traits"] }
proc-macro2 = "1.0"
uuid = { version = "0.8", features = ["v4"] }
-351
View File
@@ -1,351 +0,0 @@
use std::fmt::{self, Write};
use proc_macro2::Span;
use syn::{
parse::{Error, Result},
spanned::Spanned,
Attribute, Ident, Lit, LitStr, Meta, NestedMeta, Path,
};
use crate::{
structures::{ApplicationCommandOptionType, Arg},
util::{AsOption, LitExt},
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ValueKind {
// #[<name>]
Name,
// #[<name> = <value>]
Equals,
// #[<name>([<value>, <value>, <value>, ...])]
List,
// #[<name>([<prop> = <value>, <prop> = <value>, ...])]
EqualsList,
// #[<name>(<value>)]
SingleList,
}
impl fmt::Display for ValueKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValueKind::Name => f.pad("`#[<name>]`"),
ValueKind::Equals => f.pad("`#[<name> = <value>]`"),
ValueKind::List => f.pad("`#[<name>([<value>, <value>, <value>, ...])]`"),
ValueKind::EqualsList => {
f.pad("`#[<name>([<prop> = <value>, <prop> = <value>, ...])]`")
}
ValueKind::SingleList => f.pad("`#[<name>(<value>)]`"),
}
}
}
fn to_ident(p: Path) -> Result<Ident> {
if p.segments.is_empty() {
return Err(Error::new(p.span(), "cannot convert an empty path to an identifier"));
}
if p.segments.len() > 1 {
return Err(Error::new(p.span(), "the path must not have more than one segment"));
}
if !p.segments[0].arguments.is_empty() {
return Err(Error::new(p.span(), "the singular path segment must not have any arguments"));
}
Ok(p.segments[0].ident.clone())
}
#[derive(Debug)]
pub struct Values {
pub name: Ident,
pub literals: Vec<(Option<String>, Lit)>,
pub kind: ValueKind,
pub span: Span,
}
impl Values {
#[inline]
pub fn new(
name: Ident,
kind: ValueKind,
literals: Vec<(Option<String>, Lit)>,
span: Span,
) -> Self {
Values { name, literals, kind, span }
}
}
pub fn parse_values(attr: &Attribute) -> Result<Values> {
fn is_list_or_named_list(meta: &NestedMeta) -> ValueKind {
match meta {
// catch if the nested value is a literal value
NestedMeta::Lit(_) => ValueKind::List,
// catch if the nested value is a meta value
NestedMeta::Meta(m) => match m {
// path => some quoted value
Meta::Path(_) => ValueKind::List,
Meta::List(_) | Meta::NameValue(_) => ValueKind::EqualsList,
},
}
}
let meta = attr.parse_meta()?;
match meta {
Meta::Path(path) => {
let name = to_ident(path)?;
Ok(Values::new(name, ValueKind::Name, Vec::new(), attr.span()))
}
Meta::List(meta) => {
let name = to_ident(meta.path)?;
let nested = meta.nested;
if nested.is_empty() {
return Err(Error::new(attr.span(), "list cannot be empty"));
}
if is_list_or_named_list(nested.first().unwrap()) == ValueKind::List {
let mut lits = Vec::with_capacity(nested.len());
for meta in nested {
match meta {
// catch if the nested value is a literal value
NestedMeta::Lit(l) => lits.push((None, l)),
// catch if the nested value is a meta value
NestedMeta::Meta(m) => match m {
// path => some quoted value
Meta::Path(path) => {
let i = to_ident(path)?;
lits.push((None, Lit::Str(LitStr::new(&i.to_string(), i.span()))))
}
Meta::List(_) | Meta::NameValue(_) => {
return Err(Error::new(attr.span(), "cannot nest a list; only accept literals and identifiers at this level"))
}
},
}
}
let kind = if lits.len() == 1 { ValueKind::SingleList } else { ValueKind::List };
Ok(Values::new(name, kind, lits, attr.span()))
} else {
let mut lits = Vec::with_capacity(nested.len());
for meta in nested {
match meta {
// catch if the nested value is a literal value
NestedMeta::Lit(_) => {
return Err(Error::new(attr.span(), "key-value pairs expected"))
}
// catch if the nested value is a meta value
NestedMeta::Meta(m) => match m {
Meta::NameValue(n) => {
let name = to_ident(n.path)?.to_string();
let value = n.lit;
lits.push((Some(name), value));
}
Meta::List(_) | Meta::Path(_) => {
return Err(Error::new(attr.span(), "key-value pairs expected"))
}
},
}
}
Ok(Values::new(name, ValueKind::EqualsList, lits, attr.span()))
}
}
Meta::NameValue(meta) => {
let name = to_ident(meta.path)?;
let lit = meta.lit;
Ok(Values::new(name, ValueKind::Equals, vec![(None, lit)], attr.span()))
}
}
}
#[derive(Debug, Clone)]
struct DisplaySlice<'a, T>(&'a [T]);
impl<'a, T: fmt::Display> fmt::Display for DisplaySlice<'a, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut iter = self.0.iter().enumerate();
match iter.next() {
None => f.write_str("nothing")?,
Some((idx, elem)) => {
write!(f, "{}: {}", idx, elem)?;
for (idx, elem) in iter {
f.write_char('\n')?;
write!(f, "{}: {}", idx, elem)?;
}
}
}
Ok(())
}
}
#[inline]
fn is_form_acceptable(expect: &[ValueKind], kind: ValueKind) -> bool {
if expect.contains(&ValueKind::List) && kind == ValueKind::SingleList {
true
} else {
expect.contains(&kind)
}
}
#[inline]
fn validate(values: &Values, forms: &[ValueKind]) -> Result<()> {
if !is_form_acceptable(forms, values.kind) {
return Err(Error::new(
values.span,
// Using the `_args` version here to avoid an allocation.
format_args!("the attribute must be in of these forms:\n{}", DisplaySlice(forms)),
));
}
Ok(())
}
#[inline]
pub fn parse<T: AttributeOption>(values: Values) -> Result<T> {
T::parse(values)
}
pub trait AttributeOption: Sized {
fn parse(values: Values) -> Result<Self>;
}
impl AttributeOption for Vec<String> {
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::List])?;
Ok(values.literals.into_iter().map(|(_, l)| l.to_str()).collect())
}
}
impl AttributeOption for String {
#[inline]
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::Equals, ValueKind::SingleList])?;
Ok(values.literals[0].1.to_str())
}
}
impl AttributeOption for bool {
#[inline]
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::Name, ValueKind::SingleList])?;
Ok(values.literals.get(0).map_or(true, |(_, l)| l.to_bool()))
}
}
impl AttributeOption for Ident {
#[inline]
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::SingleList])?;
Ok(values.literals[0].1.to_ident())
}
}
impl AttributeOption for Vec<Ident> {
#[inline]
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::List])?;
Ok(values.literals.into_iter().map(|(_, l)| l.to_ident()).collect())
}
}
impl AttributeOption for Option<String> {
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::Name, ValueKind::Equals, ValueKind::SingleList])?;
Ok(values.literals.get(0).map(|(_, l)| l.to_str()))
}
}
impl AttributeOption for Arg {
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::EqualsList])?;
let mut arg: Arg = Default::default();
for (key, value) in &values.literals {
match key {
Some(s) => match s.as_str() {
"name" => {
arg.name = value.to_str();
}
"description" => {
arg.description = value.to_str();
}
"required" => {
arg.required = value.to_bool();
}
"kind" => arg.kind = ApplicationCommandOptionType::from_str(value.to_str()),
_ => {
return Err(Error::new(key.span(), "unexpected attribute"));
}
},
_ => {
return Err(Error::new(key.span(), "unnamed attribute"));
}
}
}
Ok(arg)
}
}
impl<T: AttributeOption> AttributeOption for AsOption<T> {
#[inline]
fn parse(values: Values) -> Result<Self> {
Ok(AsOption(Some(T::parse(values)?)))
}
}
macro_rules! attr_option_num {
($($n:ty),*) => {
$(
impl AttributeOption for $n {
fn parse(values: Values) -> Result<Self> {
validate(&values, &[ValueKind::SingleList])?;
Ok(match &values.literals[0].1 {
Lit::Int(l) => l.base10_parse::<$n>()?,
l => {
let s = l.to_str();
// Use `as_str` to guide the compiler to use `&str`'s parse method.
// We don't want to use our `parse` method here (`impl AttributeOption for String`).
match s.as_str().parse::<$n>() {
Ok(n) => n,
Err(_) => return Err(Error::new(l.span(), "invalid integer")),
}
}
})
}
}
impl AttributeOption for Option<$n> {
#[inline]
fn parse(values: Values) -> Result<Self> {
<$n as AttributeOption>::parse(values).map(Some)
}
}
)*
}
}
attr_option_num!(u16, u32, usize);
-10
View File
@@ -1,10 +0,0 @@
pub mod suffixes {
pub const COMMAND: &str = "COMMAND";
pub const ARG: &str = "ARG";
pub const SUBCOMMAND: &str = "SUBCOMMAND";
pub const SUBCOMMAND_GROUP: &str = "GROUP";
pub const CHECK: &str = "CHECK";
pub const HOOK: &str = "HOOK";
}
pub use self::suffixes::*;
-321
View File
@@ -1,321 +0,0 @@
#![deny(rust_2018_idioms)]
#![deny(broken_intra_doc_links)]
use proc_macro::TokenStream;
use proc_macro2::Ident;
use quote::quote;
use syn::{parse::Error, parse_macro_input, parse_quote, spanned::Spanned, Lit, Type};
use uuid::Uuid;
pub(crate) mod attributes;
pub(crate) mod consts;
pub(crate) mod structures;
#[macro_use]
pub(crate) mod util;
use attributes::*;
use consts::*;
use structures::*;
use util::*;
macro_rules! match_options {
($v:expr, $values:ident, $options:ident, $span:expr => [$($name:ident);*]) => {
match $v {
$(
stringify!($name) => $options.$name = propagate_err!($crate::attributes::parse($values)),
)*
_ => {
return Error::new($span, format_args!("invalid attribute: {:?}", $v))
.to_compile_error()
.into();
},
}
};
}
#[proc_macro_attribute]
pub fn command(attr: TokenStream, input: TokenStream) -> TokenStream {
enum LastItem {
Fun,
SubFun,
SubGroup,
SubGroupFun,
}
let mut fun = parse_macro_input!(input as CommandFun);
let _name = if !attr.is_empty() {
parse_macro_input!(attr as Lit).to_str()
} else {
fun.name.to_string()
};
let mut hooks: Vec<Ident> = Vec::new();
let mut options = Options::new();
let mut last_desc = LastItem::Fun;
for attribute in &fun.attributes {
let span = attribute.span();
let values = propagate_err!(parse_values(attribute));
let name = values.name.to_string();
let name = &name[..];
match name {
"subcommand" => {
let new_subcommand = Subcommand::new(propagate_err!(attributes::parse(values)));
if let Some(subcommand_group) = options.subcommand_groups.last_mut() {
last_desc = LastItem::SubGroupFun;
subcommand_group.subcommands.push(new_subcommand);
} else {
last_desc = LastItem::SubFun;
options.subcommands.push(new_subcommand);
}
}
"subcommandgroup" => {
let new_group = SubcommandGroup::new(propagate_err!(attributes::parse(values)));
last_desc = LastItem::SubGroup;
options.subcommand_groups.push(new_group);
}
"arg" => {
let arg = propagate_err!(attributes::parse(values));
match last_desc {
LastItem::Fun => {
options.cmd_args.push(arg);
}
LastItem::SubFun => {
options.subcommands.last_mut().unwrap().cmd_args.push(arg);
}
LastItem::SubGroup => {
panic!("Argument not expected under subcommand group");
}
LastItem::SubGroupFun => {
options
.subcommand_groups
.last_mut()
.unwrap()
.subcommands
.last_mut()
.unwrap()
.cmd_args
.push(arg);
}
}
}
"example" => {
options.examples.push(propagate_err!(attributes::parse(values)));
}
"description" => {
let line: String = propagate_err!(attributes::parse(values));
match last_desc {
LastItem::Fun => {
util::append_line(&mut options.description, line);
}
LastItem::SubFun => {
util::append_line(
&mut options.subcommands.last_mut().unwrap().description,
line,
);
}
LastItem::SubGroup => {
util::append_line(
&mut options.subcommand_groups.last_mut().unwrap().description,
line,
);
}
LastItem::SubGroupFun => {
util::append_line(
&mut options
.subcommand_groups
.last_mut()
.unwrap()
.subcommands
.last_mut()
.unwrap()
.description,
line,
);
}
}
}
"hook" => {
hooks.push(propagate_err!(attributes::parse(values)));
}
_ => {
match_options!(name, values, options, span => [
aliases;
group;
can_blacklist;
supports_dm
]);
}
}
}
let Options {
aliases,
description,
group,
examples,
can_blacklist,
supports_dm,
mut cmd_args,
mut subcommands,
mut subcommand_groups,
} = options;
let visibility = fun.visibility;
let name = fun.name.clone();
let body = fun.body;
let root_ident = name.with_suffix(COMMAND);
let command_path = quote!(crate::framework::Command);
populate_fut_lifetimes_on_refs(&mut fun.args);
let mut subcommand_group_idents = subcommand_groups
.iter()
.map(|subcommand| {
root_ident
.with_suffix(subcommand.name.replace("-", "_").as_str())
.with_suffix(SUBCOMMAND_GROUP)
})
.collect::<Vec<Ident>>();
let mut subcommand_idents = subcommands
.iter()
.map(|subcommand| {
root_ident
.with_suffix(subcommand.name.replace("-", "_").as_str())
.with_suffix(SUBCOMMAND)
})
.collect::<Vec<Ident>>();
let mut arg_idents = cmd_args
.iter()
.map(|arg| root_ident.with_suffix(arg.name.replace("-", "_").as_str()).with_suffix(ARG))
.collect::<Vec<Ident>>();
let mut tokens = quote! {};
tokens.extend(
subcommand_groups
.iter_mut()
.zip(subcommand_group_idents.iter())
.map(|(group, group_ident)| group.as_tokens(group_ident))
.fold(quote! {}, |mut a, b| {
a.extend(b);
a
}),
);
tokens.extend(
subcommands
.iter_mut()
.zip(subcommand_idents.iter())
.map(|(subcommand, sc_ident)| subcommand.as_tokens(sc_ident))
.fold(quote! {}, |mut a, b| {
a.extend(b);
a
}),
);
tokens.extend(
cmd_args.iter_mut().zip(arg_idents.iter()).map(|(arg, ident)| arg.as_tokens(ident)).fold(
quote! {},
|mut a, b| {
a.extend(b);
a
},
),
);
arg_idents.append(&mut subcommand_group_idents);
arg_idents.append(&mut subcommand_idents);
let args = fun.args;
let variant = if args.len() == 2 {
quote!(crate::framework::CommandFnType::Multi)
} else {
let string: Type = parse_quote!(String);
let final_arg = args.get(2).unwrap();
if final_arg.kind == string {
quote!(crate::framework::CommandFnType::Text)
} else {
quote!(crate::framework::CommandFnType::Slash)
}
};
tokens.extend(quote! {
#[allow(missing_docs)]
pub static #root_ident: #command_path = #command_path {
fun: #variant(#name),
names: &[#_name, #(#aliases),*],
desc: #description,
group: #group,
examples: &[#(#examples),*],
can_blacklist: #can_blacklist,
supports_dm: #supports_dm,
args: &[#(&#arg_idents),*],
hooks: &[#(&#hooks),*],
};
#[allow(missing_docs)]
#visibility fn #name<'fut> (#(#args),*) -> ::serenity::futures::future::BoxFuture<'fut, ()> {
use ::serenity::futures::future::FutureExt;
async move {
#(#body)*;
}.boxed()
}
});
tokens.into()
}
#[proc_macro_attribute]
pub fn check(_attr: TokenStream, input: TokenStream) -> TokenStream {
let mut fun = parse_macro_input!(input as CommandFun);
let n = fun.name.clone();
let name = n.with_suffix(HOOK);
let fn_name = n.with_suffix(CHECK);
let visibility = fun.visibility;
let body = fun.body;
let ret = fun.ret;
populate_fut_lifetimes_on_refs(&mut fun.args);
let args = fun.args;
let hook_path = quote!(crate::framework::Hook);
let uuid = Uuid::new_v4().as_u128();
(quote! {
#[allow(missing_docs)]
#visibility fn #fn_name<'fut>(#(#args),*) -> ::serenity::futures::future::BoxFuture<'fut, #ret> {
use ::serenity::futures::future::FutureExt;
async move {
let _output: #ret = { #(#body)* };
#[allow(unreachable_code)]
_output
}.boxed()
}
#[allow(missing_docs)]
pub static #name: #hook_path = #hook_path {
fun: #fn_name,
uuid: #uuid,
};
})
.into()
}
-331
View File
@@ -1,331 +0,0 @@
use proc_macro2::TokenStream as TokenStream2;
use quote::{quote, ToTokens};
use syn::{
braced,
parse::{Error, Parse, ParseStream, Result},
spanned::Spanned,
Attribute, Block, FnArg, Ident, Pat, ReturnType, Stmt, Token, Type, Visibility,
};
use crate::{
consts::{ARG, SUBCOMMAND},
util::{Argument, IdentExt2, Parenthesised},
};
fn parse_argument(arg: FnArg) -> Result<Argument> {
match arg {
FnArg::Typed(typed) => {
let pat = typed.pat;
let kind = typed.ty;
match *pat {
Pat::Ident(id) => {
let name = id.ident;
let mutable = id.mutability;
Ok(Argument { mutable, name, kind: *kind })
}
Pat::Wild(wild) => {
let token = wild.underscore_token;
let name = Ident::new("_", token.spans[0]);
Ok(Argument { mutable: None, name, kind: *kind })
}
_ => Err(Error::new(pat.span(), format_args!("unsupported pattern: {:?}", pat))),
}
}
FnArg::Receiver(_) => {
Err(Error::new(arg.span(), format_args!("`self` arguments are prohibited: {:?}", arg)))
}
}
}
#[derive(Debug)]
pub struct CommandFun {
/// `#[...]`-style attributes.
pub attributes: Vec<Attribute>,
/// Populated cooked attributes. These are attributes outside of the realm of this crate's procedural macros
/// and will appear in generated output.
pub visibility: Visibility,
pub name: Ident,
pub args: Vec<Argument>,
pub ret: Type,
pub body: Vec<Stmt>,
}
impl Parse for CommandFun {
fn parse(input: ParseStream<'_>) -> Result<Self> {
let attributes = input.call(Attribute::parse_outer)?;
let visibility = input.parse::<Visibility>()?;
input.parse::<Token![async]>()?;
input.parse::<Token![fn]>()?;
let name = input.parse()?;
// (...)
let Parenthesised(args) = input.parse::<Parenthesised<FnArg>>()?;
let ret = match input.parse::<ReturnType>()? {
ReturnType::Type(_, t) => (*t).clone(),
ReturnType::Default => Type::Verbatim(quote!(())),
};
// { ... }
let bcont;
braced!(bcont in input);
let body = bcont.call(Block::parse_within)?;
let args = args.into_iter().map(parse_argument).collect::<Result<Vec<_>>>()?;
Ok(Self { attributes, visibility, name, args, ret, body })
}
}
impl ToTokens for CommandFun {
fn to_tokens(&self, stream: &mut TokenStream2) {
let Self { attributes: _, visibility, name, args, ret, body } = self;
stream.extend(quote! {
#visibility async fn #name (#(#args),*) -> #ret {
#(#body)*
}
});
}
}
#[derive(Debug)]
pub(crate) enum ApplicationCommandOptionType {
SubCommand,
SubCommandGroup,
String,
Integer,
Boolean,
User,
Channel,
Role,
Mentionable,
Number,
Unknown,
}
impl ApplicationCommandOptionType {
pub fn from_str(s: String) -> Self {
match s.as_str() {
"SubCommand" => Self::SubCommand,
"SubCommandGroup" => Self::SubCommandGroup,
"String" => Self::String,
"Integer" => Self::Integer,
"Boolean" => Self::Boolean,
"User" => Self::User,
"Channel" => Self::Channel,
"Role" => Self::Role,
"Mentionable" => Self::Mentionable,
"Number" => Self::Number,
_ => Self::Unknown,
}
}
}
impl ToTokens for ApplicationCommandOptionType {
fn to_tokens(&self, stream: &mut TokenStream2) {
let path = quote!(
serenity::model::interactions::application_command::ApplicationCommandOptionType
);
let variant = match self {
ApplicationCommandOptionType::SubCommand => quote!(SubCommand),
ApplicationCommandOptionType::SubCommandGroup => quote!(SubCommandGroup),
ApplicationCommandOptionType::String => quote!(String),
ApplicationCommandOptionType::Integer => quote!(Integer),
ApplicationCommandOptionType::Boolean => quote!(Boolean),
ApplicationCommandOptionType::User => quote!(User),
ApplicationCommandOptionType::Channel => quote!(Channel),
ApplicationCommandOptionType::Role => quote!(Role),
ApplicationCommandOptionType::Mentionable => quote!(Mentionable),
ApplicationCommandOptionType::Number => quote!(Number),
ApplicationCommandOptionType::Unknown => quote!(Unknown),
};
stream.extend(quote! {
#path::#variant
});
}
}
#[derive(Debug)]
pub(crate) struct Arg {
pub name: String,
pub description: String,
pub kind: ApplicationCommandOptionType,
pub required: bool,
}
impl Arg {
pub fn as_tokens(&self, ident: &Ident) -> TokenStream2 {
let arg_path = quote!(crate::framework::Arg);
let Arg { name, description, kind, required } = self;
quote! {
#[allow(missing_docs)]
pub static #ident: #arg_path = #arg_path {
name: #name,
description: #description,
kind: #kind,
required: #required,
options: &[]
};
}
}
}
impl Default for Arg {
fn default() -> Self {
Self {
name: String::new(),
description: String::new(),
kind: ApplicationCommandOptionType::String,
required: false,
}
}
}
#[derive(Debug)]
pub(crate) struct Subcommand {
pub name: String,
pub description: String,
pub cmd_args: Vec<Arg>,
}
impl Subcommand {
pub fn as_tokens(&mut self, ident: &Ident) -> TokenStream2 {
let arg_path = quote!(crate::framework::Arg);
let subcommand_path = ApplicationCommandOptionType::SubCommand;
let arg_idents = self
.cmd_args
.iter()
.map(|arg| ident.with_suffix(arg.name.as_str()).with_suffix(ARG))
.collect::<Vec<Ident>>();
let mut tokens = self
.cmd_args
.iter_mut()
.zip(arg_idents.iter())
.map(|(arg, ident)| arg.as_tokens(ident))
.fold(quote! {}, |mut a, b| {
a.extend(b);
a
});
let Subcommand { name, description, .. } = self;
tokens.extend(quote! {
#[allow(missing_docs)]
pub static #ident: #arg_path = #arg_path {
name: #name,
description: #description,
kind: #subcommand_path,
required: false,
options: &[#(&#arg_idents),*],
};
});
tokens
}
}
impl Default for Subcommand {
fn default() -> Self {
Self { name: String::new(), description: String::new(), cmd_args: vec![] }
}
}
impl Subcommand {
pub(crate) fn new(name: String) -> Self {
Self { name, ..Default::default() }
}
}
#[derive(Debug)]
pub(crate) struct SubcommandGroup {
pub name: String,
pub description: String,
pub subcommands: Vec<Subcommand>,
}
impl SubcommandGroup {
pub fn as_tokens(&mut self, ident: &Ident) -> TokenStream2 {
let arg_path = quote!(crate::framework::Arg);
let subcommand_group_path = ApplicationCommandOptionType::SubCommandGroup;
let arg_idents = self
.subcommands
.iter()
.map(|arg| {
ident
.with_suffix(self.name.as_str())
.with_suffix(arg.name.as_str())
.with_suffix(SUBCOMMAND)
})
.collect::<Vec<Ident>>();
let mut tokens = self
.subcommands
.iter_mut()
.zip(arg_idents.iter())
.map(|(subcommand, ident)| subcommand.as_tokens(ident))
.fold(quote! {}, |mut a, b| {
a.extend(b);
a
});
let SubcommandGroup { name, description, .. } = self;
tokens.extend(quote! {
#[allow(missing_docs)]
pub static #ident: #arg_path = #arg_path {
name: #name,
description: #description,
kind: #subcommand_group_path,
required: false,
options: &[#(&#arg_idents),*],
};
});
tokens
}
}
impl Default for SubcommandGroup {
fn default() -> Self {
Self { name: String::new(), description: String::new(), subcommands: vec![] }
}
}
impl SubcommandGroup {
pub(crate) fn new(name: String) -> Self {
Self { name, ..Default::default() }
}
}
#[derive(Debug, Default)]
pub(crate) struct Options {
pub aliases: Vec<String>,
pub description: String,
pub group: String,
pub examples: Vec<String>,
pub can_blacklist: bool,
pub supports_dm: bool,
pub cmd_args: Vec<Arg>,
pub subcommands: Vec<Subcommand>,
pub subcommand_groups: Vec<SubcommandGroup>,
}
impl Options {
#[inline]
pub fn new() -> Self {
Self { group: "None".to_string(), ..Default::default() }
}
}
-176
View File
@@ -1,176 +0,0 @@
use proc_macro::TokenStream;
use proc_macro2::{Span, TokenStream as TokenStream2};
use quote::{format_ident, quote, ToTokens};
use syn::{
braced, bracketed, parenthesized,
parse::{Error, Parse, ParseStream, Result as SynResult},
punctuated::Punctuated,
token::{Comma, Mut},
Ident, Lifetime, Lit, Type,
};
pub trait LitExt {
fn to_str(&self) -> String;
fn to_bool(&self) -> bool;
fn to_ident(&self) -> Ident;
}
impl LitExt for Lit {
fn to_str(&self) -> String {
match self {
Lit::Str(s) => s.value(),
Lit::ByteStr(s) => unsafe { String::from_utf8_unchecked(s.value()) },
Lit::Char(c) => c.value().to_string(),
Lit::Byte(b) => (b.value() as char).to_string(),
_ => panic!("values must be a (byte)string or a char"),
}
}
fn to_bool(&self) -> bool {
if let Lit::Bool(b) = self {
b.value
} else {
self.to_str()
.parse()
.unwrap_or_else(|_| panic!("expected bool from {:?}", self))
}
}
#[inline]
fn to_ident(&self) -> Ident {
Ident::new(&self.to_str(), self.span())
}
}
pub trait IdentExt2: Sized {
fn to_uppercase(&self) -> Self;
fn with_suffix(&self, suf: &str) -> Ident;
}
impl IdentExt2 for Ident {
#[inline]
fn to_uppercase(&self) -> Self {
format_ident!("{}", self.to_string().to_uppercase())
}
#[inline]
fn with_suffix(&self, suffix: &str) -> Ident {
format_ident!("{}_{}", self.to_string().to_uppercase(), suffix)
}
}
#[inline]
pub fn into_stream(e: Error) -> TokenStream {
e.to_compile_error().into()
}
macro_rules! propagate_err {
($res:expr) => {{
match $res {
Ok(v) => v,
Err(e) => return $crate::util::into_stream(e),
}
}};
}
#[derive(Debug)]
pub struct Bracketed<T>(pub Punctuated<T, Comma>);
impl<T: Parse> Parse for Bracketed<T> {
fn parse(input: ParseStream<'_>) -> SynResult<Self> {
let content;
bracketed!(content in input);
Ok(Bracketed(content.parse_terminated(T::parse)?))
}
}
#[derive(Debug)]
pub struct Braced<T>(pub Punctuated<T, Comma>);
impl<T: Parse> Parse for Braced<T> {
fn parse(input: ParseStream<'_>) -> SynResult<Self> {
let content;
braced!(content in input);
Ok(Braced(content.parse_terminated(T::parse)?))
}
}
#[derive(Debug)]
pub struct Parenthesised<T>(pub Punctuated<T, Comma>);
impl<T: Parse> Parse for Parenthesised<T> {
fn parse(input: ParseStream<'_>) -> SynResult<Self> {
let content;
parenthesized!(content in input);
Ok(Parenthesised(content.parse_terminated(T::parse)?))
}
}
#[derive(Debug)]
pub struct AsOption<T>(pub Option<T>);
impl<T: ToTokens> ToTokens for AsOption<T> {
fn to_tokens(&self, stream: &mut TokenStream2) {
match &self.0 {
Some(o) => stream.extend(quote!(Some(#o))),
None => stream.extend(quote!(None)),
}
}
}
impl<T> Default for AsOption<T> {
#[inline]
fn default() -> Self {
AsOption(None)
}
}
#[derive(Debug)]
pub struct Argument {
pub mutable: Option<Mut>,
pub name: Ident,
pub kind: Type,
}
impl ToTokens for Argument {
fn to_tokens(&self, stream: &mut TokenStream2) {
let Argument {
mutable,
name,
kind,
} = self;
stream.extend(quote! {
#mutable #name: #kind
});
}
}
#[inline]
pub fn populate_fut_lifetimes_on_refs(args: &mut Vec<Argument>) {
for arg in args {
if let Type::Reference(reference) = &mut arg.kind {
reference.lifetime = Some(Lifetime::new("'fut", Span::call_site()));
}
}
}
pub fn append_line(desc: &mut String, mut line: String) {
if line.starts_with(' ') {
line.remove(0);
}
match line.rfind("\\$") {
Some(i) => {
desc.push_str(line[..i].trim_end());
desc.push(' ');
}
None => {
desc.push_str(&line);
desc.push('\n');
}
}
}
-233
View File
@@ -1,233 +0,0 @@
CREATE DATABASE IF NOT EXISTS reminders;
SET FOREIGN_KEY_CHECKS=0;
USE reminders;
CREATE TABLE reminders.guilds (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
guild BIGINT UNSIGNED UNIQUE NOT NULL,
name VARCHAR(100),
prefix VARCHAR(5) DEFAULT '$' NOT NULL,
timezone VARCHAR(32) DEFAULT 'UTC' NOT NULL,
default_channel_id INT UNSIGNED,
default_username VARCHAR(32) DEFAULT 'Reminder' NOT NULL,
default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (default_channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL
);
CREATE TABLE reminders.channels (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
channel BIGINT UNSIGNED UNIQUE NOT NULL,
name VARCHAR(100),
nudge SMALLINT NOT NULL DEFAULT 0,
blacklisted BOOL NOT NULL DEFAULT FALSE,
webhook_id BIGINT UNSIGNED UNIQUE,
webhook_token TEXT,
paused BOOL NOT NULL DEFAULT 0,
paused_until TIMESTAMP,
guild_id INT UNSIGNED,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE
);
CREATE TABLE reminders.users (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
user BIGINT UNSIGNED UNIQUE NOT NULL,
name VARCHAR(37) NOT NULL,
dm_channel INT UNSIGNED UNIQUE NOT NULL,
language VARCHAR(2) DEFAULT 'EN' NOT NULL,
timezone VARCHAR(32) DEFAULT 'UTC' NOT NULL,
meridian_time BOOLEAN DEFAULT 0 NOT NULL,
allowed_dm BOOLEAN DEFAULT 1 NOT NULL,
patreon BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id),
FOREIGN KEY (dm_channel) REFERENCES reminders.channels(id) ON DELETE RESTRICT
);
CREATE TABLE reminders.roles (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
role BIGINT UNSIGNED UNIQUE NOT NULL,
name VARCHAR(100),
guild_id INT UNSIGNED NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE
);
CREATE TABLE reminders.embeds (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '',
description VARCHAR(2048) NOT NULL DEFAULT '',
image_url VARCHAR(512),
thumbnail_url VARCHAR(512),
footer VARCHAR(2048) NOT NULL DEFAULT '',
footer_icon VARCHAR(512),
color MEDIUMINT UNSIGNED NOT NULL DEFAULT 0x0,
PRIMARY KEY (id)
);
CREATE TABLE reminders.embed_fields (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '',
value VARCHAR(1024) NOT NULL DEFAULT '',
inline BOOL NOT NULL DEFAULT 0,
embed_id INT UNSIGNED NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE CASCADE
);
CREATE TABLE reminders.messages (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
content VARCHAR(2048) NOT NULL DEFAULT '',
tts BOOL NOT NULL DEFAULT 0,
embed_id INT UNSIGNED,
attachment MEDIUMBLOB,
attachment_name VARCHAR(260),
PRIMARY KEY (id),
FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE SET NULL
);
CREATE TABLE reminders.reminders (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
uid VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(24) NOT NULL DEFAULT 'Reminder',
message_id INT UNSIGNED NOT NULL,
channel_id INT UNSIGNED NOT NULL,
`time` INT UNSIGNED DEFAULT 0 NOT NULL,
`interval` INT UNSIGNED DEFAULT NULL,
expires TIMESTAMP DEFAULT NULL,
enabled BOOLEAN DEFAULT 1 NOT NULL,
avatar VARCHAR(512),
username VARCHAR(32),
method ENUM('remind', 'natural', 'dashboard', 'todo', 'countdown'),
set_at TIMESTAMP DEFAULT NOW(),
set_by INT UNSIGNED,
PRIMARY KEY (id),
FOREIGN KEY (message_id) REFERENCES reminders.messages(id) ON DELETE RESTRICT,
FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE CASCADE,
FOREIGN KEY (set_by) REFERENCES reminders.users(id) ON DELETE SET NULL
);
CREATE TRIGGER message_cleanup AFTER DELETE ON reminders.reminders
FOR EACH ROW
DELETE FROM reminders.messages WHERE id = OLD.message_id;
CREATE TRIGGER embed_cleanup AFTER DELETE ON reminders.messages
FOR EACH ROW
DELETE FROM reminders.embeds WHERE id = OLD.embed_id;
CREATE TABLE reminders.todos (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
user_id INT UNSIGNED,
guild_id INT UNSIGNED,
channel_id INT UNSIGNED,
value VARCHAR(2000) NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL
);
CREATE TABLE reminders.command_restrictions (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
role_id INT UNSIGNED NOT NULL,
command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (role_id) REFERENCES reminders.roles(id) ON DELETE CASCADE,
UNIQUE KEY (`role_id`, `command`)
);
CREATE TABLE reminders.timers (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
start_time TIMESTAMP NOT NULL DEFAULT NOW(),
name VARCHAR(32) NOT NULL,
owner BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE reminders.events (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
`time` TIMESTAMP NOT NULL DEFAULT NOW(),
event_name ENUM('edit', 'enable', 'disable', 'delete') NOT NULL,
bulk_count INT UNSIGNED,
guild_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED,
reminder_id INT UNSIGNED,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
FOREIGN KEY (reminder_id) REFERENCES reminders.reminders(id) ON DELETE SET NULL
);
CREATE TABLE reminders.command_aliases (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
guild_id INT UNSIGNED NOT NULL,
name VARCHAR(12) NOT NULL,
command VARCHAR(2048) NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
UNIQUE KEY (`guild_id`, `name`)
);
CREATE TABLE reminders.guild_users (
guild INT UNSIGNED NOT NULL,
user INT UNSIGNED NOT NULL,
can_access BOOL NOT NULL DEFAULT 0,
FOREIGN KEY (guild) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user) REFERENCES reminders.users(id) ON DELETE CASCADE,
UNIQUE KEY (guild, user)
);
CREATE EVENT reminders.event_cleanup
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
ON COMPLETION PRESERVE
DO DELETE FROM reminders.events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);
File diff suppressed because one or more lines are too long
-13
View File
@@ -1,13 +0,0 @@
USE reminders;
CREATE TABLE macro (
id INT UNSIGNED AUTO_INCREMENT,
guild_id INT UNSIGNED NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(100),
commands TEXT NOT NULL,
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
PRIMARY KEY (id)
);
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "models"
version = "0.1.0"
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "models"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "entity"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono-tz = "^0.6"
sea-orm = { version = "^0.8", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] }
+60
View File
@@ -0,0 +1,60 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "channel")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: i64,
pub guild_id: Option<i64>,
pub nudge: i32,
pub webhook_id: Option<i64>,
pub webhook_token: Option<String>,
pub paused: bool,
pub paused_until: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild,
#[sea_orm(has_many = "super::user::Entity")]
User,
#[sea_orm(has_many = "super::reminder::Entity")]
Reminder,
#[sea_orm(has_many = "super::todo::Entity")]
Todo,
}
impl Related<super::guild::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guild.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::reminder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Reminder.def()
}
}
impl Related<super::todo::Entity> for Entity {
fn to() -> RelationDef {
Relation::Todo.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+34
View File
@@ -0,0 +1,34 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "command_macro")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub guild_id: i64,
pub name: String,
pub description: Option<String>,
pub commands: Option<Json>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild,
}
impl Related<super::guild::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guild.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+48
View File
@@ -0,0 +1,48 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "guild")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::reminder_template::Entity")]
ReminderTemplate,
#[sea_orm(has_many = "super::channel::Entity")]
Channel,
#[sea_orm(has_many = "super::todo::Entity")]
Todo,
#[sea_orm(has_many = "super::command_macro::Entity")]
CommandMacro,
}
impl Related<super::reminder_template::Entity> for Entity {
fn to() -> RelationDef {
Relation::ReminderTemplate.def()
}
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::todo::Entity> for Entity {
fn to() -> RelationDef {
Relation::Todo.def()
}
}
impl Related<super::command_macro::Entity> for Entity {
fn to() -> RelationDef {
Relation::CommandMacro.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+1
View File
@@ -0,0 +1 @@
+14
View File
@@ -0,0 +1,14 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
pub mod prelude;
pub mod channel;
pub mod command_macro;
pub mod guild;
pub mod reminder;
pub mod reminder_template;
pub mod sea_orm_active_enums;
pub mod seaql_migrations;
pub mod timer;
pub mod todo;
pub mod user;
+8
View File
@@ -0,0 +1,8 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
pub use super::{
channel::Entity as Channel, command_macro::Entity as CommandMacro, guild::Entity as Guild,
reminder::Entity as Reminder, reminder_template::Entity as ReminderTemplate,
seaql_migrations::Entity as SeaqlMigrations, timer::Entity as Timer, todo::Entity as Todo,
user::Entity as User,
};
+73
View File
@@ -0,0 +1,73 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
use super::sea_orm_active_enums::Timezone;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "reminder")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub uid: String,
pub name: String,
pub channel_id: i64,
pub utc_time: DateTimeUtc,
pub timezone: Timezone,
pub interval_seconds: Option<i32>,
pub interval_months: Option<i32>,
pub enabled: bool,
pub expires: Option<DateTimeUtc>,
pub username: Option<String>,
pub avatar: Option<String>,
pub content: Option<String>,
pub tts: bool,
pub attachment: Option<Vec<u8>>,
pub attachment_name: Option<String>,
pub embed_title: Option<String>,
pub embed_description: Option<String>,
pub embed_image_url: Option<String>,
pub embed_thumbnail_url: Option<String>,
pub embed_footer: Option<String>,
pub embed_footer_url: Option<String>,
pub embed_author: Option<String>,
pub embed_author_url: Option<String>,
pub embed_color: Option<i32>,
pub embed_fields: Option<Json>,
pub set_at: DateTimeUtc,
pub set_by: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Channel,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::SetBy",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+48
View File
@@ -0,0 +1,48 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "reminder_template")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub guild_id: i64,
pub name: String,
pub username: Option<String>,
pub avatar: Option<String>,
pub content: Option<String>,
pub tts: bool,
pub attachment: Option<Vec<u8>>,
pub attachment_name: Option<String>,
pub embed_title: Option<String>,
pub embed_description: Option<String>,
pub embed_image_url: Option<String>,
pub embed_thumbnail_url: Option<String>,
pub embed_footer: Option<String>,
pub embed_footer_url: Option<String>,
pub embed_author: Option<String>,
pub embed_author_url: Option<String>,
pub embed_color: Option<i32>,
pub embed_fields: Option<Json>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild,
}
impl Related<super::guild::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guild.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "seaql_migrations")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub version: String,
pub applied_at: i64,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}
+36
View File
@@ -0,0 +1,36 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "timer")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub start_time: DateTimeUtc,
pub name: String,
pub user_id: Option<i64>,
pub guild_id: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild2,
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::UserId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild1,
}
impl ActiveModelBehavior for ActiveModel {}
+62
View File
@@ -0,0 +1,62 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "todo")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: Option<i64>,
pub guild_id: Option<i64>,
pub channel_id: Option<i64>,
pub value: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Channel,
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::guild::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guild.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+50
View File
@@ -0,0 +1,50 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
use super::sea_orm_active_enums::Timezone;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: i64,
pub dm_channel: i64,
pub timezone: Timezone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::DmChannel",
to = "super::channel::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Channel,
#[sea_orm(has_many = "super::reminder::Entity")]
Reminder,
#[sea_orm(has_many = "super::todo::Entity")]
Todo,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::reminder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Reminder.def()
}
}
impl Related<super::todo::Entity> for Entity {
fn to() -> RelationDef {
Relation::Todo.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+2400
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
entity = { path = "../entity" }
chrono-tz = "^0.6"
[dependencies.sea-orm-migration]
version = "^0.8.0"
+37
View File
@@ -0,0 +1,37 @@
# Running Migrator CLI
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```
+12
View File
@@ -0,0 +1,12 @@
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20220101_000001_create_table::Migration)]
}
}
@@ -0,0 +1,553 @@
use chrono_tz::{Tz, TZ_VARIANTS};
use sea_orm_migration::prelude::*;
use crate::extension::postgres::Type;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20220101_000001_create_table"
}
}
#[derive(Iden)]
pub enum Guild {
Table,
Id,
}
#[derive(Iden)]
pub enum Channel {
Table,
Id,
GuildId,
Nudge,
WebhookId,
WebhookToken,
Paused,
PausedUntil,
}
#[derive(Iden)]
pub enum User {
Table,
Id,
DmChannel,
Timezone,
}
#[derive(Iden)]
pub enum Reminder {
Table,
Id,
Uid,
Name,
ChannelId,
UtcTime,
Timezone,
IntervalSeconds,
IntervalMonths,
Enabled,
Expires,
Username,
Avatar,
Content,
Tts,
Attachment,
AttachmentName,
EmbedTitle,
EmbedDescription,
EmbedImageUrl,
EmbedThumbnailUrl,
EmbedFooter,
EmbedFooterUrl,
EmbedAuthor,
EmbedAuthorUrl,
EmbedColor,
EmbedFields,
SetAt,
SetBy,
}
#[derive(Iden)]
pub enum ReminderTemplate {
Table,
Id,
GuildId,
Name,
Username,
Avatar,
Content,
Tts,
Attachment,
AttachmentName,
EmbedTitle,
EmbedDescription,
EmbedImageUrl,
EmbedThumbnailUrl,
EmbedFooter,
EmbedFooterUrl,
EmbedAuthor,
EmbedAuthorUrl,
EmbedColor,
EmbedFields,
}
#[derive(Iden)]
pub enum Timer {
Table,
Id,
StartTime,
Name,
UserId,
GuildId,
}
#[derive(Iden)]
pub enum Todo {
Table,
Id,
UserId,
GuildId,
ChannelId,
Value,
}
#[derive(Iden)]
pub enum CommandMacro {
Table,
Id,
GuildId,
Name,
Description,
Commands,
}
pub enum Timezone {
Type,
Tz(Tz),
}
impl Iden for Timezone {
fn unquoted(&self, s: &mut dyn Write) {
write!(
s,
"{}",
match self {
Self::Type => "timezone".to_string(),
Self::Tz(tz) => tz.to_string(),
}
)
.unwrap();
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_type(
Type::create()
.as_enum(Timezone::Type)
.values(TZ_VARIANTS.iter().map(|tz| Timezone::Tz(tz.to_owned())))
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Guild::Table)
.if_not_exists()
.col(ColumnDef::new(Guild::Id).big_integer().not_null().primary_key())
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Channel::Table)
.if_not_exists()
.col(ColumnDef::new(Channel::Id).big_integer().not_null().primary_key())
.col(ColumnDef::new(Channel::GuildId).big_integer())
.col(ColumnDef::new(Channel::Nudge).integer().not_null().default(0))
.col(ColumnDef::new(Channel::WebhookId).big_integer())
.col(ColumnDef::new(Channel::WebhookToken).string())
.col(ColumnDef::new(Channel::Paused).boolean().not_null().default(false))
.col(ColumnDef::new(Channel::PausedUntil).date_time())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_channel_guild")
.from(Channel::Table, Channel::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(ColumnDef::new(User::Id).big_integer().not_null().primary_key())
.col(ColumnDef::new(User::DmChannel).big_integer().not_null())
.col(
ColumnDef::new(User::Timezone)
.custom(Timezone::Type)
.not_null()
.default("UTC"),
)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_user_channel")
.from(User::Table, User::DmChannel)
.to(Channel::Table, Channel::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Reminder::Table)
.if_not_exists()
.col(
ColumnDef::new(Reminder::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Reminder::Uid).string().char_len(64).not_null())
.col(
ColumnDef::new(Reminder::Name)
.string()
.char_len(24)
.default("Reminder")
.not_null(),
)
.col(ColumnDef::new(Reminder::ChannelId).big_integer().not_null())
.col(ColumnDef::new(Reminder::UtcTime).date_time().not_null())
.col(
ColumnDef::new(Reminder::Timezone)
.custom(Timezone::Type)
.not_null()
.default("UTC"),
)
.col(ColumnDef::new(Reminder::IntervalSeconds).integer())
.col(ColumnDef::new(Reminder::IntervalMonths).integer())
.col(ColumnDef::new(Reminder::Enabled).boolean().not_null().default(false))
.col(ColumnDef::new(Reminder::Expires).date_time())
.col(ColumnDef::new(Reminder::Username).string_len(32))
.col(ColumnDef::new(Reminder::Avatar).string_len(512))
.col(ColumnDef::new(Reminder::Content).string_len(2000))
.col(ColumnDef::new(Reminder::Tts).boolean().not_null().default(false))
.col(ColumnDef::new(Reminder::Attachment).binary_len(8 * 1024 * 1024))
.col(ColumnDef::new(Reminder::AttachmentName).string_len(260))
.col(ColumnDef::new(Reminder::EmbedTitle).string_len(256))
.col(ColumnDef::new(Reminder::EmbedDescription).string_len(4096))
.col(ColumnDef::new(Reminder::EmbedImageUrl).string_len(500))
.col(ColumnDef::new(Reminder::EmbedThumbnailUrl).string_len(500))
.col(ColumnDef::new(Reminder::EmbedFooter).string_len(2048))
.col(ColumnDef::new(Reminder::EmbedFooterUrl).string_len(500))
.col(ColumnDef::new(Reminder::EmbedAuthor).string_len(256))
.col(ColumnDef::new(Reminder::EmbedAuthorUrl).string_len(500))
.col(ColumnDef::new(Reminder::EmbedColor).integer())
.col(ColumnDef::new(Reminder::EmbedFields).json())
.col(ColumnDef::new(Reminder::SetAt).date_time().not_null().default("NOW()"))
.col(ColumnDef::new(Reminder::SetBy).big_integer().not_null())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_reminder_channel")
.from(Reminder::Table, Reminder::ChannelId)
.to(Channel::Table, Channel::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_reminder_user")
.from(Reminder::Table, Reminder::SetBy)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(ReminderTemplate::Table)
.if_not_exists()
.col(
ColumnDef::new(ReminderTemplate::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(ReminderTemplate::GuildId).big_integer().not_null())
.col(
ColumnDef::new(ReminderTemplate::Name)
.string()
.char_len(24)
.default("Reminder")
.not_null(),
)
.col(ColumnDef::new(ReminderTemplate::Username).string_len(32))
.col(ColumnDef::new(ReminderTemplate::Avatar).string_len(512))
.col(ColumnDef::new(ReminderTemplate::Content).string_len(2000))
.col(ColumnDef::new(ReminderTemplate::Tts).boolean().not_null().default(false))
.col(ColumnDef::new(ReminderTemplate::Attachment).binary_len(8 * 1024 * 1024))
.col(ColumnDef::new(ReminderTemplate::AttachmentName).string_len(260))
.col(ColumnDef::new(ReminderTemplate::EmbedTitle).string_len(256))
.col(ColumnDef::new(ReminderTemplate::EmbedDescription).string_len(4096))
.col(ColumnDef::new(ReminderTemplate::EmbedImageUrl).string_len(500))
.col(ColumnDef::new(ReminderTemplate::EmbedThumbnailUrl).string_len(500))
.col(ColumnDef::new(ReminderTemplate::EmbedFooter).string_len(2048))
.col(ColumnDef::new(ReminderTemplate::EmbedFooterUrl).string_len(500))
.col(ColumnDef::new(ReminderTemplate::EmbedAuthor).string_len(256))
.col(ColumnDef::new(ReminderTemplate::EmbedAuthorUrl).string_len(500))
.col(ColumnDef::new(ReminderTemplate::EmbedColor).integer())
.col(ColumnDef::new(ReminderTemplate::EmbedFields).json())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_reminder_template_guild")
.from(ReminderTemplate::Table, ReminderTemplate::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Timer::Table)
.if_not_exists()
.col(
ColumnDef::new(Timer::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Timer::StartTime).date_time().not_null().default("NOW()"))
.col(ColumnDef::new(Timer::Name).string_len(32).not_null().default("Timer"))
.col(ColumnDef::new(Timer::UserId).big_integer())
.col(ColumnDef::new(Timer::GuildId).big_integer())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_timer_user")
.from(Timer::Table, Timer::UserId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_timer_guild")
.from(Timer::Table, Timer::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Todo::Table)
.if_not_exists()
.col(
ColumnDef::new(Todo::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Todo::UserId).big_integer())
.col(ColumnDef::new(Todo::GuildId).big_integer())
.col(ColumnDef::new(Todo::ChannelId).big_integer())
.col(ColumnDef::new(Todo::Value).string_len(2000).not_null())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_todo_user")
.from(Todo::Table, Todo::UserId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_todo_guild")
.from(Todo::Table, Todo::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_todo_channel")
.from(Todo::Table, Todo::ChannelId)
.to(Channel::Table, Channel::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(CommandMacro::Table)
.if_not_exists()
.col(
ColumnDef::new(CommandMacro::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(CommandMacro::GuildId).big_integer().not_null())
.col(ColumnDef::new(CommandMacro::Name).string_len(100).not_null())
.col(ColumnDef::new(CommandMacro::Description).string_len(100))
.col(ColumnDef::new(CommandMacro::Commands).json())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_command_macro_guild")
.from(CommandMacro::Table, CommandMacro::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_foreign_key(
ForeignKey::drop().table(Channel::Table).name("fk_channel_guild").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(User::Table).name("fk_user_channel").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Reminder::Table).name("fk_reminder_channel").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Reminder::Table).name("fk_reminder_user").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop()
.table(ReminderTemplate::Table)
.name("fk_reminder_template_guild")
.to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Timer::Table).name("fk_timer_user").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Timer::Table).name("fk_timer_guild").to_owned(),
)
.await?;
manager
.drop_foreign_key(ForeignKey::drop().table(Todo::Table).name("fk_todo_user").to_owned())
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Todo::Table).name("fk_todo_guild").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Todo::Table).name("fk_todo_channel").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop()
.table(CommandMacro::Table)
.name("fk_command_macro_guild")
.to_owned(),
)
.await?;
manager.drop_table(Table::drop().table(Guild::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Channel::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(User::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Reminder::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(ReminderTemplate::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Timer::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Todo::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(CommandMacro::Table).to_owned()).await?;
manager.drop_type(Type::drop().name(Timezone::Type).to_owned()).await?;
Ok(())
}
}
+6
View File
@@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}
+1
View File
@@ -0,0 +1 @@
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "postman"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["process", "full"] }
regex = "1.4"
log = "0.4"
env_logger = "0.8"
chrono = "0.4"
chrono-tz = { version = "0.5", features = ["serde"] }
lazy_static = "1.4"
num-integer = "0.1"
serde = "1.0"
serde_json = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
+50
View File
@@ -0,0 +1,50 @@
mod sender;
use std::env;
use log::{info, warn};
use serenity::client::Context;
use sqlx::{Executor, MySql};
use tokio::{
sync::broadcast::Receiver,
time::{sleep_until, Duration, Instant},
};
type Database = MySql;
pub async fn initialize(
mut kill: Receiver<()>,
ctx: Context,
pool: impl Executor<'_, Database = Database> + Copy,
) -> Result<(), &'static str> {
tokio::select! {
output = _initialize(ctx, pool) => Ok(output),
_ = kill.recv() => {
warn!("Received terminate signal. Goodbye");
Err("Received terminate signal. Goodbye")
}
}
}
async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database> + Copy) {
let remind_interval = env::var("REMIND_INTERVAL")
.map(|inner| inner.parse::<u64>().ok())
.ok()
.flatten()
.unwrap_or(10);
loop {
let sleep_to = Instant::now() + Duration::from_secs(remind_interval);
let reminders = sender::Reminder::fetch_reminders(pool).await;
if reminders.len() > 0 {
info!("Preparing to send {} reminders.", reminders.len());
for reminder in reminders {
reminder.send(pool, ctx.clone()).await;
}
}
sleep_until(sleep_to).await;
}
}
+595
View File
@@ -0,0 +1,595 @@
use chrono::Duration;
use chrono_tz::Tz;
use lazy_static::lazy_static;
use log::{error, info, warn};
use num_integer::Integer;
use regex::{Captures, Regex};
use serde::Deserialize;
use serenity::{
builder::CreateEmbed,
http::{CacheHttp, Http, HttpError, StatusCode},
model::{
channel::{Channel, Embed as SerenityEmbed},
id::ChannelId,
webhook::Webhook,
},
Error, Result,
};
use sqlx::{
types::{
chrono::{NaiveDateTime, Utc},
Json,
},
Executor,
};
use crate::Database;
lazy_static! {
pub static ref TIMEFROM_REGEX: Regex =
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
pub static ref TIMENOW_REGEX: Regex =
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
}
fn fmt_displacement(format: &str, seconds: u64) -> String {
let mut seconds = seconds;
let mut days: u64 = 0;
let mut hours: u64 = 0;
let mut minutes: u64 = 0;
for (rep, time_type, div) in
[("%d", &mut days, 86400), ("%h", &mut hours, 3600), ("%m", &mut minutes, 60)].iter_mut()
{
if format.contains(*rep) {
let (divided, new_seconds) = seconds.div_rem(&div);
**time_type = divided;
seconds = new_seconds;
}
}
format
.replace("%s", &seconds.to_string())
.replace("%m", &minutes.to_string())
.replace("%h", &hours.to_string())
.replace("%d", &days.to_string())
}
pub fn substitute(string: &str) -> String {
let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| {
let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str());
if let (Some(final_time), Some(format)) = (final_time, format) {
let dt = NaiveDateTime::from_timestamp(final_time, 0);
let now = Utc::now().naive_utc();
let difference = {
if now < dt {
dt - Utc::now().naive_utc()
} else {
Utc::now().naive_utc() - dt
}
};
fmt_displacement(format, difference.num_seconds() as u64)
} else {
String::new()
}
});
TIMENOW_REGEX
.replace(&new, |caps: &Captures| {
let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str());
if let (Some(timezone), Some(format)) = (timezone, format) {
let now = Utc::now().with_timezone(&timezone);
now.format(format).to_string()
} else {
String::new()
}
})
.to_string()
}
struct Embed {
title: String,
description: String,
image_url: Option<String>,
thumbnail_url: Option<String>,
footer: String,
footer_url: Option<String>,
author: String,
author_url: Option<String>,
color: u32,
fields: Json<Vec<EmbedField>>,
}
#[derive(Deserialize)]
struct EmbedField {
title: String,
value: String,
inline: bool,
}
impl Embed {
pub async fn from_id(
pool: impl Executor<'_, Database = Database> + Copy,
id: u32,
) -> Option<Self> {
match sqlx::query_as!(
Self,
r#"
SELECT
`embed_title` AS title,
`embed_description` AS description,
`embed_image_url` AS image_url,
`embed_thumbnail_url` AS thumbnail_url,
`embed_footer` AS footer,
`embed_footer_url` AS footer_url,
`embed_author` AS author,
`embed_author_url` AS author_url,
`embed_color` AS color,
IFNULL(`embed_fields`, '[]') AS "fields:_"
FROM reminders
WHERE `id` = ?"#,
id
)
.fetch_one(pool)
.await
{
Ok(mut embed) => {
embed.title = substitute(&embed.title);
embed.description = substitute(&embed.description);
embed.footer = substitute(&embed.footer);
embed.fields.iter_mut().for_each(|mut field| {
field.title = substitute(&field.title);
field.value = substitute(&field.value);
});
if embed.has_content() {
Some(embed)
} else {
None
}
}
Err(e) => {
warn!("Error loading embed from reminder: {:?}", e);
None
}
}
}
pub fn has_content(&self) -> bool {
if self.title.is_empty()
&& self.description.is_empty()
&& self.image_url.is_none()
&& self.thumbnail_url.is_none()
&& self.footer.is_empty()
&& self.footer_url.is_none()
&& self.author.is_empty()
&& self.author_url.is_none()
&& self.fields.0.is_empty()
{
false
} else {
true
}
}
}
impl Into<CreateEmbed> for Embed {
fn into(self) -> CreateEmbed {
let mut c = CreateEmbed::default();
c.title(&self.title)
.description(&self.description)
.color(self.color)
.author(|a| {
a.name(&self.author);
if let Some(author_icon) = &self.author_url {
a.icon_url(author_icon);
}
a
})
.footer(|f| {
f.text(&self.footer);
if let Some(footer_icon) = &self.footer_url {
f.icon_url(footer_icon);
}
f
});
for field in &self.fields.0 {
c.field(&field.title, &field.value, field.inline);
}
if let Some(image_url) = &self.image_url {
c.image(image_url);
}
if let Some(thumbnail_url) = &self.thumbnail_url {
c.thumbnail(thumbnail_url);
}
c
}
}
#[derive(Debug)]
pub struct Reminder {
id: u32,
channel_id: u64,
webhook_id: Option<u64>,
webhook_token: Option<String>,
channel_paused: bool,
channel_paused_until: Option<NaiveDateTime>,
enabled: bool,
tts: bool,
pin: bool,
content: String,
attachment: Option<Vec<u8>>,
attachment_name: Option<String>,
utc_time: NaiveDateTime,
timezone: String,
restartable: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_months: Option<u32>,
avatar: Option<String>,
username: Option<String>,
}
impl Reminder {
pub async fn fetch_reminders(pool: impl Executor<'_, Database = Database> + Copy) -> Vec<Self> {
match sqlx::query_as_unchecked!(
Reminder,
r#"
SELECT
reminders.`id` AS id,
channels.`channel` AS channel_id,
channels.`webhook_id` AS webhook_id,
channels.`webhook_token` AS webhook_token,
channels.`paused` AS 'channel_paused',
channels.`paused_until` AS 'channel_paused_until',
reminders.`enabled` AS 'enabled',
reminders.`tts` AS tts,
reminders.`pin` AS pin,
reminders.`content` AS content,
reminders.`attachment` AS attachment,
reminders.`attachment_name` AS attachment_name,
reminders.`utc_time` AS 'utc_time',
reminders.`timezone` AS timezone,
reminders.`restartable` AS restartable,
reminders.`expires` AS 'expires',
reminders.`interval_seconds` AS 'interval_seconds',
reminders.`interval_months` AS 'interval_months',
reminders.`avatar` AS avatar,
reminders.`username` AS username
FROM
reminders
INNER JOIN
channels
ON
reminders.channel_id = channels.id
WHERE
reminders.`utc_time` < NOW()
LIMIT 25
"#,
)
.fetch_all(pool)
.await
{
Ok(reminders) => reminders
.into_iter()
.map(|mut rem| {
rem.content = substitute(&rem.content);
rem
})
.collect::<Vec<Self>>(),
Err(e) => {
warn!("Could not fetch reminders: {:?}", e);
vec![]
}
}
}
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
let _ = sqlx::query!(
"
UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
",
self.channel_id
)
.execute(pool)
.await;
}
async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
if self.interval_seconds.is_some() || self.interval_months.is_some() {
let now = Utc::now().naive_local();
let mut updated_reminder_time = self.utc_time;
if let Some(interval) = self.interval_months {
match sqlx::query!(
// use the second date_add to force return value to datetime
"SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time",
updated_reminder_time,
interval
)
.fetch_one(pool)
.await
{
Ok(row) => match row.new_time {
Some(datetime) => {
updated_reminder_time = datetime;
}
None => {
warn!("Could not update interval by months: got NULL");
updated_reminder_time += Duration::days(30);
}
},
Err(e) => {
warn!("Could not update interval by months: {:?}", e);
// naively fallback to adding 30 days
updated_reminder_time += Duration::days(30);
}
}
}
if let Some(interval) = self.interval_seconds {
while updated_reminder_time < now {
updated_reminder_time += Duration::seconds(interval as i64);
}
}
if self.expires.map_or(false, |expires| {
NaiveDateTime::from_timestamp(updated_reminder_time.timestamp(), 0) > expires
}) {
self.force_delete(pool).await;
} else {
sqlx::query!(
"
UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
",
updated_reminder_time,
self.id
)
.execute(pool)
.await
.expect(&format!("Could not update time on Reminder {}", self.id));
}
} else {
self.force_delete(pool).await;
}
}
async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) {
sqlx::query!(
"
DELETE FROM reminders WHERE `id` = ?
",
self.id
)
.execute(pool)
.await
.expect(&format!("Could not delete Reminder {}", self.id));
}
async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) {
let _ = http.as_ref().pin_message(self.channel_id, message_id.into(), None).await;
}
pub async fn send(
&self,
pool: impl Executor<'_, Database = Database> + Copy,
cache_http: impl CacheHttp,
) {
async fn send_to_channel(
cache_http: impl CacheHttp,
reminder: &Reminder,
embed: Option<CreateEmbed>,
) -> Result<()> {
let channel = ChannelId(reminder.channel_id).to_channel(&cache_http).await;
match channel {
Ok(Channel::Guild(channel)) => {
match channel
.send_message(&cache_http, |m| {
m.content(&reminder.content).tts(reminder.tts);
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
m.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
m.set_embed(embed);
}
m
})
.await
{
Ok(m) => {
if reminder.pin {
reminder.pin_message(m.id, cache_http.http()).await;
}
Ok(())
}
Err(e) => Err(e),
}
}
Ok(Channel::Private(channel)) => {
match channel
.send_message(&cache_http.http(), |m| {
m.content(&reminder.content).tts(reminder.tts);
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
m.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
m.set_embed(embed);
}
m
})
.await
{
Ok(m) => {
if reminder.pin {
reminder.pin_message(m.id, cache_http.http()).await;
}
Ok(())
}
Err(e) => Err(e),
}
}
Err(e) => Err(e),
_ => Err(Error::Other("Channel not of valid type")),
}
}
async fn send_to_webhook(
cache_http: impl CacheHttp,
reminder: &Reminder,
webhook: Webhook,
embed: Option<CreateEmbed>,
) -> Result<()> {
match webhook
.execute(&cache_http.http(), reminder.pin || reminder.restartable, |w| {
w.content(&reminder.content).tts(reminder.tts);
if let Some(username) = &reminder.username {
w.username(username);
}
if let Some(avatar) = &reminder.avatar {
w.avatar_url(avatar);
}
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
w.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
w.embeds(vec![SerenityEmbed::fake(|c| {
*c = embed;
c
})]);
}
w
})
.await
{
Ok(m) => {
if reminder.pin {
if let Some(message) = m {
reminder.pin_message(message.id, cache_http.http()).await;
}
}
Ok(())
}
Err(e) => Err(e),
}
}
if self.enabled
&& !(self.channel_paused
&& self
.channel_paused_until
.map_or(true, |inner| inner >= Utc::now().naive_local()))
{
let _ = sqlx::query!(
"
UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
",
self.channel_id
)
.execute(pool)
.await;
let embed = Embed::from_id(pool, self.id).await.map(|e| e.into());
let result = if let (Some(webhook_id), Some(webhook_token)) =
(self.webhook_id, &self.webhook_token)
{
let webhook_res =
cache_http.http().get_webhook_with_token(webhook_id, webhook_token).await;
if let Ok(webhook) = webhook_res {
send_to_webhook(cache_http, &self, webhook, embed).await
} else {
warn!("Webhook vanished: {:?}", webhook_res);
self.reset_webhook(pool).await;
send_to_channel(cache_http, &self, embed).await
}
} else {
send_to_channel(cache_http, &self, embed).await
};
if let Err(e) = result {
error!("Error sending {:?}: {:?}", self, e);
if let Error::Http(error) = e {
if error.status_code() == Some(StatusCode::NOT_FOUND) {
warn!("Seeing channel is deleted. Removing reminder");
self.force_delete(pool).await;
} else if let HttpError::UnsuccessfulRequest(error) = *error {
if error.error.code == 50007 {
warn!("User cannot receive DMs");
self.force_delete(pool).await;
} else {
self.refresh(pool).await;
}
}
} else {
self.refresh(pool).await;
}
} else {
self.refresh(pool).await;
}
} else {
info!("Reminder {} is paused", self.id);
self.refresh(pool).await;
}
}
}
+98 -80
View File
@@ -1,16 +1,13 @@
use chrono::offset::Utc;
use regex_command_attr::command;
use serenity::{builder::CreateEmbedFooter, client::Context};
use poise::{serenity_prelude as serenity, serenity_prelude::Mentionable};
use crate::{
framework::{CommandInvoke, CreateGenericResponse},
models::CtxData,
THEME_COLOR,
};
use crate::{models::CtxData, Context, Error, THEME_COLOR};
fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter {
let shard_count = ctx.cache.shard_count();
let shard = ctx.shard_id;
fn footer(
ctx: Context<'_>,
) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
let shard_count = ctx.discord().cache.shard_count();
let shard = ctx.discord().shard_id;
move |f| {
f.text(format!(
@@ -22,19 +19,17 @@ fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEm
}
}
#[command]
#[description("Get an overview of the bot commands")]
async fn help(ctx: &Context, invoke: &mut CommandInvoke) {
/// Get an overview of bot commands
#[poise::command(slash_command)]
pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx);
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().embed(|e| {
e.title("Help")
.color(*THEME_COLOR)
.description(
"__Info Commands__
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Help")
.color(*THEME_COLOR)
.description(
"__Info Commands__
`/help` `/info` `/donate` `/dashboard` `/clock`
*run these commands with no options*
@@ -58,25 +53,25 @@ __Setup Commands__
__Advanced Commands__
`/macro` - Record and replay command sequences
",
)
.footer(footer)
}),
)
.await;
)
.footer(footer)
})
})
.await?;
Ok(())
}
#[command]
#[aliases("invite")]
#[description("Get information about the bot")]
async fn info(ctx: &Context, invoke: &mut CommandInvoke) {
/// Get information about the bot
#[poise::command(slash_command)]
pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx);
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
let _ = ctx
.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Info")
.description(format!(
.description(
"Help: `/help`
**Welcome to Reminder Bot!**
@@ -86,26 +81,25 @@ 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(())
}
#[command]
#[description("Details on supporting the bot and Patreon benefits")]
#[group("Info")]
async fn donate(ctx: &Context, invoke: &mut CommandInvoke) {
/// Details on supporting the bot and Patreon benefits
#[poise::command(slash_command)]
pub async fn donate(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx);
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title("Donate")
.description("Thinking of adding a monthly contribution? Click below for my Patreon and official bot server :)
ctx.send(|m| m.embed(|e| {
e.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/**
@@ -120,43 +114,67 @@ With your new rank, you'll be able to:
Just $2 USD/month!
*Please note, you must be in the JellyWX Discord server to receive Patreon features*")
.footer(footer)
.color(*THEME_COLOR)
}),
)
.await;
.footer(footer)
.color(*THEME_COLOR)
}),
)
.await?;
Ok(())
}
#[command]
#[description("Get the link to the online dashboard")]
#[group("Info")]
async fn dashboard(ctx: &Context, invoke: &mut CommandInvoke) {
/// Get the link to the online dashboard
#[poise::command(slash_command)]
pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx);
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title("Dashboard")
.description("**https://reminder-bot.com/dashboard**")
.footer(footer)
.color(*THEME_COLOR)
}),
)
.await;
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Dashboard")
.description("**https://reminder-bot.com/dashboard**")
.footer(footer)
.color(*THEME_COLOR)
})
})
.await?;
Ok(())
}
#[command]
#[description("View the current time in your selected timezone")]
#[group("Info")]
async fn clock(ctx: &Context, invoke: &mut CommandInvoke) {
let ud = ctx.user_data(&invoke.author_id()).await.unwrap();
let now = Utc::now().with_timezone(&ud.timezone());
/// 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 _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(format!("Current time: {}", now.format("%H:%M"))),
)
.await;
let tz = ctx.timezone().await;
let now = Utc::now().with_timezone(&tz);
ctx.send(|m| {
m.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(|m| {
m.ephemeral(true).content(format!(
"Time in {}'s timezone: `{}`",
user.mention(),
now.format("%H:%M")
))
})
.await?;
Ok(())
}
+360 -323
View File
@@ -1,54 +1,65 @@
use std::collections::hash_map::Entry;
use chrono::offset::Utc;
use chrono_tz::{Tz, TZ_VARIANTS};
use levenshtein::levenshtein;
use regex_command_attr::command;
use serenity::client::Context;
use poise::CreateReply;
use crate::{
component_models::pager::{MacroPager, Pager},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
framework::{CommandInvoke, CommandOptions, CreateGenericResponse, OptionValue},
hooks::{CHECK_GUILD_PERMISSIONS_HOOK, GUILD_ONLY_HOOK},
models::{command_macro::CommandMacro, CtxData},
PopularTimezones, RecordingMacros, RegexFramework, SQLPool,
models::{
command_macro::{guild_command_macro, CommandMacro},
CtxData,
},
Context, Data, Error,
};
#[command("timezone")]
#[description("Select your timezone")]
#[arg(
name = "timezone",
description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee",
kind = "String",
required = false
)]
async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let mut user_data = ctx.user_data(invoke.author_id()).await.unwrap();
async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
if partial.is_empty() {
ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
} else {
TZ_VARIANTS
.iter()
.filter(|tz| tz.to_string().contains(&partial))
.take(25)
.map(|t| t.to_string())
.collect::<Vec<String>>()
}
}
/// 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(OptionValue::String(timezone)) = args.get("timezone") {
if let Some(timezone) = timezone {
match timezone.parse::<Tz>() {
Ok(tz) => {
user_data.timezone = timezone.clone();
user_data.commit_changes(&pool).await;
user_data.commit_changes(&ctx.data().database).await;
let now = Utc::now().with_timezone(&tz);
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title("Timezone Set")
.description(format!(
"Timezone has been set to **{}**. Your current time should be `{}`",
timezone,
now.format("%H:%M").to_string()
))
.color(*THEME_COLOR)
}),
)
.await;
ctx.send(|m| {
m.embed(|e| {
e.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(_) => {
@@ -56,8 +67,8 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption
.iter()
.filter(|tz| {
timezone.contains(&tz.to_string())
|| tz.to_string().contains(timezone)
|| levenshtein(&tz.to_string(), timezone) < 4
|| tz.to_string().contains(&timezone)
|| levenshtein(&tz.to_string(), &timezone) < 4
})
.take(25)
.map(|t| t.to_owned())
@@ -66,313 +77,331 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption
let fields = filtered_tz.iter().map(|tz| {
(
tz.to_string(),
format!(
"🕗 `{}`",
Utc::now().with_timezone(tz).format("%H:%M").to_string()
),
format!("🕗 `{}`", Utc::now().with_timezone(tz).format("%H:%M")),
true,
)
});
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.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(|f| f.text(footer_text))
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
}),
)
.await;
ctx.send(|m| {
m.embed(|e| {
e.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(|f| f.text(footer_text))
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
})
})
.await?;
}
}
} else {
let popular_timezones = ctx.data.read().await.get::<PopularTimezones>().cloned().unwrap();
let popular_timezones_iter = popular_timezones.iter().map(|t| {
(
t.to_string(),
format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()),
true,
)
let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| {
(t.to_string(), format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M")), true)
});
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title("Timezone Usage")
.description(
"**Usage:**
ctx.send(|m| {
m.embed(|e| {
e.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(|f| f.text(footer_text))
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
})
})
.await?;
}
Ok(())
}
async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
sqlx::query!(
"
SELECT name
FROM macro
WHERE
guild_id = (SELECT id FROM guilds WHERE guild = ?)
AND name LIKE CONCAT(?, '%')",
ctx.guild_id().unwrap().0,
partial,
)
.fetch_all(&ctx.data().database)
.await
.unwrap_or_default()
.iter()
.map(|s| s.name.clone())
.collect()
}
/// 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(())
}
/// Start recording up to 5 commands to replay
#[poise::command(
slash_command,
rename = "record",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "record_macro"
)]
pub async fn record_macro(
ctx: Context<'_>,
#[description = "Name for the new macro"] name: String,
#[description = "Description for the new macro"] description: Option<String>,
) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();
let row = sqlx::query!(
"
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
guild_id.0,
name
)
.fetch_one(&ctx.data().database)
.await;
if row.is_ok() {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Unique Name Required")
.description(
"A macro already exists under this name.
Please select a unique name for your macro.",
)
.color(*THEME_COLOR)
})
})
.await?;
} else {
let okay = {
let mut lock = ctx.data().recording_macros.write().await;
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
true
} else {
false
}
};
if okay {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Recording Started")
.description(
"Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands ran as part of recording will be inconsequential",
)
.color(*THEME_COLOR)
.fields(popular_timezones_iter)
.footer(|f| f.text(footer_text))
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
}),
)
.await;
}
}
#[command("macro")]
#[description("Record and replay command sequences")]
#[subcommand("record")]
#[description("Start recording up to 5 commands to replay")]
#[arg(name = "name", description = "Name for the new macro", kind = "String", required = true)]
#[arg(
name = "description",
description = "Description for the new macro",
kind = "String",
required = false
)]
#[subcommand("finish")]
#[description("Finish current recording")]
#[subcommand("list")]
#[description("List recorded macros")]
#[subcommand("run")]
#[description("Run a recorded macro")]
#[arg(name = "name", description = "Name of the macro to run", kind = "String", required = true)]
#[subcommand("delete")]
#[description("Delete a recorded macro")]
#[arg(name = "name", description = "Name of the macro to delete", kind = "String", required = true)]
#[supports_dm(false)]
#[hook(GUILD_ONLY_HOOK)]
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
async fn macro_cmd(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
match args.subcommand.clone().unwrap().as_str() {
"record" => {
let guild_id = invoke.guild_id().unwrap();
let name = args.get("name").unwrap().to_string();
let row = sqlx::query!(
"SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
guild_id.0,
name
)
.fetch_one(&pool)
.await;
if row.is_ok() {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().ephemeral().embed(|e| {
e
.title("Unique Name Required")
.description("A macro already exists under this name. Please select a unique name for your macro.")
.color(*THEME_COLOR)
}),
)
.await;
} else {
let macro_buffer = ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap();
let okay = {
let mut lock = macro_buffer.write().await;
if lock.contains_key(&(guild_id, invoke.author_id())) {
false
} else {
lock.insert(
(guild_id, invoke.author_id()),
CommandMacro {
guild_id,
name,
description: args.get("description").map(|d| d.to_string()),
commands: vec![],
},
);
true
}
};
if okay {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().ephemeral().embed(|e| {
e
.title("Macro Recording Started")
.description(
"Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands ran as part of recording will be inconsequential")
.color(*THEME_COLOR)
}),
)
.await;
} else {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().ephemeral().embed(|e| {
e.title("Macro Already Recording")
.description(
"You are already recording a macro in this server.
})
})
.await?;
} else {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Already Recording")
.description(
"You are already recording a macro in this server.
Please use `/macro finish` to end this recording before starting another.",
)
.color(*THEME_COLOR)
}),
)
.await;
}
}
.color(*THEME_COLOR)
})
})
.await?;
}
"finish" => {
let key = (invoke.guild_id().unwrap(), invoke.author_id());
let macro_buffer = ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap();
{
let lock = macro_buffer.read().await;
let contained = lock.get(&key);
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().embed(|e| {
e.title("No Macro Recorded")
.description("Use `/macro record` to start recording a macro")
.color(*THEME_COLOR)
}),
)
.await;
} else {
let command_macro = contained.unwrap();
let json = serde_json::to_string(&command_macro.commands).unwrap();
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
command_macro.guild_id.0,
command_macro.name,
command_macro.description,
json
)
.execute(&pool)
.await
.unwrap();
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().embed(|e| {
e.title("Macro Recorded")
.description("Use `/macro run` to execute the macro")
.color(*THEME_COLOR)
}),
)
.await;
}
}
{
let mut lock = macro_buffer.write().await;
lock.remove(&key);
}
}
"list" => {
let macros = CommandMacro::from_guild(ctx, invoke.guild_id().unwrap()).await;
let resp = show_macro_page(&macros, 0);
invoke.respond(&ctx, resp).await.unwrap();
}
"run" => {
let macro_name = args.get("name").unwrap().to_string();
match sqlx::query!(
"SELECT commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
invoke.guild_id().unwrap().0,
macro_name
)
.fetch_one(&pool)
.await
{
Ok(row) => {
invoke.defer(&ctx).await;
let commands: Vec<CommandOptions> =
serde_json::from_str(&row.commands).unwrap();
let framework = ctx.data.read().await.get::<RegexFramework>().cloned().unwrap();
for command in commands {
framework.run_command_from_options(ctx, invoke, command).await;
}
}
Err(sqlx::Error::RowNotFound) => {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new()
.content(format!("Macro \"{}\" not found", macro_name)),
)
.await;
}
Err(e) => {
panic!("{}", e);
}
}
}
"delete" => {
let macro_name = args.get("name").unwrap().to_string();
match sqlx::query!(
"SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
invoke.guild_id().unwrap().0,
macro_name
)
.fetch_one(&pool)
.await
{
Ok(row) => {
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
.execute(&pool)
.await
.unwrap();
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new()
.content(format!("Macro \"{}\" deleted", macro_name)),
)
.await;
}
Err(sqlx::Error::RowNotFound) => {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new()
.content(format!("Macro \"{}\" not found", macro_name)),
)
.await;
}
Err(e) => {
panic!("{}", e);
}
}
}
_ => {}
}
Ok(())
}
pub fn max_macro_page(macros: &[CommandMacro]) -> usize {
/// Finish current macro recording
#[poise::command(
slash_command,
rename = "finish",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "finish_macro"
)]
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
let key = (ctx.guild_id().unwrap(), ctx.author().id);
{
let lock = ctx.data().recording_macros.read().await;
let contained = lock.get(&key);
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
ctx.send(|m| {
m.embed(|e| {
e.title("No Macro Recorded")
.description("Use `/macro record` to start recording a macro")
.color(*THEME_COLOR)
})
})
.await?;
} else {
let command_macro = contained.unwrap();
let json = serde_json::to_string(&command_macro.commands).unwrap();
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
command_macro.guild_id.0,
command_macro.name,
command_macro.description,
json
)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.send(|m| {
m.embed(|e| {
e.title("Macro Recorded")
.description("Use `/macro run` to execute the macro")
.color(*THEME_COLOR)
})
})
.await?;
}
}
{
let mut lock = ctx.data().recording_macros.write().await;
lock.remove(&key);
}
Ok(())
}
/// List recorded macros
#[poise::command(
slash_command,
rename = "list",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "list_macro"
)]
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
let macros = ctx.command_macros().await?;
let resp = show_macro_page(&macros, 0);
ctx.send(|m| {
*m = resp;
m
})
.await?;
Ok(())
}
/// Run a recorded macro
#[poise::command(
slash_command,
rename = "run",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "run_macro"
)]
pub async fn run_macro(
ctx: poise::ApplicationContext<'_, Data, Error>,
#[description = "Name of macro to run"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match guild_command_macro(&Context::Application(ctx), &name).await {
Some(command_macro) => {
ctx.defer_response(false).await?;
for command in command_macro.commands {
if let Some(action) = command.action {
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
.await
{
Ok(()) => {}
Err(e) => {
println!("{:?}", e);
}
}
} else {
Context::Application(ctx)
.say(format!("Command \"{}\" not found", command.command_name))
.await?;
}
}
}
None => {
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
}
}
Ok(())
}
/// Delete a recorded macro
#[poise::command(
slash_command,
rename = "delete",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "delete_macro"
)]
pub async fn delete_macro(
ctx: Context<'_>,
#[description = "Name of macro to delete"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match sqlx::query!(
"
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
ctx.guild_id().unwrap().0,
name
)
.fetch_one(&ctx.data().database)
.await
{
Ok(row) => {
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
}
Err(sqlx::Error::RowNotFound) => {
ctx.say(format!("Macro \"{}\" not found", name)).await?;
}
Err(e) => {
panic!("{}", e);
}
}
Ok(())
}
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
let mut skipped_char_count = 0;
macros
@@ -396,15 +425,19 @@ pub fn max_macro_page(macros: &[CommandMacro]) -> usize {
})
}
pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericResponse {
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
let pager = MacroPager::new(page);
if macros.is_empty() {
return CreateGenericResponse::new().embed(|e| {
let mut reply = CreateReply::default();
reply.embed(|e| {
e.title("Macros")
.description("No Macros Set Up. Use `/macro record` to get started.")
.color(*THEME_COLOR)
});
return reply;
}
let pages = max_macro_page(macros);
@@ -447,7 +480,9 @@ pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericRes
let display = display_vec.join("\n");
CreateGenericResponse::new()
let mut reply = CreateReply::default();
reply
.embed(|e| {
e.title("Macros")
.description(display)
@@ -458,5 +493,7 @@ pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericRes
pager.create_button_row(pages, comp);
comp
})
});
reply
}
File diff suppressed because it is too large Load Diff
+215 -124
View File
@@ -1,5 +1,4 @@
use regex_command_attr::command;
use serenity::client::Context;
use poise::CreateReply;
use crate::{
component_models::{
@@ -7,134 +6,218 @@ use crate::{
ComponentDataModel, TodoSelector,
},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR},
framework::{CommandInvoke, CommandOptions, CreateGenericResponse},
hooks::CHECK_GUILD_PERMISSIONS_HOOK,
SQLPool,
Context, Error,
};
#[command]
#[description("Manage todo lists")]
#[subcommandgroup("server")]
#[description("Manage the server todo list")]
#[subcommand("add")]
#[description("Add an item to the server todo list")]
#[arg(
name = "task",
description = "The task to add to the todo list",
kind = "String",
required = true
/// Manage todo lists
#[poise::command(
slash_command,
rename = "todo",
identifying_name = "todo_base",
default_member_permissions = "MANAGE_GUILD"
)]
#[subcommand("view")]
#[description("View and remove from the server todo list")]
#[subcommandgroup("channel")]
#[description("Manage the channel todo list")]
#[subcommand("add")]
#[description("Add to the channel todo list")]
#[arg(
name = "task",
description = "The task to add to the todo list",
kind = "String",
required = true
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"
)]
#[subcommand("view")]
#[description("View and remove from the channel todo list")]
#[subcommandgroup("user")]
#[description("Manage your personal todo list")]
#[subcommand("add")]
#[description("Add to your personal todo list")]
#[arg(
name = "task",
description = "The task to add to the todo list",
kind = "String",
required = true
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"
)]
#[subcommand("view")]
#[description("View and remove from your personal todo list")]
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
async fn todo(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
if invoke.guild_id().is_none() && args.subcommand_group != Some("user".to_string()) {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content("Please use `/todo user` in direct messages"),
)
.await;
} else {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
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().0,
task
)
.execute(&ctx.data().database)
.await
.unwrap();
let keys = match args.subcommand_group.as_ref().unwrap().as_str() {
"server" => (None, None, invoke.guild_id().map(|g| g.0)),
"channel" => (None, Some(invoke.channel_id().0), invoke.guild_id().map(|g| g.0)),
_ => (Some(invoke.author_id().0), None, None),
};
ctx.say("Item added to todo list").await?;
match args.get("task") {
Some(task) => {
let task = task.to_string();
Ok(())
}
sqlx::query!(
"INSERT INTO todos (user_id, channel_id, guild_id, value) VALUES ((SELECT id FROM users WHERE user = ?), (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?), ?)",
keys.0,
keys.1,
keys.2,
task
)
.execute(&pool)
.await
.unwrap();
let _ = invoke
.respond(&ctx, CreateGenericResponse::new().content("Item added to todo list"))
.await;
}
None => {
let values = if let Some(uid) = keys.0 {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN users ON todos.user_id = users.id
WHERE users.user = ?",
uid,
)
.fetch_all(&pool)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>()
} else if let Some(cid) = keys.1 {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN channels ON todos.channel_id = channels.id
WHERE channels.channel = ?",
cid,
)
.fetch_all(&pool)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>()
} else {
sqlx::query!(
"SELECT todos.id, value FROM todos
/// 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 = ?",
keys.2,
)
.fetch_all(&pool)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>()
};
ctx.guild_id().unwrap().0,
)
.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, keys.0, keys.1, keys.2);
let resp = show_todo_page(&values, 0, None, None, ctx.guild_id().map(|g| g.0));
invoke.respond(&ctx, resp).await.unwrap();
}
}
}
ctx.send(|r| {
*r = resp;
r
})
.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> {
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().0,
ctx.channel_id().0,
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().0,
)
.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().0), ctx.guild_id().map(|g| g.0));
ctx.send(|r| {
*r = resp;
r
})
.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.0,
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.0,
)
.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.0), None, None);
ctx.send(|r| {
*r = resp;
r
})
.await?;
Ok(())
}
pub fn max_todo_page(todo_values: &[(usize, String)]) -> usize {
@@ -164,7 +247,7 @@ pub fn show_todo_page(
user_id: Option<u64>,
channel_id: Option<u64>,
guild_id: Option<u64>,
) -> CreateGenericResponse {
) -> CreateReply {
let pager = TodoPager::new(page, user_id, channel_id, guild_id);
let pages = max_todo_page(todo_values);
@@ -219,17 +302,23 @@ pub fn show_todo_page(
};
if todo_ids.is_empty() {
CreateGenericResponse::new().embed(|e| {
let mut reply = CreateReply::default();
reply.embed(|e| {
e.title(format!("{} Todo List", title))
.description("Todo List Empty!")
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
.color(*THEME_COLOR)
})
});
reply
} else {
let todo_selector =
ComponentDataModel::TodoSelector(TodoSelector { page, user_id, channel_id, guild_id });
CreateGenericResponse::new()
let mut reply = CreateReply::default();
reply
.embed(|e| {
e.title(format!("{} Todo List", title))
.description(display)
@@ -247,7 +336,7 @@ pub fn show_todo_page(
opt.create_option(|o| {
o.label(format!("Mark {} complete", count + first_num))
.value(id)
.description(disp.split_once(" ").unwrap_or(("", "")).1)
.description(disp.split_once(' ').unwrap_or(("", "")).1)
});
}
@@ -255,6 +344,8 @@ pub fn show_todo_page(
})
})
})
})
});
reply
}
}
+168 -47
View File
@@ -3,17 +3,23 @@ pub(crate) mod pager;
use std::io::Cursor;
use chrono_tz::Tz;
use log::warn;
use poise::{
serenity::{
builder::CreateEmbed,
client::Context,
model::{
channel::Channel,
interactions::{
message_component::MessageComponentInteraction, InteractionResponseType,
},
prelude::InteractionApplicationCommandCallbackDataFlags,
},
},
serenity_prelude as serenity,
};
use rmp_serde::Serializer;
use serde::{Deserialize, Serialize};
use serenity::{
builder::CreateEmbed,
client::Context,
model::{
channel::Channel,
interactions::{message_component::MessageComponentInteraction, InteractionResponseType},
prelude::InteractionApplicationCommandCallbackDataFlags,
},
};
use crate::{
commands::{
@@ -23,9 +29,9 @@ use crate::{
},
component_models::pager::{DelPager, LookPager, MacroPager, Pager, TodoPager},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
framework::CommandInvoke,
models::{command_macro::CommandMacro, reminder::Reminder},
SQLPool,
models::reminder::Reminder,
utils::send_as_initial_response,
Data,
};
#[derive(Deserialize, Serialize)]
@@ -38,6 +44,7 @@ pub enum ComponentDataModel {
DelSelector(DelSelector),
TodoSelector(TodoSelector),
MacroPager(MacroPager),
UndoReminder(UndoReminder),
}
impl ComponentDataModel {
@@ -55,7 +62,7 @@ impl ComponentDataModel {
rmp_serde::from_read(cur).unwrap()
}
pub async fn act(&self, ctx: &Context, component: MessageComponentInteraction) {
pub async fn act(&self, ctx: &Context, data: &Data, component: &MessageComponentInteraction) {
match self {
ComponentDataModel::LookPager(pager) => {
let flags = pager.flags;
@@ -72,7 +79,7 @@ impl ComponentDataModel {
component.channel_id
};
let reminders = Reminder::from_channel(ctx, channel_id, &flags).await;
let reminders = Reminder::from_channel(&data.database, channel_id, &flags).await;
let pages = reminders
.iter()
@@ -122,7 +129,7 @@ impl ComponentDataModel {
.create_interaction_response(&ctx, |r| {
r.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|response| {
response.embeds(vec![embed]).components(|comp| {
response.set_embeds(vec![embed]).components(|comp| {
pager.create_button_row(pages, comp);
comp
@@ -133,45 +140,68 @@ impl ComponentDataModel {
.await;
}
ComponentDataModel::DelPager(pager) => {
let reminders =
Reminder::from_guild(ctx, component.guild_id, component.user.id).await;
let reminders = Reminder::from_guild(
&ctx,
&data.database,
component.guild_id,
component.user.id,
)
.await;
let max_pages = max_delete_page(&reminders, &pager.timezone);
let resp = show_delete_page(&reminders, pager.next_page(max_pages), pager.timezone);
let mut invoke = CommandInvoke::component(component);
let _ = invoke.respond(&ctx, resp).await;
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|d| {
send_as_initial_response(resp, d);
d
},
)
})
.await;
}
ComponentDataModel::DelSelector(selector) => {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let selected_id = component.data.values.join(",");
sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id)
.execute(&pool)
.execute(&data.database)
.await
.unwrap();
let reminders =
Reminder::from_guild(ctx, component.guild_id, component.user.id).await;
let reminders = Reminder::from_guild(
&ctx,
&data.database,
component.guild_id,
component.user.id,
)
.await;
let resp = show_delete_page(&reminders, selector.page, selector.timezone);
let mut invoke = CommandInvoke::component(component);
let _ = invoke.respond(&ctx, resp).await;
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|d| {
send_as_initial_response(resp, d);
d
},
)
})
.await;
}
ComponentDataModel::TodoPager(pager) => {
if Some(component.user.id.0) == pager.user_id || pager.user_id.is_none() {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let values = if let Some(uid) = pager.user_id {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN users ON todos.user_id = users.id
WHERE users.user = ?",
INNER JOIN users ON todos.user_id = users.id
WHERE users.user = ?",
uid,
)
.fetch_all(&pool)
.fetch_all(&data.database)
.await
.unwrap()
.iter()
@@ -180,11 +210,11 @@ impl ComponentDataModel {
} else if let Some(cid) = pager.channel_id {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN channels ON todos.channel_id = channels.id
WHERE channels.channel = ?",
INNER JOIN channels ON todos.channel_id = channels.id
WHERE channels.channel = ?",
cid,
)
.fetch_all(&pool)
.fetch_all(&data.database)
.await
.unwrap()
.iter()
@@ -193,11 +223,11 @@ impl ComponentDataModel {
} else {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
pager.guild_id,
)
.fetch_all(&pool)
.fetch_all(&data.database)
.await
.unwrap()
.iter()
@@ -215,8 +245,15 @@ impl ComponentDataModel {
pager.guild_id,
);
let mut invoke = CommandInvoke::component(component);
let _ = invoke.respond(&ctx, resp).await;
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::UpdateMessage)
.interaction_response_data(|d| {
send_as_initial_response(resp, d);
d
})
})
.await;
} else {
let _ = component
.create_interaction_response(&ctx, |r| {
@@ -233,11 +270,10 @@ impl ComponentDataModel {
}
ComponentDataModel::TodoSelector(selector) => {
if Some(component.user.id.0) == selector.user_id || selector.user_id.is_none() {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let selected_id = component.data.values.join(",");
sqlx::query!("DELETE FROM todos WHERE FIND_IN_SET(id, ?)", selected_id)
.execute(&pool)
.execute(&data.database)
.await
.unwrap();
@@ -248,7 +284,7 @@ impl ComponentDataModel {
selector.channel_id,
selector.guild_id,
)
.fetch_all(&pool)
.fetch_all(&data.database)
.await
.unwrap()
.iter()
@@ -263,8 +299,15 @@ impl ComponentDataModel {
selector.guild_id,
);
let mut invoke = CommandInvoke::component(component);
let _ = invoke.respond(&ctx, resp).await;
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::UpdateMessage)
.interaction_response_data(|d| {
send_as_initial_response(resp, d);
d
})
})
.await;
} else {
let _ = component
.create_interaction_response(&ctx, |r| {
@@ -280,15 +323,87 @@ impl ComponentDataModel {
}
}
ComponentDataModel::MacroPager(pager) => {
let mut invoke = CommandInvoke::component(component);
let macros = CommandMacro::from_guild(ctx, invoke.guild_id().unwrap()).await;
let macros = data.command_macros(component.guild_id.unwrap()).await.unwrap();
let max_page = max_macro_page(&macros);
let page = pager.next_page(max_page);
let resp = show_macro_page(&macros, page);
let _ = invoke.respond(&ctx, resp).await;
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|d| {
send_as_initial_response(resp, d);
d
},
)
})
.await;
}
ComponentDataModel::UndoReminder(undo_reminder) => {
if component.user.id == undo_reminder.user_id {
let reminder =
Reminder::from_id(&data.database, undo_reminder.reminder_id).await;
if let Some(reminder) = reminder {
match reminder.delete(&data.database).await {
Ok(()) => {
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::UpdateMessage)
.interaction_response_data(|d| {
d.embed(|e| {
e.title("Reminder Canceled")
.description(
"This reminder has been canceled.",
)
.color(*THEME_COLOR)
})
.components(|c| c)
})
})
.await;
}
Err(e) => {
warn!("Error canceling reminder: {:?}", e);
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|d| {
d.content(
"The reminder could not be canceled: it may have already been deleted. Check `/del`!")
.ephemeral(true)
})
})
.await;
}
}
} else {
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|d| {
d.content(
"The reminder could not be canceled: it may have already been deleted. Check `/del`!")
.ephemeral(true)
})
})
.await;
}
} else {
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|d| {
d.content(
"Only the user who performed the command can use this button.")
.ephemeral(true)
})
})
.await;
}
}
}
}
@@ -307,3 +422,9 @@ pub struct TodoSelector {
pub channel_id: Option<u64>,
pub guild_id: Option<u64>,
}
#[derive(Serialize, Deserialize)]
pub struct UndoReminder {
pub user_id: serenity::UserId,
pub reminder_id: u32,
}
+3 -1
View File
@@ -1,8 +1,10 @@
// todo split pager out into a single struct
use chrono_tz::Tz;
use poise::serenity::{
builder::CreateComponents, model::interactions::message_component::ButtonStyle,
};
use serde::{Deserialize, Serialize};
use serde_repr::*;
use serenity::{builder::CreateComponents, model::interactions::message_component::ButtonStyle};
use crate::{component_models::ComponentDataModel, models::reminder::look_flags::LookFlags};
+6 -8
View File
@@ -1,17 +1,19 @@
pub const DAY: u64 = 86_400;
pub const HOUR: u64 = 3_600;
pub const MINUTE: u64 = 60;
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000;
pub const SELECT_MAX_ENTRIES: usize = 25;
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
const THEME_COLOR_FALLBACK: u32 = 0x8fb677;
pub const MACRO_MAX_COMMANDS: usize = 5;
use std::{collections::HashSet, env, iter::FromIterator};
use poise::serenity::model::prelude::AttachmentType;
use regex::Regex;
use serenity::http::AttachmentType;
lazy_static! {
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
@@ -34,15 +36,11 @@ lazy_static! {
);
pub static ref CNC_GUILD: Option<u64> =
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
pub static ref MIN_INTERVAL: i64 = env::var("MIN_INTERVAL")
.ok()
.map(|inner| inner.parse::<i64>().ok())
.flatten()
.unwrap_or(600);
pub static ref MIN_INTERVAL: i64 =
env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
.ok()
.map(|inner| inner.parse::<i64>().ok())
.flatten()
.and_then(|inner| inner.parse::<i64>().ok())
.unwrap_or(60 * 60 * 24 * 365 * 50);
pub static ref LOCAL_TIMEZONE: String =
env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string());
+128
View File
@@ -0,0 +1,128 @@
use std::{collections::HashMap, env, sync::atomic::Ordering};
use log::{error, info, warn};
use poise::{
serenity::{model::interactions::Interaction, utils::shard_id},
serenity_prelude as serenity,
};
use crate::{component_models::ComponentDataModel, Data, Error};
pub async fn listener(
ctx: &serenity::Context,
event: &poise::Event<'_>,
data: &Data,
) -> Result<(), Error> {
match event {
poise::Event::Ready { .. } => {
ctx.set_activity(serenity::Activity::watching("for /remind")).await;
}
poise::Event::CacheReady { .. } => {
info!("Cache Ready! Preparing extra processes");
if !data.is_loop_running.load(Ordering::Relaxed) {
let kill_tx = data.broadcast.clone();
let kill_recv = data.broadcast.subscribe();
let ctx1 = ctx.clone();
let ctx2 = ctx.clone();
let pool1 = data.database.clone();
let pool2 = data.database.clone();
let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string());
if !run_settings.contains("postman") {
tokio::spawn(async move {
match postman::initialize(kill_recv, ctx1, &pool1).await {
Ok(_) => {}
Err(e) => {
error!("postman exiting: {}", e);
}
};
});
} else {
warn!("Not running postman");
}
if !run_settings.contains("web") {
tokio::spawn(async move {
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
});
} else {
warn!("Not running web");
}
data.is_loop_running.swap(true, Ordering::Relaxed);
}
}
poise::Event::ChannelDelete { channel } => {
sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64())
.execute(&data.database)
.await
.unwrap();
}
poise::Event::GuildCreate { guild, is_new } => {
if *is_new {
let guild_id = guild.id.as_u64().to_owned();
sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id)
.execute(&data.database)
.await
.unwrap();
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
let shard_count = ctx.cache.shard_count();
let current_shard_id = shard_id(guild_id, shard_count);
let guild_count = ctx
.cache
.guilds()
.iter()
.filter(|g| {
shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id
})
.count() as u64;
let mut hm = HashMap::new();
hm.insert("server_count", guild_count);
hm.insert("shard_id", current_shard_id);
hm.insert("shard_count", shard_count);
let response = data
.http
.post(
format!(
"https://top.gg/api/bots/{}/stats",
ctx.cache.current_user_id().as_u64()
)
.as_str(),
)
.header("Authorization", token)
.json(&hm)
.send()
.await;
if let Err(res) = response {
println!("DiscordBots Response: {:?}", res);
}
}
}
}
poise::Event::GuildDelete { incomplete, .. } => {
let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0)
.execute(&data.database)
.await;
}
poise::Event::InteractionCreate { interaction } => {
if let Interaction::MessageComponent(component) = interaction {
let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
component_model.act(ctx, data, component).await;
}
}
_ => {}
}
Ok(())
}
-692
View File
@@ -1,692 +0,0 @@
// todo move framework to its own module, split out permission checks
use std::{
collections::{HashMap, HashSet},
hash::{Hash, Hasher},
sync::Arc,
};
use log::info;
use serde::{Deserialize, Serialize};
use serenity::{
builder::{CreateApplicationCommands, CreateComponents, CreateEmbed},
cache::Cache,
client::Context,
futures::prelude::future::BoxFuture,
http::Http,
model::{
guild::Guild,
id::{ChannelId, GuildId, RoleId, UserId},
interactions::{
application_command::{
ApplicationCommand, ApplicationCommandInteraction, ApplicationCommandOptionType,
},
message_component::MessageComponentInteraction,
InteractionApplicationCommandCallbackDataFlags, InteractionResponseType,
},
prelude::application_command::ApplicationCommandInteractionDataOption,
},
prelude::TypeMapKey,
Result as SerenityResult,
};
use crate::SQLPool;
pub struct CreateGenericResponse {
content: String,
embed: Option<CreateEmbed>,
components: Option<CreateComponents>,
flags: InteractionApplicationCommandCallbackDataFlags,
}
impl CreateGenericResponse {
pub fn new() -> Self {
Self {
content: "".to_string(),
embed: None,
components: None,
flags: InteractionApplicationCommandCallbackDataFlags::empty(),
}
}
pub fn ephemeral(mut self) -> Self {
self.flags.insert(InteractionApplicationCommandCallbackDataFlags::EPHEMERAL);
self
}
pub fn content<D: ToString>(mut self, content: D) -> Self {
self.content = content.to_string();
self
}
pub fn embed<F: FnOnce(&mut CreateEmbed) -> &mut CreateEmbed>(mut self, f: F) -> Self {
let mut embed = CreateEmbed::default();
f(&mut embed);
self.embed = Some(embed);
self
}
pub fn components<F: FnOnce(&mut CreateComponents) -> &mut CreateComponents>(
mut self,
f: F,
) -> Self {
let mut components = CreateComponents::default();
f(&mut components);
self.components = Some(components);
self
}
}
#[derive(Clone)]
enum InvokeModel {
Slash(ApplicationCommandInteraction),
Component(MessageComponentInteraction),
}
#[derive(Clone)]
pub struct CommandInvoke {
model: InvokeModel,
already_responded: bool,
deferred: bool,
}
impl CommandInvoke {
pub fn component(component: MessageComponentInteraction) -> Self {
Self { model: InvokeModel::Component(component), already_responded: false, deferred: false }
}
fn slash(interaction: ApplicationCommandInteraction) -> Self {
Self { model: InvokeModel::Slash(interaction), already_responded: false, deferred: false }
}
pub async fn defer(&mut self, http: impl AsRef<Http>) {
if !self.deferred {
match &self.model {
InvokeModel::Slash(i) => {
i.create_interaction_response(http, |r| {
r.kind(InteractionResponseType::DeferredChannelMessageWithSource)
})
.await
.unwrap();
self.deferred = true;
}
InvokeModel::Component(i) => {
i.create_interaction_response(http, |r| {
r.kind(InteractionResponseType::DeferredChannelMessageWithSource)
})
.await
.unwrap();
self.deferred = true;
}
}
}
}
pub fn channel_id(&self) -> ChannelId {
match &self.model {
InvokeModel::Slash(i) => i.channel_id,
InvokeModel::Component(i) => i.channel_id,
}
}
pub fn guild_id(&self) -> Option<GuildId> {
match &self.model {
InvokeModel::Slash(i) => i.guild_id,
InvokeModel::Component(i) => i.guild_id,
}
}
pub fn guild(&self, cache: impl AsRef<Cache>) -> Option<Guild> {
self.guild_id().map(|id| id.to_guild_cached(cache)).flatten()
}
pub fn author_id(&self) -> UserId {
match &self.model {
InvokeModel::Slash(i) => i.user.id,
InvokeModel::Component(i) => i.user.id,
}
}
pub async fn respond(
&mut self,
http: impl AsRef<Http>,
generic_response: CreateGenericResponse,
) -> SerenityResult<()> {
match &self.model {
InvokeModel::Slash(i) => {
if self.already_responded {
i.create_followup_message(http, |d| {
d.allowed_mentions(|m| m.empty_parse());
d.content(generic_response.content);
if let Some(embed) = generic_response.embed {
d.add_embed(embed);
}
if let Some(components) = generic_response.components {
d.components(|c| {
*c = components;
c
});
}
d
})
.await
.map(|_| ())
} else if self.deferred {
i.edit_original_interaction_response(http, |d| {
d.allowed_mentions(|m| m.empty_parse());
d.content(generic_response.content);
if let Some(embed) = generic_response.embed {
d.add_embed(embed);
}
if let Some(components) = generic_response.components {
d.components(|c| {
*c = components;
c
});
}
d
})
.await
.map(|_| ())
} else {
i.create_interaction_response(http, |r| {
r.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|d| {
d.allowed_mentions(|m| m.empty_parse());
d.content(generic_response.content);
if let Some(embed) = generic_response.embed {
d.add_embed(embed);
}
if let Some(components) = generic_response.components {
d.components(|c| {
*c = components;
c
});
}
d
})
})
.await
.map(|_| ())
}
}
InvokeModel::Component(i) => i
.create_interaction_response(http, |r| {
r.kind(InteractionResponseType::UpdateMessage).interaction_response_data(|d| {
d.allowed_mentions(|m| m.empty_parse());
d.content(generic_response.content);
if let Some(embed) = generic_response.embed {
d.add_embed(embed);
}
if let Some(components) = generic_response.components {
d.components(|c| {
*c = components;
c
});
}
d
})
})
.await
.map(|_| ()),
}?;
self.already_responded = true;
Ok(())
}
}
#[derive(Debug)]
pub struct Arg {
pub name: &'static str,
pub description: &'static str,
pub kind: ApplicationCommandOptionType,
pub required: bool,
pub options: &'static [&'static Self],
}
#[derive(Serialize, Deserialize, Clone)]
pub enum OptionValue {
String(String),
Integer(i64),
Boolean(bool),
User(UserId),
Channel(ChannelId),
Role(RoleId),
Mentionable(u64),
Number(f64),
}
impl OptionValue {
pub fn as_i64(&self) -> Option<i64> {
match self {
OptionValue::Integer(i) => Some(*i),
_ => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
OptionValue::Boolean(b) => Some(*b),
_ => None,
}
}
pub fn as_channel_id(&self) -> Option<ChannelId> {
match self {
OptionValue::Channel(c) => Some(*c),
_ => None,
}
}
pub fn to_string(&self) -> String {
match self {
OptionValue::String(s) => s.to_string(),
OptionValue::Integer(i) => i.to_string(),
OptionValue::Boolean(b) => b.to_string(),
OptionValue::User(u) => u.to_string(),
OptionValue::Channel(c) => c.to_string(),
OptionValue::Role(r) => r.to_string(),
OptionValue::Mentionable(m) => m.to_string(),
OptionValue::Number(n) => n.to_string(),
}
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct CommandOptions {
pub command: String,
pub subcommand: Option<String>,
pub subcommand_group: Option<String>,
pub options: HashMap<String, OptionValue>,
}
impl CommandOptions {
pub fn get(&self, key: &str) -> Option<&OptionValue> {
self.options.get(key)
}
}
impl CommandOptions {
fn new(command: &'static Command) -> Self {
Self {
command: command.names[0].to_string(),
subcommand: None,
subcommand_group: None,
options: Default::default(),
}
}
fn populate(mut self, interaction: &ApplicationCommandInteraction) -> Self {
fn match_option(
option: ApplicationCommandInteractionDataOption,
cmd_opts: &mut CommandOptions,
) {
match option.kind {
ApplicationCommandOptionType::SubCommand => {
cmd_opts.subcommand = Some(option.name);
for opt in option.options {
match_option(opt, cmd_opts);
}
}
ApplicationCommandOptionType::SubCommandGroup => {
cmd_opts.subcommand_group = Some(option.name);
for opt in option.options {
match_option(opt, cmd_opts);
}
}
ApplicationCommandOptionType::String => {
cmd_opts.options.insert(
option.name,
OptionValue::String(option.value.unwrap().as_str().unwrap().to_string()),
);
}
ApplicationCommandOptionType::Integer => {
cmd_opts.options.insert(
option.name,
OptionValue::Integer(option.value.map(|m| m.as_i64()).flatten().unwrap()),
);
}
ApplicationCommandOptionType::Boolean => {
cmd_opts.options.insert(
option.name,
OptionValue::Boolean(option.value.map(|m| m.as_bool()).flatten().unwrap()),
);
}
ApplicationCommandOptionType::User => {
cmd_opts.options.insert(
option.name,
OptionValue::User(UserId(
option
.value
.map(|m| m.as_str().map(|s| s.parse::<u64>().ok()))
.flatten()
.flatten()
.unwrap(),
)),
);
}
ApplicationCommandOptionType::Channel => {
cmd_opts.options.insert(
option.name,
OptionValue::Channel(ChannelId(
option
.value
.map(|m| m.as_str().map(|s| s.parse::<u64>().ok()))
.flatten()
.flatten()
.unwrap(),
)),
);
}
ApplicationCommandOptionType::Role => {
cmd_opts.options.insert(
option.name,
OptionValue::Role(RoleId(
option
.value
.map(|m| m.as_str().map(|s| s.parse::<u64>().ok()))
.flatten()
.flatten()
.unwrap(),
)),
);
}
ApplicationCommandOptionType::Mentionable => {
cmd_opts.options.insert(
option.name,
OptionValue::Mentionable(
option.value.map(|m| m.as_u64()).flatten().unwrap(),
),
);
}
ApplicationCommandOptionType::Number => {
cmd_opts.options.insert(
option.name,
OptionValue::Number(option.value.map(|m| m.as_f64()).flatten().unwrap()),
);
}
_ => {}
}
}
for option in &interaction.data.options {
match_option(option.clone(), &mut self)
}
self
}
}
pub enum HookResult {
Continue,
Halt,
}
type SlashCommandFn =
for<'fut> fn(&'fut Context, &'fut mut CommandInvoke, CommandOptions) -> BoxFuture<'fut, ()>;
type MultiCommandFn = for<'fut> fn(&'fut Context, &'fut mut CommandInvoke) -> BoxFuture<'fut, ()>;
pub type HookFn = for<'fut> fn(
&'fut Context,
&'fut mut CommandInvoke,
&'fut CommandOptions,
) -> BoxFuture<'fut, HookResult>;
pub enum CommandFnType {
Slash(SlashCommandFn),
Multi(MultiCommandFn),
}
pub struct Hook {
pub fun: HookFn,
pub uuid: u128,
}
impl PartialEq for Hook {
fn eq(&self, other: &Self) -> bool {
self.uuid == other.uuid
}
}
pub struct Command {
pub fun: CommandFnType,
pub names: &'static [&'static str],
pub desc: &'static str,
pub examples: &'static [&'static str],
pub group: &'static str,
pub args: &'static [&'static Arg],
pub can_blacklist: bool,
pub supports_dm: bool,
pub hooks: &'static [&'static Hook],
}
impl Hash for Command {
fn hash<H: Hasher>(&self, state: &mut H) {
self.names[0].hash(state)
}
}
impl PartialEq for Command {
fn eq(&self, other: &Self) -> bool {
self.names[0] == other.names[0]
}
}
impl Eq for Command {}
pub struct RegexFramework {
pub commands_map: HashMap<String, &'static Command>,
pub commands: HashSet<&'static Command>,
ignore_bots: bool,
dm_enabled: bool,
debug_guild: Option<GuildId>,
hooks: Vec<&'static Hook>,
}
impl TypeMapKey for RegexFramework {
type Value = Arc<RegexFramework>;
}
impl RegexFramework {
pub fn new() -> Self {
Self {
commands_map: HashMap::new(),
commands: HashSet::new(),
ignore_bots: true,
dm_enabled: true,
debug_guild: None,
hooks: vec![],
}
}
pub fn ignore_bots(mut self, ignore_bots: bool) -> Self {
self.ignore_bots = ignore_bots;
self
}
pub fn dm_enabled(mut self, dm_enabled: bool) -> Self {
self.dm_enabled = dm_enabled;
self
}
pub fn add_hook(mut self, fun: &'static Hook) -> Self {
self.hooks.push(fun);
self
}
pub fn add_command(mut self, command: &'static Command) -> Self {
self.commands.insert(command);
for name in command.names {
self.commands_map.insert(name.to_string(), command);
}
self
}
pub fn debug_guild(mut self, guild_id: Option<GuildId>) -> Self {
self.debug_guild = guild_id;
self
}
fn _populate_commands<'a>(
&self,
commands: &'a mut CreateApplicationCommands,
) -> &'a mut CreateApplicationCommands {
for command in &self.commands {
commands.create_application_command(|c| {
c.name(command.names[0]).description(command.desc);
for arg in command.args {
c.create_option(|o| {
o.name(arg.name)
.description(arg.description)
.kind(arg.kind)
.required(arg.required);
for option in arg.options {
o.create_sub_option(|s| {
s.name(option.name)
.description(option.description)
.kind(option.kind)
.required(option.required);
for sub_option in option.options {
s.create_sub_option(|ss| {
ss.name(sub_option.name)
.description(sub_option.description)
.kind(sub_option.kind)
.required(sub_option.required)
});
}
s
});
}
o
});
}
c
});
}
commands
}
pub async fn build_slash(&self, http: impl AsRef<Http>) {
info!("Building slash commands...");
match self.debug_guild {
None => {
ApplicationCommand::set_global_application_commands(&http, |c| {
self._populate_commands(c)
})
.await
.unwrap();
}
Some(debug_guild) => {
debug_guild
.set_application_commands(&http, |c| self._populate_commands(c))
.await
.unwrap();
}
}
info!("Slash commands built!");
}
pub async fn execute(&self, ctx: Context, interaction: ApplicationCommandInteraction) {
{
if let Some(guild_id) = interaction.guild_id {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let _ = sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id.0)
.execute(&pool)
.await;
}
}
let command = {
self.commands_map
.get(&interaction.data.name)
.expect(&format!("Received invalid command: {}", interaction.data.name))
};
let args = CommandOptions::new(command).populate(&interaction);
let mut command_invoke = CommandInvoke::slash(interaction);
for hook in command.hooks {
match (hook.fun)(&ctx, &mut command_invoke, &args).await {
HookResult::Continue => {}
HookResult::Halt => {
return;
}
}
}
for hook in &self.hooks {
match (hook.fun)(&ctx, &mut command_invoke, &args).await {
HookResult::Continue => {}
HookResult::Halt => {
return;
}
}
}
match command.fun {
CommandFnType::Slash(t) => t(&ctx, &mut command_invoke, args).await,
CommandFnType::Multi(m) => m(&ctx, &mut command_invoke).await,
}
}
pub async fn run_command_from_options(
&self,
ctx: &Context,
command_invoke: &mut CommandInvoke,
command_options: CommandOptions,
) {
let command = {
self.commands_map
.get(&command_options.command)
.expect(&format!("Received invalid command: {}", command_options.command))
};
match command.fun {
CommandFnType::Slash(t) => t(&ctx, command_invoke, command_options).await,
CommandFnType::Multi(m) => m(&ctx, command_invoke).await,
}
}
}
+51 -110
View File
@@ -1,107 +1,72 @@
use regex_command_attr::check;
use serenity::{client::Context, model::channel::Channel};
use poise::serenity::model::channel::Channel;
use crate::{
framework::{CommandInvoke, CommandOptions, CreateGenericResponse, HookResult},
moderation_cmds, RecordingMacros,
};
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
#[check]
pub async fn guild_only(
ctx: &Context,
invoke: &mut CommandInvoke,
_args: &CommandOptions,
) -> HookResult {
if invoke.guild_id().is_some() {
HookResult::Continue
} else {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content("This command can only be used in servers"),
)
.await;
async fn macro_check(ctx: Context<'_>) -> bool {
if let Context::Application(app_ctx) = ctx {
if let Some(guild_id) = ctx.guild_id() {
if ctx.command().identifying_name != "finish_macro" {
let mut lock = ctx.data().recording_macros.write().await;
HookResult::Halt
}
}
if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) {
if command_macro.commands.len() >= MACRO_MAX_COMMANDS {
let _ = ctx.send(|m| {
m.ephemeral(true).content(
format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS),
)
})
.await;
} else {
let recorded = RecordedCommand {
action: None,
command_name: ctx.command().identifying_name.clone(),
options: Vec::from(app_ctx.args),
};
#[check]
pub async fn macro_check(
ctx: &Context,
invoke: &mut CommandInvoke,
args: &CommandOptions,
) -> HookResult {
if let Some(guild_id) = invoke.guild_id() {
if args.command != moderation_cmds::MACRO_CMD_COMMAND.names[0] {
let active_recordings =
ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap();
let mut lock = active_recordings.write().await;
command_macro.commands.push(recorded);
if let Some(command_macro) = lock.get_mut(&(guild_id, invoke.author_id())) {
if command_macro.commands.len() >= 5 {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content("5 commands already recorded. Please use `/macro finish` to end recording."),
)
.await;
} else {
command_macro.commands.push(args.clone());
let _ = ctx
.send(|m| m.ephemeral(true).content("Command recorded to macro"))
.await;
}
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content("Command recorded to macro"),
)
.await;
return false;
}
HookResult::Halt
} else {
HookResult::Continue
}
} else {
HookResult::Continue
}
} else {
HookResult::Continue
}
true
}
#[check]
pub async fn check_self_permissions(
ctx: &Context,
invoke: &mut CommandInvoke,
_args: &CommandOptions,
) -> HookResult {
if let Some(guild) = invoke.guild(&ctx) {
let user_id = ctx.cache.current_user_id();
async fn check_self_permissions(ctx: Context<'_>) -> bool {
if let Some(guild) = ctx.guild() {
let user_id = ctx.discord().cache.current_user_id();
let manage_webhooks =
guild.member_permissions(&ctx, user_id).await.map_or(false, |p| p.manage_webhooks());
let (view_channel, send_messages, embed_links) = invoke
let manage_webhooks = guild
.member_permissions(&ctx.discord(), user_id)
.await
.map_or(false, |p| p.manage_webhooks());
let (view_channel, send_messages, embed_links) = ctx
.channel_id()
.to_channel_cached(&ctx)
.map(|c| {
.to_channel_cached(&ctx.discord())
.and_then(|c| {
if let Channel::Guild(channel) = c {
channel.permissions_for_user(ctx, user_id).ok()
channel.permissions_for_user(&ctx.discord(), user_id).ok()
} else {
None
}
})
.flatten()
.map_or((false, false, false), |p| {
(p.read_messages(), p.send_messages(), p.embed_links())
(p.view_channel(), p.send_messages(), p.embed_links())
});
if manage_webhooks && send_messages && embed_links {
HookResult::Continue
true
} else {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content(format!(
let _ = ctx
.send(|m| {
m.content(format!(
"Please ensure the bot has the correct permissions:
{} **View Channel**
@@ -112,41 +77,17 @@ pub async fn check_self_permissions(
if send_messages { "" } else { "" },
if manage_webhooks { "" } else { "" },
if embed_links { "" } else { "" },
)),
)
))
})
.await;
HookResult::Halt
false
}
} else {
HookResult::Continue
true
}
}
#[check]
pub async fn check_guild_permissions(
ctx: &Context,
invoke: &mut CommandInvoke,
_args: &CommandOptions,
) -> HookResult {
if let Some(guild) = invoke.guild(&ctx) {
let permissions = guild.member_permissions(&ctx, invoke.author_id()).await.unwrap();
if !permissions.manage_guild() {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content(
"You must have the \"Manage Server\" permission to use this command",
),
)
.await;
HookResult::Halt
} else {
HookResult::Continue
}
} else {
HookResult::Continue
}
pub async fn all_checks(ctx: Context<'_>) -> Result<bool, Error> {
Ok(macro_check(ctx).await && check_self_permissions(ctx).await)
}
+251
View File
@@ -0,0 +1,251 @@
/*
With modifications, 2022 Jude Southworth
Original copyright notice:
Copyright 2021 Paul Colomiets
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
use std::{error::Error as StdError, fmt, str::Chars};
/// Error parsing human-friendly duration
#[derive(Debug, PartialEq, Clone)]
pub enum Error {
/// Invalid character during parsing
///
/// More specifically anything that is not alphanumeric is prohibited
///
/// The field is an byte offset of the character in the string.
InvalidCharacter(usize),
/// Non-numeric value where number is expected
///
/// This usually means that either time unit is broken into words,
/// e.g. `m sec` instead of `msec`, or just number is omitted,
/// for example `2 hours min` instead of `2 hours 1 min`
///
/// The field is an byte offset of the errorneous character
/// in the string.
NumberExpected(usize),
/// Unit in the number is not one of allowed units
///
/// See documentation of `parse_duration` for the list of supported
/// time units.
///
/// The two fields are start and end (exclusive) of the slice from
/// the original string, containing errorneous value
UnknownUnit {
/// Start of the invalid unit inside the original string
start: usize,
/// End of the invalid unit inside the original string
end: usize,
/// The unit verbatim
unit: String,
/// A number associated with the unit
value: u64,
},
/// The numeric value is too large
///
/// Usually this means value is too large to be useful. If user writes
/// data in subsecond units, then the maximum is about 3k years. When
/// using seconds, or larger units, the limit is even larger.
NumberOverflow,
/// The value was an empty string (or consists only whitespace)
Empty,
}
impl StdError for Error {}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::InvalidCharacter(offset) => write!(f, "invalid character at {}", offset),
Error::NumberExpected(offset) => write!(f, "expected number at {}", offset),
Error::UnknownUnit { unit, value, .. } if unit.is_empty() => {
write!(f, "time unit needed, for example {0}sec or {0}ms", value,)
}
Error::UnknownUnit { unit, .. } => {
write!(
f,
"unknown time unit {:?}, \
supported units: ns, us, ms, sec, min, hours, days, \
weeks, months, years (and few variations)",
unit
)
}
Error::NumberOverflow => write!(f, "number is too large"),
Error::Empty => write!(f, "value was empty"),
}
}
}
trait OverflowOp: Sized {
fn mul(self, other: Self) -> Result<Self, Error>;
fn add(self, other: Self) -> Result<Self, Error>;
}
impl OverflowOp for u64 {
fn mul(self, other: Self) -> Result<Self, Error> {
self.checked_mul(other).ok_or(Error::NumberOverflow)
}
fn add(self, other: Self) -> Result<Self, Error> {
self.checked_add(other).ok_or(Error::NumberOverflow)
}
}
#[derive(Copy, Clone)]
pub struct Interval {
pub month: u64,
pub sec: u64,
}
struct Parser<'a> {
iter: Chars<'a>,
src: &'a str,
current: (u64, u64, u64),
}
impl<'a> Parser<'a> {
fn off(&self) -> usize {
self.src.len() - self.iter.as_str().len()
}
fn parse_first_char(&mut self) -> Result<Option<u64>, Error> {
let off = self.off();
for c in self.iter.by_ref() {
match c {
'0'..='9' => {
return Ok(Some(c as u64 - '0' as u64));
}
c if c.is_whitespace() => continue,
_ => {
return Err(Error::NumberExpected(off));
}
}
}
Ok(None)
}
fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> {
let (mut month, mut sec, nsec) = match &self.src[start..end] {
"nanos" | "nsec" | "ns" => (0u64, 0u64, n),
"usec" | "us" => (0, 0u64, n.mul(1000)?),
"millis" | "msec" | "ms" => (0, 0u64, n.mul(1_000_000)?),
"seconds" | "second" | "secs" | "sec" | "s" => (0, n, 0),
"minutes" | "minute" | "min" | "mins" | "m" => (0, n.mul(60)?, 0),
"hours" | "hour" | "hr" | "hrs" | "h" => (0, n.mul(3600)?, 0),
"days" | "day" | "d" => (0, n.mul(86400)?, 0),
"weeks" | "week" | "w" => (0, n.mul(86400 * 7)?, 0),
"months" | "month" | "M" => (n, 0, 0),
"years" | "year" | "y" => (12, 0, 0),
_ => {
return Err(Error::UnknownUnit {
start,
end,
unit: self.src[start..end].to_string(),
value: n,
});
}
};
let mut nsec = self.current.2 + nsec;
if nsec > 1_000_000_000 {
sec += nsec / 1_000_000_000;
nsec %= 1_000_000_000;
}
sec += self.current.1;
month += self.current.0;
self.current = (month, sec, nsec);
Ok(())
}
fn parse(mut self) -> Result<Interval, Error> {
let mut n = self.parse_first_char()?.ok_or(Error::Empty)?;
'outer: loop {
let mut off = self.off();
while let Some(c) = self.iter.next() {
match c {
'0'..='9' => {
n = n
.checked_mul(10)
.and_then(|x| x.checked_add(c as u64 - '0' as u64))
.ok_or(Error::NumberOverflow)?;
}
c if c.is_whitespace() => {}
'a'..='z' | 'A'..='Z' => {
break;
}
_ => {
return Err(Error::InvalidCharacter(off));
}
}
off = self.off();
}
let start = off;
let mut off = self.off();
while let Some(c) = self.iter.next() {
match c {
'0'..='9' => {
self.parse_unit(n, start, off)?;
n = c as u64 - '0' as u64;
continue 'outer;
}
c if c.is_whitespace() => break,
'a'..='z' | 'A'..='Z' => {}
_ => {
return Err(Error::InvalidCharacter(off));
}
}
off = self.off();
}
self.parse_unit(n, start, off)?;
n = match self.parse_first_char()? {
Some(n) => n,
None => return Ok(Interval { month: self.current.0, sec: self.current.1 }),
};
}
}
}
/// Parse duration object `1hour 12min 5s`
///
/// The duration object is a concatenation of time spans. Where each time