38 Commits

Author SHA1 Message Date
1c1f5662d3 removed guild only hook. permissions on commands. fix for macro command count. 2022-05-13 08:59:46 +01:00
ded750aa2d update dependencies 2022-04-19 15:23:27 +01:00
4c4f0927f1 fix attachments. remove webhook sending for speedup 2022-04-09 12:21:28 +01:00
0f05018cab removed remainder of old personal dashboard code. fixed big lighthouse issues. 2022-04-07 21:41:24 +01:00
85d27c5bba fields are now json and work. fix for intervals. moved some code together 2022-04-07 17:13:02 +01:00
d946ef1dca process intervals. inlining fields 2022-04-03 21:53:28 +01:00
f21d522435 mobile appearence 2022-03-27 19:17:30 +01:00
3add718cdf interval field client processing 2022-03-27 18:03:42 +01:00
f4ef7afea0 show newly created reminders. fix color rendering. 2022-03-27 14:15:01 +01:00
f8547bba70 upload attachments 2022-03-26 20:03:58 +00:00
08fd88ce54 styling interval inputs 2022-03-24 22:40:05 +00:00
abfe492192 put a plain background on images 2022-03-24 21:36:22 +00:00
afb2fbe4ff patch reminders 2022-03-22 22:21:47 +00:00
878ea11502 graceful shutdown 2022-03-21 23:11:52 +00:00
93da746bdc support articles 2022-03-20 21:41:38 +00:00
9e6a387f82 support articles 2022-03-20 21:04:24 +00:00
af9d8bea62 collapse/expand elements. moved the embed color picker 2022-03-20 18:29:27 +00:00
318be1fa5e prettier for javascript formatting. sorting 2022-03-20 15:46:22 +00:00
3b6e02e16e working on editing reminders 2022-03-20 00:10:19 +00:00
a56f84f659 timezone help. moved javascript to separate file 2022-03-19 23:47:40 +00:00
3e4dd0fa48 channel selection shows properly. loader 2022-03-19 21:28:11 +00:00
d0d2d50966 create reminders :) 2022-03-19 17:41:34 +00:00
e2e5b022a0 create reminder route. formatting on frontend 2022-03-05 19:43:02 +00:00
6ae2353c92 add distinct identifying names. log errors in run_macro 2022-02-20 12:19:39 +00:00
06c4deeaa9 component models 2022-02-19 22:11:21 +00:00
afc376c44f everything except component model actions 2022-02-19 18:21:11 +00:00
84ee7e77c5 2nd attempt at doing poise stuff 2022-02-19 14:32:03 +00:00
620f054703 extracted event handler. removed custom sharding code. extracted util functions 2022-02-19 13:28:24 +00:00
cb471c52f3 optionally dont run web/postman 2022-02-19 12:45:33 +00:00
37420b2b1f ..... 2022-02-11 20:03:53 +00:00
49974b7153 moved dashboard crate into here 2022-02-11 17:44:08 +00:00
a3844dde9e moved postman into separate crate 2022-02-06 15:47:59 +00:00
d62c8c95c2 support months in sender 2022-02-01 23:41:28 +00:00
05606dfec1 update lock 2022-02-01 23:05:14 +00:00
68ee42f244 Merge remote-tracking branch 'origin/next' into next
# Conflicts:
#	Cargo.lock
2022-02-01 23:04:44 +00:00
fad28faabb interval months/interval seconds 2022-02-01 23:04:31 +00:00
e5ab99f67b removed some log messages. rustfmt 2021-12-21 13:46:10 +00:00
e47715917e integrate reminder sender 2021-12-20 13:48:18 +00:00
136 changed files with 75782 additions and 3940 deletions

2
.prettierrc.toml Normal file
View File

@ -0,0 +1,2 @@
printWidth = 90
tabWidth = 4

2379
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,12 @@
[package]
name = "reminder_rs"
version = "1.6.0-beta2"
version = "1.6.0-beta3"
authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018"
[dependencies]
poise = "0.2"
dotenv = "0.15"
humantime = "2.1"
tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11"
regex = "1.4"
@ -25,22 +25,8 @@ 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"

View File

@ -30,15 +30,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
Rocket.toml Normal file
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"

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

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

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::*;

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

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

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');
}
}
}

View File

@ -0,0 +1,4 @@
USE reminders;
ALTER TABLE reminders.reminders RENAME COLUMN `interval` TO `interval_seconds`;
ALTER TABLE reminders.reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;

View File

@ -0,0 +1,34 @@
USE reminders;
CREATE TABLE reminder_template (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(24) NOT NULL DEFAULT 'Reminder',
`guild_id` INT UNSIGNED NOT NULL,
`username` VARCHAR(32) DEFAULT NULL,
`avatar` VARCHAR(512) DEFAULT NULL,
`content` VARCHAR(2048) NOT NULL DEFAULT '',
`tts` BOOL NOT NULL DEFAULT 0,
`attachment` MEDIUMBLOB,
`attachment_name` VARCHAR(260),
`embed_title` VARCHAR(256) NOT NULL DEFAULT '',
`embed_description` VARCHAR(2048) NOT NULL DEFAULT '',
`embed_image_url` VARCHAR(512),
`embed_thumbnail_url` VARCHAR(512),
`embed_footer` VARCHAR(2048) NOT NULL DEFAULT '',
`embed_footer_url` VARCHAR(512),
`embed_author` VARCHAR(256) NOT NULL DEFAULT '',
`embed_author_url` VARCHAR(512),
`embed_color` INT UNSIGNED NOT NULL DEFAULT 0x0,
`embed_fields` JSON,
PRIMARY KEY (id),
FOREIGN KEY (`guild_id`) REFERENCES guilds (`id`) ON DELETE CASCADE
);
ALTER TABLE reminders ADD COLUMN embed_fields JSON;

18
postman/Cargo.toml Normal file
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
postman/src/lib.rs Normal file
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;
}
}

559
postman/src/sender.rs Normal file
View File

