Merge pull request #7 from reminder-bot/poise-2

Poise 2
This commit is contained in:
Jude Southworth 2022-05-13 09:00:26 +01:00 committed by GitHub
commit d7a0b727fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 5660 additions and 5291 deletions

2
.prettierrc.toml Normal file
View File

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

880
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,8 @@ 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,28 +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.reminder_web]
path = "web"
[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"
]

View File

@ -1,7 +1,8 @@
[default]
address = "127.0.0.1"
address = "0.0.0.0"
port = 5000
template_dir = "web/templates"
limits = { json = "10MiB" }
[debug]
secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="

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

@ -24,8 +24,11 @@ CREATE TABLE reminder_template (
`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 channels (`id`) ON DELETE CASCADE,
FOREIGN KEY (`guild_id`) REFERENCES guilds (`id`) ON DELETE CASCADE
);
ALTER TABLE reminders ADD COLUMN embed_fields JSON;

View File

@ -12,21 +12,7 @@ chrono = "0.4"
chrono-tz = { version = "0.5", features = ["serde"] }
lazy_static = "1.4"
num-integer = "0.1"
sqlx = { version = "0.5.10", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
[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"
]
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"] }

View File

@ -1,23 +1,40 @@
mod sender;
use log::info;
use std::env;
use log::{info, warn};
use serenity::client::Context;
use sqlx::{Executor, MySql};
use std::env;
use tokio::time::sleep_until;
use tokio::time::{Duration, Instant};
use tokio::{
sync::broadcast::Receiver,
time::{sleep_until, Duration, Instant},
};
type Database = MySql;
pub async fn initialize(ctx: Context, pool: impl Executor<'_, Database = Database> + Copy) {
let REMIND_INTERVAL = env::var("REMIND_INTERVAL")
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 sleep_to = Instant::now() + Duration::from_secs(remind_interval);
let reminders = sender::Reminder::fetch_reminders(pool).await;
if reminders.len() > 0 {

View File

@ -1,10 +1,10 @@
use crate::Database;
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},
@ -16,10 +16,15 @@ use serenity::{
Error, Result,
};
use sqlx::{
types::chrono::{NaiveDateTime, Utc},
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();
@ -93,11 +98,6 @@ pub fn substitute(string: &str) -> String {
}
struct Embed {
inner: EmbedInner,
fields: Vec<EmbedField>,
}
struct EmbedInner {
title: String,
description: String,
image_url: Option<String>,
@ -107,8 +107,10 @@ struct EmbedInner {
author: String,
author_url: Option<String>,
color: u32,
fields: Json<Vec<EmbedField>>,
}
#[derive(Deserialize)]
struct EmbedField {
title: String,
value: String,
@ -120,76 +122,54 @@ impl Embed {
pool: impl Executor<'_, Database = Database> + Copy,
id: u32,
) -> Option<Self> {
let mut inner = sqlx::query_as_unchecked!(
EmbedInner,
"
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
FROM
reminders
WHERE
`id` = ?
",
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();
inner.title = substitute(&inner.title);
inner.description = substitute(&inner.description);
inner.footer = substitute(&inner.footer);
embed.title = substitute(&embed.title);
embed.description = substitute(&embed.description);
embed.footer = substitute(&embed.footer);
let mut fields = sqlx::query_as_unchecked!(
EmbedField,
"
SELECT
title,
value,
inline
FROM
embed_fields
WHERE
reminder_id = ?
",
id
)
.fetch_all(pool)
.await
.unwrap();
fields.iter_mut().for_each(|mut field| {
embed.fields.iter_mut().for_each(|mut field| {
field.title = substitute(&field.title);
field.value = substitute(&field.value);
});
let e = Embed { inner, fields };
if e.has_content() {
Some(e)
if embed.has_content() {
Some(embed)
} else {
None
}
}
pub fn has_content(&self) -> bool {
if self.inner.title.is_empty()
&& self.inner.description.is_empty()
&& self.inner.image_url.is_none()
&& self.inner.thumbnail_url.is_none()
&& self.inner.footer.is_empty()
&& self.inner.footer_url.is_none()
&& self.inner.author.is_empty()
&& self.inner.author_url.is_none()
&& self.fields.is_empty()
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 {
@ -202,37 +182,37 @@ impl Into<CreateEmbed> for Embed {
fn into(self) -> CreateEmbed {
let mut c = CreateEmbed::default();
c.title(&self.inner.title)
.description(&self.inner.description)
.color(self.inner.color)
c.title(&self.title)
.description(&self.description)
.color(self.color)
.author(|a| {
a.name(&self.inner.author);
a.name(&self.author);
if let Some(author_icon) = &self.inner.author_url {
if let Some(author_icon) = &self.author_url {
a.icon_url(author_icon);
}
a
})
.footer(|f| {
f.text(&self.inner.footer);
f.text(&self.footer);
if let Some(footer_icon) = &self.inner.footer_url {
if let Some(footer_icon) = &self.footer_url {
f.icon_url(footer_icon);
}
f
});
for field in &self.fields {
for field in &self.fields.0 {
c.field(&field.title, &field.value, field.inline);
}
if let Some(image_url) = &self.inner.image_url {
if let Some(image_url) = &self.image_url {
c.image(image_url);
}
if let Some(thumbnail_url) = &self.inner.thumbnail_url {
if let Some(thumbnail_url) = &self.thumbnail_url {
c.thumbnail(thumbnail_url);
}

View File

@ -1,16 +1,13 @@
use chrono::offset::Utc;
use regex_command_attr::command;
use serenity::{builder::CreateEmbedFooter, client::Context};
use poise::{serenity_prelude as serenity, serenity_prelude::Mentionable};
use crate::{
framework::{CommandInvoke, CreateGenericResponse},
models::CtxData,
THEME_COLOR,
};
use crate::{models::CtxData, Context, Error, THEME_COLOR};
fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter {
let shard_count = ctx.cache.shard_count();
let shard = ctx.shard_id;
fn footer(
ctx: Context<'_>,
) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
let shard_count = ctx.discord().cache.shard_count();
let shard = ctx.discord().shard_id;
move |f| {
f.text(format!(
@ -22,19 +19,17 @@ fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEm
}
}
#[command]
#[description("Get an overview of the bot commands")]
async fn help(ctx: &Context, invoke: &mut CommandInvoke) {
/// Get an overview of bot commands
#[poise::command(slash_command)]
pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx);
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().embed(|e| {
e.title("Help")
.color(*THEME_COLOR)
.description(
"__Info Commands__
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Help")
.color(*THEME_COLOR)
.description(
"__Info Commands__
`/help` `/info` `/donate` `/dashboard` `/clock`
*run these commands with no options*
@ -58,23 +53,23 @@ __Setup Commands__
__Advanced Commands__
`/macro` - Record and replay command sequences
",
)
.footer(footer)
}),
)
.await;
)
.footer(footer)
})
})
.await?;
Ok(())
}
#[command]
#[aliases("invite")]
#[description("Get information about the bot")]
async fn info(ctx: &Context, invoke: &mut CommandInvoke) {
/// Get information about the bot
#[poise::command(slash_command)]
pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx);
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
let _ = ctx
.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Info")
.description(format!(
"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| {
e.title("Donate")
.description("Thinking of adding a monthly contribution? Click below for my Patreon and official bot server :)
ctx.send(|m| m.embed(|e| {
e.title("Donate")
.description("Thinking of adding a monthly contribution?
Click below for my Patreon and official bot server :)
**https://www.patreon.com/jellywx/**
**https://discord.jellywx.com/**
@ -120,43 +114,67 @@ With your new rank, you'll be able to:
Just $2 USD/month!
*Please note, you must be in the JellyWX Discord server to receive Patreon features*")
.footer(footer)
.color(*THEME_COLOR)
}),
)
.await;
.footer(footer)
.color(*THEME_COLOR)
}),
)
.await?;
Ok(())
}
#[command]
#[description("Get the link to the online dashboard")]
#[group("Info")]
async fn dashboard(ctx: &Context, invoke: &mut CommandInvoke) {
/// Get the link to the online dashboard
#[poise::command(slash_command)]
pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx);
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title("Dashboard")
.description("**https://reminder-bot.com/dashboard**")
.footer(footer)
.color(*THEME_COLOR)
}),
)
.await;
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Dashboard")
.description("**https://reminder-bot.com/dashboard**")
.footer(footer)
.color(*THEME_COLOR)
})
})
.await?;
Ok(())
}
#[command]
#[description("View the current time in your selected timezone")]
#[group("Info")]
async fn clock(ctx: &Context, invoke: &mut CommandInvoke) {
let ud = ctx.user_data(&invoke.author_id()).await.unwrap();
let now = Utc::now().with_timezone(&ud.timezone());
/// View the current time in your selected timezone
#[poise::command(slash_command)]
pub async fn clock(ctx: Context<'_>) -> Result<(), Error> {
ctx.defer_ephemeral().await?;
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content(format!("Current time: {}", now.format("%H:%M"))),
)
.await;
let tz = ctx.timezone().await;
let now = Utc::now().with_timezone(&tz);
ctx.send(|m| {
m.ephemeral(true).content(format!("Time in **{}**: `{}`", tz, now.format("%H:%M")))
})
.await?;
Ok(())
}
/// View the current time in a user's selected timezone
#[poise::command(context_menu_command = "View Local Time")]
pub async fn clock_context_menu(ctx: Context<'_>, user: serenity::User) -> Result<(), Error> {
ctx.defer_ephemeral().await?;
let user_data = ctx.user_data(user.id).await?;
let tz = user_data.timezone();
let now = Utc::now().with_timezone(&tz);
ctx.send(|m| {
m.ephemeral(true).content(format!(
"Time in {}'s timezone: `{}`",
user.mention(),
now.format("%H:%M")
))
})
.await?;
Ok(())
}

View File

@ -1,54 +1,63 @@
use chrono::offset::Utc;
use chrono_tz::{Tz, TZ_VARIANTS};
use levenshtein::levenshtein;
use regex_command_attr::command;
use serenity::client::Context;
use poise::CreateReply;
use crate::{
component_models::pager::{MacroPager, Pager},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
framework::{CommandInvoke, CommandOptions, CreateGenericResponse, OptionValue},
hooks::{CHECK_GUILD_PERMISSIONS_HOOK, GUILD_ONLY_HOOK},
models::{command_macro::CommandMacro, CtxData},
PopularTimezones, RecordingMacros, RegexFramework, SQLPool,
models::{
command_macro::{guild_command_macro, CommandMacro},
CtxData,
},
Context, Data, Error,
};
#[command("timezone")]
#[description("Select your timezone")]
#[arg(
name = "timezone",
description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee",
kind = "String",
required = false
)]
async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let mut user_data = ctx.user_data(invoke.author_id()).await.unwrap();
async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
if partial.is_empty() {
ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
} else {
TZ_VARIANTS
.iter()
.filter(|tz| tz.to_string().contains(&partial))
.take(25)
.map(|t| t.to_string())
.collect::<Vec<String>>()
}
}
/// Select your timezone
#[poise::command(slash_command, identifying_name = "timezone")]
pub async fn timezone(
ctx: Context<'_>,
#[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"]
#[autocomplete = "timezone_autocomplete"]
timezone: Option<String>,
) -> Result<(), Error> {
let mut user_data = ctx.author_data().await.unwrap();
let footer_text = format!("Current timezone: {}", user_data.timezone);
if let Some(OptionValue::String(timezone)) = args.get("timezone") {
if let Some(timezone) = timezone {
match timezone.parse::<Tz>() {
Ok(tz) => {
user_data.timezone = timezone.clone();
user_data.commit_changes(&pool).await;
user_data.commit_changes(&ctx.data().database).await;
let now = Utc::now().with_timezone(&tz);
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title("Timezone Set")
.description(format!(
"Timezone has been set to **{}**. Your current time should be `{}`",
timezone,
now.format("%H:%M").to_string()
))
.color(*THEME_COLOR)
}),
)
.await;
ctx.send(|m| {
m.embed(|e| {
e.title("Timezone Set")
.description(format!(
"Timezone has been set to **{}**. Your current time should be `{}`",
timezone,
now.format("%H:%M").to_string()
))
.color(*THEME_COLOR)
})
})
.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| {
e.title("Timezone Not Recognized")
.description("Possibly you meant one of the following timezones, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):")
.color(*THEME_COLOR)
.fields(fields)
.footer(|f| f.text(footer_text))
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
}),
)
.await;
ctx.send(|m| {
m.embed(|e| {
e.title("Timezone Not Recognized")
.description("Possibly you meant one of the following timezones, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):")
.color(*THEME_COLOR)
.fields(fields)
.footer(|f| f.text(footer_text))
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
})
})
.await?;
}
}
} else {
let popular_timezones = ctx.data.read().await.get::<PopularTimezones>().cloned().unwrap();
let popular_timezones_iter = popular_timezones.iter().map(|t| {
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,279 +105,311 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption
)
});
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title("Timezone Usage")
.description(
"**Usage:**
ctx.send(|m| {
m.embed(|e| {
e.title("Timezone Usage")
.description(
"**Usage:**
`/timezone Name`
**Example:**
`/timezone Europe/London`
You may want to use one of the popular timezones below, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):",
)
.color(*THEME_COLOR)
.fields(popular_timezones_iter)
.footer(|f| f.text(footer_text))
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
})
})
.await?;
}
Ok(())
}
async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
sqlx::query!(
"
SELECT name
FROM macro
WHERE
guild_id = (SELECT id FROM guilds WHERE guild = ?)
AND name LIKE CONCAT(?, '%')",
ctx.guild_id().unwrap().0,
partial,
)
.fetch_all(&ctx.data().database)
.await
.unwrap_or(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"
)]
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Start recording up to 5 commands to replay
#[poise::command(
slash_command,
rename = "record",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "record_macro"
)]
pub async fn record_macro(
ctx: Context<'_>,
#[description = "Name for the new macro"] name: String,
#[description = "Description for the new macro"] description: Option<String>,
) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();
let row = sqlx::query!(
"
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
guild_id.0,
name
)
.fetch_one(&ctx.data().database)
.await;
if row.is_ok() {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Unique Name Required")
.description(
"A macro already exists under this name.
Please select a unique name for your macro.",
)
.color(*THEME_COLOR)
})
})
.await?;
} else {
let okay = {
let mut lock = ctx.data().recording_macros.write().await;
if lock.contains_key(&(guild_id, ctx.author().id)) {
false
} else {
lock.insert(
(guild_id, ctx.author().id),
CommandMacro { guild_id, name, description, commands: vec![] },
);
true
}
};
if okay {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Recording Started")
.description(
"Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands ran as part of recording will be inconsequential",
)
.color(*THEME_COLOR)
.fields(popular_timezones_iter)
.footer(|f| f.text(footer_text))
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
}),
)
.await;
}
}
#[command("macro")]
#[description("Record and replay command sequences")]
#[subcommand("record")]
#[description("Start recording up to 5 commands to replay")]
#[arg(name = "name", description = "Name for the new macro", kind = "String", required = true)]
#[arg(
name = "description",
description = "Description for the new macro",
kind = "String",
required = false
)]
#[subcommand("finish")]
#[description("Finish current recording")]
#[subcommand("list")]
#[description("List recorded macros")]
#[subcommand("run")]
#[description("Run a recorded macro")]
#[arg(name = "name", description = "Name of the macro to run", kind = "String", required = true)]
#[subcommand("delete")]
#[description("Delete a recorded macro")]
#[arg(name = "name", description = "Name of the macro to delete", kind = "String", required = true)]
#[supports_dm(false)]
#[hook(GUILD_ONLY_HOOK)]
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
async fn macro_cmd(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
match args.subcommand.clone().unwrap().as_str() {
"record" => {
let guild_id = invoke.guild_id().unwrap();
let name = args.get("name").unwrap().to_string();
let row = sqlx::query!(
"SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
guild_id.0,
name
)
.fetch_one(&pool)
.await;
if row.is_ok() {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().ephemeral().embed(|e| {
e
.title("Unique Name Required")
.description("A macro already exists under this name. Please select a unique name for your macro.")
.color(*THEME_COLOR)
}),
)
.await;
} else {
let macro_buffer = ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap();
let okay = {
let mut lock = macro_buffer.write().await;
if lock.contains_key(&(guild_id, invoke.author_id())) {
false
} else {
lock.insert(
(guild_id, invoke.author_id()),
CommandMacro {
guild_id,
name,
description: args.get("description").map(|d| d.to_string()),
commands: vec![],
},
);
true
}
};
if okay {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().ephemeral().embed(|e| {
e
.title("Macro Recording Started")
.description(
"Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands ran as part of recording will be inconsequential")
.color(*THEME_COLOR)
}),
)
.await;
} else {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().ephemeral().embed(|e| {
e.title("Macro Already Recording")
.description(
"You are already recording a macro in this server.
})
})
.await?;
} else {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Already Recording")
.description(
"You are already recording a macro in this server.
Please use `/macro finish` to end this recording before starting another.",
)
.color(*THEME_COLOR)
}),
)
.await;
}
}
.color(*THEME_COLOR)
})
})
.await?;
}
"finish" => {
let key = (invoke.guild_id().unwrap(), invoke.author_id());
let macro_buffer = ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap();
{
let lock = macro_buffer.read().await;
let contained = lock.get(&key);
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().embed(|e| {
e.title("No Macro Recorded")
.description("Use `/macro record` to start recording a macro")
.color(*THEME_COLOR)
}),
)
.await;
} else {
let command_macro = contained.unwrap();
let json = serde_json::to_string(&command_macro.commands).unwrap();
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
command_macro.guild_id.0,
command_macro.name,
command_macro.description,
json
)
.execute(&pool)
.await
.unwrap();
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().embed(|e| {
e.title("Macro Recorded")
.description("Use `/macro run` to execute the macro")
.color(*THEME_COLOR)
}),
)
.await;
}
}
{
let mut lock = macro_buffer.write().await;
lock.remove(&key);
}
}
"list" => {
let macros = CommandMacro::from_guild(ctx, invoke.guild_id().unwrap()).await;
let resp = show_macro_page(&macros, 0);
invoke.respond(&ctx, resp).await.unwrap();
}
"run" => {
let macro_name = args.get("name").unwrap().to_string();
match sqlx::query!(
"SELECT commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
invoke.guild_id().unwrap().0,
macro_name
)
.fetch_one(&pool)
.await
{
Ok(row) => {
invoke.defer(&ctx).await;
let commands: Vec<CommandOptions> =
serde_json::from_str(&row.commands).unwrap();
let framework = ctx.data.read().await.get::<RegexFramework>().cloned().unwrap();
for command in commands {
framework.run_command_from_options(ctx, invoke, command).await;
}
}
Err(sqlx::Error::RowNotFound) => {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new()
.content(format!("Macro \"{}\" not found", macro_name)),
)
.await;
}
Err(e) => {
panic!("{}", e);
}
}
}
"delete" => {
let macro_name = args.get("name").unwrap().to_string();
match sqlx::query!(
"SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
invoke.guild_id().unwrap().0,
macro_name
)
.fetch_one(&pool)
.await
{
Ok(row) => {
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
.execute(&pool)
.await
.unwrap();
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new()
.content(format!("Macro \"{}\" deleted", macro_name)),
)
.await;
}
Err(sqlx::Error::RowNotFound) => {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new()
.content(format!("Macro \"{}\" not found", macro_name)),
)
.await;
}
Err(e) => {
panic!("{}", e);
}
}
}
_ => {}
}
Ok(())
}
pub fn max_macro_page(macros: &[CommandMacro]) -> usize {
/// Finish current macro recording
#[poise::command(
slash_command,
rename = "finish",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "finish_macro"
)]
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
let key = (ctx.guild_id().unwrap(), ctx.author().id);
{
let lock = ctx.data().recording_macros.read().await;
let contained = lock.get(&key);
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
ctx.send(|m| {
m.embed(|e| {
e.title("No Macro Recorded")
.description("Use `/macro record` to start recording a macro")
.color(*THEME_COLOR)
})
})
.await?;
} else {
let command_macro = contained.unwrap();
let json = serde_json::to_string(&command_macro.commands).unwrap();
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
command_macro.guild_id.0,
command_macro.name,
command_macro.description,
json
)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.send(|m| {
m.embed(|e| {
e.title("Macro Recorded")
.description("Use `/macro run` to execute the macro")
.color(*THEME_COLOR)
})
})
.await?;
}
}
{
let mut lock = ctx.data().recording_macros.write().await;
lock.remove(&key);
}
Ok(())
}
/// List recorded macros
#[poise::command(
slash_command,
rename = "list",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "list_macro"
)]
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
let macros = ctx.command_macros().await?;
let resp = show_macro_page(&macros, 0);
ctx.send(|m| {
*m = resp;
m
})
.await?;
Ok(())
}
/// Run a recorded macro
#[poise::command(
slash_command,
rename = "run",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "run_macro"
)]
pub async fn run_macro(
ctx: poise::ApplicationContext<'_, Data, Error>,
#[description = "Name of macro to run"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match guild_command_macro(&Context::Application(ctx), &name).await {
Some(command_macro) => {
ctx.defer_response(false).await?;
for command in command_macro.commands {
if let Some(action) = command.action {
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
.await
{
Ok(()) => {}
Err(e) => {
println!("{:?}", e);
}
}
} else {
Context::Application(ctx)
.say(format!("Command \"{}\" not found", command.command_name))
.await?;
}
}
}
None => {
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
}
}
Ok(())
}
/// Delete a recorded macro
#[poise::command(
slash_command,
rename = "delete",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "delete_macro"
)]
pub async fn delete_macro(
ctx: Context<'_>,
#[description = "Name of macro to delete"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match sqlx::query!(
"
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
ctx.guild_id().unwrap().0,
name
)
.fetch_one(&ctx.data().database)
.await
{
Ok(row) => {
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
}
Err(sqlx::Error::RowNotFound) => {
ctx.say(format!("Macro \"{}\" not found", name)).await?;
}
Err(e) => {
panic!("{}", e);
}
}
Ok(())
}
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
let mut skipped_char_count = 0;
macros
@ -396,15 +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,17 +7,20 @@ 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::{
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::{
@ -28,34 +31,30 @@ use crate::{
Reminder,
},
timer::Timer,
user_data::UserData,
CtxData,
},
time_parser::natural_parser,
utils::{check_guild_subscription, check_subscription},
SQLPool,
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);
@ -63,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!(
"Reminders in this channel have been silenced until **<t:{}:D>**",
timestamp
)),
)
.await;
ctx.say(format!(
"Reminders in this channel have been silenced until **<t:{}:D>**",
timestamp
))
.await?;
} else {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.content("Time could not be processed. Please write the time as clearly as possible"),
)
.await;
ctx.say(
"Time could not be processed. Please write the time as clearly as possible",
)
.await?;
}
}
_ => {
channel.paused = !channel.paused;
channel.paused_until = None;
channel.commit_changes(&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()
@ -167,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
@ -278,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) {
Some(channel.name)
} else {
None
};
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;
@ -327,41 +252,52 @@ async fn look(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
let pager = LookPager::new(flags, timezone);
invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new()
.embed(|e| {
e.title(format!(
"Reminders{}",
channel_name.map_or(String::new(), |n| format!(" on #{}", n))
))
.description(display)
.footer(|f| f.text(format!("Page {} of {}", 1, pages)))
.color(*THEME_COLOR)
})
.components(|comp| {
pager.create_button_row(pages, comp);
ctx.send(|r| {
r.ephemeral(true)
.embed(|e| {
e.title(format!(
"Reminders{}",
channel_name.map_or(String::new(), |n| format!(" on #{}", n))
))
.description(display)
.footer(|f| f.text(format!("Page {} of {}", 1, pages)))
.color(*THEME_COLOR)
})
.components(|comp| {
pager.create_button_row(pages, comp);
comp
}),
)
.await
.unwrap();
comp
})
})
.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 {
@ -386,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);
@ -448,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)
@ -486,290 +424,230 @@ pub fn show_delete_page(
})
})
})
});
reply
}
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);
let delta = (now - start_time).num_seconds();
let (minutes, seconds) = delta.div_rem(&60);
let (hours, minutes) = minutes.div_rem(&60);
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?;
}
Ok(())
}
#[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 {
let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
let now = NaiveDateTime::from_timestamp(unix_time, 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);
let delta = (now - start_time).num_seconds();
let count = Timer::count_from_owner(owner, &ctx.data().database).await;
let (minutes, seconds) = delta.div_rem(&60);
let (hours, minutes) = minutes.div_rem(&60);
let (days, hours) = hours.div_rem(&24);
if count >= 25 {
ctx.say("You already have 25 timers. Please delete some timers before creating a new one")
.await?;
} else {
if name.len() <= 32 {
Timer::create(&name, owner, &ctx.data().database).await;
format!("{} days, {:02}:{:02}:{:02}", days, hours, minutes, seconds)
ctx.say("Created a new timer").await?;
} else {
ctx.say(format!(
"Please name your timer something shorted (max. 32 characters, you used {})",
name.len()
))
.await?;
}
}
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let owner = invoke.guild_id().map(|g| g.0).unwrap_or_else(|| invoke.author_id().0);
match args.subcommand.clone().unwrap().as_str() {
"start" => {
let count = Timer::count_from_owner(owner, &pool).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;
} else {
let name = args.get("name").unwrap().to_string();
if name.len() <= 32 {
Timer::create(&name, owner, &pool).await;
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("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;
}
}
}
"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)
.await;
if exists.is_ok() {
sqlx::query!(
"
DELETE FROM timers WHERE owner = ? AND name = ?
",
owner,
name
)
.execute(&pool)
.await
.unwrap();
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("Deleted a timer"),
)
.await;
} else {
let _ = invoke
.respond(
ctx.http.clone(),
CreateGenericResponse::new().content("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
/// Delete a timer
#[poise::command(
slash_command,
rename = "delete",
identifying_name = "delete_timer",
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`"),
)
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;
return;
if exists.is_ok() {
sqlx::query!("DELETE FROM timers WHERE owner = ? AND name = ?", owner, name)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.say("Deleted a timer").await?;
} else {
ctx.say("Could not find a timer by that name").await?;
}
invoke.defer(&ctx).await;
Ok(())
}
let user_data = ctx.user_data(invoke.author_id()).await.unwrap();
let timezone = user_data.timezone();
/// Create a new reminder
#[poise::command(
slash_command,
identifying_name = "remind",
default_member_permissions = "MANAGE_GUILD"
)]
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?;
let time = {
let time_str = args.get("time").unwrap().to_string();
return Ok(());
}
natural_parser(&time_str, &timezone.to_string()).await
};
ctx.defer().await?;
let user_data = ctx.author_data().await.unwrap();
let timezone = ctx.timezone().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)
{
(
parse_duration(&repeat.to_string())
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`",
),
)
.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;
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 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)
.timezone(timezone)
.expires(expires)
.interval(interval);
.expires(processed_expires)
.interval(processed_interval);
builder.set_scopes(scopes);
@ -777,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| {
*c = embed;
c
}),
)
.await;
ctx.send(|m| {
m.embed(|c| {
*c = embed;
c
})
})
.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();
pub async fn todo_guild_add(
ctx: Context<'_>,
#[description = "The task to add to the todo list"] task: String,
) -> Result<(), Error> {
sqlx::query!(
"INSERT INTO todos (guild_id, value)
VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)",
ctx.guild_id().unwrap().0,
task
)
.execute(&ctx.data().database)
.await
.unwrap();
let keys = match args.subcommand_group.as_ref().unwrap().as_str() {
"server" => (None, None, invoke.guild_id().map(|g| g.0)),
"channel" => (None, Some(invoke.channel_id().0), invoke.guild_id().map(|g| g.0)),
_ => (Some(invoke.author_id().0), None, None),
};
ctx.say("Item added to todo list").await?;
match args.get("task") {
Some(task) => {
let task = task.to_string();
Ok(())
}
sqlx::query!(
"INSERT INTO todos (user_id, channel_id, guild_id, value) VALUES ((SELECT id FROM users WHERE user = ?), (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?), ?)",
keys.0,
keys.1,
keys.2,
task
)
.execute(&pool)
.await
.unwrap();
let _ = invoke
.respond(&ctx, CreateGenericResponse::new().content("Item added to todo list"))
.await;
}
None => {
let values = if let Some(uid) = keys.0 {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN users ON todos.user_id = users.id
WHERE users.user = ?",
uid,
)
.fetch_all(&pool)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>()
} else if let Some(cid) = keys.1 {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN channels ON todos.channel_id = channels.id
WHERE channels.channel = ?",
cid,
)
.fetch_all(&pool)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>()
} else {
sqlx::query!(
"SELECT todos.id, value FROM todos
/// View and remove from the server todo list
#[poise::command(
slash_command,
rename = "view",
guild_only = true,
identifying_name = "todo_guild_view",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> {
let values = sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
keys.2,
)
.fetch_all(&pool)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>()
};
ctx.guild_id().unwrap().0,
)
.fetch_all(&ctx.data().database)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>();
let resp = show_todo_page(&values, 0, keys.0, keys.1, keys.2);
let resp = show_todo_page(&values, 0, None, None, ctx.guild_id().map(|g| g.0));
invoke.respond(&ctx, resp).await.unwrap();
}
}
}
ctx.send(|r| {
*r = resp;
r
})
.await?;
Ok(())
}
/// Manage the channel todo list
#[poise::command(
slash_command,
rename = "channel",
guild_only = true,
identifying_name = "todo_channel_base",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn todo_channel_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Add an item to the channel todo list
#[poise::command(
slash_command,
rename = "add",
guild_only = true,
identifying_name = "todo_channel_add",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn todo_channel_add(
ctx: Context<'_>,
#[description = "The task to add to the todo list"] task: String,
) -> Result<(), Error> {
sqlx::query!(
"INSERT INTO todos (guild_id, channel_id, value)
VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)",
ctx.guild_id().unwrap().0,
ctx.channel_id().0,
task
)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.say("Item added to todo list").await?;
Ok(())
}
/// View and remove from the channel todo list
#[poise::command(
slash_command,
rename = "view",
guild_only = true,
identifying_name = "todo_channel_view",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn todo_channel_view(ctx: Context<'_>) -> Result<(), Error> {
let values = sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN channels ON todos.channel_id = channels.id
WHERE channels.channel = ?",
ctx.channel_id().0,
)
.fetch_all(&ctx.data().database)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>();
let resp =
show_todo_page(&values, 0, None, Some(ctx.channel_id().0), ctx.guild_id().map(|g| g.0));
ctx.send(|r| {
*r = resp;
r
})
.await?;
Ok(())
}
/// Manage your personal todo list
#[poise::command(slash_command, rename = "user", identifying_name = "todo_user_base")]
pub async fn todo_user_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Add an item to your personal todo list
#[poise::command(slash_command, rename = "add", identifying_name = "todo_user_add")]
pub async fn todo_user_add(
ctx: Context<'_>,
#[description = "The task to add to the todo list"] task: String,
) -> Result<(), Error> {
sqlx::query!(
"INSERT INTO todos (user_id, value)
VALUES ((SELECT id FROM users WHERE user = ?), ?)",
ctx.author().id.0,
task
)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.say("Item added to todo list").await?;
Ok(())
}
/// View and remove from your personal todo list
#[poise::command(slash_command, rename = "view", identifying_name = "todo_user_view")]
pub async fn todo_user_view(ctx: Context<'_>) -> Result<(), Error> {
let values = sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN users ON todos.user_id = users.id
WHERE users.user = ?",
ctx.author().id.0,
)
.fetch_all(&ctx.data().database)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>();
let resp = show_todo_page(&values, 0, Some(ctx.author().id.0), None, None);
ctx.send(|r| {
*r = resp;
r
})
.await?;
Ok(())
}
pub fn max_todo_page(todo_values: &[(usize, String)]) -> usize {
@ -164,7 +247,7 @@ pub fn show_todo_page(
user_id: Option<u64>,
channel_id: Option<u64>,
guild_id: Option<u64>,
) -> CreateGenericResponse {
) -> CreateReply {
let pager = TodoPager::new(page, user_id, channel_id, guild_id);
let pages = max_todo_page(todo_values);
@ -219,17 +302,23 @@ pub fn show_todo_page(
};
if todo_ids.is_empty() {
CreateGenericResponse::new().embed(|e| {
let mut reply = CreateReply::default();
reply.embed(|e| {
e.title(format!("{} Todo List", title))
.description("Todo List Empty!")
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
.color(*THEME_COLOR)
})
});
reply
} else {
let todo_selector =
ComponentDataModel::TodoSelector(TodoSelector { page, user_id, channel_id, guild_id });
CreateGenericResponse::new()
let mut reply = CreateReply::default();
reply
.embed(|e| {
e.title(format!("{} Todo List", title))
.description(display)
@ -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,4 +1,6 @@
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;
@ -6,11 +8,12 @@ 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::model::prelude::AttachmentType;
lazy_static! {
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (

View File

@ -1,161 +1,126 @@
use std::{collections::HashMap, env, sync::atomic::Ordering};
use log::{info, warn};
use serenity::{
async_trait,
client::{Context, EventHandler},
model::{
channel::GuildChannel,
gateway::{Activity, Ready},
guild::{Guild, UnavailableGuild},
id::GuildId,
interactions::Interaction,
},
utils::shard_id,
use log::{error, info, warn};
use poise::{
serenity::{model::interactions::Interaction, utils::shard_id},
serenity_prelude as serenity,
};
use crate::{ComponentDataModel, Handler, RegexFramework, ReqwestClient, SQLPool};
use crate::{component_models::ComponentDataModel, Data, Error};
#[async_trait]
impl EventHandler for Handler {
async fn cache_ready(&self, ctx_base: Context, _guilds: Vec<GuildId>) {
info!("Cache Ready!");
info!("Preparing to send reminders");
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 !self.is_loop_running.load(Ordering::Relaxed) {
let ctx1 = ctx_base.clone();
let ctx2 = ctx_base.clone();
if !data.is_loop_running.load(Ordering::Relaxed) {
let kill_tx = data.broadcast.clone();
let kill_recv = data.broadcast.subscribe();
let pool1 = ctx1.data.read().await.get::<SQLPool>().cloned().unwrap();
let pool2 = ctx2.data.read().await.get::<SQLPool>().cloned().unwrap();
let ctx1 = ctx.clone();
let ctx2 = ctx.clone();
let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string());
let pool1 = data.database.clone();
let pool2 = data.database.clone();
if !run_settings.contains("postman") {
tokio::spawn(async move {
postman::initialize(ctx1, &pool1).await;
});
} else {
warn!("Not running postman")
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);
}
if !run_settings.contains("web") {
tokio::spawn(async move {
reminder_web::initialize(ctx2, pool2).await.unwrap();
});
} else {
warn!("Not running web")
}
self.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();
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()
sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id)
.execute(&data.database)
.await
.get::<ReqwestClient>()
.cloned()
.expect("Could not get ReqwestClient from data");
.unwrap();
let response = client
.post(
format!(
"https://top.gg/api/bots/{}/stats",
ctx.cache.current_user_id().as_u64()
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(),
)
.as_str(),
)
.header("Authorization", token)
.json(&hm)
.send()
.await;
.header("Authorization", token)
.json(&hm)
.send()
.await;
if let Err(res) = response {
println!("DiscordBots Response: {:?}", res);
if let Err(res) = response {
println!("DiscordBots Response: {:?}", res);
}
}
}
}
}
async fn guild_delete(&self, ctx: Context, incomplete: UnavailableGuild, _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;
}
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, component).await;
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"),
)
.await;
async fn macro_check(ctx: Context<'_>) -> bool {
if let Context::Application(app_ctx) = ctx {
if let Some(guild_id) = ctx.guild_id() {
if ctx.command().identifying_name != "finish_macro" {
let mut lock = ctx.data().recording_macros.write().await;
HookResult::Halt
}
}
if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) {
if command_macro.commands.len() >= MACRO_MAX_COMMANDS {
let _ = ctx.send(|m| {
m.ephemeral(true).content(
format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS),
)
})
.await;
} else {
let recorded = RecordedCommand {
action: None,
command_name: ctx.command().identifying_name.clone(),
options: Vec::from(app_ctx.args),
};
#[check]
pub async fn macro_check(
ctx: &Context,
invoke: &mut CommandInvoke,
args: &CommandOptions,
) -> HookResult {
if let Some(guild_id) = invoke.guild_id() {
if args.command != moderation_cmds::MACRO_CMD_COMMAND.names[0] {
let active_recordings =
ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap();
let mut lock = active_recordings.write().await;
command_macro.commands.push(recorded);
if let Some(command_macro) = lock.get_mut(&(guild_id, invoke.author_id())) {
if command_macro.commands.len() >= 5 {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content("5 commands already recorded. Please use `/macro finish` to end recording."),
)
.await;
let _ = ctx
.send(|m| m.ephemeral(true).content("Command recorded to macro"))
.await;
}
false
} else {
command_macro.commands.push(args.clone());
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content("Command recorded to macro"),
)
.await;
true
}
HookResult::Halt
} else {
HookResult::Continue
true
}
} else {
HookResult::Continue
true
}
} else {
HookResult::Continue
true
}
}
#[check]
pub async fn check_self_permissions(
ctx: &Context,
invoke: &mut CommandInvoke,
_args: &CommandOptions,
) -> HookResult {
if let Some(guild) = invoke.guild(&ctx) {
let user_id = ctx.cache.current_user_id();
async fn check_self_permissions(ctx: Context<'_>) -> bool {
if let Some(guild) = ctx.guild() {
let user_id = ctx.discord().cache.current_user_id();
let manage_webhooks =
guild.member_permissions(&ctx, user_id).await.map_or(false, |p| p.manage_webhooks());
let (view_channel, send_messages, embed_links) = invoke
let manage_webhooks = guild
.member_permissions(&ctx.discord(), user_id)
.await
.map_or(false, |p| p.manage_webhooks());
let (view_channel, send_messages, embed_links) = ctx
.channel_id()
.to_channel_cached(&ctx)
.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)
}

View File

@ -1,5 +1,9 @@
/*
Copyright 2021 Paul Colomiets, 2022 Jude Southworth
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,

View File

@ -6,7 +6,6 @@ mod commands;
mod component_models;
mod consts;
mod event_handlers;
mod framework;
mod hooks;
mod interval_parser;
mod models;
@ -16,145 +15,188 @@ mod utils;
use std::{
collections::HashMap,
env,
sync::{atomic::AtomicBool, Arc},
error::Error as StdError,
fmt::{Debug, Display, Formatter},
sync::atomic::AtomicBool,
};
use chrono_tz::Tz;
use dotenv::dotenv;
use log::info;
use serenity::{
client::Client,
http::client::Http,
model::{
gateway::GatewayIntents,
id::{GuildId, UserId},
},
prelude::TypeMapKey,
use poise::serenity::model::{
gateway::{Activity, GatewayIntents},
id::{GuildId, UserId},
};
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::THEME_COLOR,
framework::RegexFramework,
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>;
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 {
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<()>,
}
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 application_id = {
let http = Http::new_with_token(&token);
http.get_current_application_info().await?.id
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 dm_enabled = env::var("DM_ENABLED").map_or(true, |var| var == "1");
let database =
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
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 popular_timezones = sqlx::query!(
"SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21"
)
.fetch_all(&database)
.await
.unwrap()
.iter()
.map(|t| t.timezone.parse::<Tz>().unwrap())
.collect::<Vec<Tz>>();
let framework_arc = Arc::new(framework);
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;
let mut client = Client::builder(&token)
register_application_commands(
ctx,
framework,
env::var("DEBUG_GUILD")
.map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid")))
.ok(),
)
.await
.unwrap();
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)
.application_id(application_id.0)
.event_handler(Handler { is_loop_running: AtomicBool::from(false) })
.await
.expect("Error occurred creating client");
{
let pool = MySqlPool::connect(
&env::var("DATABASE_URL").expect("Missing DATABASE_URL from environment"),
)
.await
.unwrap();
let popular_timezones = sqlx::query!(
"SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21"
)
.fetch_all(&pool)
.await
.unwrap()
.iter()
.map(|t| t.timezone.parse::<Tz>().unwrap())
.collect::<Vec<Tz>>();
let mut data = client.data.write().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())));
}
framework_arc.build_slash(&client.cache_and_http.http).await;
info!("Starting client as autosharded");
client.start_autosharded().await?;
.run_autosharded()
.await?;
Ok(())
}

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();
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_one(&ctx.data().database)
.await
.ok()?;
sqlx::query!(
"SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
guild_id.0
)
.fetch_all(&pool)
.await
.unwrap()
.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>>()
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()
.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,15 +14,14 @@ use serenity::{
use sqlx::MySqlPool;
use crate::{
consts,
consts::{DAY, 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(
@ -31,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)]
@ -145,7 +143,7 @@ pub struct MultiReminderBuilder<'a> {
expires: Option<NaiveDateTime>,
content: Content,
set_by: Option<u32>,
ctx: &'a Context,
ctx: &'a Context<'a>,
guild_id: Option<GuildId>,
}
@ -210,8 +208,6 @@ 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();
@ -225,12 +221,17 @@ impl<'a> MultiReminderBuilder<'a> {
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)
@ -243,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)
}
@ -282,7 +293,7 @@ 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,

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,15 +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::look_flags::{LookFlags, TimeDisplayType},
SQLPool,
Database,
};
#[derive(Debug, Clone)]
@ -33,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,
"
@ -70,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();
@ -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
@ -163,7 +167,7 @@ WHERE
",
channels
)
.fetch_all(&pool)
.fetch_all(pool)
.await
} else {
sqlx::query_as_unchecked!(
@ -196,7 +200,7 @@ WHERE
",
guild_id.as_u64()
)
.fetch_all(&pool)
.fetch_all(pool)
.await
}
} else {
@ -230,7 +234,7 @@ WHERE
",
user.as_u64()
)
.fetch_all(&pool)
.fetch_all(pool)
.await
}
.unwrap()

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
)

View File

@ -1,9 +1,42 @@
use serenity::{
http::CacheHttp,
model::id::{GuildId, UserId},
use poise::{
serenity::{
builder::CreateApplicationCommands,
http::CacheHttp,
model::id::{GuildId, UserId},
},
serenity_prelude as serenity,
};
use crate::consts::{CNC_GUILD, SUBSCRIPTION_ROLES};
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 {
@ -35,3 +68,40 @@ pub async fn check_guild_subscription(
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);
}
}

View File

@ -7,11 +7,15 @@ 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 = { git = "https://github.com/serenity-rs/serenity", branch = "next", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
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"] }
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono"] }
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"

View File

@ -1,4 +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 DISCORD_CDN: &'static str = "https://cdn.discordapp.com/avatars";
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);
}

View File

@ -2,22 +2,36 @@
extern crate rocket;
mod consts;
#[macro_use]
mod macros;
mod routes;
use rocket::fs::{relative, FileServer};
use std::collections::HashMap;
use std::{collections::HashMap, env};
use oauth2::basic::BasicClient;
use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use crate::consts::{DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN};
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;
use serenity::{
client::Context,
http::CacheHttp,
model::id::{GuildId, UserId},
};
use sqlx::{MySql, Pool};
use std::env;
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();
@ -36,6 +50,16 @@ async fn not_found() -> Template {
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();
@ -43,9 +67,17 @@ async fn internal_server_error() -> Template {
}
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")?)),
@ -58,7 +90,17 @@ pub async fn initialize(
rocket::build()
.attach(Template::fairing())
.register("/", catchers![not_authorized, forbidden, not_found, internal_server_error])
.register(
"/",
catchers![
not_authorized,
forbidden,
not_found,
internal_server_error,
unprocessable_entity,
payload_too_large,
],
)
.manage(oauth2_client)
.manage(reqwest_client)
.manage(serenity_context)
@ -71,24 +113,36 @@ pub async fn initialize(
routes::cookies,
routes::privacy,
routes::terms,
routes::help,
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::user::create_reminder,
routes::dashboard::user::get_reminders,
routes::dashboard::user::overwrite_reminder,
routes::dashboard::user::delete_reminder,
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,
@ -98,5 +152,45 @@ pub async fn initialize(
.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

@ -1,14 +1,35 @@
use rocket::State;
use std::env;
use crate::consts::DISCORD_CDN;
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 super::Reminder;
use rocket::serde::json::{json, Json, Value as JsonValue};
use serenity::client::Context;
use serenity::http::CacheHttp;
use serenity::model::id::GuildId;
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 {
@ -18,61 +39,69 @@ struct ChannelInfo {
webhook_name: Option<String>,
}
// todo check the user can access this guild
#[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>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
let channels_res = GuildId(id).channels(ctx.inner()).await;
check_authorization!(cookies, ctx.inner(), id);
match channels_res {
Ok(channels) => {
let mut channel_info = vec![];
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)>>();
for (channel_id, channel) in
channels.iter().filter(|(_, channel)| channel.is_text_based())
{
let mut ch = ChannelInfo {
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,
};
if let Ok(webhook_details) = sqlx::query!(
"SELECT webhook_id, webhook_token FROM channels WHERE channel = ?",
channel.id.as_u64()
)
.fetch_one(pool.inner())
.await
{
if let (Some(webhook_id), Some(webhook_token)) =
(webhook_details.webhook_id, webhook_details.webhook_token)
{
let webhook_res =
ctx.http.get_webhook_with_token(webhook_id, &webhook_token).await;
if let Ok(webhook) = webhook_res {
ch.webhook_avatar = webhook.avatar.map(|a| {
format!("{}/{}/{}.webp?size=128", DISCORD_CDN, webhook_id, a)
});
ch.webhook_name = webhook.name;
}
}
}
channel_info.push(ch);
}
})
.collect::<Vec<ChannelInfo>>();
json!(channel_info)
}
Err(e) => {
warn!("Could not fetch channels from {}: {:?}", id, e);
json!({"error": "Could not get channels"})
None => {
json!({"error": "Bot not in guild"})
}
}
}
@ -83,9 +112,10 @@ struct RoleInfo {
name: String,
}
// todo check the user can access this guild
#[get("/api/guild/<id>/roles")]
pub async fn get_guild_roles(id: u64, ctx: &State<Context>) -> JsonValue {
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 {
@ -105,14 +135,376 @@ pub async fn get_guild_roles(id: u64, ctx: &State<Context>) -> JsonValue {
}
}
#[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 {
json!({"error": "Not implemented"})
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")]
@ -130,42 +522,36 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq
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.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, ?)
",
"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())
@ -188,18 +574,160 @@ WHERE
#[patch("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn edit_reminder(
id: u64,
reminder: Json<Reminder>,
reminder: Json<PatchReminder>,
serenity_context: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
json!({"error": "Not implemented"})
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/<id>/reminders", data = "<reminder>")]
#[delete("/api/guild/<_>/reminders", data = "<reminder>")]
pub async fn delete_reminder(
id: u64,
reminder: Json<Reminder>,
reminder: Json<DeleteReminder>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
json!({"error": "Not implemented"})
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

@ -1,19 +1,80 @@
use std::collections::HashMap;
use chrono::naive::NaiveDateTime;
use rocket::http::CookieJar;
use rocket::response::Redirect;
use rand::{rngs::OsRng, seq::IteratorRandom};
use rocket::{http::CookieJar, response::Redirect};
use rocket_dyn_templates::Template;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
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>,
@ -29,25 +90,90 @@ pub struct Reminder {
embed_image_url: Option<String>,
embed_thumbnail_url: Option<String>,
embed_title: String,
enabled: i8,
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: i8,
restartable: i8,
tts: i8,
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;
use std::str::FromStr;
use std::{fmt::Display, str::FromStr};
use serde::{de, Deserialize, Deserializer, Serializer};
@ -69,11 +195,104 @@ mod string {
}
}
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() {
@ -83,3 +302,13 @@ pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirec
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

@ -1,22 +1,23 @@
use rocket::serde::json::{json, Json, Value as JsonValue};
use rocket::{http::CookieJar, State};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serenity::model::{
id::{GuildId, RoleId},
permissions::Permissions,
};
use sqlx::{MySql, Pool};
use std::env;
use super::Reminder;
use crate::consts::DISCORD_API;
use crate::routes::dashboard::DeleteReminder;
use chrono_tz::Tz;
use serenity::client::Context;
use serenity::model::id::UserId;
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 {
@ -162,241 +163,3 @@ pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Cli
json!({"error": "Not authorized"})
}
}
#[post("/api/user/reminders", data = "<reminder>")]
pub async fn create_reminder(
reminder: Json<Reminder>,
ctx: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
match sqlx::query!(
"INSERT INTO reminders (
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
enabled,
expires,
interval_seconds,
interval_months,
name,
pin,
restartable,
tts,
username,
`utc_time`
) VALUES (
avatar = ?,
content = ?,
embed_author = ?,
embed_author_url = ?,
embed_color = ?,
embed_description = ?,
embed_footer = ?,
embed_footer_url = ?,
embed_image_url = ?,
embed_thumbnail_url = ?,
embed_title = ?,
enabled = ?,
expires = ?,
interval_seconds = ?,
interval_months = ?,
name = ?,
pin = ?,
restartable = ?,
tts = ?,
username = ?,
`utc_time` = ?
)",
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.enabled,
reminder.expires,
reminder.interval_seconds,
reminder.interval_months,
reminder.name,
reminder.pin,
reminder.restartable,
reminder.tts,
reminder.username,
reminder.utc_time,
)
.execute(pool.inner())
.await
{
Ok(_) => {
json!({})
}
Err(e) => {
warn!("Error in `create_reminder`: {:?}", e);
json!({"error": "Could not create reminder"})
}
}
}
#[get("/api/user/reminders")]
pub async fn get_reminders(
pool: &State<Pool<MySql>>,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
) -> JsonValue {
if let Some(user_id) =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten()
{
let query_res = sqlx::query!(
"SELECT channel FROM channels INNER JOIN users ON users.dm_channel = channels.id WHERE users.user = ?",
user_id
)
.fetch_one(pool.inner())
.await;
let dm_channel = if let Ok(query) = query_res {
Some(query.channel)
} else {
if let Ok(dm_channel) = UserId(user_id).create_dm_channel(&ctx.inner()).await {
Some(dm_channel.id.as_u64().to_owned())
} else {
None
}
};
if let Some(channel_id) = dm_channel {
let reminders = sqlx::query_as!(
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.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 INNER JOIN channels ON channels.id = reminders.channel_id WHERE channels.channel = ?",
channel_id
)
.fetch_all(pool.inner())
.await
.unwrap_or(vec![]);
json!(reminders)
} else {
json!({"error": "User's DM channel could not be determined"})
}
} else {
json!({"error": "Not authorized"})
}
}
#[put("/api/user/reminders", data = "<reminder>")]
pub async fn overwrite_reminder(reminder: Json<Reminder>, pool: &State<Pool<MySql>>) -> JsonValue {
match sqlx::query!(
"UPDATE reminders SET
avatar = ?,
content = ?,
embed_author = ?,
embed_author_url = ?,
embed_color = ?,
embed_description = ?,
embed_footer = ?,
embed_footer_url = ?,
embed_image_url = ?,
embed_thumbnail_url = ?,
embed_title = ?,
enabled = ?,
expires = ?,
interval_seconds = ?,
interval_months = ?,
name = ?,
pin = ?,
restartable = ?,
tts = ?,
username = ?,
`utc_time` = ?
WHERE uid = ?",
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.enabled,
reminder.expires,
reminder.interval_seconds,
reminder.interval_months,
reminder.name,
reminder.pin,
reminder.restartable,
reminder.tts,
reminder.username,
reminder.utc_time,
reminder.uid
)
.execute(pool.inner())
.await
{
Ok(_) => {
json!({})
}
Err(e) => {
warn!("Error in `overwrite_reminder`: {:?}", e);
json!({"error": "Could not modify reminder"})
}
}
}
#[delete("/api/user/reminders", data = "<reminder>")]
pub async fn delete_reminder(
reminder: Json<DeleteReminder>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
if sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid)
.execute(pool.inner())
.await
.is_ok()
{
json!({})
} else {
json!({"error": "Could not delete reminder"})
}
}

View File

@ -1,18 +1,18 @@
use crate::consts::DISCORD_API;
use log::warn;
use oauth2::basic::BasicClient;
use oauth2::reqwest::async_http_client;
use oauth2::{
AuthorizationCode, CsrfToken, PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse,
basic::BasicClient, reqwest::async_http_client, AuthorizationCode, CsrfToken,
PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse,
};
use reqwest::Client;
use rocket::http::private::cookie::Expiration;
use rocket::http::{Cookie, CookieJar, SameSite};
use rocket::response::{Flash, Redirect};
use rocket::uri;
use rocket::State;
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>,

View File

@ -1,9 +1,10 @@
pub mod dashboard;
pub mod login;
use std::collections::HashMap;
use rocket::request::FlashMessage;
use rocket_dyn_templates::Template;
use std::collections::HashMap;
#[get("/")]
pub async fn index(flash: Option<FlashMessage<'_>>) -> Template {
@ -44,8 +45,44 @@ pub async fn terms() -> Template {
Template::render("terms", &map)
}
#[get("/help")]
#[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)
}

View File

@ -3,52 +3,61 @@
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;
}

View File

@ -2,6 +2,89 @@
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;
@ -9,6 +92,33 @@ div.reminderContent {
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;
@ -52,6 +162,15 @@ span.patreon-color {
color: #f96854;
}
p.pageTitle {
margin-left: 12px;
}
#welcome > div {
height: 100%;
padding-top: 30vh;
}
div#pageNavbar {
background-color: #363636;
}
@ -71,7 +190,7 @@ img.rounded-corners {
div.brand {
text-align: center;
height: 48px;
height: 52px;
background-color: #8fb677;
}
@ -87,24 +206,61 @@ div.dashboard-sidebar {
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;
height: 100vh;
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;
}
@ -254,7 +410,6 @@ textarea, input {
border-radius: 4px;
border-left: 4px solid #fff;
background-color: #2f3136;
width: 500px;
}
.embed-author-box {
@ -340,36 +495,6 @@ textarea, input {
flex-wrap: wrap;
}
.icon-toggle {
color: #fff;
opacity: 0.2;
}
.preview-toggle {
color: #fcb620;
opacity: 0.2;
}
a.level-item.preview-toggle:hover {
color: #fcb620;
}
.preview-toggle.is-active {
opacity: 1;
}
a.level-item.icon-toggle:hover {
color: inherit;
}
a.level-item:hover {
color: rgb(55, 127, 242)
}
.icon-toggle.is-active {
opacity: 1;
}
.channel-select {
font-size: 1.125rem;
margin-bottom: 4px;
@ -381,3 +506,73 @@ a.level-item:hover {
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;
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 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

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 = "";
});
});
}
});

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

View File

@ -24,7 +24,6 @@
<link rel="stylesheet" href="/static/css/fa.css">
<link rel="stylesheet" href="/static/css/font.css">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% if flashed_message %}
@ -37,7 +36,7 @@
<div class="navbar-brand">
<a class="navbar-item" href="/">
<figure class="image">
<img src="/static/img/logo_flat.jpg" alt="Reminder Bot Logo" class="is-rounded" style="width: auto;">
<img src="/static/img/logo_flat.webp" alt="Reminder Bot Logo" class="is-rounded" style="width: auto;">
</figure>
</a>
@ -49,22 +48,25 @@
</div>
<div class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item">
<a class="navbar-item" href="https://invite.reminder-bot.com">
<i class="fas fa-plus"></i>
</a>
<a class="navbar-item">
<a class="navbar-item" href="https://github.com/jellywx">
<i class="fab fa-github"></i>
</a>
<a class="navbar-item">
<a class="navbar-item" href="https://discord.jellywx.com">
<i class="fab fa-discord"></i>
</a>
<a class="navbar-item" href="/help">
<i class="fas fa-book"></i>
</a>
</div>
<div class="navbar-end">
<div class="navbar-item">
<a class="button is-rounded is-light" href="/dashboard">
<p>
Dashboard <span class="icon"><i class="fas fa-chevron-right"></i></span>
<span>Dashboard</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
@ -72,15 +74,18 @@
</div>
<div class="navbar-menu is-hidden-desktop" id="pageNavbar">
<a class="navbar-item">
<a class="navbar-item" href="https://invite.reminder-bot.com">
<i class="fas fa-plus"></i>
</a>
<a class="navbar-item">
<a class="navbar-item" href="https://github.com/jellywx">
<i class="fab fa-github"></i>
</a>
<a class="navbar-item">
<a class="navbar-item" href="https://discord.jellywx.com">
<i class="fab fa-discord"></i>
</a>
<a class="navbar-item" href="/help">
<i class="fas fa-book"></i>
</a>
<div class="navbar-item">
<a href="/dashboard">
@ -109,7 +114,7 @@
<div class="hero-foot has-text-centered">
<a class="button is-size-4 is-rounded is-success" href="https://invite.reminder-bot.com">
<p class="is-size-4">
Add to your Server <span class="icon"><i class="fas fa-chevron-right"></i></span>
<span>Add to your Server</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
@ -117,7 +122,7 @@
<div class="hero-foot has-text-centered">
<a class="button is-size-4 is-rounded is-primary" href="https://discord.jellywx.com">
<p class="is-size-4">
Join Discord <span class="icon"><i class="fas fa-chevron-right"></i></span>
<span>Join Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
@ -125,7 +130,7 @@
<div class="hero-foot has-text-centered">
<a class="button is-size-4 is-rounded is-light" href="/oauth/login">
<p class="is-size-4">
Login with Discord <span class="icon"><i class="fas fa-chevron-right"></i></span>
<span>Login with Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
@ -153,9 +158,6 @@
<a href="https://patreon.com/jellywx"><strong>Patreon</strong></a> | <a href="https://discord.jellywx.com"><strong>Discord</strong></a> | <a href="https://github.com/JellyWX"><strong>GitHub</strong></a>
<br>
or, <a href="mailto:jude@jellywx.com">Email me</a>
<br>
<span class="icon"><i class="fab fa-monero"></i></span>
<strong>XMR</strong> 49oP6DzzEzdC6VkxE6hGoLSRw5awvEx5cGGXH327tck15LWk8SFgLUAjS2yZqssMWN3JPRraR68ApHi4GGSLtCDmLbF7euH
</p>
</div>
</footer>

View File

@ -12,7 +12,7 @@
<section class="section">
<div class="container">
<h2 class="title">User Data</h2>
<h2 class="title">User data</h2>
<p class="is-size-5 pl-6">
This website uses some necessary cookies and session data to operate. None of this can be disabled, since
it is all necessary for the site to function. <strong>However,</strong> it is worth mentioning that all of
@ -38,7 +38,7 @@
<section class="section">
<div class="container">
<h2 class="title">Session Storage</h2>
<h2 class="title">Session storage</h2>
<p class="is-size-5 pl-6">
Session data are data that is stored just for the active browser session. Session storage is read and
written by our server and cannot be modified on your computer.
@ -51,7 +51,7 @@
<section class="section">
<div class="container">
<h2 class="title">How Can We Trust You?</h2>
<h2 class="title">How can we trust you?</h2>
<p class="is-size-5 pl-6">
Feel free to audit this website. Go to our <a href="https://github.com/reminder-bot">GitHub</a> to get started, or just press <kbd>F12</kbd>
</p>

View File

@ -6,6 +6,7 @@
<meta charset="UTF-8">
<meta name="yandex-verification" content="bb77b8681eb64a90"/>
<meta name="google-site-verification" content="7h7UVTeEe0AOzHiH3cFtsqMULYGN-zCZdMT_YCkW1Ho"/>
<!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; font-src fonts.gstatic.com 'self'"> -->
<!-- favicon -->
<link rel="apple-touch-icon" sizes="180x180"
@ -27,10 +28,7 @@
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/dtsel.css">
<script src="/static/js/iro.js"></script>
<script src="/static/js/dtsel.js"></script>
<script src="/static/js/luxon.min.js"></script>
</head>
<body>
<nav class="navbar is-spaced is-size-4 is-hidden-desktop dashboard-navbar" role="navigation"
@ -38,7 +36,7 @@
<div class="navbar-brand">
<a class="navbar-item" href="/">
<figure class="image">
<img src="/static/img/logo_flat.jpg" alt="Reminder Bot Logo">
<img src="/static/img/logo_flat.webp" alt="Reminder Bot Logo">
</figure>
</a>
@ -54,8 +52,21 @@
</div>
</nav>
<div id="loader" class="is-hidden hero is-fullheight">
<div class="hero-body">
<div class="container has-text-centered">
<p class="title">
<i class="fas fa-cog fa-spin"></i>
</p>
<p class="subtitle">
<strong>Loading...</strong>
</p>
</div>
</div>
</div>
<!-- dead image used to check which other images are dead -->
<img style="display: none;" src="" id="dead">
<img src="" id="dead">
<div class="notification is-danger flash-message" id="errors">
<span class="icon"><i class="far fa-exclamation-circle"></i></span> <span class="error-message"></span>
@ -72,7 +83,7 @@
<input class="input" id="urlInput" placeholder="Image URL...">
</section>
<footer class="modal-card-foot">
<button class="button is-success">Save</button>
<button class="button is-success" id="setImgUrl">Save</button>
<button class="button close-modal">Cancel</button>
</footer>
</div>
@ -87,7 +98,7 @@
<button class="delete close-modal" aria-label="close"></button>
</header>
<section class="modal-card-body">
<div style="display: flex; justify-content: center">
<div class="colorpicker-container">
<div id="colorpicker"></div>
</div>
<input class="input" id="colorInput">
@ -104,7 +115,7 @@
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<label class="modal-card-title" for="urlInput">Update Timezone</label>
<label class="modal-card-title" for="urlInput">Update Timezone <a href="/help/timezone"><span><i class="fa fa-question-circle"></i></span></a></label>
<button class="delete close-modal" aria-label="close"></button>
</header>
<section class="modal-card-body">
@ -114,7 +125,7 @@
<br>
Your browser timezone is: <strong><span class="browser-timezone">%browsertimezone%</span></strong> (<span class="browser-time">HH:mm</span>)
<br>
Your bot timezone is: <strong><span class="bot-timezone">%bottimezone</span></strong> (<span class="bot-time">HH:mm</span>)
Your bot timezone is: <strong><span class="bot-timezone">%bottimezone%</span></strong> (<span class="bot-time">HH:mm</span>)
</p>
<br>
<div class="has-text-centered">
@ -127,11 +138,76 @@
<button class="modal-close is-large close-modal" aria-label="close"></button>
</div>
<div class="modal" id="chooseTemplateModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<label class="modal-card-title" for="urlInput">Load Template</label>
<button class="delete close-modal" aria-label="close"></button>
</header>
<section class="modal-card-body">
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="templateSelect">
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-file-spreadsheet"></i>
</div>
</div>
<br>
<div class="has-text-centered">
<button class="button is-success close-modal" id="load-template">Load Template</button>
<button class="button is-danger" id="delete-template">Delete</button>
</div>
</section>
</div>
<button class="modal-close is-large close-modal" aria-label="close"></button>
</div>
<div class="modal" id="dataManagerModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<label class="modal-card-title" for="urlInput">Import/Export Manager <a href="/help/iemanager"><span><i class="fa fa-question-circle"></i></span></a></label>
<button class="delete close-modal" aria-label="close"></button>
</header>
<section class="modal-card-body">
<div class="has-text-centered">
<button class="button is-success is-outlined" id="import-data">Import Data</button>
<button class="button is-success" id="export-data">Export Data</button>
</div>
</section>
</div>
<button class="modal-close is-large close-modal" aria-label="close"></button>
</div>
<div class="modal" id="deleteReminderModal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<label class="modal-card-title">Delete Reminder</label>
<button class="delete close-modal" aria-label="close"></button>
</header>
<section class="modal-card-body">
<p>
This reminder will be permanently deleted. Are you sure?
</p>
<br>
<div class="has-text-centered">
<button class="button is-danger" id="delete-reminder-confirm">Delete</button>
<button class="button is-light close-modal">Cancel</button>
</div>
</section>
</div>
<button class="modal-close is-large close-modal" aria-label="close"></button>
</div>
<div class="columns is-gapless dashboard-frame">
<div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch" style="display: flex; flex-direction: column;">
<div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch">
<a href="/">
<div class="brand">
<img src="/static/img/logo_flat.jpg" alt="Reminder bot logo"
<img src="/static/img/logo_flat.webp" alt="Reminder bot logo"
class="dashboard-brand">
</div>
</a>
@ -141,29 +217,22 @@
d="M0,192L60,170.7C120,149,240,107,360,96C480,85,600,107,720,138.7C840,171,960,213,1080,197.3C1200,181,1320,107,1380,69.3L1440,32L1440,0L1380,0C1320,0,1200,0,1080,0C960,0,840,0,720,0C600,0,480,0,360,0C240,0,120,0,60,0L0,0Z"></path>
</g>
</svg>
<aside class="menu" style="display: flex; flex-direction: column; flex-grow: 1;">
<p class="menu-label">
Personal
</p>
<ul class="menu-list">
<li>
<a class="switch-pane" data-pane="personal">
<span class="icon"><i class="fas fa-map-pin"></i></span> @%username%
</a>
</li>
</ul>
<aside class="menu">
<p class="menu-label">
Servers
</p>
<ul class="menu-list guildList">
</ul>
<div class="aside-footer" style="margin-top: auto;">
<div class="aside-footer">
<p class="menu-label">
Settings
</p>
<ul class="menu-list">
<li>
<a class="show-modal" data-modal="dataManagerModal">
<span class="icon"><i class="fas fa-exchange"></i></span> Import/Export
</a>
<a class="show-modal" data-modal="chooseTimezoneModal">
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
</a>
@ -176,7 +245,7 @@
<div class="dashboard-sidebar mobile-sidebar is-hidden-desktop" id="mobileSidebar">
<a href="/">
<div class="brand">
<img src="/static/img/logo_flat.jpg" alt="Reminder bot logo"
<img src="/static/img/logo_flat.webp" alt="Reminder bot logo"
class="dashboard-brand">
</div>
</a>
@ -186,31 +255,24 @@
d="M0,192L60,170.7C120,149,240,107,360,96C480,85,600,107,720,138.7C840,171,960,213,1080,197.3C1200,181,1320,107,1380,69.3L1440,32L1440,0L1380,0C1320,0,1200,0,1080,0C960,0,840,0,720,0C600,0,480,0,360,0C240,0,120,0,60,0L0,0Z"></path>
</g>
</svg>
<aside class="menu" style="display: flex; flex-direction: column; flex-grow: 1;">
<p class="menu-label">
Personal
</p>
<ul class="menu-list">
<li>
<a class="switch-pane" data-pane="personal">
<span class="icon"><i class="fas fa-map-pin"></i></span> @%username%
</a>
</li>
</ul>
<aside class="menu">
<p class="menu-label">
Servers
</p>
<ul class="menu-list guildList">
</ul>
<div class="aside-footer" style="margin-top: auto;">
<div class="aside-footer">
<p class="menu-label">
Settings
</p>
<ul class="menu-list">
<li>
<a class="show-modal" data-modal="dataManagerModal">
<span class="icon"><i class="fas fa-exchange"></i></span> Import/Export
</a>
<a class="show-modal" data-modal="chooseTimezoneModal">
<span class="icon"><i class="fas fa-map-pin"></i></span> Timezone
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
</a>
</li>
</ul>
@ -220,58 +282,67 @@
<!-- main content -->
<div class="column is-main-content">
<p class="title pageTitle" style="margin-left: 12px;"></p>
<p class="title pageTitle"></p>
<section id="welcome">
<div class="has-text-centered" style="height: 100%; padding-top: 30vh;">
<div class="has-text-centered">
<p class="title">Welcome!</p>
<p class="subtitle is-hidden-touch">Select an option from the side to get started</p>
<p class="subtitle is-hidden-desktop">Press the <span class="icon"><i class="fal fa-bars"></i></span> to get started</p>
</div>
</section>
<section id="personal" class="is-hidden">
{% include "reminder_dashboard/reminder_dashboard_personal" %}
</section>
<section id="guild" class="is-hidden">
{% include "reminder_dashboard/reminder_dashboard" %}
</section>
<section id="guild-error" class="is-hidden hero is-fullheight">
<div class="hero-body">
<div class="container has-text-centered">
<p class="title">
We couldn't get this server's data
</p>
<p class="subtitle">
Please check Reminder Bot is in the server, and has correct permissions.
</p>
<a class="button is-size-4 is-rounded is-success" href="https://invite.reminder-bot.com">
<p class="is-size-4">
<span>Add to Server</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</div>
</section>
</div>
<!-- /main content -->
</div>
<footer class="footer">
<div class="content has-text-centered">
<p>
<strong>Reminder Bot</strong>, created by <a href="https://github.com/JellyWX"><strong>JellyWX</strong></a>
<br>
<a href="https://patreon.com/jellywx"><strong>Patreon</strong></a> | <a
href="https://discord.jellywx.com"><strong>Discord</strong></a> | <a
href="https://github.com/JellyWX"><strong>GitHub</strong></a>
<br>
or, <a href="mailto:jude@jellywx.com">Email me</a>
</p>
</div>
</footer>
<template id="embedFieldTemplate">
<div class="embed-field-box">
<label class="is-sr-only" for="embedFieldTitle">Field Title</label>
<textarea class="discord-field-title field-input message-input autoresize"
placeholder="Field Title..." rows="1"
maxlength="256" id="embedFieldTitle" name="embed_field_title[]"></textarea>
<div data-inlined="1" class="embed-field-box">
<div class="is-flex">
<label>
<span class="is-sr-only">Field Title</span>
<textarea class="discord-field-title field-input message-input autoresize"
placeholder="Field Title..." rows="1"
maxlength="256" name="embed_field_title[]"></textarea>
</label>
<button class="button is-small inline-btn">
<span class="is-sr-only">Toggle field inline</span><i class="fas fa-arrows-h"></i>
</button>
</div>
<label class="is-sr-only" for="embedFieldValue">Field Value</label>
<textarea
class="discord-field-value field-input message-input autoresize"
placeholder="Field Value..."
maxlength="1024" id="embedFieldValue" name="embed_field_value[]"
rows="1"></textarea>
<label>
<span class="is-sr-only">Field Value</span>
<textarea
class="discord-field-value field-input message-input autoresize"
placeholder="Field Value..."
maxlength="1024" name="embed_field_value[]"
rows="1"></textarea>
</label>
</div>
</template>
<template id="guildListEntry">
<li>
<a class="switch-pane" data-pane="guild">
<span class="icon"><i class="fas fa-map-pin"></i></span> %guildname%
<span class="icon"><i class="fas fa-map-pin"></i></span> <span class="guild-name">%guildname%</span>
</a>
</li>
</template>
@ -280,527 +351,12 @@
{% include "reminder_dashboard/guild_reminder" %}
</template>
<template id="personalReminder">
{% include "reminder_dashboard/personal_reminder" %}
</template>
<script src="/static/js/iro.js"></script>
<script src="/static/js/dtsel.js"></script>
<script>
<script src="/static/js/interval.js"></script>
<script src="/static/js/timezone.js" defer></script>
<script src="/static/js/main.js" defer></script>
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 {
for (let role of data) {
// todo
}
}
})
}
function fetch_reminders(guild_id) {
// fetch dm reminders instead
if (guild_id === undefined) {
const $reminderBox = document.querySelector('div#personalReminders');
// reset div contents
$reminderBox.innerHTML = '';
// fetch reminders
fetch('dashboard/api/user/reminders')
.then(response => response.json())
.then(data => {
if (data.error) {
show_error(data.error);
} else {
const $template = document.querySelector('template#personalReminder');
for (let reminder of data) {
let newFrame = $template.content.cloneNode(true);
for (let prop in reminder) {
if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) {
let $input = newFrame.querySelector(`*[name="${prop}"]`);
let $image = newFrame.querySelector(`img.${prop}`);
if ($input !== null) {
$input.value = reminder[prop];
} else if ($image !== null) {
$image.src = reminder[prop];
}
}
}
$reminderBox.append(newFrame);
}
}
});
} else {
const $reminderBox = document.querySelector('div#guildReminders');
// reset div contents
$reminderBox.innerHTML = '';
// fetch reminders
fetch(`dashboard/api/guild/${guild_id}/reminders`)
.then(response => response.json())
.then(data => {
if (data.error) {
show_error(data.error);
} else {
console.log(data);
const $template = document.querySelector('template#guildReminder');
for (let reminder of data) {
let newFrame = $template.content.cloneNode(true);
// populate channels
newFrame.querySelector('select.channel-selector');
for (let prop in reminder) {
if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) {
let $input = newFrame.querySelector(`*[name="${prop}"]`);
let $image = newFrame.querySelector(`img.${prop}`);
if ($input !== null) {
$input.value = reminder[prop];
} else if ($image !== null) {
$image.src = reminder[prop];
}
}
}
$reminderBox.appendChild(newFrame);
}
}
});
}
}
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);
}
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)
let colorPicker = new iro.ColorPicker('#colorpicker');
let $discordFrame;
const $colorPickerModal = document.querySelector('div#pickColorModal');
const $colorPickerInput = $colorPickerModal.querySelector('input');
let timezone = luxon.DateTime.now().zone.name;
const browserTimezone = luxon.DateTime.now().zone.name;
let botTimezone = 'UTC';
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();
}
});
});
$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;
});
document.querySelectorAll('.discord-embed').forEach((element) => {
element.addEventListener('click', (e) => {
if (e.offsetX < parseInt(window.getComputedStyle(element).borderLeftWidth)) {
$discordFrame = element;
$colorPickerModal.classList.toggle('is-active');
colorPicker.color.rgbString = window.getComputedStyle($discordFrame).borderLeftColor;
}
})
});
document.querySelectorAll('.set-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;
})
});
$colorPickerModal.querySelector('button.is-success').addEventListener('click', () => {
$discordFrame.style.borderLeftColor = colorPicker.color.rgbString;
$colorPickerModal.classList.remove('is-active')
});
document.querySelectorAll('a.show-modal').forEach((element) => {
element.addEventListener('click', (e) => {
e.preventDefault();
document.getElementById(element.dataset['modal']).classList.toggle('is-active');
})
})
document.addEventListener('DOMContentLoaded', () => {
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');
});
});
fetch('/dashboard/api/user')
.then(response => response.json())
.then(data => {
if (data.error) {
show_error(data.error);
} else {
document.querySelectorAll('a.switch-pane').forEach((element) => {
element.innerHTML = element.innerHTML.replace('%username%', data.name);
element.addEventListener('click', (e) => {
e.preventDefault();
switch_pane(element.dataset['pane']);
element.classList.add('is-active');
resize_textareas();
document.querySelectorAll('p.pageTitle').forEach((el) => {
el.textContent = 'Your Reminders';
});
});
});
if (data.timezone !== null) {
botTimezone = data.timezone;
}
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');
$anchor.innerHTML = $clone.querySelector('a').innerHTML.replace('%guildname%', guild.name);
$anchor.dataset['guild'] = guild.id;
$anchor.dataset['name'] = guild.name;
$anchor.addEventListener('click', (e) => {
e.preventDefault();
switch_pane($anchor.dataset['pane']);
reset_guild_pane();
fetch_roles($anchor.dataset['guild']);
fetch(`/dashboard/api/guild/${$anchor.dataset['guild']}/channels`)
.then(response => response.json())
.then(data => {
if (data.error) {
show_error(data.error);
} else {
document.querySelectorAll('select.channel-selector').forEach((el) => {
for (let channel of data) {
let newOption = document.createElement('option');
newOption.value = channel.id;
newOption.textContent = channel.name;
if (channel.webhook_avatar !== null) {
newOption.dataset['webhookAvatar'] = channel.webhook_avatar;
}
if (channel.webhook_name !== null) {
newOption.dataset['webhookName'] = channel.webhook_name;
}
el.appendChild(newOption);
}
update_select(el);
});
}
});
fetch_reminders($anchor.dataset['guild']);
document.querySelectorAll('p.pageTitle').forEach((el) => {
el.textContent = $anchor.dataset['name'] + ' Reminders';
});
document.querySelectorAll('select.channel-selector').forEach((el) => {
el.addEventListener('change', (e) => {
update_select(e.target);
})
});
$anchor.classList.add('is-active');
resize_textareas();
});
element.append($clone);
});
}
}
});
});
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.is-success').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.querySelectorAll('.customizable').forEach((element) => {
element.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
$img = element.querySelector('img');
$urlModal.classList.toggle('is-active')
});
});
document.querySelectorAll('a.icon-toggle').forEach((element) => {
element.addEventListener('click', (e) => {
e.preventDefault();
element.classList.toggle('is-active');
})
});
let $showButton = document.querySelector('button#showReminderCreator');
$showButton.addEventListener('click', () => {
$showButton.querySelector('span.icon i').classList.toggle('fa-chevron-right');
$showButton.querySelector('span.icon i').classList.toggle('fa-chevron-down');
document.querySelector('div#reminderCreator').classList.toggle('is-hidden');
});
document.querySelectorAll('.discord-field-title').forEach((element) => {
const $template = document.querySelector('template#embedFieldTemplate');
const $complement = element.parentElement.querySelector('.discord-field-value');
// when the user clicks out of the field title and if the field title/value are empty, remove the field
element.addEventListener('blur', () => {
if (element.value === '' && $complement.value === '' && element.parentElement.nextElementSibling !== null) {
element.parentElement.remove();
}
});
$complement.addEventListener('blur', () => {
if (element.value === '' && $complement.value === '' && element.parentElement.nextElementSibling !== null) {
element.parentElement.remove();
}
});
// when the user inputs into the end field, create a new field after it
element.addEventListener('input', () => {
if (element.value !== '' && $complement.value !== '' && element.parentElement.nextElementSibling === null) {
const $clone = $template.content.cloneNode(true);
element.parentElement.parentElement.append($clone);
}
});
$complement.addEventListener('input', () => {
if (element.value !== '' && $complement.value !== '' && element.parentElement.nextElementSibling === null) {
const $clone = $template.content.cloneNode(true);
element.parentElement.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('.discord-field-title').forEach((element) => {
const $template = document.querySelector('template#embedFieldTemplate');
const $complement = element.parentElement.querySelector('.discord-field-value');
// when the user clicks out of the field title and if the field title/value are empty, remove the field
element.addEventListener('blur', () => {
if (element.value === '' && $complement.value === '' && element.parentElement.nextElementSibling !== null) {
element.parentElement.remove();
}
});
$complement.addEventListener('blur', () => {
if (element.value === '' && $complement.value === '' && element.parentElement.nextElementSibling !== null) {
element.parentElement.remove();
}
});
// when the user inputs into the end field, create a new field after it
element.addEventListener('input', () => {
if (element.value !== '' && $complement.value !== '' && element.parentElement.nextElementSibling === null) {
const $clone = $template.content.cloneNode(true);
element.parentElement.parentElement.append($clone);
}
});
$complement.addEventListener('input', () => {
if (element.value !== '' && $complement.value !== '' && element.parentElement.nextElementSibling === null) {
const $clone = $template.content.cloneNode(true);
element.parentElement.parentElement.append($clone);
}
});
});
resize_textareas();
});
</script>
</body>
</html>

View File

@ -1,14 +1,98 @@
{% extends "base" %}
{% set title = "Commands" %}
{% set page_title = "Commands" %}
{% block init %}
{% set title = "Support" %}
{% set page_title = "Support Articles" %}
{% set page_subtitle = "Can't find what you're looking for? Join our Discord!" %}
{% set show_contact = true %}
{% endblock %}
{% block content %}
<section class="section">
<div class="container">
<h2 class="title">Information Commands</h2>
<div class="inset-content" >
<div class="tile is-ancestor">
<div class="tile is-parent">
<article class="tile is-child notification">
<p class="title">Timezone</p>
<p class="subtitle">Learn how to configure your timezone</p>
<div class="content has-text-centered">
<a class="button is-size-4 is-rounded is-light" href="/help/timezone">
<p class="is-size-4">
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</article>
</div>
<div class="tile is-parent">
<article class="tile is-child notification">
<p class="title">Creating reminders</p>
<p class="subtitle">Learn to create reminders for your server</p>
<div class="content has-text-centered">
<a class="button is-size-4 is-rounded is-light" href="/help/create_reminder">
<p class="is-size-4">
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</article>
</div>
<div class="tile is-parent is-vertical">
<article class="tile is-child notification">
<p class="title">Delete reminders</p>
<p class="subtitle">Learn to delete reminders that you've created</p>
<div class="content has-text-centered">
<a class="button is-size-4 is-rounded is-light" href="/help/delete_reminder">
<p class="is-size-4">
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</article>
</div>
</div>
</section>
<div class="tile is-ancestor">
<div class="tile is-parent">
<article class="tile is-child notification">
<p class="title">Timers</p>
<p class="subtitle">Learn to manage timers</p>
<div class="content has-text-centered">
<a class="button is-size-4 is-rounded is-light" href="/help/timers">
<p class="is-size-4">
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</article>
</div>
<div class="tile is-parent">
<article class="tile is-child notification">
<p class="title">Todo Lists</p>
<p class="subtitle">Learn to manage various todo lists</p>
<div class="content has-text-centered">
<a class="button is-size-4 is-rounded is-light" href="/help/todo_lists">
<p class="is-size-4">
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</article>
</div>
<div class="tile is-parent is-vertical">
<article class="tile is-child notification">
<p class="title">Macros</p>
<p class="subtitle">Learn how to create combination commands called macros, to suit advanced use cases</p>
<div class="content has-text-centered">
<a class="button is-size-4 is-rounded is-light" href="/help/macros">
<p class="is-size-4">
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</article>
</div>
</div>
</div>
{% endblock %}

View File

@ -27,7 +27,7 @@
<p class="title">Advanced Options <span class="icon"><i class="fad fa-palette"></i></span></p>
<p class="subtitle">Decorate your announcements with our web dashboard</p>
<figure class="image">
<img class="rounded-corners" src="/static/img/tournament-demo.png" alt="Discord slash commands demonstration">
<img class="rounded-corners" src="/static/img/tournament-demo.png" alt="Advanced options demonstration">
</figure>
</article>
</div>

View File

@ -12,8 +12,63 @@
<section class="section">
<div class="container">
<h2 class="title">Privacy Policy</h2>
<h2 class="title">Who we are</h2>
<p class="is-size-5 pl-6">
Reminder Bot is operated solely by Jude Southworth. You can contact me by email at
<a href="mailto:jude@jellywx.com">jude@jellywx.com</a>, or via private/public message on Discord at
<a href="https://discord.jellywx.com">https://discord.jellywx.com</a>.
</p>
</div>
</section>
<section class="section">
<div class="container">
<h2 class="title">What data we collect</h2>
<p class="is-size-5 pl-6">
Reminder Bot stores limited data necessary for the function of the bot. This data
is your <strong>unique user ID</strong>, <strong>timezone</strong>, and <strong>direct message channel</strong>.
<br>
<br>
Timezones are provided by the user or the user's browser.
</p>
</div>
</section>
<section class="section">
<div class="container">
<h2 class="title">Why we collect this data</h2>
<p class="is-size-5 pl-6">
Unique user IDs are stored to <strong>keep track of who sets reminders</strong>. User timezones are
stored to allow users to set reminders in their local timezone. Direct message channels are stored to
allow the setting of reminders for your direct message channel.
</p>
</div>
</section>
<section class="section">
<div class="container">
<h2 class="title">Who your data is shared with</h2>
<p class="is-size-5 pl-6">
Your data may also be guarded by the privacy policies of <strong>MEGA</strong>, our backup provider, and
<strong>Hetzner</strong>, our hosting provider.
</p>
</div>
</section>
<section class="section">
<div class="container">
<h2 class="title">Accessing or removing your data</h2>
<p class="is-size-5 pl-6">
Your timezone can be removed with the command <strong>/timezone UTC</strong>. Other data can be removed
on request. Please contact me.
<br>
<br>
Reminders created in a guild/channel will be removed automatically when the bot is removed from the
guild, the guild is deleted, or channel is deleted. Data is otherwise not removed automatically.
<br>
<br>
Reminders deleted with <strong>/del</strong> or via the dashboard are removed from the live database
instantly, but may persist in backups.
</p>
</div>
</section>

View File

@ -1,191 +1,256 @@
<div class="columns reminderContent">
<div class="columns reminderContent {% if creating %}creator{% endif %}">
<div class="column discord-frame">
<div class="">
<article class="media">
<figure class="media-left">
<p class="image is-32x32 customizable">
<a>
<img class="is-rounded discord-avatar" src="">
</a>
</p>
</figure>
<div class="media-content">
<div class="content">
<div class="discord-message-header">
<label class="is-sr-only" for="reminderUsername">Username Override</label>
<input class="discord-username message-input" placeholder="Username Override"
maxlength="32" id="reminderUsername" name="username">
</div>
<label class="is-sr-only" for="messageContent">Message</label>
<textarea class="message-input autoresize discord-content"
placeholder="Message Content..."
maxlength="2000" id="messageContent" name="content" rows="1"></textarea>
<article class="media">
<figure class="media-left">
<p class="image is-32x32 customizable">
<a>
<img class="is-rounded discord-avatar" src="/static/img/bg.webp" alt="Image for discord avatar">
</a>
</p>
</figure>
<div class="media-content">
<div class="content">
<div class="discord-message-header">
<label class="is-sr-only">Username Override</label>
<input class="discord-username message-input" placeholder="Username Override"
maxlength="32" name="username">
</div>
<label class="is-sr-only">Message</label>
<textarea class="message-input autoresize discord-content"
placeholder="Message Content..."
maxlength="2000" name="content" rows="1"></textarea>
<div class="discord-embed">
<div class="embed-body">
<div class="a">
<div class="embed-author-box">
<div class="a">
<p class="image is-24x24 customizable">
<a>
<img class="is-rounded" src="">
</a>
</p>
</div>
<div class="b">
<label class="is-sr-only" for="embedAuthor">Embed Author</label>
<textarea
class="discord-embed-author message-input autoresize"
placeholder="Embed Author..." rows="1" maxlength="256"
id="embedAuthor" name="embed_author"></textarea>
</div>
<div class="discord-embed">
<div class="embed-body">
<button class="change-color button is-rounded is-small">
<span class="is-sr-only">Choose embed color</span><i class="fas fa-eye-dropper"></i>
</button>
<div class="a">
<div class="embed-author-box">
<div class="a">
<p class="image is-24x24 customizable">
<a>
<img class="is-rounded embed_author_url" src="/static/img/bg.webp" alt="Image for embed author">
</a>
</p>
</div>
<label class="is-sr-only" for="embedTitle">Embed Title</label>
<textarea class="discord-title message-input autoresize"
placeholder="Embed Title..."
maxlength="256" id="embedTitle" rows="1"
name="embed_title"></textarea>
<br>
<label class="is-sr-only" for="embedDescription">Embed Description</label>
<textarea class="discord-description message-input autoresize "
placeholder="Embed Description..."
maxlength="4096" id="embedDescription" name="embed_description"
rows="1"></textarea>
<br>
<div class="embed-multifield-box">
<div class="embed-field-box">
<label class="is-sr-only" for="embedFieldTitle">Field Title</label>
<textarea
class="discord-field-title field-input message-input autoresize "
placeholder="Field Title..." rows="1"
maxlength="256" id="embedFieldTitle"
name="embed_field_title[]"></textarea>
<label class="is-sr-only" for="embedFieldValue">Field Value</label>
<textarea
class="discord-field-value field-input message-input autoresize "
placeholder="Field Value..."
maxlength="1024" id="embedFieldValue" name="embed_field_value[]"
rows="1"></textarea>
</div>
<div class="b">
<label class="is-sr-only" for="embedAuthor">Embed Author</label>
<textarea
class="discord-embed-author message-input autoresize"
placeholder="Embed Author..." rows="1" maxlength="256"
name="embed_author"></textarea>
</div>
</div>
<div class="b">
<p class="image thumbnail customizable">
<a>
<img class="" src="" alt="Square thumbnail embedded image">
</a>
</p>
<label class="is-sr-only" for="embedTitle">Embed Title</label>
<textarea class="discord-title message-input autoresize"
placeholder="Embed Title..."
maxlength="256" rows="1"
name="embed_title"></textarea>
<br>
<label class="is-sr-only" for="embedDescription">Embed Description</label>
<textarea class="discord-description message-input autoresize "
placeholder="Embed Description..."
maxlength="4096" name="embed_description"
rows="1"></textarea>
<br>
<div class="embed-multifield-box">
<div data-inlined="1" class="embed-field-box">
<label class="is-sr-only" for="embedFieldTitle">Field Title</label>
<div class="is-flex">
<textarea class="discord-field-title field-input message-input autoresize"
placeholder="Field Title..." rows="1"
maxlength="256" name="embed_field_title[]"></textarea>
<button class="button is-small inline-btn">
<span class="is-sr-only">Toggle field inline</span><i class="fas fa-arrows-h"></i>
</button>
</div>
<label class="is-sr-only" for="embedFieldValue">Field Value</label>
<textarea
class="discord-field-value field-input message-input autoresize "
placeholder="Field Value..."
maxlength="1024" name="embed_field_value[]"
rows="1"></textarea>
</div>
</div>
</div>
<p class="image is-400x300 customizable">
<div class="b">
<p class="image thumbnail customizable">
<a>
<img class="embed_thumbnail_url" src="/static/img/bg.webp" alt="Square thumbnail embedded image">
</a>
</p>
</div>
</div>
<p class="image is-400x300 customizable">
<a>
<img class="embed_image_url" src="/static/img/bg.webp" alt="Large embedded image">
</a>
</p>
<div class="embed-footer-box">
<p class="image is-20x20 customizable">
<a>
<img class="" src="" alt="Large embedded image">
<img class="is-rounded embed_footer_url" src="/static/img/bg.webp" alt="Footer profile-like image">
</a>
</p>
<div class="embed-footer-box">
<p class="image is-20x20 customizable">
<a>
<img class="is-rounded " src="" alt="Footer profile-like image">
</a>
</p>
<label class="is-sr-only" for="embedFooter">Embed Footer text</label>
<textarea class="discord-embed-footer message-input autoresize "
placeholder="Embed Footer..."
maxlength="2048" id="embedFooter" name="embed_author" rows="1"></textarea>
</div>
<label class="is-sr-only" for="embedFooter">Embed Footer text</label>
<textarea class="discord-embed-footer message-input autoresize "
placeholder="Embed Footer..."
maxlength="2048" name="embed_footer" rows="1"></textarea>
</div>
</div>
</div>
</article>
</div>
</div>
</article>
</div>
<div class="column">
<div class="field">
<div class="control">
<label class="label sr-only">Reminder Name</label>
<input class="input" type="text" name="name" placeholder="Reminder Name">
<div class="column settings">
<div class="columns is-mobile reminder-topbar">
<div class="column">
<div class="field">
<div class="control">
<label class="label sr-only">Reminder Name</label>
<input class="input" type="text" name="name" placeholder="Reminder Name">
</div>
</div>
</div>
<div class="column is-narrow">
<button class="button is-rounded hide-box">
<span class="is-sr-only">Hide reminder</span><i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
<div class="field">
<label class="label">Channel</label>
<div class="control">
<div class="select">
<select id="channelOption" name="channel" class="channel-selector">
</select>
<div class="columns">
<div class="column">
<div class="field channel-field">
<div class="collapses">
<label class="label" for="channelOption">Channel*</label>
</div>
<div class="control has-icons-left">
<div class="select">
<select name="channel" class="channel-selector">
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-hashtag"></i>
</div>
</div>
</div>
</div>
<div class="column">
<div class="field">
<div class="control">
<label class="label collapses">
Time*
<input class="input" type="datetime-local" step="1" name="time">
</label>
</div>
</div>
</div>
</div>
<div class="field">
<div class="control">
<label class="checkbox">
Enable TTS
<input type="checkbox">
</label>
<div class="collapses">
<div class="is-locked">
<div class="field">
<label class="label">Interval <a class="foreground" href="/help/interval"><i class="fas fa-question-circle"></i></a></label>
<div class="control intervalSelector" style="min-width: 400px;" >
<div class="input interval-group">
<div class="interval-group-left">
<label>
<span class="is-sr-only">Interval months</span>
<input class="w2" type="text" pattern="\d*" name="interval_months" maxlength="2" placeholder=""> <span class="half-rem"></span> months, <span class="half-rem"></span>
</label>
<label>
<span class="is-sr-only">Interval days</span>
<input class="w3" type="text" pattern="\d*" name="interval_days" maxlength="4" placeholder=""> <span class="half-rem"></span> days, <span class="half-rem"></span>
</label>
<label>
<span class="is-sr-only">Interval hours</span>
<input class="w2" type="text" pattern="\d*" name="interval_hours" maxlength="2" placeholder="HH">:
</label>
<label>
<span class="is-sr-only">Interval minutes</span>
<input class="w2" type="text" pattern="\d*" name="interval_minutes" maxlength="2" placeholder="MM">:
</label>
<label>
<span class="is-sr-only">Interval seconds</span>
<input class="w2" type="text" pattern="\d*" name="interval_seconds" maxlength="2" placeholder="SS">
</label>
</div>
<button class="clear"><span class="is-sr-only">Clear interval</span><span class="icon"><i class="fas fa-trash"></i></span></button>
</div>
</div>
</div>
<div class="field">
<div class="control">
<label class="label">
Expiration
<input class="input" type="datetime-local" step="1" name="expiration">
</label>
</div>
</div>
</div>
<div class="columns">
<div class="column has-text-centered">
<div class="is-boxed">
<label class="label">Enable TTS <input type="checkbox" name="tts"></label>
</div>
</div>
<div class="column has-text-centered">
<div class="is-boxed">
<label class="label">Pin Message <input type="checkbox" name="pin"></label>
</div>
</div>
<div class="column has-text-centered">
<div class="file is-small is-boxed">
<label class="file-label">
<input class="file-input" type="file" name="attachment">
<span class="file-cta">
<span class="file-label">
Add Attachment
</span>
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
</span>
</label>
</div>
</div>
</div>
<div>
<span class="pad-left"></span>
{% if creating %}
<button class="button is-success" id="createReminder">
<span>Create Reminder</span> <span class="icon"><i class="fas fa-sparkles"></i></span>
</button>
<button class="button is-success is-outlined" id="createTemplate">
<span>Create Template</span> <span class="icon"><i class="fas fa-file-spreadsheet"></i></span>
</button>
<button class="button is-outlined show-modal is-pulled-right" data-modal="chooseTemplateModal">
Load Template
</button>
{% else %}
<button class="button is-success save-btn">
<span>Save</span> <span class="icon"><i class="fas fa-save"></i></span>
</button>
<button class="button is-warning disable-enable">
</button>
<button class="button is-danger delete-reminder">
Delete
</button>
{% endif %}
</div>
</div>
<div class="field">
<div class="control">
<label class="checkbox">
Pin Reminder
<input type="checkbox">
</label>
</div>
</div>
<a class="set-color">
<p>
Set Embed Color <span class="icon is-small"><i
class="far fa-eye-dropper"></i></span>
</p>
</a>
<a class="level-item file-upload">
<div class="file">
<input class="file-input" type="file" name="attachment">
<p>
Attach File
<span class="icon is-small">
<i class="far fa-file-upload"></i>
</span>
</p>
</div>
</a>
<a class="level-item set-interval">
<p>
Set Interval <span class="icon is-small"><i class="far fa-repeat"></i></span>
</p>
</a>
<span style="width: 12px;"></span>
{% if creating %}
<button class="button is-outlined">
Load Template
</button>
<button class="button is-success is-outlined" id="createTemplate">
Create Template
</button>
<button class="button is-success" id="createReminder">
Create Reminder
</button>
{% else %}
<button class="button is-primary" disabled>
Saved!
</button>
<button class="button is-warning">
Disable
</button>
<button class="button is-danger">
Delete
</button>
{% endif %}
</div>
</div>

View File

@ -1,186 +0,0 @@
<div class="discord-frame">
<article class="media">
<figure class="media-left">
<p class="image is-32x32">
<img class="is-rounded" src="/static/img/icon.png" alt="reminder bot icon">
</p>
</figure>
<div class="media-content">
<div class="content">
<div class="is-hidden-touch">
<div class="discord-message-header">
Reminder Bot -
<label class="is-sr-only" for="reminderDate">Reminder Date</label>
<input class="time-input date" placeholder="YYYY/MM/DD"
id="reminderDate" name="date">
<label class="is-sr-only" for="reminderTime">Reminder Time</label>
<input class="time-input time" placeholder="HH:MM:SS"
id="reminderTime" name="time">
</div>
</div>
<div class="is-hidden-desktop">
<label class="is-sr-only" for="reminderDate">Reminder Date</label>
<input class="time-input date" placeholder="YYYY/MM/DD"
id="reminderDate" name="date">
<label class="is-sr-only" for="reminderTime">Reminder Time</label>
<input class="time-input time" placeholder="HH:MM:SS"
id="reminderTime" name="time">
<div class="discord-message-header">
Reminder Bot
</div>
</div>
<label class="is-sr-only" for="messageContent">Message</label>
<textarea class="message-input autoresize discord-content preview-mode"
placeholder="Message Content..."
maxlength="2000" id="messageContent" name="content" rows="1"></textarea>
<div class="discord-embed">
<div class="embed-body">
<div class="a">
<div class="embed-author-box">
<div class="a">
<p class="image is-24x24 customizable">
<a>
<img class="is-rounded preview-mode" src="">
</a>
</p>
</div>
<div class="b">
<label class="is-sr-only" for="embedAuthor">Embed Author</label>
<textarea
class="discord-embed-author message-input preview-mode autoresize"
placeholder="Embed Author..." rows="1" maxlength="256"
id="embedAuthor" name="embed_author"></textarea>
</div>
</div>
<label class="is-sr-only" for="embedTitle">Embed Title</label>
<textarea class="discord-title message-input preview-mode autoresize"
placeholder="Embed Title..."
maxlength="256" id="embedTitle" rows="1"
name="embed_title"></textarea>
<br>
<label class="is-sr-only" for="embedDescription">Embed Description</label>
<textarea class="discord-description message-input autoresize preview-mode"
placeholder="Embed Description..."
maxlength="2048" id="embedDescription" name="embed_description"
rows="1"></textarea>
<br>
<div class="embed-multifield-box">
<div class="embed-field-box">
<label class="is-sr-only" for="embedFieldTitle">Field Title</label>
<textarea
class="discord-field-title field-input message-input autoresize preview-mode"
placeholder="Field Title..." rows="1"
maxlength="256" id="embedFieldTitle"
name="embed_field_title[]"></textarea>
<label class="is-sr-only" for="embedFieldValue">Field Value</label>
<textarea
class="discord-field-value field-input message-input autoresize preview-mode"
placeholder="Field Value..."
maxlength="1024" id="embedFieldValue" name="embed_field_value[]"
rows="1"></textarea>
</div>
</div>
</div>
<div class="b">
<p class="image thumbnail customizable">
<a>
<img class="preview-mode" src="">
</a>
</p>
</div>
</div>
<p class="image is-400x300 customizable">
<a>
<img class="preview-mode" src="">
</a>
</p>
<div class="embed-footer-box">
<p class="image is-20x20 customizable">
<a>
<img class="is-rounded preview-mode" src="">
</a>
</p>
<label class="is-sr-only" for="embedAuthor">Embed Author</label>
<textarea class="discord-embed-footer message-input autoresize preview-mode"
placeholder="Embed Footer..."
maxlength="2048" id="embedAuthor" name="embed_author" rows="1"></textarea>
</div>
</div>
</div>
<nav class="level is-mobile">
<div class="level-left">
<a class="level-item icon-toggle tts-toggle" title="Enable TTS">
<p>
TTS <span class="icon is-small"><i class="far fa-comment-lines"></i></span>
</p>
</a>
<a class="level-item icon-toggle autopin-toggle" title="Enable Autopin">
<p>
Pin <span class="icon is-small"><i class="far fa-thumbtack"></i></span>
</p>
</a>
<span style="width: 12px;"></span>
<a class="level-item set-color">
<p>
Set Embed Color <span class="icon is-small"><i
class="far fa-eye-dropper"></i></span>
</p>
</a>
<a class="level-item file-upload">
<div class="file">
<input class="file-input" type="file" name="attachment">
<p>
Attach File
<span class="icon is-small">
<i class="far fa-file-upload"></i>
</span>
</p>
</div>
</a>
<a class="level-item set-interval">
<p>
Set Interval <span class="icon is-small"><i class="far fa-repeat"></i></span>
</p>
</a>
<span style="width: 12px;"></span>
<a class="level-item preview-toggle" title="Preview Message">
<p>
Preview Mode <span class="icon is-small"><i class="far fa-eye"></i></span>
</p>
</a>
</div>
</nav>
<nav class="level is-mobile">
<div class="level-left">
<a class="level-item create-reminder" title="Create Reminder">
<p>
Create <span class="icon is-small"><i class="far fa-calendar-plus"></i></span>
</p>
</a>
<a class="level-item icon-toggle disable-reminder" title="Disable/enable Reminder">
<p>
Disable <span class="icon is-small"><i class="far fa-comment-slash"></i></span>
</p>
</a>
<a class="level-item delete-reminder" title="Delete Reminder">
<p>
Delete <span class="icon is-small"><i class="far fa-trash"></i></span>
</p>
</a>
</div>
</nav>
</div>
</article>
</div>

View File

@ -1,22 +1,52 @@
<div style="margin: 0 12px 12px 12px;">
<div class="create-reminder">
<button class="button is-rounded is-light" id="showReminderCreator">
<strong>Create Reminder <span class="icon left-pad"><i class="fas fa-chevron-down"></i></span></strong>
</button>
<div id="reminderCreator">
{% set creating = true %}
{% include "reminder_dashboard/guild_reminder" %}
</div>
<div class="buttons has-addons" style="margin-top: 1rem;">
<button class="button is-static">Order By</button>
<button class="button" disabled>Time</button>
<button class="button">Name</button>
<button class="button">Channel</button>
</div>
<div id="guildReminders">
<div class="create-reminder">
<strong>Create Reminder</strong>
<div id="reminderCreator">
{% set creating = true %}
{% include "reminder_dashboard/guild_reminder" %}
{% set creating = false %}
</div>
<br>
<div class="field">
<div class="columns is-mobile">
<div class="column">
<strong>Reminders</strong>
</div>
<div class="column is-narrow">
<div class="control has-icons-left">
<div class="select is-small">
<select id="orderBy">
<option value="time" selected>Time</option>
<option value="name">Name</option>
<option value="channel">Channel</option>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-sort-amount-down"></i>
</div>
</div>
</div>
<div class="column is-narrow">
<div class="control has-icons-left">
<div class="select is-small">
<select id="expandAll">
<option value="" selected></option>
<option value="expand">Expand All</option>
<option value="collapse">Collapse All</option>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-expand-arrows"></i>
</div>
</div>
</div>
</div>
</div>
<div id="guildReminders">
</div>
</div>
<script src="/static/js/sort.js"></script>
<script src="/static/js/expand.js"></script>

View File

@ -1,23 +0,0 @@
<div style="margin: 0 12px 12px 12px;">
<div class="create-reminder">
<p>
<strong>Message Designer</strong>
</p>
{% include "reminder_dashboard/personal_reminder" %}
<p style="font-size: 0.8rem;">
Most fields are optional. Use 'Preview Mode' to see how the reminder will appear in Discord.
Scaling is not exact.
</p>
<div class="field">
<p class="control">
<a class="button is-success">
Create
</a>
</p>
</div>
</div>
<div id="personalReminders">
</div>
</div>

View File

@ -0,0 +1,37 @@
{% extends "base" %}
{% block init %}
{% set title = "Support" %}
{% set page_title = "Create Reminders" %}
{% set page_subtitle = "" %}
{% endblock %}
{% block content %}
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<p class="title">Create reminders via commands</p>
<p class="content">
You can create reminders with the <code>/remind</code> command.
<br>
Fill out the "time" and "content" fields. If you wish, press on "Optional" to view other options
for the reminder.
</p>
</div>
</div>
</section>
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<p class="title">Create reminders via the dashboard</p>
<p class="content">
Reminders can also be created on the dashboard.
</p>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,64 @@
{% extends "base" %}
{% block init %}
{% set title = "Support" %}
{% set page_title = "Deleting Reminders" %}
{% set page_subtitle = "" %}
{% set show_invite = false %}
{% endblock %}
{% block content %}
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<p class="title">Deleting reminders via commands</p>
<p class="content">
Deleting reminders is as easy as typing <code>/del</code>.
<br>
</p>
<figure>
<img src="/static/img/support/delete_reminder/cmd-1.png" alt="/del">
</figure>
<figure>
<img src="/static/img/support/delete_reminder/cmd-2.png" alt="Reminder deleted">
</figure>
<p class="content">
Note that you cannot delete reminders that were set for another user's direct messages. To delete
reminders in your direct messages, use <code>/del</code> in the direct message channel with
Reminder Bot.
</p>
</div>
</div>
</section>
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<p class="title">Deleting reminders via the dashboard</p>
<p class="content">
Reminders in servers can be deleted via the dashboard. First, select your server from the menu.
</p>
<figure>
<img src="/static/img/support/delete_reminder/1.png" alt="Selecting server">
</figure>
<br>
<p class="content">
Then, find the reminder you wish to delete.
</p>
<figure>
<img src="/static/img/support/delete_reminder/2.png" alt="Finding reminder">
</figure>
<br>
<p class="content">
Finally, press the 'Delete' button under the reminder.
</p>
<figure>
<img src="/static/img/support/delete_reminder/3.png" alt="Delete button">
</figure>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "base" %}
{% block init %}
{% set title = "Support" %}
{% set page_title = "Manage Macros" %}
{% set page_subtitle = "For advanced functionality" %}
{% set show_invite = false %}
{% endblock %}
{% block content %}
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<p class="title">Create macros via commands</p>
<p class="content">
</p>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "base" %}
{% block init %}
{% set title = "Support" %}
{% set page_title = "Timers" %}
{% set page_subtitle = "" %}
{% set show_invite = false %}
{% endblock %}
{% block content %}
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<p class="title">Create timers via commands</p>
<p class="content">
</p>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends "base" %}
{% block init %}
{% set title = "Support" %}
{% set page_title = "Timezone Help" %}
{% set page_subtitle = "Timezones are tricky. Read on for help" %}
{% set show_invite = false %}
{% endblock %}
{% block content %}
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<p class="title">Selecting your timezone manually</p>
<p class="content">
To select your timezone manually, use <code>/timezone</code>. This will set your timezone
across all servers with Reminder Bot.
<br>
You should only ever have to do this once. To avoid needing to change timezone due to daylight
savings, choose a DST-aware region, for example <strong>Europe/London</strong> instead of
<strong>GMT</strong>, or <strong>US/New_York</strong> instead of <strong>EST</strong>.
</p>
</div>
</div>
</section>
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<p class="title">Selecting your timezone automatically</p>
<p class="content">
A new feature we offer is the ability to configure Reminder Bot's timezone from your browser. To do
this, go to our dashboard, press 'Timezone' in the bottom left (desktop) or at the bottom of the
navigation menu (mobile). Then, choose 'Set Bot Timezone' to set Reminder Bot to use your browser's
timezone.
<br>
From here, you can also configure the dashboard to alternatively use the manually configured
timezone instead of the browser's timezone, if your browser is reporting your timezone incorrectly,
or if you have a special use-case.
</p>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "base" %}
{% block init %}
{% set title = "Support" %}
{% set page_title = "Todo lists" %}
{% set page_subtitle = "" %}
{% set show_invite = false %}
{% endblock %}
{% block content %}
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<p class="title">Add to todo lists via commands</p>
<p class="content">
</p>
</div>
</div>
</section>
{% endblock %}

View File

@ -45,10 +45,20 @@
<h2 class="title">JellyWX's Home</h2>
<ul class="is-size-5 pl-6">
<li>Do not discuss politics, harass other users, or use language intended to upset other users</li>
<li>Do not send malicious links</li>
<li>Do not share personal information about yourself or any other user. This includes but is not
limited to real names<sup>1</sup>, addresses, phone numbers, country of origin<sup>2</sup>, religion, email address,
IP address.</li>
<li>Do not send malicious links or attachments</li>
<li>Do not advertise</li>
<li>Do not send unwarranted direct messages</li>
</ul>
<p class="small">
<sup>1</sup> Some users may use their real name on their account. In this case, do not assert that
this is a user's real name, or use it to try and identify a user.
<br>
<sup>2</sup> Country of current residence may be discussed, as this is relevant to timezone and
DST selection.
</p>
</div>
</section>