@ -0,0 +1,559 @@
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, 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").unwrap().as_str();
let format = caps.name("format").unwrap().as_str();
if let Ok(final_time) = final_time.parse::<i64>() {
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").unwrap().as_str();
println!("{}", timezone);
if let Ok(tz) = timezone.parse::<Tz>() {
let format = caps.name("format").unwrap().as_str();
let now = Utc::now().with_timezone(&tz);
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> {
let mut embed = 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
.unwrap();
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
}
}
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> {
sqlx::query_as_unchecked!(
Reminder,
"
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()
",
)
.fetch_all(pool)
.await
.unwrap()
.into_iter()
.map(|mut rem| {
rem.content = substitute(&rem.content);
rem
})
.collect::<Vec<Self>>()
}
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 {
let row = 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
.unwrap();
updated_reminder_time = row.new_time.unwrap();
}
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::from_u16(404).unwrap()) {
error!("Seeing channel is deleted. Removing reminder");
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;
}
}
}

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,15 +19,13 @@ 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| {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Help")
.color(*THEME_COLOR)
.description(
@ -60,21 +55,21 @@ __Advanced Commands__
",
)
.footer(footer)
}),
)
.await;
})
})
.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!(
"Help: `/help`
@ -89,23 +84,22 @@ 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| {
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 :)
.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/**
@ -124,39 +118,63 @@ Just $2 USD/month!
.color(*THEME_COLOR)
}),
)
.await;
.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| {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Dashboard")
.description("**https://reminder-bot.com/dashboard**")
.footer(footer)
.color(*THEME_COLOR)
}),
)
.await;
})
})
.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(())
}

View File

@ -1,44 +1,53 @@
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| {
ctx.send(|m| {
m.embed(|e| {
e.title("Timezone Set")
.description(format!(
"Timezone has been set to **{}**. Your current time should be `{}`",
@ -46,9 +55,9 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption
now.format("%H:%M").to_string()
))
.color(*THEME_COLOR)
}),
)
.await;
})
})
.await?;
}
Err(_) => {
@ -56,8 +65,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())
@ -74,25 +83,21 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption
)
});
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
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;
})
})
.await?;
}
}
} else {
let popular_timezones = ctx.data.read().await.get::<PopularTimezones>().cloned().unwrap();
let popular_timezones_iter = popular_timezones.iter().map(|t| {
let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| {
(
t.to_string(),
format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()),
@ -100,10 +105,8 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption
)
});
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
ctx.send(|m| {
m.embed(|e| {
e.title("Timezone Usage")
.description(
"**Usage:**
@ -118,137 +121,150 @@ You may want to use one of the popular timezones below, otherwise click [here](h
.fields(popular_timezones_iter)
.footer(|f| f.text(footer_text))
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
}),
)
.await;
})
})
.await?;
}
Ok(())
}
#[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
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(vec![])
.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"
)]
#[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();
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
match args.subcommand.clone().unwrap().as_str() {
"record" => {
let guild_id = invoke.guild_id().unwrap();
let name = args.get("name").unwrap().to_string();
/// 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 = ?",
"
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
guild_id.0,
name
)
.fetch_one(&pool)
.fetch_one(&ctx.data().database)
.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)
}),
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.",
)
.await;
.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;
let mut lock = ctx.data().recording_macros.write().await;
if lock.contains_key(&(guild_id, invoke.author_id())) {
if lock.contains_key(&(guild_id, ctx.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![],
},
(guild_id, ctx.author().id),
CommandMacro { guild_id, name, description, commands: vec![] },
);
true
}
};
if okay {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().ephemeral().embed(|e| {
e
.title("Macro Recording Started")
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)
}),
"Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands ran as part of recording will be inconsequential",
)
.await;
.color(*THEME_COLOR)
})
})
.await?;
} else {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().ephemeral().embed(|e| {
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;
})
})
.await?;
}
}
}
"finish" => {
let key = (invoke.guild_id().unwrap(), invoke.author_id());
let macro_buffer = ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap();
Ok(())
}
/// 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 = macro_buffer.read().await;
let lock = ctx.data().recording_macros.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| {
ctx.send(|m| {
m.embed(|e| {
e.title("No Macro Recorded")
.description("Use `/macro record` to start recording a macro")
.color(*THEME_COLOR)
}),
)
.await;
})
})
.await?;
} else {
let command_macro = contained.unwrap();
let json = serde_json::to_string(&command_macro.commands).unwrap();
@ -260,119 +276,140 @@ Please use `/macro finish` to end this recording before starting another.",
command_macro.description,
json
)
.execute(&pool)
.execute(&ctx.data().database)
.await
.unwrap();
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().embed(|e| {
ctx.send(|m| {
m.embed(|e| {
e.title("Macro Recorded")
.description("Use `/macro run` to execute the macro")
.color(*THEME_COLOR)
}),
)
.await;
})
})
.await?;
}
}
{
let mut lock = macro_buffer.write().await;
let mut lock = ctx.data().recording_macros.write().await;
lock.remove(&key);
}
}
"list" => {
let macros = CommandMacro::from_guild(ctx, invoke.guild_id().unwrap()).await;
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);
invoke.respond(&ctx, resp).await.unwrap();
}
"run" => {
let macro_name = args.get("name").unwrap().to_string();
ctx.send(|m| {
*m = resp;
m
})
.await?;
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)
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(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;
}
Ok(()) => {}
Err(e) => {
panic!("{}", e);
println!("{:?}", e);
}
}
} else {
Context::Application(ctx)
.say(format!("Command \"{}\" not found", command.command_name))
.await?;
}
}
}
"delete" => {
let macro_name = args.get("name").unwrap().to_string();
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 = ?",
invoke.guild_id().unwrap().0,
macro_name
"
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
ctx.guild_id().unwrap().0,
name
)
.fetch_one(&pool)
.fetch_one(&ctx.data().database)
.await
{
Ok(row) => {
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
.execute(&pool)
.execute(&ctx.data().database)
.await
.unwrap();
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new()
.content(format!("Macro \"{}\" deleted", macro_name)),
)
.await;
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
}
Err(sqlx::Error::RowNotFound) => {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new()
.content(format!("Macro \"{}\" not found", macro_name)),
)
.await;
ctx.say(format!("Macro \"{}\" not found", name)).await?;
}
Err(e) => {
panic!("{}", e);
}
}
}
_ => {}
}
Ok(())
}
pub fn max_macro_page(macros: &[CommandMacro]) -> usize {
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
let mut skipped_char_count = 0;
macros
@ -396,15 +433,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 +488,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 +501,7 @@ pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericRes
pager.create_button_row(pages, comp);
comp
})
});
reply
}

View File

@ -7,18 +7,21 @@ use std::{
use chrono::NaiveDateTime;
use chrono_tz::Tz;
use num_integer::Integer;
use regex_command_attr::command;
use serenity::{builder::CreateEmbed, client::Context, model::channel::Channel};
use poise::{
serenity::{builder::CreateEmbed, model::channel::Channel},
CreateReply,
};
use crate::{
check_guild_subscription, check_subscription,
component_models::{
pager::{DelPager, LookPager, Pager},
ComponentDataModel, DelSelector,
},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, REGEX_CHANNEL_USER, SELECT_MAX_ENTRIES, THEME_COLOR},
framework::{CommandInvoke, CommandOptions, CreateGenericResponse, OptionValue},
hooks::CHECK_GUILD_PERMISSIONS_HOOK,
consts::{
EMBED_DESCRIPTION_MAX_LENGTH, HOUR, MINUTE, REGEX_CHANNEL_USER, SELECT_MAX_ENTRIES,
THEME_COLOR,
},
interval_parser::parse_duration,
models::{
reminder::{
builder::{MultiReminderBuilder, ReminderScope},
@ -28,33 +31,30 @@ use crate::{
Reminder,
},
timer::Timer,
user_data::UserData,
CtxData,
},
time_parser::natural_parser,
SQLPool,
utils::{check_guild_subscription, check_subscription},
Context, Error,
};
#[command("pause")]
#[description("Pause all reminders on the current channel until a certain time or indefinitely")]
#[arg(
name = "until",
description = "When to pause until (hint: try 'next Wednesday', or '10 minutes')",
kind = "String",
required = false
/// Pause all reminders on the current channel until a certain time or indefinitely
#[poise::command(
slash_command,
identifying_name = "pause",
default_member_permissions = "MANAGE_GUILD"
)]
#[supports_dm(false)]
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
async fn pause(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
pub async fn pause(
ctx: Context<'_>,
#[description = "When to pause until"] until: Option<String>,
) -> Result<(), Error> {
let timezone = ctx.timezone().await;
let timezone = UserData::timezone_of(&invoke.author_id(), &pool).await;
let mut channel = ctx.channel_data().await.unwrap();
let mut channel = ctx.channel_data(invoke.channel_id()).await.unwrap();
match args.get("until") {
Some(OptionValue::String(until)) => {
let parsed = natural_parser(until, &timezone.to_string()).await;
match until {
Some(until) => {
let parsed = natural_parser(&until, &timezone.to_string()).await;
if let Some(timestamp) = parsed {
let dt = NaiveDateTime::from_timestamp(timestamp, 0);
@ -62,92 +62,57 @@ async fn pause(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions)
channel.paused = true;
channel.paused_until = Some(dt);
channel.commit_changes(&pool).await;
channel.commit_changes(&ctx.data().database).await;
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(format!(
ctx.say(format!(
"Reminders in this channel have been silenced until **<t:{}:D>**",
timestamp
)),
)
.await;
))
.await?;
} else {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.content("Time could not be processed. Please write the time as clearly as possible"),
ctx.say(
"Time could not be processed. Please write the time as clearly as possible",
)
.await;
.await?;
}
}
_ => {
channel.paused = !channel.paused;
channel.paused_until = None;
channel.commit_changes(&pool).await;
channel.commit_changes(&ctx.data().database).await;
if channel.paused {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.content("Reminders in this channel have been silenced indefinitely"),
)
.await;
ctx.say("Reminders in this channel have been silenced indefinitely").await?;
} else {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.content("Reminders in this channel have been unsilenced"),
)
.await;
ctx.say("Reminders in this channel have been unsilenced").await?;
}
}
}
Ok(())
}
#[command("offset")]
#[description("Move all reminders in the current server by a certain amount of time. Times get added together")]
#[arg(
name = "hours",
description = "Number of hours to offset by",
kind = "Integer",
required = false
/// Move all reminders in the current server by a certain amount of time. Times get added together
#[poise::command(
slash_command,
identifying_name = "offset",
default_member_permissions = "MANAGE_GUILD"
)]
#[arg(
name = "minutes",
description = "Number of minutes to offset by",
kind = "Integer",
required = false
)]
#[arg(
name = "seconds",
description = "Number of seconds to offset by",
kind = "Integer",
required = false
)]
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
async fn offset(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let combined_time = args.get("hours").map_or(0, |h| h.as_i64().unwrap() * 3600)
+ args.get("minutes").map_or(0, |m| m.as_i64().unwrap() * 60)
+ args.get("seconds").map_or(0, |s| s.as_i64().unwrap());
pub async fn offset(
ctx: Context<'_>,
#[description = "Number of hours to offset by"] hours: Option<isize>,
#[description = "Number of minutes to offset by"] minutes: Option<isize>,
#[description = "Number of seconds to offset by"] seconds: Option<isize>,
) -> Result<(), Error> {
let combined_time = hours.map_or(0, |h| h * HOUR as isize)
+ minutes.map_or(0, |m| m * MINUTE as isize)
+ seconds.map_or(0, |s| s);
if combined_time == 0 {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.content("Please specify one of `hours`, `minutes` or `seconds`"),
)
.await;
ctx.say("Please specify one of `hours`, `minutes` or `seconds`").await?;
} else {
if let Some(guild) = invoke.guild(ctx.cache.clone()) {
if let Some(guild) = ctx.guild() {
let channels = guild
.channels
.iter()
@ -166,110 +131,75 @@ INNER JOIN
`channels` ON `channels`.id = reminders.channel_id
SET reminders.`utc_time` = DATE_ADD(reminders.`utc_time`, INTERVAL ? SECOND)
WHERE FIND_IN_SET(channels.`channel`, ?)",
combined_time,
combined_time as i64,
channels
)
.execute(&pool)
.execute(&ctx.data().database)
.await
.unwrap();
} else {
sqlx::query!(
"UPDATE reminders INNER JOIN `channels` ON `channels`.id = reminders.channel_id SET reminders.`utc_time` = reminders.`utc_time` + ? WHERE channels.`channel` = ?",
combined_time,
invoke.channel_id().0
combined_time as i64,
ctx.channel_id().0
)
.execute(&pool)
.execute(&ctx.data().database)
.await
.unwrap();
}
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.content(format!("All reminders offset by {} seconds", combined_time)),
)
.await;
ctx.say(format!("All reminders offset by {} seconds", combined_time)).await?;
}
Ok(())
}
#[command("nudge")]
#[description("Nudge all future reminders on this channel by a certain amount (don't use for DST! See `/offset`)")]
#[arg(
name = "minutes",
description = "Number of minutes to nudge new reminders by",
kind = "Integer",
required = false
/// Nudge all future reminders on this channel by a certain amount (don't use for DST! See `/offset`)
#[poise::command(
slash_command,
identifying_name = "nudge",
default_member_permissions = "MANAGE_GUILD"
)]
#[arg(
name = "seconds",
description = "Number of seconds to nudge new reminders by",
kind = "Integer",
required = false
)]
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
async fn nudge(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
pub async fn nudge(
ctx: Context<'_>,
#[description = "Number of minutes to nudge new reminders by"] minutes: Option<isize>,
#[description = "Number of seconds to nudge new reminders by"] seconds: Option<isize>,
) -> Result<(), Error> {
let combined_time = minutes.map_or(0, |m| m * MINUTE as isize) + seconds.map_or(0, |s| s);
let combined_time = args.get("minutes").map_or(0, |m| m.as_i64().unwrap() * 60)
+ args.get("seconds").map_or(0, |s| s.as_i64().unwrap());
if combined_time < i16::MIN as i64 || combined_time > i16::MAX as i64 {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("Nudge times must be less than 500 minutes"),
)
.await;
if combined_time < i16::MIN as isize || combined_time > i16::MAX as isize {
ctx.say("Nudge times must be less than 500 minutes").await?;
} else {
let mut channel_data = ctx.channel_data(invoke.channel_id()).await.unwrap();
let mut channel_data = ctx.channel_data().await.unwrap();
channel_data.nudge = combined_time as i16;
channel_data.commit_changes(&pool).await;
channel_data.commit_changes(&ctx.data().database).await;
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(format!(
"Future reminders will be nudged by {} seconds",
combined_time
)),
)
.await;
ctx.say(format!("Future reminders will be nudged by {} seconds", combined_time)).await?;
}
Ok(())
}
#[command("look")]
#[description("View reminders on a specific channel")]
#[arg(
name = "channel",
description = "The channel to view reminders on",
kind = "Channel",
required = false
/// View reminders on a specific channel
#[poise::command(
slash_command,
identifying_name = "look",
default_member_permissions = "MANAGE_GUILD"
)]
#[arg(
name = "disabled",
description = "Whether to show disabled reminders or not",
kind = "Boolean",
required = false
)]
#[arg(
name = "relative",
description = "Whether to display times as relative or exact times",
kind = "Boolean",
required = false
)]
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
async fn look(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let timezone = UserData::timezone_of(&invoke.author_id(), &pool).await;
pub async fn look(
ctx: Context<'_>,
#[description = "Channel to view reminders on"] channel: Option<Channel>,
#[description = "Whether to show disabled reminders or not"] disabled: Option<bool>,
#[description = "Whether to display times as relative or exact times"] relative: Option<bool>,
) -> Result<(), Error> {
let timezone = ctx.timezone().await;
let flags = LookFlags {
show_disabled: args.get("disabled").map(|i| i.as_bool()).flatten().unwrap_or(true),
channel_id: args.get("channel").map(|i| i.as_channel_id()).flatten(),
time_display: args.get("relative").map_or(TimeDisplayType::Relative, |b| {
if b.as_bool() == Some(true) {
show_disabled: disabled.unwrap_or(true),
channel_id: channel.map(|c| c.id()),
time_display: relative.map_or(TimeDisplayType::Relative, |b| {
if b {
TimeDisplayType::Relative
} else {
TimeDisplayType::Absolute
@ -277,33 +207,29 @@ async fn look(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
}),
};
let channel_opt = invoke.channel_id().to_channel_cached(&ctx);
let channel_opt = ctx.channel_id().to_channel_cached(&ctx.discord());
let channel_id = if let Some(Channel::Guild(channel)) = channel_opt {
if Some(channel.guild_id) == invoke.guild_id() {
flags.channel_id.unwrap_or_else(|| invoke.channel_id())
if Some(channel.guild_id) == ctx.guild_id() {
flags.channel_id.unwrap_or_else(|| ctx.channel_id())
} else {
invoke.channel_id()
ctx.channel_id()
}
} else {
invoke.channel_id()
ctx.channel_id()
};
let channel_name = if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) {
let channel_name =
if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx.discord()) {
Some(channel.name)
} else {
None
};
let reminders = Reminder::from_channel(ctx, channel_id, &flags).await;
let reminders = Reminder::from_channel(&ctx.data().database, channel_id, &flags).await;
if reminders.is_empty() {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("No reminders on specified channel"),
)
.await;
let _ = ctx.say("No reminders on specified channel").await;
} else {
let mut char_count = 0;
@ -326,10 +252,8 @@ async fn look(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
let pager = LookPager::new(flags, timezone);
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
ctx.send(|r| {
r.ephemeral(true)
.embed(|e| {
e.title(format!(
"Reminders{}",
@ -343,24 +267,37 @@ async fn look(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
pager.create_button_row(pages, comp);
comp
}),
)
.await
.unwrap();
})
})
.await?;
}
Ok(())
}
#[command("del")]
#[description("Delete reminders")]
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
async fn delete(ctx: &Context, invoke: &mut CommandInvoke, _args: CommandOptions) {
let timezone = ctx.timezone(invoke.author_id()).await;
/// Delete reminders
#[poise::command(
slash_command,
rename = "del",
identifying_name = "delete",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn delete(ctx: Context<'_>) -> Result<(), Error> {
let timezone = ctx.timezone().await;
let reminders = Reminder::from_guild(ctx, invoke.guild_id(), invoke.author_id()).await;
let reminders =
Reminder::from_guild(&ctx.discord(), &ctx.data().database, ctx.guild_id(), ctx.author().id)
.await;
let resp = show_delete_page(&reminders, 0, timezone);
let _ = invoke.respond(&ctx, resp).await;
ctx.send(|r| {
*r = resp;
r
})
.await?;
Ok(())
}
pub fn max_delete_page(reminders: &[Reminder], timezone: &Tz) -> usize {
@ -385,20 +322,20 @@ pub fn max_delete_page(reminders: &[Reminder], timezone: &Tz) -> usize {
})
}
pub fn show_delete_page(
reminders: &[Reminder],
page: usize,
timezone: Tz,
) -> CreateGenericResponse {
pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> CreateReply {
let pager = DelPager::new(page, timezone);
if reminders.is_empty() {
return CreateGenericResponse::new()
let mut reply = CreateReply::default();
reply
.embed(|e| e.title("Delete Reminders").description("No Reminders").color(*THEME_COLOR))
.components(|comp| {
pager.create_button_row(0, comp);
comp
});
return reply;
}
let pages = max_delete_page(reminders, &timezone);
@ -447,7 +384,9 @@ pub fn show_delete_page(
let del_selector = ComponentDataModel::DelSelector(DelSelector { page, timezone });
CreateGenericResponse::new()
let mut reply = CreateReply::default();
reply
.embed(|e| {
e.title("Delete Reminders")
.description(display)
@ -485,22 +424,12 @@ pub fn show_delete_page(
})
})
})
})
});
reply
}
#[command("timer")]
#[description("Manage timers")]
#[subcommand("list")]
#[description("List the timers in this server or DM channel")]
#[subcommand("start")]
#[description("Start a new timer from now")]
#[arg(name = "name", description = "Name for the new timer", kind = "String", required = true)]
#[subcommand("delete")]
#[description("Delete a timer")]
#[arg(name = "name", description = "Name of the timer to delete", kind = "String", required = true)]
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
async fn timer(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
fn time_difference(start_time: NaiveDateTime) -> String {
fn time_difference(start_time: NaiveDateTime) -> String {
let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
let now = NaiveDateTime::from_timestamp(unix_time, 0);
@ -511,266 +440,214 @@ async fn timer(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions)
let (days, hours) = hours.div_rem(&24);
format!("{} days, {:02}:{:02}:{:02}", days, hours, minutes, seconds)
}
/// Manage timers
#[poise::command(
slash_command,
rename = "timer",
identifying_name = "timer_base",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn timer_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// List the timers in this server or DM channel
#[poise::command(
slash_command,
rename = "list",
identifying_name = "list_timer",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn list_timer(ctx: Context<'_>) -> Result<(), Error> {
let owner = ctx.guild_id().map(|g| g.0).unwrap_or_else(|| ctx.author().id.0);
let timers = Timer::from_owner(owner, &ctx.data().database).await;
if !timers.is_empty() {
ctx.send(|m| {
m.embed(|e| {
e.fields(timers.iter().map(|timer| {
(&timer.name, format!("⌚ `{}`", time_difference(timer.start_time)), false)
}))
.color(*THEME_COLOR)
})
})
.await?;
} else {
ctx.say("No timers currently. Use `/timer start` to create a new timer").await?;
}
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
Ok(())
}
let owner = invoke.guild_id().map(|g| g.0).unwrap_or_else(|| invoke.author_id().0);
/// Start a new timer from now
#[poise::command(
slash_command,
rename = "start",
identifying_name = "start_timer",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn start_timer(
ctx: Context<'_>,
#[description = "Name for the new timer"] name: String,
) -> Result<(), Error> {
let owner = ctx.guild_id().map(|g| g.0).unwrap_or_else(|| ctx.author().id.0);
match args.subcommand.clone().unwrap().as_str() {
"start" => {
let count = Timer::count_from_owner(owner, &pool).await;
let count = Timer::count_from_owner(owner, &ctx.data().database).await;
if count >= 25 {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.content("You already have 25 timers. Please delete some timers before creating a new one"),
)
.await;
ctx.say("You already have 25 timers. Please delete some timers before creating a new one")
.await?;
} else {
let name = args.get("name").unwrap().to_string();
if name.len() <= 32 {
Timer::create(&name, owner, &pool).await;
Timer::create(&name, owner, &ctx.data().database).await;
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("Created a new timer"),
)
.await;
ctx.say("Created a new timer").await?;
} else {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.content(format!("Please name your timer something shorted (max. 32 characters, you used {})", name.len())),
)
.await;
ctx.say(format!(
"Please name your timer something shorted (max. 32 characters, you used {})",
name.len()
))
.await?;
}
}
}
"delete" => {
let name = args.get("name").unwrap().to_string();
let exists = sqlx::query!(
"
SELECT 1 as _r FROM timers WHERE owner = ? AND name = ?
",
owner,
name
)
.fetch_one(&pool)
Ok(())
}
/// Delete a timer
#[poise::command(
slash_command,
rename = "delete",
identifying_name = "delete_timer",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn delete_timer(
ctx: Context<'_>,
#[description = "Name of timer to delete"] name: String,
) -> Result<(), Error> {
let owner = ctx.guild_id().map(|g| g.0).unwrap_or_else(|| ctx.author().id.0);
let exists =
sqlx::query!("SELECT 1 as _r FROM timers WHERE owner = ? AND name = ?", owner, name)
.fetch_one(&ctx.data().database)
.await;
if exists.is_ok() {
sqlx::query!(
"
DELETE FROM timers WHERE owner = ? AND name = ?
",
owner,
name
)
.execute(&pool)
sqlx::query!("DELETE FROM timers WHERE owner = ? AND name = ?", owner, name)
.execute(&ctx.data().database)
.await
.unwrap();
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("Deleted a timer"),
)
.await;
ctx.say("Deleted a timer").await?;
} else {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("Could not find a timer by that name"),
)
.await;
ctx.say("Could not find a timer by that name").await?;
}
}
"list" => {
let timers = Timer::from_owner(owner, &pool).await;
if !timers.is_empty() {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.fields(timers.iter().map(|timer| {
(
&timer.name,
format!("⌚ `{}`", time_difference(timer.start_time)),
false,
)
}))
.color(*THEME_COLOR)
}),
)
.await;
} else {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(
"No timers currently. Use `/timer start` to create a new timer",
),
)
.await;
}
}
_ => {}
}
Ok(())
}
#[command("remind")]
#[description("Create a new reminder")]
#[arg(
name = "time",
description = "A description of the time to set the reminder for",
kind = "String",
required = true
/// Create a new reminder
#[poise::command(
slash_command,
identifying_name = "remind",
default_member_permissions = "MANAGE_GUILD"
)]
#[arg(
name = "content",
description = "The message content to send",
kind = "String",
required = true
)]
#[arg(
name = "channels",
description = "Channel or user mentions to set the reminder for",
kind = "String",
required = false
)]
#[arg(
name = "interval",
description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder",
kind = "String",
required = false
)]
#[arg(
name = "expires",
description = "(Patreon only) For repeating reminders, the time at which the reminder will stop sending",
kind = "String",
required = false
)]
#[arg(
name = "tts",
description = "Set the TTS flag on the reminder message (like the /tts command)",
kind = "Boolean",
required = false
)]
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
async fn remind(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
if args.get("interval").is_none() && args.get("expires").is_some() {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content("`expires` can only be used with `interval`"),
)
.await;
pub async fn remind(
ctx: Context<'_>,
#[description = "A description of the time to set the reminder for"] time: String,
#[description = "The message content to send"] content: String,
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
interval: Option<String>,
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop sending"]
expires: Option<String>,
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
tts: Option<bool>,
) -> Result<(), Error> {
if interval.is_none() && expires.is_some() {
ctx.say("`expires` can only be used with `interval`").await?;
return;
return Ok(());
}
invoke.defer(&ctx).await;
ctx.defer().await?;
let user_data = ctx.user_data(invoke.author_id()).await.unwrap();
let timezone = user_data.timezone();
let user_data = ctx.author_data().await.unwrap();
let timezone = ctx.timezone().await;
let time = {
let time_str = args.get("time").unwrap().to_string();
natural_parser(&time_str, &timezone.to_string()).await
};
let time = natural_parser(&time, &timezone.to_string()).await;
match time {
Some(time) => {
let content = {
let content = args.get("content").unwrap().to_string();
let tts = args.get("tts").map_or(false, |arg| arg.as_bool().unwrap_or(false));
let tts = tts.unwrap_or(false);
Content { content, tts, attachment: None, attachment_name: None }
};
let scopes = {
let list = args
.get("channels")
.map(|arg| parse_mention_list(&arg.to_string()))
.unwrap_or_default();
let list =
channels.map(|arg| parse_mention_list(&arg.to_string())).unwrap_or_default();
if list.is_empty() {
if invoke.guild_id().is_some() {
vec![ReminderScope::Channel(invoke.channel_id().0)]
if ctx.guild_id().is_some() {
vec![ReminderScope::Channel(ctx.channel_id().0)]
} else {
vec![ReminderScope::User(invoke.author_id().0)]
vec![ReminderScope::User(ctx.author().id.0)]
}
} else {
list
}
};
let (interval, expires) = if let Some(repeat) = args.get("interval") {
if check_subscription(&ctx, invoke.author_id()).await
|| (invoke.guild_id().is_some()
&& check_guild_subscription(&ctx, invoke.guild_id().unwrap()).await)
let (processed_interval, processed_expires) = if let Some(repeat) = &interval {
if check_subscription(&ctx.discord(), ctx.author().id).await
|| (ctx.guild_id().is_some()
&& check_guild_subscription(&ctx.discord(), ctx.guild_id().unwrap()).await)
{
(
humantime::parse_duration(&repeat.to_string())
.or_else(|_| {
humantime::parse_duration(&format!("1 {}", repeat.to_string()))
})
.map(|duration| duration.as_secs() as i64)
parse_duration(repeat)
.or_else(|_| parse_duration(&format!("1 {}", repeat.to_string())))
.ok(),
{
if let Some(arg) = args.get("expires") {
natural_parser(&arg.to_string(), &timezone.to_string()).await
if let Some(arg) = &expires {
natural_parser(arg, &timezone.to_string()).await
} else {
None
}
},
)
} else {
let _ = invoke
.respond(&ctx, CreateGenericResponse::new()
.content("`repeat` is only available to Patreon subscribers or self-hosted users")
).await;
ctx.say(
"`repeat` is only available to Patreon subscribers or self-hosted users",
)
.await?;
return;
return Ok(());
}
} else {
(None, None)
};
if interval.is_none() && args.get("interval").is_some() {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content(
"Repeat interval could not be processed. Try and format the repetition similar to `1 hour` or `4 days`",
),
if processed_interval.is_none() && interval.is_some() {
ctx.say(
"Repeat interval could not be processed. Try similar to `1 hour` or `4 days`",
)
.await;
} else if expires.is_none() && args.get("expires").is_some() {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content(
"Expiry time failed to process. Please make it as clear as possible",
),
)
.await;
.await?;
} else if processed_expires.is_none() && expires.is_some() {
ctx.say("Expiry time failed to process. Please make it as clear as possible")
.await?;
} else {
let mut builder = MultiReminderBuilder::new(ctx, invoke.guild_id())
let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id())
.author(user_data)
.content(content)
.time(time)
.expires(expires)
.interval(interval);
.timezone(timezone)
.expires(processed_expires)
.interval(processed_interval);
builder.set_scopes(scopes);
@ -778,23 +655,21 @@ async fn remind(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions)
let embed = create_response(successes, errors, time);
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().embed(|c| {
ctx.send(|m| {
m.embed(|c| {
*c = embed;
c
}),
)
.await;
})
})
.await?;
}
}
None => {
let _ = invoke
.respond(&ctx, CreateGenericResponse::new().content("Time could not be processed"))
.await;
ctx.say("Time could not be processed").await?;
}
}
Ok(())
}
fn create_response(

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();
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),
};
match args.get("task") {
Some(task) => {
let task = task.to_string();
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 (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,
"INSERT INTO todos (guild_id, value)
VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)",
ctx.guild_id().unwrap().0,
task
)
.execute(&pool)
.execute(&ctx.data().database)
.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!(
ctx.say("Item added to todo list").await?;
Ok(())
}
/// View and remove from the server todo list
#[poise::command(
slash_command,
rename = "view",
guild_only = true,
identifying_name = "todo_guild_view",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> {
let values = sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
keys.2,
ctx.guild_id().unwrap().0,
)
.fetch_all(&pool)
.fetch_all(&ctx.data().database)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>()
};
.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)
@ -255,6 +344,8 @@ pub fn show_todo_page(
})
})
})
})
});
reply
}
}

View File

@ -3,9 +3,7 @@ pub(crate) mod pager;
use std::io::Cursor;
use chrono_tz::Tz;
use rmp_serde::Serializer;
use serde::{Deserialize, Serialize};
use serenity::{
use poise::serenity::{
builder::CreateEmbed,
client::Context,
model::{
@ -14,6 +12,8 @@ use serenity::{
prelude::InteractionApplicationCommandCallbackDataFlags,
},
};
use rmp_serde::Serializer;
use serde::{Deserialize, Serialize};
use crate::{
commands::{
@ -23,9 +23,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)]
@ -55,7 +55,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 +72,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 +122,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 +133,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 +203,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 +216,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 +238,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 +263,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 +277,7 @@ impl ComponentDataModel {
selector.channel_id,
selector.guild_id,
)
.fetch_all(&pool)
.fetch_all(&data.database)
.await
.unwrap()
.iter()
@ -263,8 +292,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 +316,23 @@ 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;
}
}
}

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

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> = (

126
src/event_handlers.rs Normal file
View File

@ -0,0 +1,126 @@
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::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 } => match interaction {
Interaction::MessageComponent(component) => {
let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
component_model.act(ctx, data, component).await;
}
_ => {}
},
_ => {}
}
Ok(())
}

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

View File

@ -1,107 +1,79 @@
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"),
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;
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),
};
HookResult::Halt
command_macro.commands.push(recorded);
let _ = ctx
.send(|m| m.ephemeral(true).content("Command recorded to macro"))
.await;
}
false
} else {
true
}
} else {
true
}
} else {
true
}
} else {
true
}
}
#[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;
async fn check_self_permissions(ctx: Context<'_>) -> bool {
if let Some(guild) = ctx.guild() {
let user_id = ctx.discord().cache.current_user_id();
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 _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content("Command recorded to macro"),
)
.await;
}
HookResult::Halt
} else {
HookResult::Continue
}
} else {
HookResult::Continue
}
} else {
HookResult::Continue
}
}
#[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();
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)
.to_channel_cached(&ctx.discord())
.map(|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 +84,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
src/interval_parser.rs Normal file
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 == &"" => {
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 = sec + nsec / 1_000_000_000;
nsec %= 1_000_000_000;
}
sec = self.current.1 + sec;
month = self.current.0 + month;
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
/// span is an integer number and a suffix. Supported suffixes:
///
/// * `nsec`, `ns` -- nanoseconds
/// * `usec`, `us` -- microseconds
/// * `msec`, `ms` -- milliseconds
/// * `seconds`, `second`, `sec`, `s`
/// * `minutes`, `minute`, `min`, `m`
/// * `hours`, `hour`, `hr`, `h`
/// * `days`, `day`, `d`
/// * `weeks`, `week`, `w`
/// * `months`, `month`, `M` -- defined as 30.44 days
/// * `years`, `year`, `y` -- defined as 365.25 days
///
/// # Examples
///
/// ```
/// use std::time::Duration;
/// use humantime::parse_duration;
///
/// assert_eq!(parse_duration("2h 37min"), Ok(Duration::new(9420, 0)));
/// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000)));
/// ```
pub fn parse_duration(s: &str) -> Result<Interval, Error> {
Parser { iter: s.chars(), src: s, current: (0, 0, 0) }.parse()
}

View File

@ -5,317 +5,198 @@ extern crate lazy_static;
mod commands;
mod component_models;
mod consts;
mod framework;
mod event_handlers;
mod hooks;
mod interval_parser;
mod models;
mod time_parser;
mod utils;
use std::{collections::HashMap, env, sync::Arc};
use std::{
collections::HashMap,
env,
error::Error as StdError,
fmt::{Debug, Display, Formatter},
sync::atomic::AtomicBool,
};
use chrono_tz::Tz;
use dotenv::dotenv;
use log::info;
use serenity::{
async_trait,
client::{bridge::gateway::GatewayIntents, Client},
http::{client::Http, CacheHttp},
model::{
channel::GuildChannel,
gateway::{Activity, Ready},
guild::{Guild, GuildUnavailable},
use poise::serenity::model::{
gateway::{Activity, GatewayIntents},
id::{GuildId, UserId},
interactions::Interaction,
},
prelude::{Context, EventHandler, TypeMapKey},
utils::shard_id,
};
use sqlx::mysql::MySqlPool;
use tokio::sync::RwLock;
use sqlx::{MySql, Pool};
use tokio::sync::{broadcast, broadcast::Sender, RwLock};
use crate::{
commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
component_models::ComponentDataModel,
consts::{CNC_GUILD, SUBSCRIPTION_ROLES, THEME_COLOR},
framework::RegexFramework,
consts::THEME_COLOR,
event_handlers::listener,
hooks::all_checks,
models::command_macro::CommandMacro,
utils::register_application_commands,
};
struct SQLPool;
type Database = MySql;
impl TypeMapKey for SQLPool {
type Value = MySqlPool;
type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>;
pub struct Data {
database: Pool<Database>,
http: reqwest::Client,
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>,
popular_timezones: Vec<Tz>,
is_loop_running: AtomicBool,
broadcast: Sender<()>,
}
struct ReqwestClient;
impl TypeMapKey for ReqwestClient {
type Value = Arc<reqwest::Client>;
}
struct PopularTimezones;
impl TypeMapKey for PopularTimezones {
type Value = Arc<Vec<Tz>>;
}
struct RecordingMacros;
impl TypeMapKey for RecordingMacros {
type Value = Arc<RwLock<HashMap<(GuildId, UserId), CommandMacro>>>;
}
struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn channel_delete(&self, ctx: Context, channel: &GuildChannel) {
let pool = ctx
.data
.read()
.await
.get::<SQLPool>()
.cloned()
.expect("Could not get SQLPool from data");
sqlx::query!(
"
DELETE FROM channels WHERE channel = ?
",
channel.id.as_u64()
)
.execute(&pool)
.await
.unwrap();
}
async fn guild_create(&self, ctx: Context, guild: Guild, is_new: bool) {
if is_new {
let guild_id = guild.id.as_u64().to_owned();
{
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let _ = sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id)
.execute(&pool)
.await;
}
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 client = ctx
.data
.read()
.await
.get::<ReqwestClient>()
.cloned()
.expect("Could not get ReqwestClient from data");
let response = client
.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);
}
}
}
}
async fn guild_delete(&self, ctx: Context, incomplete: GuildUnavailable, _full: Option<Guild>) {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0)
.execute(&pool)
.await;
}
async fn ready(&self, ctx: Context, _: Ready) {
ctx.set_activity(Activity::watching("for /remind")).await;
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
match interaction {
Interaction::ApplicationCommand(application_command) => {
let framework = ctx
.data
.read()
.await
.get::<RegexFramework>()
.cloned()
.expect("RegexFramework not found in context");
framework.execute(ctx, application_command).await;
}
Interaction::MessageComponent(component) => {
let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
component_model.act(&ctx, component).await;
}
_ => {}
}
impl std::fmt::Debug for Data {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Data {{ .. }}")
}
}
struct Ended;
impl Debug for Ended {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str("Process ended.")
}
}
impl Display for Ended {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str("Process ended.")
}
}
impl StdError for Ended {}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
let (tx, mut rx) = broadcast::channel(16);
tokio::select! {
output = _main(tx) => output,
_ = rx.recv() => Err(Box::new(Ended) as Box<dyn StdError + Send + Sync>)
}
}
async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
env_logger::init();
dotenv()?;
let token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
let http = Http::new_with_token(&token);
let options = poise::FrameworkOptions {
commands: vec![
info_cmds::help(),
info_cmds::info(),
info_cmds::donate(),
info_cmds::clock(),
info_cmds::clock_context_menu(),
info_cmds::dashboard(),
moderation_cmds::timezone(),
poise::Command {
subcommands: vec![
moderation_cmds::delete_macro(),
moderation_cmds::finish_macro(),
moderation_cmds::list_macro(),
moderation_cmds::record_macro(),
moderation_cmds::run_macro(),
],
..moderation_cmds::macro_base()
},
reminder_cmds::pause(),
reminder_cmds::offset(),
reminder_cmds::nudge(),
reminder_cmds::look(),
reminder_cmds::delete(),
poise::Command {
subcommands: vec![
reminder_cmds::list_timer(),
reminder_cmds::start_timer(),
reminder_cmds::delete_timer(),
],
..reminder_cmds::timer_base()
},
reminder_cmds::remind(),
poise::Command {
subcommands: vec![
poise::Command {
subcommands: vec![
todo_cmds::todo_guild_add(),
todo_cmds::todo_guild_view(),
],
..todo_cmds::todo_guild_base()
},
poise::Command {
subcommands: vec![
todo_cmds::todo_channel_add(),
todo_cmds::todo_channel_view(),
],
..todo_cmds::todo_channel_base()
},
poise::Command {
subcommands: vec![todo_cmds::todo_user_add(), todo_cmds::todo_user_view()],
..todo_cmds::todo_user_base()
},
],
..todo_cmds::todo_base()
},
],
allowed_mentions: None,
command_check: Some(|ctx| Box::pin(all_checks(ctx))),
listener: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)),
..Default::default()
};
let application_id = http.get_current_application_info().await?.id;
let dm_enabled = env::var("DM_ENABLED").map_or(true, |var| var == "1");
let framework = RegexFramework::new()
.ignore_bots(env::var("IGNORE_BOTS").map_or(true, |var| var == "1"))
.debug_guild(env::var("DEBUG_GUILD").map_or(None, |g| {
Some(GuildId(g.parse::<u64>().expect("DEBUG_GUILD must be a guild ID")))
}))
.dm_enabled(dm_enabled)
// info commands
.add_command(&info_cmds::HELP_COMMAND)
.add_command(&info_cmds::INFO_COMMAND)
.add_command(&info_cmds::DONATE_COMMAND)
.add_command(&info_cmds::DASHBOARD_COMMAND)
.add_command(&info_cmds::CLOCK_COMMAND)
// reminder commands
.add_command(&reminder_cmds::TIMER_COMMAND)
.add_command(&reminder_cmds::REMIND_COMMAND)
// management commands
.add_command(&reminder_cmds::DELETE_COMMAND)
.add_command(&reminder_cmds::LOOK_COMMAND)
.add_command(&reminder_cmds::PAUSE_COMMAND)
.add_command(&reminder_cmds::OFFSET_COMMAND)
.add_command(&reminder_cmds::NUDGE_COMMAND)
// to-do commands
.add_command(&todo_cmds::TODO_COMMAND)
// moderation commands
.add_command(&moderation_cmds::TIMEZONE_COMMAND)
.add_command(&moderation_cmds::MACRO_CMD_COMMAND)
.add_hook(&hooks::CHECK_SELF_PERMISSIONS_HOOK)
.add_hook(&hooks::MACRO_CHECK_HOOK);
let framework_arc = Arc::new(framework);
let mut client = Client::builder(&token)
.intents(GatewayIntents::GUILDS)
.application_id(application_id.0)
.event_handler(Handler)
.await
.expect("Error occurred creating client");
{
let pool = MySqlPool::connect(
&env::var("DATABASE_URL").expect("Missing DATABASE_URL from environment"),
)
.await
.unwrap();
let database =
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
let popular_timezones = sqlx::query!(
"SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21"
)
.fetch_all(&pool)
.fetch_all(&database)
.await
.unwrap()
.iter()
.map(|t| t.timezone.parse::<Tz>().unwrap())
.collect::<Vec<Tz>>();
let mut data = client.data.write().await;
poise::Framework::build()
.token(discord_token)
.user_data_setup(move |ctx, _bot, framework| {
Box::pin(async move {
ctx.set_activity(Activity::watching("for /remind")).await;
data.insert::<SQLPool>(pool);
data.insert::<PopularTimezones>(Arc::new(popular_timezones));
data.insert::<ReqwestClient>(Arc::new(reqwest::Client::new()));
data.insert::<RegexFramework>(framework_arc.clone());
data.insert::<RecordingMacros>(Arc::new(RwLock::new(HashMap::new())));
}
register_application_commands(
ctx,
framework,
env::var("DEBUG_GUILD")
.map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid")))
.ok(),
)
.await
.unwrap();
framework_arc.build_slash(&client.cache_and_http.http).await;
if let Ok((Some(lower), Some(upper))) = env::var("SHARD_RANGE").map(|sr| {
let mut split =
sr.split(',').map(|val| val.parse::<u64>().expect("SHARD_RANGE not an integer"));
(split.next(), split.next())
}) {
let total_shards = env::var("SHARD_COUNT")
.map(|shard_count| shard_count.parse::<u64>().ok())
.ok()
.flatten()
.expect("No SHARD_COUNT provided, but SHARD_RANGE was provided");
assert!(lower < upper, "SHARD_RANGE lower limit is not less than the upper limit");
info!("Starting client fragment with shards {}-{}/{}", lower, upper, total_shards);
client.start_shard_range([lower, upper], total_shards).await?;
} else if let Ok(total_shards) = env::var("SHARD_COUNT")
.map(|shard_count| shard_count.parse::<u64>().expect("SHARD_COUNT not an integer"))
{
info!("Starting client with {} shards", total_shards);
client.start_shards(total_shards).await?;
} else {
info!("Starting client as autosharded");
client.start_autosharded().await?;
}
Ok(Data {
http: reqwest::Client::new(),
database,
popular_timezones,
recording_macros: Default::default(),
is_loop_running: AtomicBool::new(false),
broadcast: tx,
})
})
})
.options(options)
.intents(GatewayIntents::GUILDS)
.run_autosharded()
.await?;
Ok(())
}
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
if let Ok(member) = guild_member {
for role in member.roles {
if SUBSCRIPTION_ROLES.contains(role.as_u64()) {
return true;
}
}
}
false
} else {
true
}
}
pub async fn check_guild_subscription(
cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>,
) -> bool {
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
let owner = guild.owner_id;
check_subscription(&cache_http, owner).await
} else {
false
}
}

View File

@ -1,5 +1,5 @@
use chrono::NaiveDateTime;
use serenity::model::channel::Channel;
use poise::serenity::model::channel::Channel;
use sqlx::MySqlPool;
pub struct ChannelData {

View File

@ -1,33 +1,73 @@
use serenity::{client::Context, model::id::GuildId};
use poise::serenity::model::{
id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption,
};
use serde::{Deserialize, Serialize};
use crate::{framework::CommandOptions, SQLPool};
use crate::{Context, Data, Error};
pub struct CommandMacro {
fn default_none<U, E>() -> Option<
for<'a> fn(
poise::ApplicationContext<'a, U, E>,
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>,
> {
None
}
#[derive(Serialize, Deserialize)]
pub struct RecordedCommand<U, E> {
#[serde(skip)]
#[serde(default = "default_none::<U, E>")]
pub action: Option<
for<'a> fn(
poise::ApplicationContext<'a, U, E>,
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>,
>,
pub command_name: String,
pub options: Vec<ApplicationCommandInteractionDataOption>,
}
pub struct CommandMacro<U, E> {
pub guild_id: GuildId,
pub name: String,
pub description: Option<String>,
pub commands: Vec<CommandOptions>,
pub commands: Vec<RecordedCommand<U, E>>,
}
impl CommandMacro {
pub async fn from_guild(ctx: &Context, guild_id: impl Into<GuildId>) -> Vec<Self> {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let guild_id = guild_id.into();
sqlx::query!(
"SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
guild_id.0
pub async fn guild_command_macro(
ctx: &Context<'_>,
name: &str,
) -> Option<CommandMacro<Data, Error>> {
let row = sqlx::query!(
"
SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?
",
ctx.guild_id().unwrap().0,
name
)
.fetch_all(&pool)
.fetch_one(&ctx.data().database)
.await
.unwrap()
.ok()?;
let mut commands: Vec<RecordedCommand<Data, Error>> =
serde_json::from_str(&row.commands).unwrap();
for recorded_command in &mut commands {
let command = &ctx
.framework()
.options()
.commands
.iter()
.map(|row| Self {
guild_id,
name: row.name.clone(),
description: row.description.clone(),
commands: serde_json::from_str(&row.commands).unwrap(),
})
.collect::<Vec<Self>>()
.find(|c| c.identifying_name == recorded_command.command_name);
recorded_command.action = command.map(|c| c.slash_action).flatten().clone();
}
let command_macro = CommandMacro {
guild_id: ctx.guild_id().unwrap(),
name: row.name,
description: row.description,
commands,
};
Some(command_macro)
}

View File

@ -5,62 +5,71 @@ pub mod timer;
pub mod user_data;
use chrono_tz::Tz;
use serenity::{
async_trait,
model::id::{ChannelId, UserId},
prelude::Context,
};
use poise::serenity::{async_trait, model::id::UserId};
use crate::{
models::{channel_data::ChannelData, user_data::UserData},
SQLPool,
CommandMacro, Context, Data, Error, GuildId,
};
#[async_trait]
pub trait CtxData {
async fn user_data<U: Into<UserId> + Send + Sync>(
&self,
user_id: U,
) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>>;
async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error>;
async fn timezone<U: Into<UserId> + Send + Sync>(&self, user_id: U) -> Tz;
async fn author_data(&self) -> Result<UserData, Error>;
async fn channel_data<C: Into<ChannelId> + Send + Sync>(
&self,
channel_id: C,
) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>>;
async fn timezone(&self) -> Tz;
async fn channel_data(&self) -> Result<ChannelData, Error>;
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error>;
}
#[async_trait]
impl CtxData for Context {
async fn user_data<U: Into<UserId> + Send + Sync>(
impl CtxData for Context<'_> {
async fn user_data<U: Into<UserId> + Send>(
&self,
user_id: U,
) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> {
let user_id = user_id.into();
let pool = self.data.read().await.get::<SQLPool>().cloned().unwrap();
let user = user_id.to_user(self).await.unwrap();
UserData::from_user(&user, &self, &pool).await
UserData::from_user(user_id, &self.discord(), &self.data().database).await
}
async fn timezone<U: Into<UserId> + Send + Sync>(&self, user_id: U) -> Tz {
let user_id = user_id.into();
let pool = self.data.read().await.get::<SQLPool>().cloned().unwrap();
UserData::timezone_of(user_id, &pool).await
async fn author_data(&self) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> {
UserData::from_user(&self.author().id, &self.discord(), &self.data().database).await
}
async fn channel_data<C: Into<ChannelId> + Send + Sync>(
&self,
channel_id: C,
) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> {
let channel_id = channel_id.into();
let pool = self.data.read().await.get::<SQLPool>().cloned().unwrap();
async fn timezone(&self) -> Tz {
UserData::timezone_of(self.author().id, &self.data().database).await
}
let channel = channel_id.to_channel_cached(&self).unwrap();
async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> {
let channel = self.channel_id().to_channel_cached(&self.discord()).unwrap();
ChannelData::from_channel(&channel, &pool).await
ChannelData::from_channel(&channel, &self.data().database).await
}
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error> {
self.data().command_macros(self.guild_id().unwrap()).await
}
}
impl Data {
pub(crate) async fn command_macros(
&self,
guild_id: GuildId,
) -> Result<Vec<CommandMacro<Data, Error>>, Error> {
let rows = sqlx::query!(
"SELECT name, description, commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
guild_id.0
)
.fetch_all(&self.database)
.await?.iter().map(|row| CommandMacro {
guild_id,
name: row.name.clone(),
description: row.description.clone(),
commands: serde_json::from_str(&row.commands).unwrap(),
}).collect();
Ok(rows)
}
}

View File

@ -2,8 +2,7 @@ use std::{collections::HashSet, fmt::Display};
use chrono::{Duration, NaiveDateTime, Utc};
use chrono_tz::Tz;
use serenity::{
client::Context,
use poise::serenity::{
http::CacheHttp,
model::{
channel::GuildChannel,
@ -15,14 +14,14 @@ use serenity::{
use sqlx::MySqlPool;
use crate::{
consts,
consts::{MAX_TIME, MIN_INTERVAL},
consts::{DAY, DEFAULT_AVATAR, MAX_TIME, MIN_INTERVAL},
interval_parser::Interval,
models::{
channel_data::ChannelData,
reminder::{content::Content, errors::ReminderError, helper::generate_uid, Reminder},
user_data::UserData,
},
SQLPool,
Context,
};
async fn create_webhook(
@ -30,7 +29,7 @@ async fn create_webhook(
channel: GuildChannel,
name: impl Display,
) -> SerenityResult<Webhook> {
channel.create_webhook_with_avatar(ctx.http(), name, consts::DEFAULT_AVATAR.clone()).await
channel.create_webhook_with_avatar(ctx.http(), name, DEFAULT_AVATAR.clone()).await
}
#[derive(Hash, PartialEq, Eq)]
@ -54,7 +53,8 @@ pub struct ReminderBuilder {
channel: u32,
utc_time: NaiveDateTime,
timezone: String,
interval: Option<i64>,
interval_secs: Option<i64>,
interval_months: Option<i64>,
expires: Option<NaiveDateTime>,
content: String,
tts: bool,
@ -86,7 +86,8 @@ INSERT INTO reminders (
`channel_id`,
`utc_time`,
`timezone`,
`interval`,
`interval_seconds`,
`interval_months`,
`expires`,
`content`,
`tts`,
@ -104,6 +105,7 @@ INSERT INTO reminders (
?,
?,
?,
?,
?
)
",
@ -111,7 +113,8 @@ INSERT INTO reminders (
self.channel,
utc_time,
self.timezone,
self.interval,
self.interval_secs,
self.interval_months,
self.expires,
self.content,
self.tts,
@ -136,11 +139,11 @@ pub struct MultiReminderBuilder<'a> {
scopes: Vec<ReminderScope>,
utc_time: NaiveDateTime,
timezone: Tz,
interval: Option<i64>,
interval: Option<Interval>,
expires: Option<NaiveDateTime>,
content: Content,
set_by: Option<u32>,
ctx: &'a Context,
ctx: &'a Context<'a>,
guild_id: Option<GuildId>,
}
@ -159,6 +162,12 @@ impl<'a> MultiReminderBuilder<'a> {
}
}
pub fn timezone(mut self, timezone: Tz) -> Self {
self.timezone = timezone;
self
}
pub fn content(mut self, content: Content) -> Self {
self.content = content;
@ -188,7 +197,7 @@ impl<'a> MultiReminderBuilder<'a> {
self
}
pub fn interval(mut self, interval: Option<i64>) -> Self {
pub fn interval(mut self, interval: Option<Interval>) -> Self {
self.interval = interval;
self
@ -199,26 +208,30 @@ impl<'a> MultiReminderBuilder<'a> {
}
pub async fn build(self) -> (HashSet<ReminderError>, HashSet<ReminderScope>) {
let pool = self.ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let mut errors = HashSet::new();
let mut ok_locs = HashSet::new();
if self.interval.map_or(false, |i| (i as i64) < *MIN_INTERVAL) {
if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) as i64) < *MIN_INTERVAL) {
errors.insert(ReminderError::ShortInterval);
} else if self.interval.map_or(false, |i| (i as i64) > *MAX_TIME) {
} else if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) as i64) > *MAX_TIME)
{
errors.insert(ReminderError::LongInterval);
} else {
for scope in self.scopes {
let db_channel_id = match scope {
ReminderScope::User(user_id) => {
if let Ok(user) = UserId(user_id).to_user(&self.ctx).await {
let user_data =
UserData::from_user(&user, &self.ctx, &pool).await.unwrap();
if let Ok(user) = UserId(user_id).to_user(&self.ctx.discord()).await {
let user_data = UserData::from_user(
&user,
&self.ctx.discord(),
&self.ctx.data().database,
)
.await
.unwrap();
if let Some(guild_id) = self.guild_id {
if guild_id.member(&self.ctx, user).await.is_err() {
if guild_id.member(&self.ctx.discord(), user).await.is_err() {
Err(ReminderError::InvalidTag)
} else {
Ok(user_data.dm_channel)
@ -231,26 +244,36 @@ impl<'a> MultiReminderBuilder<'a> {
}
}
ReminderScope::Channel(channel_id) => {
let channel = ChannelId(channel_id).to_channel(&self.ctx).await.unwrap();
let channel =
ChannelId(channel_id).to_channel(&self.ctx.discord()).await.unwrap();
if let Some(guild_channel) = channel.clone().guild() {
if Some(guild_channel.guild_id) != self.guild_id {
Err(ReminderError::InvalidTag)
} else {
let mut channel_data =
ChannelData::from_channel(&channel, &pool).await.unwrap();
ChannelData::from_channel(&channel, &self.ctx.data().database)
.await
.unwrap();
if channel_data.webhook_id.is_none()
|| channel_data.webhook_token.is_none()
{
match create_webhook(&self.ctx, guild_channel, "Reminder").await
match create_webhook(
&self.ctx.discord(),
guild_channel,
"Reminder",
)
.await
{
Ok(webhook) => {
channel_data.webhook_id =
Some(webhook.id.as_u64().to_owned());
channel_data.webhook_token = webhook.token;
channel_data.commit_changes(&pool).await;
channel_data
.commit_changes(&self.ctx.data().database)
.await;
Ok(channel_data.id)
}
@ -270,12 +293,13 @@ impl<'a> MultiReminderBuilder<'a> {
match db_channel_id {
Ok(c) => {
let builder = ReminderBuilder {
pool: pool.clone(),
pool: self.ctx.data().database.clone(),
uid: generate_uid(),
channel: c,
utc_time: self.utc_time,
timezone: self.timezone.to_string(),
interval: self.interval,
interval_secs: self.interval.map(|i| i.sec as i64),
interval_months: self.interval.map(|i| i.month as i64),
expires: self.expires,
content: self.content.content.clone(),
tts: self.content.tts,

View File

@ -1,25 +1,6 @@
use num_integer::Integer;
use rand::{rngs::OsRng, seq::IteratorRandom};
use crate::consts::{CHARACTERS, DAY, HOUR, MINUTE};
pub fn longhand_displacement(seconds: u64) -> String {
let (days, seconds) = seconds.div_rem(&DAY);
let (hours, seconds) = seconds.div_rem(&HOUR);
let (minutes, seconds) = seconds.div_rem(&MINUTE);
let mut sections = vec![];
for (var, name) in
[days, hours, minutes, seconds].iter().zip(["days", "hours", "minutes", "seconds"].iter())
{
if *var > 0 {
sections.push(format!("{} {}", var, name));
}
}
sections.join(", ")
}
use crate::consts::CHARACTERS;
pub fn generate_uid() -> String {
let mut generator: OsRng = Default::default();

View File

@ -1,6 +1,6 @@
use poise::serenity::model::id::ChannelId;
use serde::{Deserialize, Serialize};
use serde_repr::*;
use serenity::model::id::ChannelId;
#[derive(Serialize_repr, Deserialize_repr, Copy, Clone, Debug)]
#[repr(u8)]

View File

@ -6,18 +6,15 @@ pub mod look_flags;
use chrono::{NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use serenity::{
client::Context,
model::id::{ChannelId, GuildId, UserId},
use poise::{
serenity::model::id::{ChannelId, GuildId, UserId},
serenity_prelude::Cache,
};
use sqlx::MySqlPool;
use sqlx::Executor;
use crate::{
models::reminder::{
helper::longhand_displacement,
look_flags::{LookFlags, TimeDisplayType},
},
SQLPool,
models::reminder::look_flags::{LookFlags, TimeDisplayType},
Database,
};
#[derive(Debug, Clone)]
@ -26,7 +23,8 @@ pub struct Reminder {
pub uid: String,
pub channel: u64,
pub utc_time: NaiveDateTime,
pub interval: Option<u32>,
pub interval_seconds: Option<u32>,
pub interval_months: Option<u32>,
pub expires: Option<NaiveDateTime>,
pub enabled: bool,
pub content: String,
@ -35,7 +33,10 @@ pub struct Reminder {
}
impl Reminder {
pub async fn from_uid(pool: &MySqlPool, uid: String) -> Option<Self> {
pub async fn from_uid(
pool: impl Executor<'_, Database = Database>,
uid: String,
) -> Option<Self> {
sqlx::query_as_unchecked!(
Self,
"
@ -44,7 +45,8 @@ SELECT
reminders.uid,
channels.channel,
reminders.utc_time,
reminders.interval,
reminders.interval_seconds,
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.content,
@ -71,12 +73,10 @@ WHERE
}
pub async fn from_channel<C: Into<ChannelId>>(
ctx: &Context,
pool: impl Executor<'_, Database = Database>,
channel_id: C,
flags: &LookFlags,
) -> Vec<Self> {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let enabled = if flags.show_disabled { "0,1" } else { "1" };
let channel_id = channel_id.into();
@ -88,7 +88,8 @@ SELECT
reminders.uid,
channels.channel,
reminders.utc_time,
reminders.interval,
reminders.interval_seconds,
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.content,
@ -113,16 +114,19 @@ ORDER BY
channel_id.as_u64(),
enabled,
)
.fetch_all(&pool)
.fetch_all(pool)
.await
.unwrap()
}
pub async fn from_guild(ctx: &Context, guild_id: Option<GuildId>, user: UserId) -> Vec<Self> {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
pub async fn from_guild(
cache: impl AsRef<Cache>,
pool: impl Executor<'_, Database = Database>,
guild_id: Option<GuildId>,
user: UserId,
) -> Vec<Self> {
if let Some(guild_id) = guild_id {
let guild_opt = guild_id.to_guild_cached(&ctx);
let guild_opt = guild_id.to_guild_cached(cache);
if let Some(guild) = guild_opt {
let channels = guild
@ -141,7 +145,8 @@ SELECT
reminders.uid,
channels.channel,
reminders.utc_time,
reminders.interval,
reminders.interval_seconds,
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.content,
@ -162,7 +167,7 @@ WHERE
",
channels
)
.fetch_all(&pool)
.fetch_all(pool)
.await
} else {
sqlx::query_as_unchecked!(
@ -173,7 +178,8 @@ SELECT
reminders.uid,
channels.channel,
reminders.utc_time,
reminders.interval,
reminders.interval_seconds,
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.content,
@ -194,7 +200,7 @@ WHERE
",
guild_id.as_u64()
)
.fetch_all(&pool)
.fetch_all(pool)
.await
}
} else {
@ -206,7 +212,8 @@ SELECT
reminders.uid,
channels.channel,
reminders.utc_time,
reminders.interval,
reminders.interval_seconds,
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.content,
@ -227,7 +234,7 @@ WHERE
",
user.as_u64()
)
.fetch_all(&pool)
.fetch_all(pool)
.await
}
.unwrap()
@ -264,12 +271,11 @@ WHERE
TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()),
};
if let Some(interval) = self.interval {
if self.interval_seconds.is_some() || self.interval_months.is_some() {
format!(
"'{}' *occurs next at* **{}**, repeating every **{}** (set by {})",
"'{}' *occurs next at* **{}**, repeating (set by {})",
self.display_content(),
time_display,
longhand_displacement(interval as u64),
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
)
} else {

View File

@ -1,9 +1,6 @@
use chrono_tz::Tz;
use log::error;
use serenity::{
http::CacheHttp,
model::{id::UserId, user::User},
};
use poise::serenity::{http::CacheHttp, model::id::UserId};
use sqlx::MySqlPool;
use crate::consts::LOCAL_TIMEZONE;
@ -11,7 +8,6 @@ use crate::consts::LOCAL_TIMEZONE;
pub struct UserData {
pub id: u32,
pub user: u64,
pub name: String,
pub dm_channel: u32,
pub timezone: String,
}
@ -40,20 +36,20 @@ SELECT timezone FROM users WHERE user = ?
.unwrap()
}
pub async fn from_user(
user: &User,
pub async fn from_user<U: Into<UserId>>(
user: U,
ctx: impl CacheHttp,
pool: &MySqlPool,
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
let user_id = user.id.as_u64().to_owned();
let user_id = user.into();
match sqlx::query_as_unchecked!(
Self,
"
SELECT id, user, name, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ?
SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ?
",
*LOCAL_TIMEZONE,
user_id
user_id.0
)
.fetch_one(pool)
.await
@ -61,27 +57,24 @@ SELECT id, user, name, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone
Ok(c) => Ok(c),
Err(sqlx::Error::RowNotFound) => {
let dm_channel = user.create_dm_channel(ctx).await?;
let dm_id = dm_channel.id.as_u64().to_owned();
let dm_channel = user_id.create_dm_channel(ctx).await?;
let pool_c = pool.clone();
sqlx::query!(
"
INSERT IGNORE INTO channels (channel) VALUES (?)
",
dm_id
dm_channel.id.0
)
.execute(&pool_c)
.await?;
sqlx::query!(
"
INSERT INTO users (user, name, dm_channel, timezone) VALUES (?, ?, (SELECT id FROM channels WHERE channel = ?), ?)
INSERT INTO users (user, dm_channel, timezone) VALUES (?, (SELECT id FROM channels WHERE channel = ?), ?)
",
user_id,
user.name,
dm_id,
user_id.0,
dm_channel.id.0,
*LOCAL_TIMEZONE
)
.execute(&pool_c)
@ -90,9 +83,9 @@ INSERT INTO users (user, name, dm_channel, timezone) VALUES (?, ?, (SELECT id FR
Ok(sqlx::query_as_unchecked!(
Self,
"
SELECT id, user, name, dm_channel, timezone FROM users WHERE user = ?
SELECT id, user, dm_channel, timezone FROM users WHERE user = ?
",
user_id
user_id.0
)
.fetch_one(pool)
.await?)
@ -109,9 +102,8 @@ SELECT id, user, name, dm_channel, timezone FROM users WHERE user = ?
pub async fn commit_changes(&self, pool: &MySqlPool) {
sqlx::query!(
"
UPDATE users SET name = ?, timezone = ? WHERE id = ?
UPDATE users SET timezone = ? WHERE id = ?
",
self.name,
self.timezone,
self.id
)

107
src/utils.rs Normal file
View File

@ -0,0 +1,107 @@
use poise::{
serenity::{
builder::CreateApplicationCommands,
http::CacheHttp,
model::id::{GuildId, UserId},
},
serenity_prelude as serenity,
};
use crate::{
consts::{CNC_GUILD, SUBSCRIPTION_ROLES},
Data, Error,
};
pub async fn register_application_commands(
ctx: &poise::serenity::client::Context,
framework: &poise::Framework<Data, Error>,
guild_id: Option<GuildId>,
) -> Result<(), poise::serenity::Error> {
let mut commands_builder = CreateApplicationCommands::default();
let commands = &framework.options().commands;
for command in commands {
if let Some(slash_command) = command.create_as_slash_command() {
commands_builder.add_application_command(slash_command);
}
if let Some(context_menu_command) = command.create_as_context_menu_command() {
commands_builder.add_application_command(context_menu_command);
}
}
let commands_builder = poise::serenity::json::Value::Array(commands_builder.0);
if let Some(guild_id) = guild_id {
ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?;
} else {
ctx.http.create_global_application_commands(&commands_builder).await?;
}
Ok(())
}
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
if let Ok(member) = guild_member {
for role in member.roles {
if SUBSCRIPTION_ROLES.contains(role.as_u64()) {
return true;
}
}
}
false
} else {
true
}
}
pub async fn check_guild_subscription(
cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>,
) -> bool {
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
let owner = guild.owner_id;
check_subscription(&cache_http, owner).await
} else {
false
}
}
/// Sends the message, specified via [`crate::CreateReply`], to the interaction initial response
/// endpoint
pub fn send_as_initial_response(
data: poise::CreateReply<'_>,
f: &mut serenity::CreateInteractionResponseData,
) {
let poise::CreateReply {
content,
embeds,
attachments: _, // serenity doesn't support attachments in initial response yet
components,
ephemeral,
allowed_mentions,
reference_message: _, // can't reply to a message in interactions
} = data;
if let Some(content) = content {
f.content(content);
}
f.set_embeds(embeds);
if let Some(allowed_mentions) = allowed_mentions {
f.allowed_mentions(|f| {
*f = allowed_mentions.clone();
f
});
}
if let Some(components) = components {
f.components(|f| {
f.0 = components.0;
f
});
}
if ephemeral {
f.flags(serenity::InteractionApplicationCommandCallbackDataFlags::EPHEMERAL);
}
}

21
web/Cargo.toml Normal file
View File

@ -0,0 +1,21 @@
[package]
name = "reminder_web"
version = "0.1.0"
authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018"
[dependencies]
rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] }
rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] }
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
oauth2 = "4"
log = "0.4"
reqwest = "0.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
chrono = "0.4"
chrono-tz = "0.5"
lazy_static = "1.4.0"
rand = "0.7"
base64 = "0.13"

32
web/private/ca_cert.pem Normal file
View File

@ -0,0 +1,32 @@
-----BEGIN CERTIFICATE-----
MIIFbzCCA1egAwIBAgIUUY2fYP5h41dtqcuNLVUS99N7+jAwDQYJKoZIhvcNAQEL
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
MDUxNTE5MDIyNFowRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQK
DAlSb2NrZXQgQ0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMIICIjANBgkqhkiG
9w0BAQEFAAOCAg8AMIICCgKCAgEA3MmVOUxbZ0rtBBbXjWkkM2n2IoPuqfiENdrM
NH9IcIbEgFb7AQ/c2vGBLWWw2RFtXxe2ubbgcHyAySQ+ENGEf+W2yox5JpcOOBP+
/KrUQ4ROqxLBWOD6+fA/q5m9/CXXJosWXly3DFE9nUEUlNXcaSxJm/bxIqnxwEMQ
NESWdG1qlsGVhHLi5sflgwAcYH+nZzdtINLyvQ5AGIs8s6LpmvUVbC3I+5oNNSwW
rRsIfCFaYLuUx7jZMaOk8Dfz8MbFL68+7OCGwmhduSHoGW8jVzDXwXv/crAmFtau
zmu43/vPDwKa4w/kQJLNr1QmiGz9FBt8DXVJ0s5bokOIlodEgAvjsL3xwnrKak0F
8ak74IBetfDdVvuC9WWmJ9/dj3z91KvCWLdZGm8ijlYZ2OuaiiJ/oC6iebaPFHEY
IQmF5wzT/nZLIbQsti9wFnOpptgkhDAcKIAk3+pncuMwZ9WRvWO2IFt+ZCqKCCDU
JliDZmm3ow/zob8DD6Txex5OkJGUF6iu8o7fjI9BxsjSc76bLrxIhvR43UXjzoHl
t3lvDH8SzSN/kxzQMXVl48h3xydJ3+ZyDOLle1bqMga8Kcin9GcEUqmL1JcyFafe
CUE/Nk1pHyrlnhpMCYIDVI3pmNLB0/m/IEWLckSr9+hXyUPJQyWiyrixwC86SPxZ
AonU2aUCAwEAAaNTMFEwHQYDVR0OBBYEFJ5uQa9kD5fioNeS/Ff/mxDcMAzhMB8G
A1UdIwQYMBaAFJ5uQa9kD5fioNeS/Ff/mxDcMAzhMA8GA1UdEwEB/wQFMAMBAf8w
DQYJKoZIhvcNAQELBQADggIBABf7adF1J40/8EjfSJ5XdG7OBUrZTas3+nuULh8B
6h1igAq0BgX9v3awmPCaxqDLqwJCsFZCk0s9Vgd62McKVKasyW1TQchWbdvlqeAB
QQfZpJtAZK12dcMl6iznC/JBTPvYl9q1FKS8kYtg19in/xlhxiiEJaL5z+slpTgT
cN12650W2FqjT9sD9jshZI/a8o53d2yUBXBBecCZ49djceBGLbxbteEi/sb9qM4f
IUxeSrvK6owxKMZ5okUqZWaFseFCkdMKpMg9eecyfSTweac8yLlZmeqX5D/H85Hr
hnWHjI5NeVZAoDkdmNeaDbAIv4f3prqC8uFP3ub8D0rG/WiPdOAd79KNgqbUUFyp
NbjSiAesx4Rm1WIgjHljFQKXJdtnxt+v1VkKV9entXJX2tye7+Z1+zurNYyUyM1J
COdXeV4jpq2mP8cLpAqX7ip1tNx9YMy5ctbAE1WsUw7lPyO91+VN2Q6MN5OyemY3
4nBzRJU8X1nsDgeNQWjzb/ZC7eoS916Gywk8Hu6GoIwvYMm3zea25n6mAAOX17vE
1YdvSzUlnVbwnbgd4BgFRGUfBVjO7qvFC7ZWLngWhBzIIYIGUJfO2rippgkxAoHH
dgWYk1MNnk2yM8WSo0VKoe4yuF9NwPlfPqeCE683JHdwGEJKZWMocszzHrGZ2KX2
I4/u
-----END CERTIFICATE-----

51
web/private/ca_key.pem Normal file
View File

@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
MIIJKAIBAAKCAgEA3MmVOUxbZ0rtBBbXjWkkM2n2IoPuqfiENdrMNH9IcIbEgFb7
AQ/c2vGBLWWw2RFtXxe2ubbgcHyAySQ+ENGEf+W2yox5JpcOOBP+/KrUQ4ROqxLB
WOD6+fA/q5m9/CXXJosWXly3DFE9nUEUlNXcaSxJm/bxIqnxwEMQNESWdG1qlsGV
hHLi5sflgwAcYH+nZzdtINLyvQ5AGIs8s6LpmvUVbC3I+5oNNSwWrRsIfCFaYLuU
x7jZMaOk8Dfz8MbFL68+7OCGwmhduSHoGW8jVzDXwXv/crAmFtauzmu43/vPDwKa
4w/kQJLNr1QmiGz9FBt8DXVJ0s5bokOIlodEgAvjsL3xwnrKak0F8ak74IBetfDd
VvuC9WWmJ9/dj3z91KvCWLdZGm8ijlYZ2OuaiiJ/oC6iebaPFHEYIQmF5wzT/nZL
IbQsti9wFnOpptgkhDAcKIAk3+pncuMwZ9WRvWO2IFt+ZCqKCCDUJliDZmm3ow/z
ob8DD6Txex5OkJGUF6iu8o7fjI9BxsjSc76bLrxIhvR43UXjzoHlt3lvDH8SzSN/
kxzQMXVl48h3xydJ3+ZyDOLle1bqMga8Kcin9GcEUqmL1JcyFafeCUE/Nk1pHyrl
nhpMCYIDVI3pmNLB0/m/IEWLckSr9+hXyUPJQyWiyrixwC86SPxZAonU2aUCAwEA
AQKCAgBVSXlfXOOiDwtnnPs/IPJe+fuecaBsABfyRcbEMLbm4OhfOzpSurHx0YC4
7KNX9qdtKFfpfX9NdIq7KEjhbk3kqfPmYkUaZxeTCgZhzAua2S0aYHBXyPCqQ+gU
fZsqH+Pwe6H0aZQ8KdXHPTCaHdK6veThXo7feQ5t2noT9rq31txpx/Xd6BNGWsmJ
xS0xCZ68/GgnWdVyumKAGKkmKzRaK3pPA5CzwFqBw7ouvFaWvLuQymU6kWk1B6Xb
NYIB7IaXWPbRwhnMV0x9C2ABEzFvqOpvT1rqDqloAR4dlvcfbsIZZkQ2mhjt6MeT
hsorwQ4yCjvtZvVRfW1gTP4iR7ZpmHgHIYQDJy7raZqRVNe9WgMxKTS/IYsHvEvH
MUBOfQEclAQ8FTUOgTg9+Hf9VXJOtjZRxY08gb+OgKxoAQKxRZmLDUZli9SNWzGe
R3UECh0gImm/CzWnV3fercLX+qHLTcyJFcb2gclAfG8v8cOT2qu8RtsJAQM2ZSn7
L8FaWXewjAwkZwCl8Zl5ky1RbBXUkcuLIkAeLAl0+ffM9xiwLhiIj8gRJoq0/jSr
K1pA8CQ/rZC+9RsI8hP4x2Fn1CU8bOyEBPeNFEIDJHBiCrs/Rl6EAzDMJHlhL/HT
f7bqssuRjnSbRCKaAdtfGTxQUEjefvqJ11xE3BfHGnKPqoasMQKCAQEA8nY+3MAB
eBPlE/H4JeVpj7/9/q+JWLFFH9E3Re25LbObzRzIQOd5bTCJkOPQXYRbRIZ1pMt9
+nZzeLKvWn5nuUFgG2S4n+BEJkBDV78LOkUuGIAPdztF2mwVumygEGJFFfZHbYrh
XtaGUKdNxn1R3Z4B8W5jWpNNkU+dvoq4Zt0LrL28HB4Sa2yrtHeXbhqSb0BA15+N
vO9mlfX2I6Yr8F+L/OBfxB0gaNLISJJsj5NSm302je/QrfoMAwbePoNPjEX1/qd2
rgj9JfM+NE2FsVnFhrV+q4DywRUdX4VBFP4+x7fPm8LxvtVUmLVA3/NtGwPdnC6U
mQh/+kKVhU2AQwKCAQEA6R2FrxZIUdmk45b/tSjlq7r1ycBYeSztjzXw6hx+w/K3
Lf+5OGyoGoKr5bZHMhHFxqoWzP6kiZ2Uyvu+pLAGLfenJPec89ZncN4FvW9yU8yL
nE2Sc8Vf2GnOw2KGJcJR11Ew4dDspHfnM3rof2G4gPC/EMZeGojXoc5GHmT4iasD
Xwje4qqx5WKvRu1Rw2y+XM5h4gDn3QLLojDOvBpt22yaqSTAupY7OliGFO9h2WPL
r9TL6va87nXNepCdtzuSlxqTVtgMu2wVu3CnQyw9RiD2M31YnUtBDLNy/UNFj/5Z
6uXYuakh8vSFFvjgCUyQwR1xCTJur/6LdpAnt9Bz9wKCAQAUqoN9KVh2tatm4c72
2/D9ca3ikW+xgZqUta5yZWrNPGvhNbzT22b8KZDwKprN/cQRuSw52aZpPMNm3EQa
AIAyyCG69ADQj7r/T6btybjZRKBDMlcfIIw5q9DGTQ/vlZCx6IX6DkZbYQmdwkTc
0D20GA2uWGxbggawhgq5/PTuv5SJKrrn4qBLS73u6eqcVeN5XA6q0kywd+9UhNxv
+W/xUxOJgE5pVto2VREBLonWSwZVfnyx6GjvC0sOzv0Ocv7KxAPNqtRwzQ9Wtr7s
klb84Nv3OW0MjTcjwfr481Cyy2DqgP5PFnSogWJuibR34jXAgbnX4BiGWrUdzaMU
86AlAoIBABnw9Bh41VFuc9/zxL7nLy++HW33HqFVc5Y1PXr/8sdhcisHQxhZVxek
JPbqIuAahDTIZsMnLy41QAKaoyt2fymMXqhJecjUuiwgOOlMxp82qu6Y30xM0Y6m
r6CkjSMUjcD1QwhOFJd01GCxM8BBIqQOpmR6fqxbQAu8hacKO3IuerCPryXwMt3A
7ppo/GlP55sySEg7K5I3pmuFHOxn0IPTgR6DfYMGBs9GXJ1lyjDD3z3Q42RhUsMC
jvwtra9fTL/N8EmAv2H39C8oqSRbfvIX5u3x6/ONFU8RhSFT5CDTADSYoVZ/0MxV
k53r0hqWz6D94r9QQmsJW4G1JwZYhx8CggEBANUybXsJRgDC5ZOlwbaq0FeTOpZ4
pSKx1RkVV+G93hK5XdXIVu365pSt3T68MgF+4wWlbC4MuKuzJltFnJBapzz5ygcU
jw+Q+l/jq+mJj8uvUfo5TAy9thuggj1a7SCs/dm5DBwYA7bfmamLbbBpA0qywMsF
/vL1bpePIFhbGMhBK7uiEzOAOZVuceZWqcUByjUYy925K/an0ANsQAch5PVeAsEv
wXC3soaCzfzUyv+eAYBy7LOcz+hpdRj1l4c9Zx6hZeBxMc5LSRoTs+HfE6GgTuI2
cCfCZNFw7DhDy1TBd3XjQ0AFykeaesImZVR6gp0NYH1kionsMtR5rl4C2tw=
-----END RSA PRIVATE KEY-----

View File

@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDYTCCAUmgAwIBAgIUA/EaCcXKgOxBiuaMNTackeF9DgcwDQYJKoZIhvcNAQEL
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK
DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDBZMBMGByqGSM49AgEGCCqGSM49
AwEHA0IABOEVdmVd218y3Yy+ocjtt5siaFsNR7tknvS21pzKzxsFc05UrF74YqRx
Ic6/AQq56C48x6gDhUCdf+nMzD0NWTijGDAWMBQGA1UdEQQNMAuCCWxvY2FsaG9z
dDANBgkqhkiG9w0BAQsFAAOCAgEAW32kadHWEIsN5WXacjtgE9jbFii/rWJjrAO/
GWuARwLvjWeEFM5xSTny1cTDEz/7Bmfd9GRRpfgxKCFBGaU7zTmOV/AokrjUay+s
KPj0EV9ZE/84KVfr+MOuhwXhSSv+NvxduFw1sdhTUHs+Wopwjte+bnOUBXmnQH97
ITSQuUvEamPUS4tJD/kQ0qSGJKNv3CShhbe3XkjUd0UKK7lZzn6hY2fy3CrkkJDT
GyzFCRf3ZDfjW5/fGXN/TNoEK65pPQVr5taK/bovDesEO7rR4Y4YgtnGlJ7YToWh
E6I77CbsJCJdmgpjPNwRlwQ8upoWAfxOHqwg32s6uWq20v4TXZTOnEKOPEJG74bh
JHtwqsy2fIdGz4kZksKGerzJffyQPhX4f3qxxrijT9Hj2EoFIQ4u/jTRpK31IW3R
gEeNB8O4iyg3crx0oHRwc6zX63O6wBJCZ+0uxLoyjwtjKCxVYbJpccjoQh6zO3UO
pqAO+NKxQ469QDBV3uRyyQ7DRPu6p67/8gn1E/o8kyU1P7eLFIhBp4T4wCaMQBG6
IpL5W7eCyuOcqfdm471N07Z4KAqiV8eBJcKaAE+WzNIvrFvCZ/zq7Gj8p07nkjK8
+ZGDUieiY0mQEpiXLQZt1xNtT/MmlAzFYrK7t6gSygykQKjO1o4GG4g+eXu3h1YK
avsOwtc=
-----END CERTIFICATE-----

View File

@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgeo/7wBIYVax9bj4m
1UEe2H+H4YLsbApDCZMslZbubRuhRANCAAThFXZlXdtfMt2MvqHI7bebImhbDUe7
ZJ70ttacys8bBXNOVKxe+GKkcSHOvwEKueguPMeoA4VAnX/pzMw9DVk4
-----END PRIVATE KEY-----

View File

@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDfjCCAWagAwIBAgIUfmkM99JpQUsQsh8TVILPQDBGOhowDQYJKoZIhvcNAQEM
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK
DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDB2MBAGByqGSM49AgEGBSuBBAAi
A2IABM5rQQNZEGWeDyrg2dVQS15c+JsX/ginN9/ArHpTgGO6xaLfkbekIj7gewUR
VQcQrYulwu02HTFDzGVvf1DdCZzIJLJUpl+jagdI05yMPFg2s5lThzGB6HENyw1I
hU1dMaMYMBYwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBDAUAA4IC
AQAzpXFEeCMvTBG+kzmHN/+esjCK8MO/Grrw3Qoa1stX0yjNWzfhlGHfWk9Mgwnp
DnIEFT9lB+nT9uTkKL81Opu2bkWXuL0MyjyYmcCfEgJeDblUbD4HYzPO/s2P7QZu
Oa1pNKBiSWe1xq+F/gkn0+nDfICq3QQOOQMzaAmlFmszN3v7pryz+x21MqTFsfuW
ymycRcwZ3VyYeceZuBxjOhR2l8I5yZbR+KPj8VS7A3vgqvkS8ixTK0LrxcLwrTIz
W9Z1skzVpux4K7iwn3qlWIJ7EbERi9Ydz0MzpjD+CHxGlgHTzT552DGZhPO8D7BE
+4IoS0YeXEGLeC2a9Hmiy3FXbM4nt3aIWMiWyhjslXIbvWOJL1Vi5GNpdQCG+rB7
lvA0IISp/tAi9duufdONjgRceRDsqf7o6TOBQdB3QxHRt9gCB2QquRh5llMUY8YH
PxFJAEzF4X5b0q0k4b50HRVNfDY0SC/XIR7S2xnLDGuoUqrOpUligBzfXV4JHrPv
YKHsWSOiVxU9eY1SYKuKqnOsGvXSSQlYqSK1ol94eOKDjNy8wekaVYAQNsdNL7o5
QSWZUcbZLkaNMFdD9twWGduAs0eTichVgyvRgPdevjTGDPBHKNSUURZRTYeW4aIJ
QzSQUQXwOXsQOmfCkaaTISsDwZAJ0wFqqST1AOhlCWD1OQ==
-----END CERTIFICATE-----

View File

@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCRBt7MeJXyJRq7grGQ
jEdBHE31hG5LuKQ5iL8yTVGk75Kc8ISll2LewbvQ3NhR6E+hZANiAATOa0EDWRBl
ng8q4NnVUEteXPibF/4IpzffwKx6U4BjusWi35G3pCI+4HsFEVUHEK2LpcLtNh0x
Q8xlb39Q3QmcyCSyVKZfo2oHSNOcjDxYNrOZU4cxgehxDcsNSIVNXTE=
-----END PRIVATE KEY-----

View File

@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDMjCCARqgAwIBAgIUSu1CGfaWlNPjjLG7SaEEgxI+AsAwDQYJKoZIhvcNAQEL
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK
DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUGAytlcAMhAKNv1fkv5rxY
xGT84C98g2xPpain+jsliHvYrqNkS1zhoxgwFjAUBgNVHREEDTALgglsb2NhbGhv
c3QwDQYJKoZIhvcNAQELBQADggIBAMgWZhr3whYbFPNYm5RZxjgEonMY7cH/Rza1
UrBkyoMRL9v00YKJTEvjl5Xl4ku7jqxY2qjValacDroD8hYTLhhkkbHxH6u+kJSC
cKRQ8QVBZMmoor8qD2Y2BuXZYGWEtgeoXh+DLHWOim7gXDD6GyFPnYFGHnRhNmkE
6fPcfw1FZmBrMM9rk31EeK9nLVRabL+vuLDEddX1LH4LwK4OuV51wQMZGYiC3M2b
JpmuPAUAUyiyN53Utuw/ubeih3cEglOwCyDPFt6XISYHO0UaQdw25uhpjxs8KTZB
qOPJAI4cvGaN3GARXvz2P/QHUiTzsf2XOXXtrO0g+F9P7t6x2xL35c0A8yxKkfsa
RKdCveRuVlsLXVLVBikqLE/OgPC6SIhIFvTYzXTaZyNfge6dRy2Wg6qWPcGwseeA
QpFKgWiY3cuy7nJm6uybR6zOEMj5GgNWIbICGguQ5161MLnv0bEmKh2d9/jQ/XN5
M+nixtGla/G4hL3FQIy9rhHVZzPWwSxl+/eE4qgnInEhmlQ2KrcpgneCt38t0vjJ
dwHZSzVv8yX7fE0OSq/DWQXJraWUcT7Ds0g7T7jWkWfS0qStVd9VJ+rYQ8dBbE9Y
gcmkm1DMUd+y9xDRDjxby7gMDWFiN2Au9FQgaIpIctTNCj0+agFBPnfXPVSIH6gX
10kA2ZVX
-----END CERTIFICATE-----

View File

@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIGtLkaYE4D+P5HLkoc3a/tNhPP+gnhPLF3jEtdYcAtpd
-----END PRIVATE KEY-----

114
web/private/gen_certs.sh Normal file
View File

@ -0,0 +1,114 @@
#! /bin/bash
# Usage:
# ./gen_certs.sh [cert-kind]
#
# [cert-kind]:
# ed25519
# rsa_sha256
# ecdsa_nistp256_sha256
# ecdsa_nistp384_sha384
#
# Generate a certificate of the [cert-kind] key type, or if no cert-kind is
# specified, all of the certificates.
#
# Examples:
# ./gen_certs.sh ed25519
# ./gen_certs.sh rsa_sha256
# TODO: `rustls` (really, `webpki`) doesn't currently use the CN in the subject
# to check if a certificate is valid for a server name sent via SNI. It's not
# clear if this is intended, since certificates _should_ have a `subjectAltName`
# with a DNS name, or if it simply hasn't been implemented yet. See
# https://bugzilla.mozilla.org/show_bug.cgi?id=552346 for a bit more info.
CA_SUBJECT="/C=US/ST=CA/O=Rocket CA/CN=Rocket Root CA"
SUBJECT="/C=US/ST=CA/O=Rocket/CN=localhost"
ALT="DNS:localhost"
function gen_ca() {
openssl genrsa -out ca_key.pem 4096
openssl req -new -x509 -days 3650 -key ca_key.pem \
-subj "${CA_SUBJECT}" -out ca_cert.pem
}
function gen_ca_if_non_existent() {
if ! [ -f ./ca_cert.pem ]; then gen_ca; fi
}
function gen_rsa_sha256() {
gen_ca_if_non_existent
openssl req -newkey rsa:4096 -nodes -sha256 -keyout rsa_sha256_key.pem \
-subj "${SUBJECT}" -out server.csr
openssl x509 -req -sha256 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \
-CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \
-in server.csr -out rsa_sha256_cert.pem
rm ca_cert.srl server.csr
}
function gen_ed25519() {
gen_ca_if_non_existent
openssl genpkey -algorithm ED25519 > ed25519_key.pem
openssl req -new -key ed25519_key.pem -subj "${SUBJECT}" -out server.csr
openssl x509 -req -extfile <(printf "subjectAltName=${ALT}") -days 3650 \
-CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \
-in server.csr -out ed25519_cert.pem
rm ca_cert.srl server.csr
}
function gen_ecdsa_nistp256_sha256() {
gen_ca_if_non_existent
openssl ecparam -out ecdsa_nistp256_sha256_key.pem -name prime256v1 -genkey
# Convert to pkcs8 format supported by rustls
openssl pkcs8 -topk8 -nocrypt -in ecdsa_nistp256_sha256_key.pem \
-out ecdsa_nistp256_sha256_key_pkcs8.pem
openssl req -new -nodes -sha256 -key ecdsa_nistp256_sha256_key_pkcs8.pem \
-subj "${SUBJECT}" -out server.csr
openssl x509 -req -sha256 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \
-CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \
-in server.csr -out ecdsa_nistp256_sha256_cert.pem
rm ca_cert.srl server.csr ecdsa_nistp256_sha256_key.pem
}
function gen_ecdsa_nistp384_sha384() {
gen_ca_if_non_existent
openssl ecparam -out ecdsa_nistp384_sha384_key.pem -name secp384r1 -genkey
# Convert to pkcs8 format supported by rustls
openssl pkcs8 -topk8 -nocrypt -in ecdsa_nistp384_sha384_key.pem \
-out ecdsa_nistp384_sha384_key_pkcs8.pem
openssl req -new -nodes -sha384 -key ecdsa_nistp384_sha384_key_pkcs8.pem \
-subj "${SUBJECT}" -out server.csr
openssl x509 -req -sha384 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \
-CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \
-in server.csr -out ecdsa_nistp384_sha384_cert.pem
rm ca_cert.srl server.csr ecdsa_nistp384_sha384_key.pem
}
case $1 in
ed25519) gen_ed25519 ;;
rsa_sha256) gen_rsa_sha256 ;;
ecdsa_nistp256_sha256) gen_ecdsa_nistp256_sha256 ;;
ecdsa_nistp384_sha384) gen_ecdsa_nistp384_sha384 ;;
*)
gen_ed25519
gen_rsa_sha256
gen_ecdsa_nistp256_sha256
gen_ecdsa_nistp384_sha384
;;
esac

View File

@ -0,0 +1,30 @@
-----BEGIN CERTIFICATE-----
MIIFLDCCAxSgAwIBAgIUZ461KXDgTT25LP1S6PK6i9ZKtOYwDQYJKoZIhvcNAQEL
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK
DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQAD
ggIPADCCAgoCggIBAKl8Re1dneFB2obYPOuZf3d6BR2xGcMhr2vmHpnVgkeg/xGI
cwHhhB04mEg4TqbZ0Q1wdKN4ruNigdrQAXDqnm4y2IB1q/uTdoXVWNsvAZmDq4N4
rPUDWagpkilV4HOHDQOI27Lo/+30wJX6H8i3J6Nag1y9spuySkL1F1SgQng9uzvP
3SwbsO9R/jS7qTZNEtirnJv3qJlxmT4BCvBuWNALShFlSZYnZk/2csYXEaVkWMUE
rnWGmTmzMZEzDOdQdPC3sJDCPQbbgD4SnQANqhOVjPSdJ6Y6joN6XYtB2EP+6LJ8
UBTICETnUROWeKqMm215Gsen32cyWkaCwEWtTFn7rJHbPvUY4vPYQZAB2/h07lYq
v67W3r5lTq1KuoStD8M/7nCIYIuNhmJLMzzWrSfJrQpYexoMII6kjOxv2kiUgb1y
bYKnFlVf4up3pTpi2/0We4SHRgc7xTcEPNvU5z5hc5JwHZIFjmi5dx50ZoAULrXl
OUhfdUPut7H38UQg3riJiNAzedUiTubek26po8qH1iS/EMgAi5RboaBovjWhgjwq
P/RP0vzpln6OdQIRaRlY9vZiik9HbFybafuA2zEkyc+zc4HqJk3vqX/0hgd9qWeL
zgsHr6A86tNUXsUaoInQbzznjpbFE85klxd6HeqasOyVDCeDUs4lWoDNp0DbAgMB
AAGjGDAWMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAgEA
sS2TUKKYtNYC68TEQb5KAa8/zi6Lhz9lyn9rpBXAcAVeBdxCqrBU0nRN8EjXEff1
oBeau9Wi9PwdK6J+4xFuer9YrZZWuWLKUBXJL9ZXEfI+USo5WVz7v/puoKZSUSu2
+Ee4+v1eoAsCtaA8IR0Sh1KTc9kg1QZW1nIdBV0zgAERWTzm1iy2Ff+euowM/lUR
FczMAJsNq83O2kaH541fRx3gC2iMN/QLwRalBR2y8hTI5wQa0Yr8moC4IsTfRrKQ
/SZDjFT1XoA9TnrByIvGQNlxK1Rm2hvK1OQn4OL6sdYK6F+MxfUn/atQNyBQ6CF+
oKuvOnE2jces8GGZIU1jYJ2271SQkzp0CW3N1ZKjClSQFAYED+5ERTGyeEL4o3Vr
V/BUd0KC7HhbWBlFc2EDmPOoOTp/ID901Kf499M68pe2Fr/63/nCAON6WFk1NPYA
+sWMfS25hikU5rOHGQgXxXAz90pwpvpoLQvaq0/azsbHvq9B4ubdcLE0xcoK+DGq
+/aOeWlYkalWp93akPYpWN3UJXzDXzzagRID/1LEGS+Ssbh1WVhAhLKVoxzBvkgm
ozVAhR3zHXI2QpQ2FEbOmkcRtpxv2CiaTsgHg2MK8cdmPx3G+ufCZjRbQx/VXVdN
vaFRdmzr/5EQ7DT5Uy8w32h1to4Fm2sAtbAFYaFLXaM=
-----END CERTIFICATE-----

View File

@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCpfEXtXZ3hQdqG
2DzrmX93egUdsRnDIa9r5h6Z1YJHoP8RiHMB4YQdOJhIOE6m2dENcHSjeK7jYoHa
0AFw6p5uMtiAdav7k3aF1VjbLwGZg6uDeKz1A1moKZIpVeBzhw0DiNuy6P/t9MCV
+h/ItyejWoNcvbKbskpC9RdUoEJ4Pbs7z90sG7DvUf40u6k2TRLYq5yb96iZcZk+
AQrwbljQC0oRZUmWJ2ZP9nLGFxGlZFjFBK51hpk5szGRMwznUHTwt7CQwj0G24A+
Ep0ADaoTlYz0nSemOo6Del2LQdhD/uiyfFAUyAhE51ETlniqjJtteRrHp99nMlpG
gsBFrUxZ+6yR2z71GOLz2EGQAdv4dO5WKr+u1t6+ZU6tSrqErQ/DP+5wiGCLjYZi
SzM81q0nya0KWHsaDCCOpIzsb9pIlIG9cm2CpxZVX+Lqd6U6Ytv9FnuEh0YHO8U3
BDzb1Oc+YXOScB2SBY5ouXcedGaAFC615TlIX3VD7rex9/FEIN64iYjQM3nVIk7m
3pNuqaPKh9YkvxDIAIuUW6GgaL41oYI8Kj/0T9L86ZZ+jnUCEWkZWPb2YopPR2xc
m2n7gNsxJMnPs3OB6iZN76l/9IYHfalni84LB6+gPOrTVF7FGqCJ0G88546WxRPO
ZJcXeh3qmrDslQwng1LOJVqAzadA2wIDAQABAoICABT4gHp/Q+K0UEKxDNCl/ISe
/3UODb78MwVpws2MAoO0YvsbZAeOjNdEwmrlNK4mc1xzVqtHanROIv0dEaCUFyhR
eEJkzPPi6h5jKIxuQ4doKFerHdNvJ6/L/P7KVmxVAII4c96uP8SErTOhcD9Ykjn/
IBPgkPH83H1ucAWTksXn9XvQG3CyuHDUN1z0/1ntrXBLw6P0v9LEoI5weJcJQEn1
q6N9Yd6HX3xzZP4nqpJJWUZ/bsqx7dGa3340z9rrNJz4TYuLzRtFG5gSm4R/LFUi
Av/dViOWST3xbROnAQhgyRAUm6AGpCdKa9i9nI6VuUGRY4PivJy7OTpSQVIdwD2K
VFbFauCmsTY7B+Z+DXe9JJV+Tlazvlwop1DApxCgLMNr2pVB/1yPzMrNYDB4Sg1c
T3stu4A8PtljTykla2C2LPFrdIijvYv9n6PSPWV4vf1ix9uYs99FhZPQJMgm+CDr
n3cFfuofIWIRJpCuu3hn4woGgW2bXor4pyMNg1yCp1T2c8g6eJUYAAIRpse0HDbT
ZUifxlNBx819bf9aqv+lhwMU4UFlmWMv3NETcCBqgUn+z4scNJ3kZwO9ZodLRblK
SpalZ6SNejTnVukg3zbwJG8w5vIrJvfjBkikAL1O5X6Kn3YqfrXAl38bIvA50ZBe
eOFcgDPClLPGlNKxQXiZAoIBAQDh0aArTjWi8Kxt2Ga3Hu4jQ7IXklL9osGAlFCB
wZs831/ktLY0c7vY+mbwrY4AtqK21A00MrNSTsp/McHwAUi410DdcTqzkGnm9BBZ
FKVO1H9KGg2t344tOXcPsFTl6SR5a0aPqagyvgdH+YWC4ccfYZWMcLELn3sOGoEp
a7VBxjLZZ+/b+/oIzhwxEaBi8N0U3IZ3SsC9Lmojnb9+LMwGs14TFfahD3uM1hnU
vww1elwPwXafWc+7Ej1bXijWBXbyzkCITzHafmDsAatEZnemHL8zKKtTn2aSTUSj
Fl/y94WR7/V4nVEQ0R3fjGC0rKh08BtKzxPjYBqjT5aI7+yfAoIBAQDAIzROr00o
65auD5TB2k/pSVKQpB/U6ESGafDxg4a+R9Ng2ESmDN5hUUZDefm0Uxf9AZqS6eno
GSAqxNDixWPy2WFzbZ0mUCoyQn+Eh09WBvt0IqDZ2+ngBEXiKA/8dsvXinBHLuHV
u+rJdLMzPfhWVo1/iXq7SOjBvDuthKROku5BEt6Q3Cs0nk6uneRIqKtaqa7KIInF
BPtUifZtCP09h6iFFGNv2g2OYRYDg8TXvjt3scKy0DUC3kTeTCPvNvHaOObGbMRU
Q85HkXburxWfsMto5m/lRsbY3COxddB47WIQ6QNewESfCab/R2lOdlXQeaH8fuxT
wWC5DMz4F0ZFAoIBAFm+EjZDnaNEnHIHB0MNIryXAabGev7beKUdzCTVCVmWuChO
/P45ZFTlppVNk9qKun2IJjsxTvyN3YHRB27XQ8xZlyiqABcudDfZlMmiH9QFNRUA
56DK8Fjetodgn0zDa8BpNqCPXw3TYVdkPX/3NEgvYtxuSJ4C4keHlv8cE+uw1bJ6
0OMO754iMyf5BlFrwaCxxyqPZauJT5sZ7Ok66lZbYC6bkukNGx+sUpWu2y5Bk2ab
jwXjDmAc7o9qCzaK82upNhI1zu0zPldsjmDfi/tS/1VYe0X/WicYWAesM7N+VPHb
eCVX98iEIqgdxKzo1QWsClyfkRrSraNrVLrVBqcCggEAaLIGK6YMNnMBPUGSPnt2
NdlVWymDiuExjcimmQOhZYf/33KZHZ4/gunljpklfqQUmzHHh6xcX7NpOsTaSedj
Sg43ss0U566g/5gKoi2VBnxxglvoKC5T51SMu+o2o8wb0QxHmBIszulBy5qClzZ6
Xpl1Kvy/2tOkuQSXxDpVydb4ao8cpfTCuj5VA4NXxFvcW1/AtbU7PRc02GEA3XMb
gu6r3jA46tb3shCnDS09Eo4/Gz7Kp+MaL8Dr5/G3Vv8qlE2TOqZD6OK1wXu7Qd43
uzd772I5sMZ7TenOrUFUYsB/QlWmF3hPLBX3YH0KHc4PfrT4lnyWzCDAUrVt7vXH
vQKCAQEAz1Q+jW+NG7CAqkOJ19n+KmGn+zddvy8x4txKx8kV0+3XIVNkseS6IT65
uemD85Gn7MYwHxcKUHghHSKyJsvCb1vSmB3JtCnrsodmQNMb6Lsq89VlM8Ylc/s3
F4AraiOlylqcEwLkanD3XSfPdQZhExZHEtpAW/Rr6zYT9J94VO1nK3+RkFI4a8kl
pEbY+tqJj3LGTuaQkshB9rDtcBT5/JsaxlMk783qkKziCPZF47BWqrJb+t7tm3qg
5gf+QUInEjdW3k3uZBL4316RP/TJlLvo29PkEwoC8X4R2EgDeHQhhRC2Fi2Wpy4O
ce4G+zZOOYXwvWGJLwNhgsve8C3oqg==
-----END PRIVATE KEY-----

52
web/src/consts.rs Normal file
View File

@ -0,0 +1,52 @@
pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/token";
pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize";
pub const DISCORD_API: &'static str = "https://discord.com/api";
pub const MAX_CONTENT_LENGTH: usize = 2000;
pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096;
pub const MAX_EMBED_TITLE_LENGTH: usize = 256;
pub const MAX_EMBED_AUTHOR_LENGTH: usize = 256;
pub const MAX_EMBED_FOOTER_LENGTH: usize = 2048;
pub const MAX_URL_LENGTH: usize = 512;
pub const MAX_USERNAME_LENGTH: usize = 100;
pub const MAX_EMBED_FIELDS: usize = 25;
pub const MAX_EMBED_FIELD_TITLE_LENGTH: usize = 256;
pub const MAX_EMBED_FIELD_VALUE_LENGTH: usize = 1024;
pub const MINUTE: usize = 60;
pub const HOUR: usize = 60 * MINUTE;
pub const DAY: usize = 24 * HOUR;
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
use std::{collections::HashSet, env, iter::FromIterator};
use lazy_static::lazy_static;
use serenity::model::prelude::AttachmentType;
lazy_static! {
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../assets/",
env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation")
)) as &[u8],
env!("WEBHOOK_AVATAR"),
)
.into();
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
env::var("SUBSCRIPTION_ROLES")
.map(|var| var
.split(',')
.filter_map(|item| { item.parse::<u64>().ok() })
.collect::<Vec<u64>>())
.unwrap_or_else(|_| Vec::new())
);
pub static ref CNC_GUILD: Option<u64> =
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL")
.ok()
.map(|inner| inner.parse::<u32>().ok())
.flatten()
.unwrap_or(600);
}

196
web/src/lib.rs Normal file
View File

@ -0,0 +1,196 @@
#[macro_use]
extern crate rocket;
mod consts;
#[macro_use]
mod macros;
mod routes;
use std::{collections::HashMap, env};
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use rocket::{
fs::FileServer,
serde::json::{json, Value as JsonValue},
tokio::sync::broadcast::Sender,
};
use rocket_dyn_templates::Template;
use serenity::{
client::Context,
http::CacheHttp,
model::id::{GuildId, UserId},
};
use sqlx::{MySql, Pool};
use crate::consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES};
type Database = MySql;
#[derive(Debug)]
enum Error {
SQLx(sqlx::Error),
Serenity(serenity::Error),
}
#[catch(401)]
async fn not_authorized() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/401", &map)
}
#[catch(403)]
async fn forbidden() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/403", &map)
}
#[catch(404)]
async fn not_found() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/404", &map)
}
#[catch(413)]
async fn payload_too_large() -> JsonValue {
json!({"error": "Data too large.", "errors": ["Data too large."]})
}
#[catch(422)]
async fn unprocessable_entity() -> JsonValue {
json!({"error": "Invalid request.", "errors": ["Invalid request."]})
}
#[catch(500)]
async fn internal_server_error() -> Template {
let map: HashMap<String, String> = HashMap::new();
Template::render("errors/500", &map)
}
pub async fn initialize(
kill_channel: Sender<()>,
serenity_context: Context,
db_pool: Pool<Database>,
) -> Result<(), Box<dyn std::error::Error>> {
info!("Checking environment variables...");
env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied");
env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied");
env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied");
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD' not supplied");
info!("Done!");
let oauth2_client = BasicClient::new(
ClientId::new(env::var("OAUTH2_CLIENT_ID")?),
Some(ClientSecret::new(env::var("OAUTH2_CLIENT_SECRET")?)),
AuthUrl::new(DISCORD_OAUTH_AUTHORIZE.to_string())?,
Some(TokenUrl::new(DISCORD_OAUTH_TOKEN.to_string())?),
)
.set_redirect_uri(RedirectUrl::new(env::var("OAUTH2_DISCORD_CALLBACK")?)?);
let reqwest_client = reqwest::Client::new();
rocket::build()
.attach(Template::fairing())
.register(
"/",
catchers![
not_authorized,
forbidden,
not_found,
internal_server_error,
unprocessable_entity,
payload_too_large,
],
)
.manage(oauth2_client)
.manage(reqwest_client)
.manage(serenity_context)
.manage(db_pool)
.mount("/static", FileServer::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static")))
.mount(
"/",
routes![
routes::index,
routes::cookies,
routes::privacy,
routes::terms,
routes::return_to_same_site
],
)
.mount(
"/help",
routes![
routes::help,
routes::help_timezone,
routes::help_create_reminder,
routes::help_delete_reminder,
routes::help_timers,
routes::help_todo_lists,
routes::help_macros,
],
)
.mount("/login", routes![routes::login::discord_login, routes::login::discord_callback])
.mount(
"/dashboard",
routes![
routes::dashboard::dashboard,
routes::dashboard::dashboard_home,
routes::dashboard::user::get_user_info,
routes::dashboard::user::update_user_info,
routes::dashboard::user::get_user_guilds,
routes::dashboard::guild::get_guild_patreon,
routes::dashboard::guild::get_guild_channels,
routes::dashboard::guild::get_guild_roles,
routes::dashboard::guild::get_reminder_templates,
routes::dashboard::guild::create_reminder_template,
routes::dashboard::guild::delete_reminder_template,
routes::dashboard::guild::create_reminder,
routes::dashboard::guild::get_reminders,
routes::dashboard::guild::edit_reminder,
routes::dashboard::guild::delete_reminder,
],
)
.launch()
.await?;
warn!("Exiting rocket runtime");
// distribute kill signal
match kill_channel.send(()) {
Ok(_) => {}
Err(e) => {
error!("Failed to issue kill signal: {:?}", e);
}
}
Ok(())
}
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
if let Ok(member) = guild_member {
for role in member.roles {
if SUBSCRIPTION_ROLES.contains(role.as_u64()) {
return true;
}
}
}
false
} else {
true
}
}
pub async fn check_guild_subscription(
cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>,
) -> bool {
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
let owner = guild.owner_id;
check_subscription(&cache_http, owner).await
} else {
false
}
}

119
web/src/macros.rs Normal file
View File

@ -0,0 +1,119 @@
macro_rules! check_length {
($max:ident, $field:expr) => {
if $field.len() > $max {
return json!({ "error": format!("{} exceeded", stringify!($max)) });
}
};
($max:ident, $field:expr, $($fields:expr),+) => {
check_length!($max, $field);
check_length!($max, $($fields),+);
};
}
macro_rules! check_length_opt {
($max:ident, $field:expr) => {
if let Some(field) = &$field {
check_length!($max, field);
}
};
($max:ident, $field:expr, $($fields:expr),+) => {
check_length_opt!($max, $field);
check_length_opt!($max, $($fields),+);
};
}
macro_rules! check_url {
($field:expr) => {
if !($field.starts_with("http://") || $field.starts_with("https://")) {
return json!({ "error": "URL invalid" });
}
};
($field:expr, $($fields:expr),+) => {
check_url!($max, $field);
check_url!($max, $($fields),+);
};
}
macro_rules! check_url_opt {
($field:expr) => {
if let Some(field) = &$field {
check_url!(field);
}
};
($field:expr, $($fields:expr),+) => {
check_url_opt!($field);
check_url_opt!($($fields),+);
};
}
macro_rules! check_authorization {
($cookies:expr, $ctx:expr, $guild:expr) => {
use serenity::model::id::UserId;
let user_id = $cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
match user_id {
Some(user_id) => {
match GuildId($guild).to_guild_cached($ctx) {
Some(guild) => {
let member = guild.member($ctx, UserId(user_id)).await;
match member {
Err(_) => {
return json!({"error": "User not in guild"})
}
Ok(_) => {}
}
}
None => {
return json!({"error": "Bot not in guild"})
}
}
}
None => {
return json!({"error": "User not authorized"});
}
}
}
}
macro_rules! update_field {
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
if let Some(value) = &$reminder.$field {
match sqlx::query(concat!(
"UPDATE reminders SET `",
stringify!($field),
"` = ? WHERE uid = ?"
))
.bind(value)
.bind(&$reminder.uid)
.execute($pool)
.await
{
Ok(_) => {}
Err(e) => {
warn!(
concat!(
"Error in `update_field!(",
stringify!($pool),
stringify!($reminder),
stringify!($field),
")': {:?}"
),
e
);
$error.push(format!("Error setting field {}", stringify!($field)));
}
}
}
};
($pool:expr, $error:ident, $reminder:ident.[$field:ident, $($fields:ident),+]) => {
update_field!($pool, $error, $reminder.[$field]);
update_field!($pool, $error, $reminder.[$($fields),+]);
};
}

View File

@ -0,0 +1,733 @@
use std::env;
use base64;
use chrono::Utc;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
State,
};
use serde::Serialize;
use serenity::{
client::Context,
model::{
channel::GuildChannel,
id::{ChannelId, GuildId, RoleId},
},
};
use sqlx::{MySql, Pool};
use crate::{
check_guild_subscription, check_subscription,
consts::{
DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
MIN_INTERVAL,
},
routes::dashboard::{
create_database_channel, generate_uid, name_default, template_name_default, DeleteReminder,
DeleteReminderTemplate, PatchReminder, Reminder, ReminderTemplate,
},
};
#[derive(Serialize)]
struct ChannelInfo {
id: String,
name: String,
webhook_avatar: Option<String>,
webhook_name: Option<String>,
}
#[get("/api/guild/<id>/patreon")]
pub async fn get_guild_patreon(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
) -> JsonValue {
check_authorization!(cookies, ctx.inner(), id);
match GuildId(id).to_guild_cached(ctx.inner()) {
Some(guild) => {
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
.member(&ctx.inner(), guild.owner_id)
.await;
let patreon = member_res.map_or(false, |member| {
member
.roles
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
});
json!({ "patreon": patreon })
}
None => {
json!({"error": "Bot not in guild"})
}
}
}
#[get("/api/guild/<id>/channels")]
pub async fn get_guild_channels(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
) -> JsonValue {
check_authorization!(cookies, ctx.inner(), id);
match GuildId(id).to_guild_cached(ctx.inner()) {
Some(guild) => {
let mut channels = guild
.channels
.iter()
.filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c)))
.filter(|(_, channel)| channel.is_text_based())
.collect::<Vec<(ChannelId, GuildChannel)>>();
channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position));
let channel_info = channels
.iter()
.map(|(channel_id, channel)| ChannelInfo {
name: channel.name.to_string(),
id: channel_id.to_string(),
webhook_avatar: None,
webhook_name: None,
})
.collect::<Vec<ChannelInfo>>();
json!(channel_info)
}
None => {
json!({"error": "Bot not in guild"})
}
}
}
#[derive(Serialize)]
struct RoleInfo {
id: String,
name: String,
}
#[get("/api/guild/<id>/roles")]
pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonValue {
check_authorization!(cookies, ctx.inner(), id);
let roles_res = ctx.cache.guild_roles(id);
match roles_res {
Some(roles) => {
let roles = roles
.iter()
.map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
.collect::<Vec<RoleInfo>>();
json!(roles)
}
None => {
warn!("Could not fetch roles from {}", id);
json!({"error": "Could not get roles"})
}
}
}
#[get("/api/guild/<id>/templates")]
pub async fn get_reminder_templates(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
check_authorization!(cookies, ctx.inner(), id);
match sqlx::query_as_unchecked!(
ReminderTemplate,
"SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
id
)
.fetch_all(pool.inner())
.await
{
Ok(templates) => {
json!(templates)
}
Err(e) => {
warn!("Could not fetch templates from {}: {:?}", id, e);
json!({"error": "Could not get templates"})
}
}
}
#[post("/api/guild/<id>/templates", data = "<reminder_template>")]
pub async fn create_reminder_template(
id: u64,
reminder_template: Json<ReminderTemplate>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
check_authorization!(cookies, ctx.inner(), id);
// validate lengths
check_length!(MAX_CONTENT_LENGTH, reminder_template.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields);
if let Some(fields) = &reminder_template.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder_template.embed_footer_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_author_url,
reminder_template.embed_image_url,
reminder_template.avatar
);
// validate urls
check_url_opt!(
reminder_template.embed_footer_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_author_url,
reminder_template.embed_image_url,
reminder_template.avatar
);
let name = if reminder_template.name.is_empty() {
template_name_default()
} else {
reminder_template.name.clone()
};
match sqlx::query!(
"INSERT INTO reminder_template
(guild_id,
name,
attachment,
attachment_name,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
tts,
username
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
id, name,
reminder_template.attachment,
reminder_template.attachment_name,
reminder_template.avatar,
reminder_template.content,
reminder_template.embed_author,
reminder_template.embed_author_url,
reminder_template.embed_color,
reminder_template.embed_description,
reminder_template.embed_footer,
reminder_template.embed_footer_url,
reminder_template.embed_image_url,
reminder_template.embed_thumbnail_url,
reminder_template.embed_title,
reminder_template.embed_fields,
reminder_template.tts,
reminder_template.username,
)
.fetch_all(pool.inner())
.await
{
Ok(_) => {
json!({})
}
Err(e) => {
warn!("Could not fetch templates from {}: {:?}", id, e);
json!({"error": "Could not get templates"})
}
}
}
#[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")]
pub async fn delete_reminder_template(
id: u64,
delete_reminder_template: Json<DeleteReminderTemplate>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
check_authorization!(cookies, ctx.inner(), id);
match sqlx::query!(
"DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?",
id, delete_reminder_template.id
)
.fetch_all(pool.inner())
.await
{
Ok(_) => {
json!({})
}
Err(e) => {
warn!("Could not delete template from {}: {:?}", id, e);
json!({"error": "Could not delete template"})
}
}
}
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn create_reminder(
id: u64,
reminder: Json<Reminder>,
cookies: &CookieJar<'_>,
serenity_context: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
check_authorization!(cookies, serenity_context.inner(), id);
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
// validate channel
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
let channel_exists = channel.is_some();
let channel_matches_guild =
channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id.0 == id));
if !channel_matches_guild || !channel_exists {
warn!(
"Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})",
reminder.channel, id, channel_exists
);
return json!({"error": "Channel not found"});
}
let channel = create_database_channel(
serenity_context.inner(),
ChannelId(reminder.channel),
pool.inner(),
)
.await;
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
return json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"});
}
let channel = channel.unwrap();
// validate lengths
check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
if let Some(fields) = &reminder.embed_fields {
for field in &fields.0 {
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
}
}
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
check_length_opt!(
MAX_URL_LENGTH,
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
// validate urls
check_url_opt!(
reminder.embed_footer_url,
reminder.embed_thumbnail_url,
reminder.embed_author_url,
reminder.embed_image_url,
reminder.avatar
);
// validate time and interval
if reminder.utc_time < Utc::now().naive_utc() {
return json!({"error": "Time must be in the future"});
}
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32
+ reminder.interval_seconds.unwrap_or(0)
< *MIN_INTERVAL
{
return json!({"error": "Interval too short"});
}
}
// check patreon if necessary
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
if !check_guild_subscription(serenity_context.inner(), GuildId(id)).await
&& !check_subscription(serenity_context.inner(), user_id).await
{
return json!({"error": "Patreon is required to set intervals"});
}
}
// base64 decode error dropped here
let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten();
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
let new_uid = generate_uid();
// write to db
match sqlx::query!(
"INSERT INTO reminders (
uid,
attachment,
attachment_name,
channel_id,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
enabled,
expires,
interval_seconds,
interval_months,
name,
pin,
restartable,
tts,
username,
`utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_uid,
attachment_data,
reminder.attachment_name,
channel,
reminder.avatar,
reminder.content,
reminder.embed_author,
reminder.embed_author_url,
reminder.embed_color,
reminder.embed_description,
reminder.embed_footer,
reminder.embed_footer_url,
reminder.embed_image_url,
reminder.embed_thumbnail_url,
reminder.embed_title,
reminder.embed_fields,
reminder.enabled,
reminder.expires,
reminder.interval_seconds,
reminder.interval_months,
name,
reminder.pin,
reminder.restartable,
reminder.tts,
reminder.username,
reminder.utc_time,
)
.execute(pool.inner())
.await
{
Ok(_) => sqlx::query_as_unchecked!(
Reminder,
"SELECT
reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_months,
reminders.name,
reminders.pin,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?",
new_uid
)
.fetch_one(pool.inner())
.await
.map(|r| json!(r))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
json!({"error": "Could not load reminder"})
}),
Err(e) => {
warn!("Error in `create_reminder`: Could not execute query: {:?}", e);
json!({"error": "Unknown error"})
}
}
}
#[get("/api/guild/<id>/reminders")]
pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySql>>) -> JsonValue {
let channels_res = GuildId(id).channels(&ctx.inner()).await;
match channels_res {
Ok(channels) => {
let channels = channels
.keys()
.into_iter()
.map(|k| k.as_u64().to_string())
.collect::<Vec<String>>()
.join(",");
sqlx::query_as_unchecked!(
Reminder,
"SELECT
reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_months,
reminders.name,
reminders.pin,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE FIND_IN_SET(channels.channel, ?)",
channels
)
.fetch_all(pool.inner())
.await
.map(|r| json!(r))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
json!({"error": "Could not load reminders"})
})
}
Err(e) => {
warn!("Could not fetch channels from {}: {:?}", id, e);
json!([])
}
}
}
#[patch("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn edit_reminder(
id: u64,
reminder: Json<PatchReminder>,
serenity_context: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
let mut error = vec![];
update_field!(pool.inner(), error, reminder.[
attachment,
attachment_name,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
enabled,
expires,
interval_seconds,
interval_months,
name,
pin,
restartable,
tts,
username,
utc_time
]);
if reminder.channel > 0 {
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
match channel {
Some(channel) => {
let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id);
if !channel_matches_guild {
warn!(
"Error in `edit_reminder`: channel {:?} not found for guild {}",
reminder.channel, id
);
return json!({"error": "Channel not found"});
}
let channel = create_database_channel(
serenity_context.inner(),
ChannelId(reminder.channel),
pool.inner(),
)
.await;
if let Err(e) = channel {
warn!("`create_database_channel` returned an error code: {:?}", e);
return json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"});
}
let channel = channel.unwrap();
match sqlx::query!(
"UPDATE reminders SET channel_id = ? WHERE uid = ?",
channel,
reminder.uid
)
.execute(pool.inner())
.await
{
Ok(_) => {}
Err(e) => {
warn!("Error setting channel: {:?}", e);
error.push("Couldn't set channel".to_string())
}
}
}
None => {
warn!(
"Error in `edit_reminder`: channel {:?} not found for guild {}",
reminder.channel, id
);
return json!({"error": "Channel not found"});
}
}
}
match sqlx::query_as_unchecked!(
Reminder,
"SELECT reminders.attachment,
reminders.attachment_name,
reminders.avatar,
channels.channel,
reminders.content,
reminders.embed_author,
reminders.embed_author_url,
reminders.embed_color,
reminders.embed_description,
reminders.embed_footer,
reminders.embed_footer_url,
reminders.embed_image_url,
reminders.embed_thumbnail_url,
reminders.embed_title,
reminders.embed_fields,
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_months,
reminders.name,
reminders.pin,
reminders.restartable,
reminders.tts,
reminders.uid,
reminders.username,
reminders.utc_time
FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?",
reminder.uid
)
.fetch_one(pool.inner())
.await
{
Ok(reminder) => json!({"reminder": reminder, "errors": error}),
Err(e) => {
warn!("Error exiting `edit_reminder': {:?}", e);
json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]})
}
}
}
#[delete("/api/guild/<_>/reminders", data = "<reminder>")]
pub async fn delete_reminder(
reminder: Json<DeleteReminder>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid)
.execute(pool.inner())
.await
{
Ok(_) => {
json!({})
}
Err(e) => {
warn!("Error in `delete_reminder`: {:?}", e);
json!({"error": "Could not delete reminder"})
}
}
}

View File

@ -0,0 +1,314 @@
use std::collections::HashMap;
use chrono::naive::NaiveDateTime;
use rand::{rngs::OsRng, seq::IteratorRandom};
use rocket::{http::CookieJar, response::Redirect};
use rocket_dyn_templates::Template;
use serde::{Deserialize, Serialize};
use serenity::{http::Http, model::id::ChannelId};
use sqlx::{types::Json, Executor};
use crate::{
consts::{CHARACTERS, DEFAULT_AVATAR},
Database, Error,
};
pub mod guild;
pub mod user;
type Unset<T> = Option<T>;
fn name_default() -> String {
"Reminder".to_string()
}
fn template_name_default() -> String {
"Template".to_string()
}
fn channel_default() -> u64 {
0
}
fn id_default() -> u32 {
0
}
#[derive(Serialize, Deserialize)]
pub struct ReminderTemplate {
#[serde(default = "id_default")]
id: u32,
#[serde(default = "id_default")]
guild_id: u32,
#[serde(default = "template_name_default")]
name: String,
attachment: Option<Vec<u8>>,
attachment_name: Option<String>,
avatar: Option<String>,
content: String,
embed_author: String,
embed_author_url: Option<String>,
embed_color: u32,
embed_description: String,
embed_footer: String,
embed_footer_url: Option<String>,
embed_image_url: Option<String>,
embed_thumbnail_url: Option<String>,
embed_title: String,
embed_fields: Option<Json<Vec<EmbedField>>>,
tts: bool,
username: Option<String>,
}
#[derive(Deserialize)]
pub struct DeleteReminderTemplate {
id: u32,
}
#[derive(Serialize, Deserialize)]
pub struct EmbedField {
title: String,
value: String,
inline: bool,
}
#[derive(Serialize, Deserialize)]
pub struct Reminder {
#[serde(with = "base64s")]
attachment: Option<Vec<u8>>,
attachment_name: Option<String>,
avatar: Option<String>,
#[serde(with = "string")]
channel: u64,
content: String,
embed_author: String,
embed_author_url: Option<String>,
embed_color: u32,
embed_description: String,
embed_footer: String,
embed_footer_url: Option<String>,
embed_image_url: Option<String>,
embed_thumbnail_url: Option<String>,
embed_title: String,
embed_fields: Option<Json<Vec<EmbedField>>>,
enabled: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_months: Option<u32>,
#[serde(default = "name_default")]
name: String,
pin: bool,
restartable: bool,
tts: bool,
#[serde(default)]
uid: String,
username: Option<String>,
utc_time: NaiveDateTime,
}
#[derive(Deserialize)]
pub struct PatchReminder {
uid: String,
#[serde(default)]
attachment: Unset<Option<String>>,
#[serde(default)]
attachment_name: Unset<Option<String>>,
#[serde(default)]
avatar: Unset<Option<String>>,
#[serde(default = "channel_default")]
#[serde(with = "string")]
channel: u64,
#[serde(default)]
content: Unset<String>,
#[serde(default)]
embed_author: Unset<String>,
#[serde(default)]
embed_author_url: Unset<Option<String>>,
#[serde(default)]
embed_color: Unset<u32>,
#[serde(default)]
embed_description: Unset<String>,
#[serde(default)]
embed_footer: Unset<String>,
#[serde(default)]
embed_footer_url: Unset<Option<String>>,
#[serde(default)]
embed_image_url: Unset<Option<String>>,
#[serde(default)]
embed_thumbnail_url: Unset<Option<String>>,
#[serde(default)]
embed_title: Unset<String>,
#[serde(default)]
embed_fields: Unset<Json<Vec<EmbedField>>>,
#[serde(default)]
enabled: Unset<bool>,
#[serde(default)]
expires: Unset<Option<NaiveDateTime>>,
#[serde(default)]
interval_seconds: Unset<Option<u32>>,
#[serde(default)]
interval_months: Unset<Option<u32>>,
#[serde(default)]
name: Unset<String>,
#[serde(default)]
pin: Unset<bool>,
#[serde(default)]
restartable: Unset<bool>,
#[serde(default)]
tts: Unset<bool>,
#[serde(default)]
username: Unset<Option<String>>,
#[serde(default)]
utc_time: Unset<NaiveDateTime>,
}
pub fn generate_uid() -> String {
let mut generator: OsRng = Default::default();
(0..64)
.map(|_| CHARACTERS.chars().choose(&mut generator).unwrap().to_owned().to_string())
.collect::<Vec<String>>()
.join("")
}
// https://github.com/serde-rs/json/issues/329#issuecomment-305608405
mod string {
use std::{fmt::Display, str::FromStr};
use serde::{de, Deserialize, Deserializer, Serializer};
pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
where
T: Display,
S: Serializer,
{
serializer.collect_str(value)
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where
T: FromStr,
T::Err: Display,
D: Deserializer<'de>,
{
String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
}
}
mod base64s {
use serde::{de, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(value: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
if let Some(opt) = value {
serializer.collect_str(&base64::encode(opt))
} else {
serializer.serialize_none()
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
where
D: Deserializer<'de>,
{
let string = String::deserialize(deserializer)?;
Some(base64::decode(string).map_err(de::Error::custom)).transpose()
}
}
#[derive(Deserialize)]
pub struct DeleteReminder {
uid: String,
}
async fn create_database_channel(
ctx: impl AsRef<Http>,
channel: ChannelId,
pool: impl Executor<'_, Database = Database> + Copy,
) -> Result<u32, crate::Error> {
println!("{:?}", channel);
let row =
sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0)
.fetch_one(pool)
.await;
match row {
Ok(row) => {
if row.webhook_token.is_none() || row.webhook_id.is_none() {
let webhook = channel
.create_webhook_with_avatar(&ctx, "Reminder", DEFAULT_AVATAR.clone())
.await
.map_err(|e| Error::Serenity(e))?;
sqlx::query!(
"UPDATE channels SET webhook_id = ?, webhook_token = ? WHERE channel = ?",
webhook.id.0,
webhook.token,
channel.0
)
.execute(pool)
.await
.map_err(|e| Error::SQLx(e))?;
}
Ok(())
}
Err(sqlx::Error::RowNotFound) => {
// create webhook
let webhook = channel
.create_webhook_with_avatar(&ctx, "Reminder", DEFAULT_AVATAR.clone())
.await
.map_err(|e| Error::Serenity(e))?;
// create database entry
sqlx::query!(
"INSERT INTO channels (
webhook_id,
webhook_token,
channel
) VALUES (?, ?, ?)",
webhook.id.0,
webhook.token,
channel.0
)
.execute(pool)
.await
.map_err(|e| Error::SQLx(e))?;
Ok(())
}
Err(e) => Err(Error::SQLx(e)),
}?;
let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0)
.fetch_one(pool)
.await
.map_err(|e| Error::SQLx(e))?;
Ok(row.id)
}
#[get("/")]
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
if cookies.get_private("userid").is_some() {
let map: HashMap<&str, String> = HashMap::new();
Ok(Template::render("dashboard", &map))
} else {
Err(Redirect::to("/login/discord"))
}
}
#[get("/<_>")]
pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
if cookies.get_private("userid").is_some() {
let map: HashMap<&str, String> = HashMap::new();
Ok(Template::render("dashboard", &map))
} else {
Err(Redirect::to("/login/discord"))
}
}

View File

@ -0,0 +1,165 @@
use std::env;
use chrono_tz::Tz;
use reqwest::Client;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
State,
};
use serde::{Deserialize, Serialize};
use serenity::{
client::Context,
model::{
id::{GuildId, RoleId},
permissions::Permissions,
},
};
use sqlx::{MySql, Pool};
use crate::consts::DISCORD_API;
#[derive(Serialize)]
struct UserInfo {
name: String,
patreon: bool,
timezone: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdateUser {
timezone: String,
}
#[derive(Serialize)]
struct GuildInfo {
id: String,
name: String,
}
#[derive(Deserialize)]
pub struct PartialGuild {
pub id: GuildId,
pub icon: Option<String>,
pub name: String,
#[serde(default)]
pub owner: bool,
#[serde(rename = "permissions_new")]
pub permissions: Option<String>,
}
#[get("/api/user")]
pub async fn get_user_info(
cookies: &CookieJar<'_>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
{
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
.member(&ctx.inner(), user_id)
.await;
let timezone = sqlx::query!("SELECT timezone FROM users WHERE user = ?", user_id)
.fetch_one(pool.inner())
.await
.map_or(None, |q| Some(q.timezone));
let user_info = UserInfo {
name: cookies
.get_private("username")
.map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()),
patreon: member_res.map_or(false, |member| {
member
.roles
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
}),
timezone,
};
json!(user_info)
} else {
json!({"error": "Not authorized"})
}
}
#[patch("/api/user", data = "<user>")]
pub async fn update_user_info(
cookies: &CookieJar<'_>,
user: Json<UpdateUser>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
{
if user.timezone.parse::<Tz>().is_ok() {
let _ = sqlx::query!(
"UPDATE users SET timezone = ? WHERE user = ?",
user.timezone,
user_id,
)
.execute(pool.inner())
.await;
json!({})
} else {
json!({"error": "Timezone not recognized"})
}
} else {
json!({"error": "Not authorized"})
}
}
#[get("/api/user/guilds")]
pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue {
if let Some(access_token) = cookies.get_private("access_token") {
let request_res = reqwest_client
.get(format!("{}/users/@me/guilds", DISCORD_API))
.bearer_auth(access_token.value())
.send()
.await;
match request_res {
Ok(response) => {
let guilds_res = response.json::<Vec<PartialGuild>>().await;
match guilds_res {
Ok(guilds) => {
let reduced_guilds = guilds
.iter()
.filter(|g| {
g.owner
|| g.permissions.as_ref().map_or(false, |p| {
let permissions =
Permissions::from_bits_truncate(p.parse().unwrap());
permissions.manage_messages()
|| permissions.manage_guild()
|| permissions.administrator()
})
})
.map(|g| GuildInfo { id: g.id.to_string(), name: g.name.to_string() })
.collect::<Vec<GuildInfo>>();
json!(reduced_guilds)
}
Err(e) => {
warn!("Error constructing user from request: {:?}", e);
json!({"error": "Could not get user details"})
}
}
}
Err(e) => {
warn!("Error getting user guilds: {:?}", e);
json!({"error": "Could not reach Discord"})
}
}
} else {
json!({"error": "Not authorized"})
}
}

149
web/src/routes/login.rs Normal file
View File

@ -0,0 +1,149 @@
use log::warn;
use oauth2::{
basic::BasicClient, reqwest::async_http_client, AuthorizationCode, CsrfToken,
PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse,
};
use reqwest::Client;
use rocket::{
http::{private::cookie::Expiration, Cookie, CookieJar, SameSite},
response::{Flash, Redirect},
uri, State,
};
use serenity::model::user::User;
use crate::consts::DISCORD_API;
#[get("/discord")]
pub async fn discord_login(
oauth2_client: &State<BasicClient>,
cookies: &CookieJar<'_>,
) -> Redirect {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_token) = oauth2_client
.authorize_url(CsrfToken::new_random)
// Set the desired scopes.
.add_scope(Scope::new("identify".to_string()))
.add_scope(Scope::new("guilds".to_string()))
.add_scope(Scope::new("email".to_string()))
// Set the PKCE code challenge.
.set_pkce_challenge(pkce_challenge)
.url();
// store the pkce secret to verify the authorization later
cookies.add_private(
Cookie::build("verify", pkce_verifier.secret().to_string())
.http_only(true)
.path("/login")
.same_site(SameSite::Lax)
.expires(Expiration::Session)
.finish(),
);
// store the csrf token to verify no interference
cookies.add_private(
Cookie::build("csrf", csrf_token.secret().to_string())
.http_only(true)
.path("/login")
.same_site(SameSite::Lax)
.expires(Expiration::Session)
.finish(),
);
Redirect::to(auth_url.to_string())
}
#[get("/discord/authorized?<code>&<state>")]
pub async fn discord_callback(
code: &str,
state: &str,
cookies: &CookieJar<'_>,
oauth2_client: &State<BasicClient>,
reqwest_client: &State<Client>,
) -> Result<Redirect, Flash<Redirect>> {
if let (Some(pkce_secret), Some(csrf_token)) =
(cookies.get_private("verify"), cookies.get_private("csrf"))
{
if state == csrf_token.value() {
let token_result = oauth2_client
.exchange_code(AuthorizationCode::new(code.to_string()))
// Set the PKCE code verifier.
.set_pkce_verifier(PkceCodeVerifier::new(pkce_secret.value().to_string()))
.request_async(async_http_client)
.await;
cookies.remove_private(Cookie::named("verify"));
cookies.remove_private(Cookie::named("csrf"));
match token_result {
Ok(token) => {
cookies.add_private(
Cookie::build("access_token", token.access_token().secret().to_string())
.secure(true)
.http_only(true)
.path("/dashboard")
.finish(),
);
let request_res = reqwest_client
.get(format!("{}/users/@me", DISCORD_API))
.bearer_auth(token.access_token().secret())
.send()
.await;
match request_res {
Ok(response) => {
let user_res = response.json::<User>().await;
match user_res {
Ok(user) => {
let user_name = format!("{}#{}", user.name, user.discriminator);
let user_id = user.id.as_u64().to_string();
cookies.add_private(Cookie::new("username", user_name));
cookies.add_private(Cookie::new("userid", user_id));
Ok(Redirect::to(uri!(super::return_to_same_site("dashboard"))))
}
Err(e) => {
warn!("Error constructing user from request: {:?}", e);
Err(Flash::new(
Redirect::to(uri!(super::return_to_same_site(""))),
"danger",
"Failed to contact Discord",
))
}
}
}
Err(e) => {
warn!("Error getting user info: {:?}", e);
Err(Flash::new(
Redirect::to(uri!(super::return_to_same_site(""))),
"danger",
"Failed to contact Discord",
))
}
}
}
Err(e) => {
warn!("Error in discord callback: {:?}", e);
Err(Flash::new(
Redirect::to(uri!(super::return_to_same_site(""))),
"warning",
"Your login request was rejected",
))
}
}
} else {
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "danger", "Your request failed to validate, and so has been rejected (error: CSRF Validation Failure)"))
}
} else {
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "warning", "Your request was missing information, and so has been rejected (error: CSRF Validation Tokens Missing)"))
}
}

88
web/src/routes/mod.rs Normal file
View File

@ -0,0 +1,88 @@
pub mod dashboard;
pub mod login;
use std::collections::HashMap;
use rocket::request::FlashMessage;
use rocket_dyn_templates::Template;
#[get("/")]
pub async fn index(flash: Option<FlashMessage<'_>>) -> Template {
let mut map: HashMap<&str, String> = HashMap::new();
if let Some(message) = flash {
map.insert("flashed_message", message.message().to_string());
map.insert("flashed_grade", message.kind().to_string());
}
Template::render("index", &map)
}
#[get("/ret?<to>")]
pub async fn return_to_same_site(to: &str) -> Template {
let mut map: HashMap<&str, String> = HashMap::new();
map.insert("to", to.to_string());
Template::render("return", &map)
}
#[get("/cookies")]
pub async fn cookies() -> Template {
let map: HashMap<&str, String> = HashMap::new();
Template::render("cookies", &map)
}
#[get("/privacy")]
pub async fn privacy() -> Template {
let map: HashMap<&str, String> = HashMap::new();
Template::render("privacy", &map)
}
#[get("/terms")]
pub async fn terms() -> Template {
let map: HashMap<&str, String> = HashMap::new();
Template::render("terms", &map)
}
#[get("/")]
pub async fn help() -> Template {
let map: HashMap<&str, String> = HashMap::new();
Template::render("help", &map)
}
#[get("/timezone")]
pub async fn help_timezone() -> Template {
let map: HashMap<&str, String> = HashMap::new();
Template::render("support/timezone", &map)
}
#[get("/create_reminder")]
pub async fn help_create_reminder() -> Template {
let map: HashMap<&str, String> = HashMap::new();
Template::render("support/create_reminder", &map)
}
#[get("/delete_reminder")]
pub async fn help_delete_reminder() -> Template {
let map: HashMap<&str, String> = HashMap::new();
Template::render("support/delete_reminder", &map)
}
#[get("/timers")]
pub async fn help_timers() -> Template {
let map: HashMap<&str, String> = HashMap::new();
Template::render("support/timers", &map)
}
#[get("/todo_lists")]
pub async fn help_todo_lists() -> Template {
let map: HashMap<&str, String> = HashMap::new();
Template::render("support/todo_lists", &map)
}
#[get("/macros")]
pub async fn help_macros() -> Template {
let map: HashMap<&str, String> = HashMap::new();
Template::render("support/macros", &map)
}

1
web/static/css/bulma.min.css vendored Normal file

File diff suppressed because one or more lines are too long

91
web/static/css/dtsel.css Normal file
View File

@ -0,0 +1,91 @@
.date-selector-wrapper {
width: 200px;
padding: 3px;
background-color: #fff;
box-shadow: 1px 1px 10px 1px #5c5c5c;
position: absolute;
font-size: 12px;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-o-user-select: none;
/* user-select: none; */
}
.cal-header, .cal-row {
display: flex;
width: 100%;
height: 30px;
line-height: 30px;
text-align: center;
}
.cal-cell, .cal-nav {
cursor: pointer;
}
.cal-day-names {
height: 25px;
line-height: 25px;
}
.cal-day-names .cal-cell {
cursor: default;
font-weight: bold;
}
.cal-cell-prev, .cal-cell-next {
color: #777;
}
.cal-months .cal-row, .cal-years .cal-row {
height: 60px;
line-height: 60px;
}
.cal-nav-prev, .cal-nav-next {
flex: 0.15;
}
.cal-nav-current {
flex: 0.75;
font-weight: bold;
}
.cal-months .cal-cell, .cal-years .cal-cell {
flex: 0.25;
}
.cal-days .cal-cell {
flex: 0.143;
}
.cal-value {
color: #fff;
background-color: #286090;
}
.cal-cell:hover, .cal-nav:hover {
background-color: #eee;
}
.cal-value:hover {
background-color: #204d74;
}
/* time footer */
.cal-time {
display: flex;
justify-content: flex-start;
height: 27px;
line-height: 27px;
}
.cal-time-label, .cal-time-value {
flex: 0.12;
text-align: center;
}
.cal-time-slider {
flex: 0.77;
background-image: linear-gradient(to right, #d1d8dd, #d1d8dd);
background-repeat: no-repeat;
background-size: 100% 1px;
background-position: left 50%;
height: 100%;
}
.cal-time-slider input {
width: 100%;
-webkit-appearance: none;
background: 0 0;
cursor: pointer;
height: 100%;
outline: 0;
user-select: auto;
}

12749
web/static/css/fa.css Normal file

File diff suppressed because it is too large Load Diff

63
web/static/css/font.css Normal file
View File

@ -0,0 +1,63 @@
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkids18E.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7nsDc.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCds18E.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwlxdr.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK3dSBYKcSV-LCoeQqfX1RYOo3qOK7g.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwlxdr.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlxdr.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCs6KVjbNBYlgo6eA.ttf) format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCv6KVjbNBYlgoCxCvTtw.ttf) format('truetype');
font-display: swap;
}

578
web/static/css/style.css Normal file
View File

@ -0,0 +1,578 @@
* {
font-family: "Ubuntu Bold", "Ubuntu", sans-serif;
}
button {
font-weight: 700;
}
/* override styles for when the div is collapsed */
div.reminderContent.is-collapsed .column.discord-frame {
display: none;
}
div.reminderContent.is-collapsed .collapses {
display: none;
}
div.reminderContent.is-collapsed .invert-collapses {
display: inline-flex;
}
div.reminderContent .invert-collapses {
display: none;
}
div.reminderContent.is-collapsed .settings {
display: flex;
flex-direction: row;
padding-bottom: 0;
}
div.reminderContent.is-collapsed .channel-field {
display: inline-flex;
order: 1;
}
div.reminderContent.is-collapsed .reminder-topbar {
display: inline-flex;
margin-bottom: 0px;
flex-grow: 1;
order: 2;
}
div.reminderContent.is-collapsed input[name="name"] {
display: inline-flex;
flex-grow: 1;
border: none;
font-weight: 700;
background: none;
}
div.reminderContent.is-collapsed button.hide-box {
display: inline-flex;
}
div.reminderContent.is-collapsed button.hide-box i {
transform: rotate(90deg);
}
/* END */
/* dashboard styles */
button.inline-btn {
height: 100%;
padding: 5px;
}
button.change-color {
position: absolute;
left: calc(-1rem - 40px);
}
button.disable-enable[data-action="enable"]:after {
content: "Enable";
}
button.disable-enable[data-action="disable"]:after {
content: "Disable";
}
.media-content {
overflow-x: visible;
}
div.discord-embed {
position: relative;
}
div.reminderContent {
padding: 2px;
background-color: #f5f5f5;
border-radius: 8px;
margin: 8px;
}
div.interval-group > button {
margin-left: auto;
}
/* Interval inputs */
div.interval-group > .interval-group-left input {
-webkit-appearance: none;
border-style: none;
background-color: #eee;
font-size: 1rem;
font-family: monospace;
}
div.interval-group > .interval-group-left input.w2 {
width: 3ch;
}
div.interval-group > .interval-group-left input.w3 {
width: 6ch;
}
div.interval-group {
display: flex;
flex-direction: row;
}
/* !Interval inputs */
.left-pad {
padding-left: 1rem;
padding-right: 0.2rem;
}
.notification {
padding-right: 1.5rem;
}
div.inset-content {
margin-left: 10%;
margin-right: 10%;
}
div.flash-message {
position: fixed;
width: calc(100% - 32px);
margin: 16px !important;
z-index: 99;
bottom: 0;
display: none;
}
div.flash-message.is-active {
display: block;
}
body {
min-height: 100vh;
}
span.spacer {
width: 10px;
}
nav .dashboard-button {
background: white ;
}
span.patreon-color {
color: #f96854;
}
p.pageTitle {
margin-left: 12px;
}
#welcome > div {
height: 100%;
padding-top: 30vh;
}
div#pageNavbar {
background-color: #363636;
}
div#pageNavbar a {
color: #fff;
text-align: center;
}
div#pageNavbar a:hover {
background-color: #4a4a4a;
}
img.rounded-corners {
border-radius: 12px;
}
div.brand {
text-align: center;
height: 52px;
background-color: #8fb677;
}
img.dashboard-brand {
text-align: center;
height: 100%;
width: auto;
}
div.dashboard-sidebar {
background-color: #363636;
width: 230px !important;
padding-right: 0;
}
div.dashboard-sidebar:not(.mobile-sidebar) {
display: flex;
flex-direction: column;
}
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
position: fixed;
bottom: 0;
width: 226px;
}
div.mobile-sidebar {
z-index: 100;
min-height: 100vh;
position: absolute;
top: 0;
display: none;
flex-direction: column;
}
#expandAll {
width: 60px;
}
div.mobile-sidebar .aside-footer {
margin-top: auto;
}
div.mobile-sidebar.is-active {
display: flex;
}
aside.menu {
display: flex;
flex-direction: column;
flex-grow: 1;
}
div.dashboard-frame {
min-height: 100vh;
margin-bottom: 0 !important;
}
.embed-field-box[data-inlined="0"] .inline-btn > i {
transform: rotate(90deg);
}
.embed-field-box[data-inlined="0"] {
min-width: 100%;
}
.embed-field-box[data-inlined="1"] {
min-width: auto;
}
.menu a {
color: #fff;
}
.menu .menu-label {
color: #bbb;
}
.menu {
padding-left: 4px;
}
.dashboard-navbar {
background-color: #8fb677 !important;
position: absolute;
top: 0;
width: 100%;
}
textarea.autoresize {
resize: none;
}
textarea, input {
width: 100%;
}
.message-input:placeholder-shown {
border-top: none;
border-left: none;
border-right: none;
border-bottom-style: dashed;
background-color: #40444b;
color: #fff;
}
.message-input {
border: none;
background-color: rgba(0, 0, 0, 0);
color: #fff;
}
.time-input {
border-top: none;
border-left: none;
border-right: none;
border-bottom-style: solid;
background-color: #40444b;
color: #fff;
width: 120px;
font-size: 0.875rem;
}
.message-input::placeholder {
color: #72767b;
}
.discord-title {
font-weight: bold;
font-size: 1rem;
margin: 4px 0 4px 0;
}
.discord-description {
font-size: 0.875rem;
}
.discord-username {
font-size: 1rem;
font-weight: bold;
margin-bottom: 4px;
width: initial;
}
.discord-message-header {
white-space: nowrap;
margin-bottom: 8px;
}
.discord-content {
margin-bottom: 8px;
}
.customizable img {
background-color: #72767b;
border-radius: 8px;
}
.customizable.is-20x20 img {
width: 20px;
height: 20px;
}
.customizable.is-24x24 img {
width: 24px;
height: 24px;
}
.customizable.is-400x300 img {
margin-top: 10px;
width: 100%;
min-height: 100px;
max-height: 400px;
}
.customizable.is-32x32 img {
width: 32px;
height: 32px;
}
.customizable.thumbnail img {
width: 100px;
height: 100px;
}
.customizable input.imageInput {
display: none;
position: absolute;
top: 0;
left: 36px;
width: 400px;
}
.customizable.thumbnail input.imageInput {
display: none;
position: absolute;
top: 0;
left: -400px;
width: 400px;
}
.customizable input.is-active {
display: block !important;
}
.discord-frame {
color: #fff;
padding: 10px;
border-radius: 8px;
background-color: #36393f;
}
.discord-embed {
padding: 8px 16px 16px 12px;
margin: 0 20px 4px 0;
border-radius: 4px;
border-left: 4px solid #fff;
background-color: #2f3136;
}
.embed-author-box {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.embed-author-box > .a {
flex: initial;
}
.embed-author-box > .b {
flex: auto;
}
.embed-footer-box {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.embed-author-box .image {
margin: 0 8px 0 0 !important;
}
.embed-footer-box .image {
margin: 0 8px 0 0 !important;
}
.discord-embed-author {
display: inline-block;
font-size: 0.875rem;
font-weight: bold;
}
.discord-embed-footer {
font-size: 0.75rem;
}
.embed-body {
display: flex;
}
.embed-body > .a {
flex-grow: 1;
flex-shrink: 1;
flex-basis: auto;
}
.embed-body input, .embed-body textarea {
min-width: 0;
}
.embed-body > .b {
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
}
.discord-field-title, .discord-field-value {
max-width: 120px;
}
.discord-field-title {
font-weight: bold;
}
.embed-field-box {
margin: 12px 8px 0 0;
max-width: 120px;
flex: initial;
}
.field-input {
font-size: 0.875rem;
width: 120px;
}
.embed-multifield-box {
display: flex;
max-width: 100%;
flex-wrap: wrap;
}
.channel-select {
font-size: 1.125rem;
margin-bottom: 4px;
margin-left: 48px;
display: inline-flex;
font-weight: bold;
color: #6e89da;
width: auto;
border-radius: 2px;
border-bottom: 1px solid #fff;
}
@media only screen and (max-width: 768px) {
.customizable.thumbnail img {
width: 60px;
height: 60px;
}
.customizable.is-24x24 img {
width: 16px;
height: 16px;
}
}
/* loader */
#loader {
position: fixed;
background-color: rgba(255, 255, 255, 0.8);
width: 100vw;
z-index: 999;
}
#loader .title {
font-size: 6rem;
}
/* END */
/* other stuff */
.half-rem {
width: 0.5rem;
}
.pad-left {
width: 12px;
}
#dead {
display: none;
}
.colorpicker-container {
display: flex;
justify-content: center;
}
.create-reminder {
margin: 0 12px 12px 12px;
}
.button.is-success:not(.is-outlined) {
color: white;
}
.button.is-outlined.is-success {
background-color: white;
}
.is-locked {
pointer-events: none;
opacity: 0.4;
}
.is-locked .foreground {
pointer-events: auto;
}
.is-locked .field:last-of-type {
display: none;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

BIN
web/static/img/bg.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

BIN
web/static/img/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

931
web/static/js/dtsel.js Normal file
View File

@ -0,0 +1,931 @@
(function () {
"use strict";
var BODYTYPES = ["DAYS", "MONTHS", "YEARS"];
var MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
var WEEKDAYS = [
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
];
/** @typedef {Object.<string, Function[]>} Handlers */
/** @typedef {function(String, Function): null} AddHandler */
/** @typedef {("DAYS"|"MONTHS"|"YEARS")} BodyType */
/** @typedef {string|number} StringNum */
/** @typedef {Object.<string, StringNum>} StringNumObj */
/**
* The local state
* @typedef {Object} InstanceState
* @property {Date} value
* @property {Number} year
* @property {Number} month
* @property {Number} day
* @property {Number} time
* @property {Number} hours
* @property {Number} minutes
* @property {Number} seconds
* @property {BodyType} bodyType
* @property {Boolean} visible
* @property {Number} cancelBlur
*/
/**
* @typedef {Object} Config
* @property {String} dateFormat
* @property {String} timeFormat
* @property {Boolean} showDate
* @property {Boolean} showTime
* @property {Number} paddingX
* @property {Number} paddingY
* @property {BodyType} defaultView
* @property {"TOP"|"BOTTOM"} direction
*/
/**
* @class
* @param {HTMLElement} elem
* @param {Config} config
*/
function DTS(elem, config) {
var config = config || {};
/** @type {Config} */
var defaultConfig = {
defaultView: BODYTYPES[0],
dateFormat: "yyyy-mm-dd",
timeFormat: "HH:MM:SS",
showDate: true,
showTime: false,
paddingX: 5,
paddingY: 5,
direction: 'TOP'
}
if (!elem) {
throw TypeError("input element or selector required for contructor");
}
if (Object.getPrototypeOf(elem) === String.prototype) {
var _elem = document.querySelectorAll(elem);
if (!_elem[0]){
throw Error('"' + elem + '" not found.');
}
elem = _elem[0];
}
this.config = setDefaults(config, defaultConfig);
this.dateFormat = this.config.dateFormat;
this.timeFormat = this.config.timeFormat;
this.dateFormatRegEx = new RegExp("yyyy|yy|mm|dd", "gi");
this.timeFormatRegEx = new RegExp("hh|mm|ss|a", "gi");
this.inputElem = elem;
this.dtbox = null;
this.setup();
}
DTS.prototype.setup = function () {
var handler = this.inputElemHandler.bind(this);
this.inputElem.addEventListener("focus", handler, false)
this.inputElem.addEventListener("blur", handler, false);
}
DTS.prototype.inputElemHandler = function (e) {
if (e.type == "focus") {
if (!this.dtbox) {
this.dtbox = new DTBox(e.target, this);
}
this.dtbox.visible = true;
} else if (e.type == "blur" && this.dtbox && this.dtbox.visible) {
var self = this;
setTimeout(function () {
if (self.dtbox.cancelBlur > 0) {
self.dtbox.cancelBlur -= 1;
} else {
self.dtbox.visible = false;
self.inputElem.blur();
}
}, 100);
}
}
/**
* @class
* @param {HTMLElement} elem
* @param {DTS} settings
*/
function DTBox(elem, settings) {
/** @type {DTBox} */
var self = this;
/** @type {Handlers} */
var handlers = {};
/** @type {InstanceState} */
var localState = {};
/**
* @param {String} key
* @param {*} default_val
*/
function getterSetter(key, default_val) {
return {
get: function () {
var val = localState[key];
return val === undefined ? default_val : val;
},
set: function (val) {
var prevState = self.state;
var _handlers = handlers[key] || [];
localState[key] = val;
for (var i = 0; i < _handlers.length; i++) {
_handlers[i].bind(self)(localState, prevState);
}
},
};
};
/** @type {AddHandler} */
function addHandler(key, handlerFn) {
if (!key || !handlerFn) {
return false;
}
if (!handlers[key]) {
handlers[key] = [];
}
handlers[key].push(handlerFn);
}
Object.defineProperties(this, {
visible: getterSetter("visible", false),
bodyType: getterSetter("bodyType", settings.config.defaultView),
value: getterSetter("value"),
year: getterSetter("year", 0),
month: getterSetter("month", 0),
day: getterSetter("day", 0),
hours: getterSetter("hours", 0),
minutes: getterSetter("minutes", 0),
seconds: getterSetter("seconds", 0),
cancelBlur: getterSetter("cancelBlur", 0),
addHandler: {value: addHandler},
month_long: {
get: function () {
return MONTHS[self.month];
},
},
month_short: {
get: function () {
return self.month_long.slice(0, 3);
},
},
state: {
get: function () {
return Object.assign({}, localState);
},
},
time: {
get: function() {
var hours = self.hours * 60 * 60 * 1000;
var minutes = self.minutes * 60 * 1000;
var seconds = self.seconds * 1000;
return hours + minutes + seconds;
}
},
});
this.el = {};
this.settings = settings;
this.elem = elem;
this.setup();
}
DTBox.prototype.setup = function () {
Object.defineProperties(this.el, {
wrapper: { value: null, configurable: true },
header: { value: null, configurable: true },
body: { value: null, configurable: true },
footer: { value: null, configurable: true }
});
this.setupWrapper();
if (this.settings.config.showDate) {
this.setupHeader();
this.setupBody();
}
if (this.settings.config.showTime) {
this.setupFooter();
}
var self = this;
this.addHandler("visible", function (state, prevState) {
if (state.visible && !prevState.visible){
document.body.appendChild(this.el.wrapper);
var parts = self.elem.value.split(/\s*,\s*/);
var startDate = undefined;
var startTime = 0;
if (self.settings.config.showDate) {
startDate = parseDate(parts[0], self.settings);
}
if (self.settings.config.showTime) {
startTime = parseTime(parts[parts.length-1], self.settings);
startTime = startTime || 0;
}
if (!(startDate && startDate.getTime())) {
startDate = new Date();
startDate = new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate()
);
}
var value = new Date(startDate.getTime() + startTime);
self.value = value;
self.year = value.getFullYear();
self.month = value.getMonth();
self.day = value.getDate();
self.hours = value.getHours();
self.minutes = value.getMinutes();
self.seconds = value.getSeconds();
if (self.settings.config.showDate) {
self.setHeaderContent();
self.setBodyContent();
}
if (self.settings.config.showTime) {
self.setFooterContent();
}
} else if (!state.visible && prevState.visible) {
document.body.removeChild(this.el.wrapper);
}
});
}
DTBox.prototype.setupWrapper = function () {
if (!this.el.wrapper) {
var el = document.createElement("div");
el.classList.add("date-selector-wrapper");
Object.defineProperty(this.el, "wrapper", { value: el });
}
var self = this;
var htmlRoot = document.getElementsByTagName('html')[0];
function setPosition(e){
var minTopSpace = 300;
var box = getOffset(self.elem);
var config = self.settings.config;
var paddingY = config.paddingY || 5;
var paddingX = config.paddingX || 5;
var top = box.top + self.elem.offsetHeight + paddingY;
var left = box.left + paddingX;
var bottom = htmlRoot.clientHeight - box.top + paddingY;
self.el.wrapper.style.left = `${left}px`;
if (box.top > minTopSpace && config.direction != 'BOTTOM') {
self.el.wrapper.style.bottom = `${bottom}px`;
self.el.wrapper.style.top = '';
} else {
self.el.wrapper.style.top = `${top}px`;
self.el.wrapper.style.bottom = '';
}
}
function handler(e) {
self.cancelBlur += 1;
setTimeout(function(){
self.elem.focus();
}, 50);
}
setPosition();
this.setPosition = setPosition;
this.el.wrapper.addEventListener("mousedown", handler, false);
this.el.wrapper.addEventListener("touchstart", handler, false);
window.addEventListener('resize', this.setPosition);
}
DTBox.prototype.setupHeader = function () {
if (!this.el.header) {
var row = document.createElement("div");
var classes = ["cal-nav-prev", "cal-nav-current", "cal-nav-next"];
row.classList.add("cal-header");
for (var i = 0; i < 3; i++) {
var cell = document.createElement("div");
cell.classList.add("cal-nav", classes[i]);
cell.onclick = this.onHeaderChange.bind(this);
row.appendChild(cell);
}
row.children[0].innerHTML = "&lt;";
row.children[2].innerHTML = "&gt;";
Object.defineProperty(this.el, "header", { value: row });
tryAppendChild(row, this.el.wrapper);
}
this.setHeaderContent();
}
DTBox.prototype.setHeaderContent = function () {
var content = this.year;
if ("DAYS" == this.bodyType) {
content = this.month_long + " " + content;
} else if ("YEARS" == this.bodyType) {
var start = this.year + 10 - (this.year % 10);
content = start - 10 + "-" + (start - 1);
}
this.el.header.children[1].innerText = content;
}
DTBox.prototype.setupBody = function () {
if (!this.el.body) {
var el = document.createElement("div");
el.classList.add("cal-body");
Object.defineProperty(this.el, "body", { value: el });
tryAppendChild(el, this.el.wrapper);
}
var toAppend = null;
function makeGrid(rows, cols, className, firstRowClass, clickHandler) {
var grid = document.createElement("div");
grid.classList.add(className);
for (var i = 1; i < rows + 1; i++) {
var row = document.createElement("div");
row.classList.add("cal-row", "cal-row-" + i);
if (i == 1 && firstRowClass) {
row.classList.add(firstRowClass);
}
for (var j = 1; j < cols + 1; j++) {
var col = document.createElement("div");
col.classList.add("cal-cell", "cal-col-" + j);
col.onclick = clickHandler;
row.appendChild(col);
}
grid.appendChild(row);
}
return grid;
}
if ("DAYS" == this.bodyType) {
toAppend = this.el.body.calDays;
if (!toAppend) {
toAppend = makeGrid(7, 7, "cal-days", "cal-day-names", this.onDateSelected.bind(this));
for (var i = 0; i < 7; i++) {
var cell = toAppend.children[0].children[i];
cell.innerText = WEEKDAYS[i].slice(0, 2);
cell.onclick = null;
}
this.el.body.calDays = toAppend;
}
} else if ("MONTHS" == this.bodyType) {
toAppend = this.el.body.calMonths;
if (!toAppend) {
toAppend = makeGrid(3, 4, "cal-months", null, this.onMonthSelected.bind(this));
for (var i = 0; i < 3; i++) {
for (var j = 0; j < 4; j++) {
var monthShort = MONTHS[4 * i + j].slice(0, 3);
toAppend.children[i].children[j].innerText = monthShort;
}
}
this.el.body.calMonths = toAppend;
}
} else if ("YEARS" == this.bodyType) {
toAppend = this.el.body.calYears;
if (!toAppend) {
toAppend = makeGrid(3, 4, "cal-years", null, this.onYearSelected.bind(this));
this.el.body.calYears = toAppend;
}
}
empty(this.el.body);
tryAppendChild(toAppend, this.el.body);
this.setBodyContent();
}
DTBox.prototype.setBodyContent = function () {
var grid = this.el.body.children[0];
var classes = ["cal-cell-prev", "cal-cell-next", "cal-value"];
if ("DAYS" == this.bodyType) {
var oneDayMilliSecs = 24 * 60 * 60 * 1000;
var start = new Date(this.year, this.month, 1);
var adjusted = new Date(start.getTime() - oneDayMilliSecs * start.getDay());
grid.children[6].style.display = "";
for (var i = 1; i < 7; i++) {
for (var j = 0; j < 7; j++) {
var cell = grid.children[i].children[j];
var month = adjusted.getMonth();
var date = adjusted.getDate();
cell.innerText = date;
cell.classList.remove(classes[0], classes[1], classes[2]);
if (month != this.month) {
if (i == 6 && j == 0) {
grid.children[6].style.display = "none";
break;
}
cell.classList.add(month < this.month ? classes[0] : classes[1]);
} else if (isEqualDate(adjusted, this.value)){
cell.classList.add(classes[2]);
}
adjusted = new Date(adjusted.getTime() + oneDayMilliSecs);
}
}
} else if ("YEARS" == this.bodyType) {
var year = this.year - (this.year % 10) - 1;
for (i = 0; i < 3; i++) {
for (j = 0; j < 4; j++) {
grid.children[i].children[j].innerText = year;
year += 1;
}
}
grid.children[0].children[0].classList.add(classes[0]);
grid.children[2].children[3].classList.add(classes[1]);
}
}
/** @param {Event} e */
DTBox.prototype.onTimeChange = function(e) {
e.stopPropagation();
if (e.type == 'mousedown') {
this.cancelBlur += 1;
return;
}
var el = e.target;
this[el.name] = parseInt(el.value) || 0;
this.setupFooter();
if (e.type == 'change') {
var self = this;
setTimeout(function(){
self.elem.focus();
}, 50);
}
this.setInputValue();
}
DTBox.prototype.setupFooter = function() {
if (!this.el.footer) {
var footer = document.createElement("div");
var handler = this.onTimeChange.bind(this);
var self = this;
function makeRow(label, name, range, changeHandler) {
var row = document.createElement("div");
row.classList.add('cal-time');
var labelCol = row.appendChild(document.createElement("div"));
labelCol.classList.add('cal-time-label');
labelCol.innerText = label;
var valueCol = row.appendChild(document.createElement("div"));
valueCol.classList.add('cal-time-value');
valueCol.innerText = '00';
var inputCol = row.appendChild(document.createElement("div"));
var slider = inputCol.appendChild(document.createElement("input"));
Object.assign(slider, {step:1, min:0, max:range, name:name, type:'range'});
Object.defineProperty(footer, name, {value: slider});
inputCol.classList.add('cal-time-slider');
slider.onchange = changeHandler;
slider.oninput = changeHandler;
slider.onmousedown = changeHandler;
self[name] = self[name] || parseInt(slider.value) || 0;
footer.appendChild(row)
}
makeRow('HH:', 'hours', 23, handler);
makeRow('MM:', 'minutes', 59, handler);
makeRow('SS:', 'seconds', 59, handler);
footer.classList.add("cal-footer");
Object.defineProperty(this.el, "footer", { value: footer });
tryAppendChild(footer, this.el.wrapper);
}
this.setFooterContent();
}
DTBox.prototype.setFooterContent = function() {
if (this.el.footer) {
var footer = this.el.footer;
footer.hours.value = this.hours;
footer.children[0].children[1].innerText = padded(this.hours, 2);
footer.minutes.value = this.minutes;
footer.children[1].children[1].innerText = padded(this.minutes, 2);
footer.seconds.value = this.seconds;
footer.children[2].children[1].innerText = padded(this.seconds, 2);
}
}
DTBox.prototype.setInputValue = function() {
var date = new Date(this.year, this.month, this.day);
var strings = [];
if (this.settings.config.showDate) {
strings.push(renderDate(date, this.settings));
}
if (this.settings.config.showTime) {
var joined = new Date(date.getTime() + this.time);
strings.push(renderTime(joined, this.settings));
}
this.elem.value = strings.join(', ');
}
DTBox.prototype.onDateSelected = function (e) {
var row = e.target.parentNode;
var date = parseInt(e.target.innerText);
if (!(row.nextSibling && row.nextSibling.nextSibling) && date < 8) {
this.month += 1;
} else if (!(row.previousSibling && row.previousSibling.previousSibling) && date > 7) {
this.month -= 1;
}
this.day = parseInt(e.target.innerText);
this.value = new Date(this.year, this.month, this.day);
this.setInputValue();
this.setHeaderContent();
this.setBodyContent();
}
/** @param {Event} e */
DTBox.prototype.onMonthSelected = function (e) {
var col = 0;
var row = 2;
var cell = e.target;
if (cell.parentNode.nextSibling){
row = cell.parentNode.previousSibling ? 1: 0;
}
if (cell.previousSibling) {
col = 3;
if (cell.nextSibling) {
col = cell.previousSibling.previousSibling ? 2 : 1;
}
}
this.month = 4 * row + col;
this.bodyType = "DAYS";
this.setHeaderContent();
this.setupBody();
}
/** @param {Event} e */
DTBox.prototype.onYearSelected = function (e) {
this.year = parseInt(e.target.innerText);
this.bodyType = "MONTHS";
this.setHeaderContent();
this.setupBody();
}
/** @param {Event} e */
DTBox.prototype.onHeaderChange = function (e) {
var cell = e.target;
if (cell.previousSibling && cell.nextSibling) {
var idx = BODYTYPES.indexOf(this.bodyType);
if (idx < 0 || !BODYTYPES[idx + 1]) {
return;
}
this.bodyType = BODYTYPES[idx + 1];
this.setupBody();
} else {
var sign = cell.previousSibling ? 1 : -1;
switch (this.bodyType) {
case "DAYS":
this.month += sign * 1;
break;
case "MONTHS":
this.year += sign * 1;
break;
case "YEARS":
this.year += sign * 10;
}
if (this.month > 11 || this.month < 0) {
this.year += Math.floor(this.month / 11);
this.month = this.month > 11 ? 0 : 11;
}
}
this.setHeaderContent();
this.setBodyContent();
}
/**
* @param {HTMLElement} elem
* @returns {{left:number, top:number}}
*/
function getOffset(elem) {
var box = elem.getBoundingClientRect();
var left = window.pageXOffset !== undefined ? window.pageXOffset :
(document.documentElement || document.body.parentNode || document.body).scrollLeft;
var top = window.pageYOffset !== undefined ? window.pageYOffset :
(document.documentElement || document.body.parentNode || document.body).scrollTop;
return { left: box.left + left, top: box.top + top };
}
function empty(e) {
for (; e.children.length; ) e.removeChild(e.children[0]);
}
function tryAppendChild(newChild, refNode) {
try {
refNode.appendChild(newChild);
return newChild;
} catch (e) {
console.trace(e);
}
}
/** @class */
function hookFuncs() {
/** @type {Handlers} */
this._funcs = {};
}
/**
* @param {string} key
* @param {Function} func
*/
hookFuncs.prototype.add = function(key, func){
if (!this._funcs[key]){
this._funcs[key] = [];
}
this._funcs[key].push(func)
}
/**
* @param {String} key
* @returns {Function[]} handlers
*/
hookFuncs.prototype.get = function(key){
return this._funcs[key] ? this._funcs[key] : [];
}
/**
* @param {Array.<string>} arr
* @param {String} string
* @returns {Array.<string>} sorted string
*/
function sortByStringIndex(arr, string) {
return arr.sort(function(a, b){
var h = string.indexOf(a);
var l = string.indexOf(b);
var rank = 0;
if (h < l) {
rank = -1;
} else if (l < h) {
rank = 1;
} else if (a.length > b.length) {
rank = -1;
} else if (b.length > a.length) {
rank = 1;
}
return rank;
});
}
/**
* Remove keys from array that are not in format
* @param {string[]} keys
* @param {string} format
* @returns {string[]} new filtered array
*/
function filterFormatKeys(keys, format) {
var out = [];
var formatIdx = 0;
for (var i = 0; i<keys.length; i++) {
var key = keys[i];
if (format.slice(formatIdx).indexOf(key) > -1) {
formatIdx += key.length;
out.push(key);
}
}
return out;
}
/**
* @template {StringNumObj} FormatObj
* @param {string} value
* @param {string} format
* @param {FormatObj} formatObj
* @param {function(Object.<string, hookFuncs>): null} setHooks
* @returns {FormatObj} formatObj
*/
function parseData(value, format, formatObj, setHooks) {
var hooks = {
canSkip: new hookFuncs(),
updateValue: new hookFuncs(),
}
var keys = sortByStringIndex(Object.keys(formatObj), format);
var filterdKeys = filterFormatKeys(keys, format);
var vstart = 0; // value start
if (setHooks) {
setHooks(hooks);
}
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var fstart = format.indexOf(key);
var _vstart = vstart; // next value start
var val = null;
var canSkip = false;
var funcs = hooks.canSkip.get(key);
vstart = vstart || fstart;
for (var j = 0; j < funcs.length; j++) {
if (funcs[j](formatObj)){
canSkip = true;
break;
}
}
if (fstart > -1 && !canSkip) {
var sep = null;
var stop = vstart + key.length;
var fnext = -1;
var nextKeyIdx = i + 1;
_vstart += key.length; // set next value start if current key is found
// get next format token used to determine separator
while (fnext == -1 && nextKeyIdx < keys.length){
var nextKey = keys[nextKeyIdx];
nextKeyIdx += 1;
if (filterdKeys.indexOf(nextKey) === -1) {
continue;
}
fnext = nextKey ? format.indexOf(nextKey) : -1; // next format start
}
if (fnext > -1){
sep = format.slice(stop, fnext);
if (sep) {
var _stop = value.slice(vstart).indexOf(sep);
if (_stop && _stop > -1){
stop = _stop + vstart;
_vstart = stop + sep.length;
}
}
}
val = parseInt(value.slice(vstart, stop));
var funcs = hooks.updateValue.get(key);
for (var k = 0; k < funcs.length; k++) {
val = funcs[k](val, formatObj, vstart, stop);
}
}
formatObj[key] = { index: vstart, value: val };
vstart = _vstart; // set next value start
}
return formatObj;
}
/**
* @param {String} value
* @param {DTS} settings
* @returns {Date} date object
*/
function parseDate(value, settings) {
/** @type {{yyyy:number=, yy:number=, mm:number=, dd:number=}} */
var formatObj = {yyyy:null, yy:null, mm:null, dd:null};
var format = ((settings.dateFormat) || '').toLowerCase();
if (!format) {
throw new TypeError('dateFormat not found (' + settings.dateFormat + ')');
}
var formatObj = parseData(value, format, formatObj, function(hooks){
hooks.canSkip.add("yy", function(data){
return data["yyyy"].value;
});
hooks.updateValue.add("yy", function(val){
return 100 * Math.floor(new Date().getFullYear() / 100) + val;
});
});
var year = formatObj["yyyy"].value || formatObj["yy"].value;
var month = formatObj["mm"].value - 1;
var date = formatObj["dd"].value;
var result = new Date(year, month, date);
return result;
}
/**
* @param {String} value
* @param {DTS} settings
* @returns {Number} time in milliseconds <= (24 * 60 * 60 * 1000) - 1
*/
function parseTime(value, settings) {
var format = ((settings.timeFormat) || '').toLowerCase();
if (!format) {
throw new TypeError('timeFormat not found (' + settings.timeFormat + ')');
}
/** @type {{hh:number=, mm:number=, ss:number=, a:string=}} */
var formatObj = {hh:null, mm:null, ss:null, a:null};
var formatObj = parseData(value, format, formatObj, function(hooks){
hooks.updateValue.add("a", function(val, data, start, stop){
return value.slice(start, start + 2);
});
});
var hours = formatObj["hh"].value;
var minutes = formatObj["mm"].value;
var seconds = formatObj["ss"].value;
var am_pm = formatObj["a"].value;
var am_pm_lower = am_pm ? am_pm.toLowerCase() : am_pm;
if (am_pm && ["am", "pm"].indexOf(am_pm_lower) > -1){
if (am_pm_lower == 'am' && hours == 12){
hours = 0;
} else if (am_pm_lower == 'pm') {
hours += 12;
}
}
var time = hours * 60 * 60 * 1000 + minutes * 60 * 1000 + seconds * 1000;
return time;
}
/**
* @param {Date} value
* @param {DTS} settings
* @returns {String} date string
*/
function renderDate(value, settings) {
var format = settings.dateFormat.toLowerCase();
var date = value.getDate();
var month = value.getMonth() + 1;
var year = value.getFullYear();
var yearShort = year % 100;
var formatObj = {
dd: date < 10 ? "0" + date : date,
mm: month < 10 ? "0" + month : month,
yyyy: year,
yy: yearShort < 10 ? "0" + yearShort : yearShort
};
var str = format.replace(settings.dateFormatRegEx, function (found) {
return formatObj[found];
});
return str;
}
/**
* @param {Date} value
* @param {DTS} settings
* @returns {String} date string
*/
function renderTime(value, settings) {
var Format = settings.timeFormat;
var format = Format.toLowerCase();
var hours = value.getHours();
var minutes = value.getMinutes();
var seconds = value.getSeconds();
var am_pm = null;
var hh_am_pm = null;
if (format.indexOf('a') > -1) {
am_pm = hours >= 12 ? 'pm' : 'am';
am_pm = Format.indexOf('A') > -1 ? am_pm.toUpperCase() : am_pm;
hh_am_pm = hours == 0 ? '12' : (hours > 12 ? hours%12 : hours);
}
var formatObj = {
hh: am_pm ? hh_am_pm : (hours < 10 ? "0" + hours : hours),
mm: minutes < 10 ? "0" + minutes : minutes,
ss: seconds < 10 ? "0" + seconds : seconds,
a: am_pm,
};
var str = format.replace(settings.timeFormatRegEx, function (found) {
return formatObj[found];
});
return str;
}
/**
* checks if two dates are equal
* @param {Date} date1
* @param {Date} date2
* @returns {Boolean} true or false
*/
function isEqualDate(date1, date2) {
if (!(date1 && date2)) return false;
return (date1.getFullYear() == date2.getFullYear() &&
date1.getMonth() == date2.getMonth() &&
date1.getDate() == date2.getDate());
}
/**
* @param {Number} val
* @param {Number} pad
* @param {*} default_val
* @returns {String} padded string
*/
function padded(val, pad, default_val) {
var default_val = default_val || 0;
var valStr = '' + (parseInt(val) || default_val);
var diff = Math.max(pad, valStr.length) - valStr.length;
return ('' + default_val).repeat(diff) + valStr;
}
/**
* @template X
* @template Y
* @param {X} obj
* @param {Y} objDefaults
* @returns {X|Y} merged object
*/
function setDefaults(obj, objDefaults) {
var keys = Object.keys(objDefaults);
for (var i=0; i<keys.length; i++) {
var key = keys[i];
if (!Object.prototype.hasOwnProperty.call(obj, key)) {
obj[key] = objDefaults[key];
}
}
return obj;
}
window.dtsel = Object.create({},{
DTS: { value: DTS },
DTObj: { value: DTBox },
fn: {
value: Object.defineProperties({}, {
empty: { value: empty },
appendAfter: {
value: function (newElem, refNode) {
refNode.parentNode.insertBefore(newElem, refNode.nextSibling);
},
},
getOffset: { value: getOffset },
parseDate: { value: parseDate },
renderDate: { value: renderDate },
parseTime: {value: parseTime},
renderTime: {value: renderTime},
setDefaults: {value: setDefaults},
}),
},
});
})();

23
web/static/js/expand.js Normal file
View File

@ -0,0 +1,23 @@
function collapse_all() {
document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => {
el.classList.add("is-collapsed");
});
}
function expand_all() {
document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => {
el.classList.remove("is-collapsed");
});
}
const expandAll = document.querySelector("#expandAll");
expandAll.addEventListener("change", (ev) => {
if (ev.target.value === "expand") {
expand_all();
} else if (ev.target.value === "collapse") {
collapse_all();
}
ev.target.value = "";
});

88
web/static/js/interval.js Normal file
View File

@ -0,0 +1,88 @@
function get_interval(element) {
let months = element.querySelector('input[name="interval_months"]').value;
let days = element.querySelector('input[name="interval_days"]').value;
let hours = element.querySelector('input[name="interval_hours"]').value;
let minutes = element.querySelector('input[name="interval_minutes"]').value;
let seconds = element.querySelector('input[name="interval_seconds"]').value;
return {
months: parseInt(months) || null,
seconds:
(parseInt(days) || 0) * 86400 +
(parseInt(hours) || 0) * 3600 +
(parseInt(minutes) || 0) * 60 +
(parseInt(seconds) || 0) || null,
};
}
function update_interval(element) {
let months = element.querySelector('input[name="interval_months"]');
let days = element.querySelector('input[name="interval_days"]');
let hours = element.querySelector('input[name="interval_hours"]');
let minutes = element.querySelector('input[name="interval_minutes"]');
let seconds = element.querySelector('input[name="interval_seconds"]');
months.value = months.value.padStart(1, "0");
days.value = days.value.padStart(1, "0");
hours.value = hours.value.padStart(2, "0");
minutes.value = minutes.value.padStart(2, "0");
seconds.value = seconds.value.padStart(2, "0");
if (seconds.value >= 60) {
let quotient = Math.floor(seconds.value / 60);
let remainder = seconds.value % 60;
seconds.value = String(remainder).padStart(2, "0");
minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(2, "0");
}
if (minutes.value >= 60) {
let quotient = Math.floor(minutes.value / 60);
let remainder = minutes.value % 60;
minutes.value = String(remainder).padStart(2, "0");
hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0");
}
if (hours.value >= 24) {
let quotient = Math.floor(hours.value / 24);
let remainder = hours.value % 24;
hours.value = String(remainder).padStart(2, "0");
days.value = Number(days.value) + Number(quotient);
}
}
const $intervalGroup = document.querySelector(".interval-group");
document.querySelector(".interval-group").addEventListener(
"blur",
(ev) => {
if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup);
},
true
);
$intervalGroup.querySelector("button.clear").addEventListener("click", () => {
$intervalGroup.querySelectorAll("input").forEach((el) => {
el.value = "";
});
});
document.addEventListener("remindersLoaded", (event) => {
for (reminder of event.detail) {
let $intervalGroup = reminder.node.querySelector(".interval-group");
$intervalGroup.addEventListener(
"blur",
(ev) => {
if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup);
},
true
);
$intervalGroup.querySelector("button.clear").addEventListener("click", () => {
$intervalGroup.querySelectorAll("input").forEach((el) => {
el.value = "";
});
});
}
});

7
web/static/js/iro.js Normal file

File diff suppressed because one or more lines are too long

2
web/static/js/js.cookie.min.js vendored Normal file
View File

@ -0,0 +1,2 @@
/*! js-cookie v3.0.0-rc.0 | MIT */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self,function(){var r=e.Cookies,n=e.Cookies=t();n.noConflict=function(){return e.Cookies=r,n}}())}(this,function(){"use strict";function e(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)e[n]=r[n]}return e}var t={read:function(e){return e.replace(/%3B/g,";")},write:function(e){return e.replace(/;/g,"%3B")}};return function r(n,i){function o(r,o,u){if("undefined"!=typeof document){"number"==typeof(u=e({},i,u)).expires&&(u.expires=new Date(Date.now()+864e5*u.expires)),u.expires&&(u.expires=u.expires.toUTCString()),r=t.write(r).replace(/=/g,"%3D"),o=n.write(String(o),r);var c="";for(var f in u)u[f]&&(c+="; "+f,!0!==u[f]&&(c+="="+u[f].split(";")[0]));return document.cookie=r+"="+o+c}}return Object.create({set:o,get:function(e){if("undefined"!=typeof document&&(!arguments.length||e)){for(var r=document.cookie?document.cookie.split("; "):[],i={},o=0;o<r.length;o++){var u=r[o].split("="),c=u.slice(1).join("="),f=t.read(u[0]).replace(/%3D/g,"=");if(i[f]=n.read(c,f),e===f)break}return e?i[e]:i}},remove:function(t,r){o(t,"",e({},r,{expires:-1}))},withAttributes:function(t){return r(this.converter,e({},this.attributes,t))},withConverter:function(t){return r(e({},this.converter,t),this.attributes)}},{attributes:{value:Object.freeze(i)},converter:{value:Object.freeze(n)}})}(t,{path:"/"})});

1
web/static/js/luxon.min.js vendored Normal file

File diff suppressed because one or more lines are too long

913
web/static/js/main.js Normal file
View File

@ -0,0 +1,913 @@
let colorPicker = new iro.ColorPicker("#colorpicker");
let $discordFrame;
const $loader = document.querySelector("#loader");
const $colorPickerModal = document.querySelector("div#pickColorModal");
const $colorPickerInput = $colorPickerModal.querySelector("input");
const $deleteReminderBtn = document.querySelector("#delete-reminder-confirm");
const $reminderTemplate = document.querySelector("template#guildReminder");
const $embedFieldTemplate = document.querySelector("template#embedFieldTemplate");
const $createReminder = document.querySelector("#reminderCreator");
const $createReminderBtn = $createReminder.querySelector("button#createReminder");
const $createTemplateBtn = $createReminder.querySelector("button#createTemplate");
const $loadTemplateBtn = document.querySelector("button#load-template");
const $deleteTemplateBtn = document.querySelector("button#delete-template");
const $templateSelect = document.querySelector("select#templateSelect");
let channels = [];
let roles = [];
let templates = {};
let globalPatreon = false;
function guildId() {
return document.querySelector(".guildList a.is-active").dataset["guild"];
}
function colorToInt(r, g, b) {
return (r << 16) + (g << 8) + b;
}
function intToColor(i) {
return `#${i.toString(16).padStart(6, "0")}`;
}
function resize_textareas() {
document.querySelectorAll("textarea.autoresize").forEach((element) => {
element.style.height = "";
element.style.height = element.scrollHeight + 3 + "px";
element.addEventListener("input", () => {
element.style.height = "";
element.style.height = element.scrollHeight + 3 + "px";
});
});
}
function switch_pane(selector) {
document.querySelectorAll("aside a").forEach((el) => {
el.classList.remove("is-active");
});
document.querySelectorAll("div.is-main-content > section").forEach((el) => {
el.classList.add("is-hidden");
});
document.getElementById(selector).classList.remove("is-hidden");
resize_textareas();
}
function update_select(sel) {
if (sel.selectedOptions[0].dataset["webhookAvatar"]) {
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
sel.selectedOptions[0].dataset["webhookAvatar"];
} else {
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = "";
}
if (sel.selectedOptions[0].dataset["webhookName"]) {
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
sel.selectedOptions[0].dataset["webhookName"];
} else {
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
"";
}
}
function reset_guild_pane() {
document
.querySelectorAll("select.channel-selector option")
.forEach((opt) => opt.remove());
}
function fetch_roles(guild_id) {
fetch(`/dashboard/api/guild/${guild_id}/roles`)
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
roles = data;
}
});
}
function fetch_templates(guild_id) {
fetch(`/dashboard/api/guild/${guild_id}/templates`)
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
templates = {};
const select = document.querySelector("#templateSelect");
select.innerHTML = "";
for (let template of data) {
templates[template["id"]] = template;
let option = document.createElement("option");
option.value = template["id"];
option.textContent = template["name"];
select.appendChild(option);
}
}
});
}
async function fetch_channels(guild_id) {
const event = new Event("channelsLoading");
document.dispatchEvent(event);
await fetch(`/dashboard/api/guild/${guild_id}/channels`)
.then((response) => response.json())
.then((data) => {
if (data.error) {
if (data.error === "Bot not in guild") {
switch_pane("guild-error");
} else {
show_error(data.error);
}
} else {
channels = data;
}
})
.then(() => {
const event = new Event("channelsLoaded");
document.dispatchEvent(event);
});
}
async function fetch_reminders(guild_id) {
document.dispatchEvent(new Event("remindersLoading"));
const $reminderBox = document.querySelector("div#guildReminders");
// reset div contents
$reminderBox.innerHTML = "";
// fetch reminders
await fetch(`/dashboard/api/guild/${guild_id}/reminders`)
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
for (let reminder of data) {
let newFrame = $reminderTemplate.content.cloneNode(true);
newFrame.querySelector(".reminderContent").dataset["uid"] =
reminder["uid"];
deserialize_reminder(reminder, newFrame, "load");
$reminderBox.appendChild(newFrame);
reminder.node = $reminderBox.lastElementChild;
}
const remindersLoadedEvent = new CustomEvent("remindersLoaded", {
detail: data,
});
document.dispatchEvent(remindersLoadedEvent);
}
});
}
async function serialize_reminder(node, mode) {
let interval, utc_time, expiration_time;
if (mode !== "template") {
interval = get_interval(node);
utc_time = luxon.DateTime.fromISO(
node.querySelector('input[name="time"]').value
).setZone("UTC");
if (utc_time.invalid) {
return { error: "Time provided invalid." };
} else {
utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
}
expiration_time = luxon.DateTime.fromISO(
node.querySelector('input[name="time"]').value
).setZone("UTC");
if (expiration_time.invalid) {
return { error: "Expiration provided invalid." };
} else {
expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
}
}
let rgb_color = window.getComputedStyle(
node.querySelector("div.discord-embed")
).borderLeftColor;
let rgb = rgb_color.match(/\d+/g);
let color = colorToInt(parseInt(rgb[0]), parseInt(rgb[1]), parseInt(rgb[2]));
let fields = [
...node.querySelectorAll("div.embed-multifield-box div.embed-field-box"),
]
.map((el) => {
return {
title: el.querySelector("textarea.discord-field-title").value,
value: el.querySelector("textarea.discord-field-value").value,
inline: el.dataset["inlined"] === "1",
};
})
.filter(({ title, value, inline }) => title.length + value.length > 0);
let attachment = null;
let attachment_name = null;
if (node.querySelector('input[name="attachment"]').files.length > 0) {
let file = node.querySelector('input[name="attachment"]').files[0];
if (file.size >= 8 * 1024 * 1024) {
return { error: "File too large." };
}
attachment = await new Promise((resolve) => {
let fileReader = new FileReader();
fileReader.onload = (e) => resolve(fileReader.result);
fileReader.readAsDataURL(file);
});
attachment = attachment.split(",")[1];
attachment_name = file.name;
}
let uid = "";
if (mode === "edit") {
uid = node.closest(".reminderContent").dataset["uid"];
}
let enabled = null;
if (mode === "create") {
enabled = true;
}
const content = node.querySelector('textarea[name="content"]').value;
const embed_author_url = has_source(node.querySelector("img.embed_author_url").src);
const embed_author = node.querySelector('textarea[name="embed_author"]').value;
const embed_description = node.querySelector(
'textarea[name="embed_description"]'
).value;
const embed_footer = node.querySelector('textarea[name="embed_footer"]').value;
const embed_footer_url = has_source(node.querySelector("img.embed_footer_url").src);
const embed_image_url = has_source(node.querySelector("img.embed_image_url").src);
const embed_thumbnail_url = has_source(
node.querySelector("img.embed_thumbnail_url").src
);
const embed_title = node.querySelector('textarea[name="embed_title"]').value;
if (
attachment === null &&
content.length == 0 &&
embed_author_url === null &&
embed_author.length == 0 &&
embed_description.length == 0 &&
embed_footer.length == 0 &&
embed_footer_url === null &&
embed_image_url === null &&
embed_thumbnail_url === null
) {
return { error: "Reminder needs content." };
}
return {
// if we're creating a reminder, ignore this field
uid: uid,
// if we're editing a reminder, ignore this field
enabled: enabled,
restartable: false,
attachment: attachment,
attachment_name: attachment_name,
avatar: has_source(node.querySelector("img.discord-avatar").src),
channel: node.querySelector("select.channel-selector").value,
content: content,
embed_author_url: embed_author_url,
embed_author: embed_author,
embed_color: color,
embed_description: embed_description,
embed_footer: embed_footer,
embed_footer_url: embed_footer_url,
embed_image_url: embed_image_url,
embed_thumbnail_url: embed_thumbnail_url,
embed_title: embed_title,
embed_fields: fields,
expires: expiration_time,
interval_seconds: mode !== "template" ? interval.seconds : null,
interval_months: mode !== "template" ? interval.months : null,
name: node.querySelector('input[name="name"]').value,
pin: node.querySelector('input[name="pin"]').checked,
tts: node.querySelector('input[name="tts"]').checked,
username: node.querySelector('input[name="username"]').value,
utc_time: utc_time,
};
}
function deserialize_reminder(reminder, frame, mode) {
// populate channels
set_channels(frame.querySelector("select.channel-selector"));
// populate majority of items
for (let prop in reminder) {
if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) {
if (prop === "attachment") {
} else if (prop === "attachment_name") {
frame.querySelector(".file-cta > .file-label").textContent =
reminder[prop];
} else {
let $input = frame.querySelector(`*[name="${prop}"]`);
let $image = frame.querySelector(`img.${prop}`);
if ($input !== null) {
$input.value = reminder[prop];
} else if ($image !== null) {
$image.src = reminder[prop];
}
}
}
}
const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box");
for (let field of reminder["embed_fields"]) {
let embed_field = $embedFieldTemplate.content.cloneNode(true);
embed_field.querySelector("textarea.discord-field-title").value = field["title"];
embed_field.querySelector("textarea.discord-field-value").value = field["value"];
embed_field.querySelector(".embed-field-box").dataset["inlined"] = field["inline"]
? "1"
: "0";
frame
.querySelector("div.embed-multifield-box")
.insertBefore(embed_field, lastChild);
}
if (mode !== "template") {
if (reminder["interval_seconds"]) update_interval(frame);
let $enableBtn = frame.querySelector(".disable-enable");
$enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable";
let timeInput = frame.querySelector('input[name="time"]');
let localTime = luxon.DateTime.fromISO(reminder["utc_time"], {
zone: "UTC",
}).setZone(timezone);
timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss");
if (reminder["expires"]) {
let expiresInput = frame.querySelector('input[name="time"]');
let expiresTime = luxon.DateTime.fromISO(reminder["expires"], {
zone: "UTC",
}).setZone(timezone);
expiresInput.value = expiresTime.toFormat("yyyy-LL-dd'T'HH:mm:ss");
}
}
}
document.addEventListener("guildSwitched", async (e) => {
$loader.classList.remove("is-hidden");
let $anchor = document.querySelector(
`.switch-pane[data-guild="${e.detail.guild_id}"]`
);
switch_pane($anchor.dataset["pane"]);
reset_guild_pane();
$anchor.classList.add("is-active");
fetch_roles(e.detail.guild_id);
fetch_templates(e.detail.guild_id);
await fetch_channels(e.detail.guild_id);
fetch_reminders(e.detail.guild_id);
document.querySelectorAll("p.pageTitle").forEach((el) => {
el.textContent = `${e.detail.guild_name} Reminders`;
});
document.querySelectorAll("select.channel-selector").forEach((el) => {
el.addEventListener("change", (e) => {
update_select(e.target);
});
});
resize_textareas();
$loader.classList.add("is-hidden");
});
document.addEventListener("channelsLoaded", () => {
document.querySelectorAll("select.channel-selector").forEach(set_channels);
});
document.addEventListener("remindersLoaded", (event) => {
const guild = guildId();
for (let reminder of event.detail) {
let node = reminder.node;
node.querySelector("button.hide-box").addEventListener("click", () => {
node.closest(".reminderContent").classList.toggle("is-collapsed");
});
node.querySelector("div.discord-embed").style.borderLeftColor = intToColor(
reminder.embed_color
);
const enableBtn = node.querySelector(".disable-enable");
enableBtn.addEventListener("click", () => {
let enable = enableBtn.dataset["action"] === "enable";
fetch(`/dashboard/api/guild/${guild}/reminders`, {
method: "PATCH",
body: JSON.stringify({
uid: reminder["uid"],
enabled: enable,
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
enableBtn.dataset["action"] = data["enabled"]
? "enable"
: "disable";
}
});
});
node.querySelector("button.delete-reminder").addEventListener("click", () => {
$deleteReminderBtn.dataset["uid"] = reminder["uid"];
$deleteReminderBtn.closest(".modal").classList.toggle("is-active");
});
const $saveBtn = node.querySelector("button.save-btn");
$saveBtn.addEventListener("click", async (event) => {
$saveBtn.querySelector("span.icon > i").classList = [
"fas fa-spinner fa-spin",
];
let reminder = await serialize_reminder(node, "edit");
if (reminder.error) {
show_error(reminder.error);
return;
}
let guild = guildId();
fetch(`/dashboard/api/guild/${guild}/reminders`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reminder),
})
.then((response) => response.json())
.then((data) => {
for (let error of data.errors) show_error(error);
});
$saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"];
window.setTimeout(() => {
$saveBtn.querySelector("span.icon > i").classList = ["fas fa-save"];
}, 1500);
});
}
});
$deleteReminderBtn.addEventListener("click", () => {
let guild = guildId();
fetch(`/dashboard/api/guild/${guild}/reminders`, {
method: "DELETE",
body: JSON.stringify({
uid: $deleteReminderBtn.dataset["uid"],
}),
}).then(() => {
document.querySelector("#deleteReminderModal").classList.remove("is-active");
fetch_reminders(guild);
});
});
function show_error(error) {
document.getElementById("errors").querySelector("span.error-message").textContent =
error;
document.getElementById("errors").classList.add("is-active");
window.setTimeout(() => {
document.getElementById("errors").classList.remove("is-active");
}, 5000);
}
$colorPickerInput.value = colorPicker.color.hexString;
$colorPickerInput.addEventListener("input", () => {
if (/^#[0-9a-fA-F]{6}$/.test($colorPickerInput.value) === true) {
colorPicker.color.hexString = $colorPickerInput.value;
}
});
colorPicker.on("color:change", function (color) {
$colorPickerInput.value = color.hexString;
});
$colorPickerModal.querySelector("button.is-success").addEventListener("click", () => {
$discordFrame.style.borderLeftColor = colorPicker.color.rgbString;
$colorPickerModal.classList.remove("is-active");
});
document.querySelectorAll(".show-modal").forEach((element) => {
element.addEventListener("click", (e) => {
e.preventDefault();
document.getElementById(element.dataset["modal"]).classList.toggle("is-active");
});
});
document.addEventListener("DOMContentLoaded", () => {
$loader.classList.remove("is-hidden");
document.querySelectorAll(".navbar-burger").forEach((el) => {
el.addEventListener("click", () => {
const target = el.dataset["target"];
const $target = document.getElementById(target);
el.classList.toggle("is-active");
$target.classList.toggle("is-active");
});
});
let hideBox = document.querySelector("#reminderCreator button.hide-box");
hideBox.addEventListener("click", () => {
hideBox.closest(".reminderContent").classList.toggle("is-collapsed");
});
fetch("/dashboard/api/user")
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
if (data.timezone !== null) botTimezone = data.timezone;
globalPatreon = data.patreon;
update_times();
}
});
fetch("/dashboard/api/user/guilds")
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
const $template = document.getElementById("guildListEntry");
for (let guild of data) {
document.querySelectorAll(".guildList").forEach((element) => {
const $clone = $template.content.cloneNode(true);
const $anchor = $clone.querySelector("a");
let $span = $clone.querySelector("a > span.guild-name");
$span.textContent = $span.textContent.replace(
"%guildname%",
guild.name
);
$anchor.dataset["guild"] = guild.id;
$anchor.dataset["name"] = guild.name;
$anchor.href = `/dashboard/${guild.id}?name=${guild.name}`;
$anchor.addEventListener("click", async (e) => {
e.preventDefault();
window.history.pushState(
{},
"",
`/dashboard/${guild.id}?name=${guild.name}`
);
const event = new CustomEvent("guildSwitched", {
detail: {
guild_name: guild.name,
guild_id: guild.id,
},
});
document.dispatchEvent(event);
});
element.append($clone);
});
}
const matches = window.location.href.match(/dashboard\/(\d+)/);
if (matches) {
let id = matches[1];
let name =
new URLSearchParams(window.location.search).get("name") || id;
const event = new CustomEvent("guildSwitched", {
detail: {
guild_name: name,
guild_id: id,
},
});
document.dispatchEvent(event);
}
}
});
$loader.classList.add("is-hidden");
});
function set_channels(element) {
for (let channel of channels) {
let newOption = document.createElement("option");
newOption.value = channel.id;
newOption.textContent = channel.name;
element.appendChild(newOption);
}
update_select(element);
}
function has_source(string) {
if (string.startsWith(`https://${window.location.hostname}`)) {
return null;
} else {
return string;
}
}
$createReminderBtn.addEventListener("click", async () => {
$createReminderBtn.querySelector("span.icon > i").classList = [
"fas fa-spinner fa-spin",
];
let reminder = await serialize_reminder($createReminder, "create");
if (reminder.error) {
show_error(reminder.error);
return;
}
let guild = guildId();
fetch(`/dashboard/api/guild/${guild}/reminders`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reminder),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
$createReminderBtn.querySelector("span.icon > i").classList = [
"fas fa-sparkles",
];
} else {
const $reminderBox = document.querySelector("div#guildReminders");
let newFrame = $reminderTemplate.content.cloneNode(true);
newFrame.querySelector(".reminderContent").dataset["uid"] = data["uid"];
deserialize_reminder(data, newFrame, "load");
$reminderBox.appendChild(newFrame);
data.node = $reminderBox.lastElementChild;
document.dispatchEvent(
new CustomEvent("remindersLoaded", {
detail: [data],
})
);
$createReminderBtn.querySelector("span.icon > i").classList = [
"fas fa-check",
];
window.setTimeout(() => {
$createReminderBtn.querySelector("span.icon > i").classList = [
"fas fa-sparkles",
];
}, 1500);
}
});
});
$createTemplateBtn.addEventListener("click", async () => {
$createTemplateBtn.querySelector("span.icon > i").classList = [
"fas fa-spinner fa-spin",
];
let reminder = await serialize_reminder($createReminder, "template");
let guild = guildId();
fetch(`/dashboard/api/guild/${guild}/templates`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reminder),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
$createTemplateBtn.querySelector("span.icon > i").classList = [
"fas fa-file-spreadsheet",
];
} else {
fetch_templates(guildId());
$createTemplateBtn.querySelector("span.icon > i").classList = [
"fas fa-check",
];
window.setTimeout(() => {
$createTemplateBtn.querySelector("span.icon > i").classList = [
"fas fa-file-spreadsheet",
];
}, 1500);
}
});
});
$loadTemplateBtn.addEventListener("click", (ev) => {
deserialize_reminder(
templates[parseInt($templateSelect.value)],
$createReminder,
"template"
);
});
$deleteTemplateBtn.addEventListener("click", (ev) => {
fetch(`/dashboard/api/guild/${guildId()}/templates`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: parseInt($templateSelect.value) }),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
$templateSelect
.querySelector(`option[value="${$templateSelect.value}"]`)
.remove();
}
});
});
document.querySelectorAll("textarea.autoresize").forEach((element) => {
element.addEventListener("input", () => {
element.style.height = "";
element.style.height = element.scrollHeight + 3 + "px";
});
});
let $img;
const $urlModal = document.querySelector("div#addImageModal");
const $urlInput = $urlModal.querySelector("input");
$urlModal.querySelector("button#setImgUrl").addEventListener("click", () => {
$img.src = $urlInput.value;
$urlInput.value = "";
$urlModal.classList.remove("is-active");
});
document.querySelectorAll("button.close-modal").forEach((element) => {
element.addEventListener("click", () => {
let $modal = element.closest("div.modal");
$urlInput.value = "";
$modal.classList.remove("is-active");
});
});
document.addEventListener("remindersLoaded", () => {
document.querySelectorAll(".customizable").forEach((element) => {
element.querySelector("a").addEventListener("click", (e) => {
e.preventDefault();
$img = element.querySelector("img");
$urlModal.classList.toggle("is-active");
});
});
const fileInput = document.querySelectorAll("input[type=file]");
fileInput.forEach((element) => {
element.addEventListener("change", () => {
if (element.files.length > 0) {
const fileName = element.parentElement.querySelector(".file-label");
fileName.textContent = element.files[0].name;
}
});
});
document.querySelectorAll(".change-color").forEach((element) => {
element.addEventListener("click", (e) => {
e.preventDefault();
$discordFrame = element
.closest("div.reminderContent")
.querySelector("div.discord-embed");
$colorPickerModal.classList.toggle("is-active");
colorPicker.color.rgbString =
window.getComputedStyle($discordFrame).borderLeftColor;
});
});
});
function check_embed_fields() {
document.querySelectorAll(".embed-field-box").forEach((element) => {
const $titleInput = element.querySelector(".discord-field-title");
const $valueInput = element.querySelector(".discord-field-value");
// when the user clicks out of the field title and if the field title/value are empty, remove the field
$titleInput.addEventListener("blur", () => {
if (
$titleInput.value === "" &&
$valueInput.value === "" &&
element.nextElementSibling !== null
) {
element.remove();
}
});
$valueInput.addEventListener("blur", () => {
if (
$titleInput.value === "" &&
$valueInput.value === "" &&
element.nextElementSibling !== null
) {
element.remove();
}
});
// when the user inputs into the end field, create a new field after it
$titleInput.addEventListener("input", () => {
if (
$titleInput.value !== "" &&
$valueInput.value !== "" &&
element.nextElementSibling === null
) {
const $clone = $embedFieldTemplate.content.cloneNode(true);
element.parentElement.append($clone);
}
});
$valueInput.addEventListener("input", () => {
if (
$titleInput.value !== "" &&
$valueInput.value !== "" &&
element.nextElementSibling === null
) {
const $clone = $embedFieldTemplate.content.cloneNode(true);
element.parentElement.append($clone);
}
});
});
}
document.addEventListener("DOMNodeInserted", () => {
document.querySelectorAll("div.mobile-sidebar a").forEach((element) => {
element.addEventListener("click", (e) => {
document.getElementById("mobileSidebar").classList.remove("is-active");
document.querySelectorAll(".navbar-burger").forEach((el) => {
el.classList.remove("is-active");
});
});
});
document.querySelectorAll('input[type="datetime-local"]').forEach((el) => {
let now = luxon.DateTime.now().setZone(timezone);
el.min = now.toFormat("yyyy-LL-dd'T'HH:mm:ss");
});
check_embed_fields();
resize_textareas();
});
document.addEventListener("click", (ev) => {
if (ev.target.closest("button.inline-btn") !== null) {
let inlined = ev.target.closest(".embed-field-box").dataset["inlined"];
ev.target.closest(".embed-field-box").dataset["inlined"] =
inlined == "1" ? "0" : "1";
}
});

70
web/static/js/sort.js Normal file
View File

@ -0,0 +1,70 @@
let guildReminders = document.querySelector("#guildReminders");
function sort_by(cond) {
if (cond === "channel") {
[...guildReminders.children]
.sort((a, b) => {
let channel1 = a.querySelector("select.channel-selector").value;
let channel2 = b.querySelector("select.channel-selector").value;
return channel1 > channel2 ? 1 : -1;
})
.forEach((node) => guildReminders.appendChild(node));
// go through and add channel categories
let currentChannelGroup = null;
for (let child of guildReminders.querySelectorAll("div.reminderContent")) {
let thisChannelGroup = child.querySelector("select.channel-selector").value;
if (currentChannelGroup !== thisChannelGroup) {
let newNode = document.createElement("div");
newNode.textContent =
"#" + channels.find((a) => a.id === thisChannelGroup).name;
newNode.classList.add("channel-tag");
guildReminders.insertBefore(newNode, child);
currentChannelGroup = thisChannelGroup;
}
}
} else {
// remove any channel tags if previous ordering was by channel
guildReminders.querySelectorAll("div.channel-tag").forEach((el) => {
el.remove();
});
if (cond === "time") {
[...guildReminders.children]
.sort((a, b) => {
let time1 = luxon.DateTime.fromISO(
a.querySelector('input[name="time"]').value
);
let time2 = luxon.DateTime.fromISO(
b.querySelector('input[name="time"]').value
);
return time1 > time2 ? 1 : -1;
})
.forEach((node) => guildReminders.appendChild(node));
} else {
[...guildReminders.children]
.sort((a, b) => {
let name1 = a.querySelector('input[name="name"]').value;
let name2 = b.querySelector('input[name="name"]').value;
return name1 > name2 ? 1 : -1;
})
.forEach((node) => guildReminders.appendChild(node));
}
}
}
const selector = document.querySelector("#orderBy");
selector.addEventListener("change", () => {
sort_by(selector.value);
});
document.addEventListener("remindersLoaded", () => {
sort_by(selector.value);
});

57
web/static/js/timezone.js Normal file
View File

@ -0,0 +1,57 @@
let timezone = luxon.DateTime.now().zone.name;
const browserTimezone = luxon.DateTime.now().zone.name;
let botTimezone = "UTC";
function update_times() {
document.querySelectorAll("span.set-timezone").forEach((element) => {
element.textContent = timezone;
});
document.querySelectorAll("span.set-time").forEach((element) => {
element.textContent = luxon.DateTime.now().setZone(timezone).toFormat("HH:mm");
});
document.querySelectorAll("span.browser-timezone").forEach((element) => {
element.textContent = browserTimezone;
});
document.querySelectorAll("span.browser-time").forEach((element) => {
element.textContent = luxon.DateTime.now().toFormat("HH:mm");
});
document.querySelectorAll("span.bot-timezone").forEach((element) => {
element.textContent = botTimezone;
});
document.querySelectorAll("span.bot-time").forEach((element) => {
element.textContent = luxon.DateTime.now().setZone(botTimezone).toFormat("HH:mm");
});
}
window.setInterval(() => {
update_times();
}, 30000);
document.getElementById("set-bot-timezone").addEventListener("click", () => {
timezone = botTimezone;
update_times();
});
document.getElementById("set-browser-timezone").addEventListener("click", () => {
timezone = browserTimezone;
update_times();
});
document.getElementById("update-bot-timezone").addEventListener("click", () => {
timezone = browserTimezone;
fetch("/dashboard/api/user", {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ timezone: timezone }),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
botTimezone = browserTimezone;
update_times();
}
});
});

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More