moving stuff to poise

This commit is contained in:
jellywx 2022-02-01 01:07:12 +00:00
parent 4f9eb58c16
commit e436d9db80
26 changed files with 1845 additions and 3027 deletions

1162
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,8 @@ authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018" edition = "2018"
[dependencies] [dependencies]
songbird = { git = "https://github.com/serenity-rs/songbird", branch = "next" }
poise = { git = "https://github.com/kangalioo/poise", branch = "master" }
dotenv = "0.15" dotenv = "0.15"
humantime = "2.1" humantime = "2.1"
tokio = { version = "1", features = ["process", "full"] } tokio = { version = "1", features = ["process", "full"] }
@ -24,23 +26,3 @@ rand = "0.7"
levenshtein = "1.0" levenshtein = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]} sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
base64 = "0.13.0" base64 = "0.13.0"
[dependencies.regex_command_attr]
path = "command_attributes"
[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,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

@ -1,16 +1,11 @@
use chrono::offset::Utc; use chrono::offset::Utc;
use regex_command_attr::command; use poise::serenity::builder::CreateEmbedFooter;
use serenity::{builder::CreateEmbedFooter, client::Context};
use crate::{ use crate::{models::CtxData, Context, Error, THEME_COLOR};
framework::{CommandInvoke, CreateGenericResponse},
models::CtxData,
THEME_COLOR,
};
fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter { fn footer(ctx: Context<'_>) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter {
let shard_count = ctx.cache.shard_count(); let shard_count = ctx.discord().cache.shard_count();
let shard = ctx.shard_id; let shard = ctx.discord().shard_id;
move |f| { move |f| {
f.text(format!( f.text(format!(
@ -22,15 +17,14 @@ fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEm
} }
} }
#[command] /// Get an overview of bot commands
#[description("Get an overview of the bot commands")] #[poise::command(slash_command)]
async fn help(ctx: &Context, invoke: &mut CommandInvoke) { pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx); let footer = footer(ctx);
let _ = invoke let _ = ctx
.respond( .send(|m| {
&ctx, m.embed(|e| {
CreateGenericResponse::new().embed(|e| {
e.title("Help") e.title("Help")
.color(*THEME_COLOR) .color(*THEME_COLOR)
.description( .description(
@ -60,21 +54,21 @@ __Advanced Commands__
", ",
) )
.footer(footer) .footer(footer)
}), })
) })
.await; .await;
Ok(())
} }
#[command] /// Get information about the bot
#[aliases("invite")] #[poise::command(slash_command)]
#[description("Get information about the bot")] pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
async fn info(ctx: &Context, invoke: &mut CommandInvoke) {
let footer = footer(ctx); let footer = footer(ctx);
let _ = invoke let _ = ctx
.respond( .send(|m| {
ctx.http.clone(), m.embed(|e| {
CreateGenericResponse::new().embed(|e| {
e.title("Info") e.title("Info")
.description(format!( .description(format!(
"Help: `/help` "Help: `/help`
@ -89,21 +83,19 @@ Use our dashboard: https://reminder-bot.com/",
)) ))
.footer(footer) .footer(footer)
.color(*THEME_COLOR) .color(*THEME_COLOR)
}), })
) })
.await; .await;
Ok(())
} }
#[command] /// Details on supporting the bot and Patreon benefits
#[description("Details on supporting the bot and Patreon benefits")] #[poise::command(slash_command)]
#[group("Info")] pub async fn donate(ctx: Context<'_>) -> Result<(), Error> {
async fn donate(ctx: &Context, invoke: &mut CommandInvoke) {
let footer = footer(ctx); let footer = footer(ctx);
let _ = invoke let _ = ctx.send(|m| m.embed(|e| {
.respond(
ctx.http.clone(),
CreateGenericResponse::new().embed(|e| {
e.title("Donate") e.title("Donate")
.description("Thinking of adding a monthly contribution? Click below for my Patreon and official bot server :) .description("Thinking of adding a monthly contribution? Click below for my Patreon and official bot server :)
@ -125,38 +117,41 @@ Just $2 USD/month!
}), }),
) )
.await; .await;
Ok(())
} }
#[command] /// Get the link to the online dashboard
#[description("Get the link to the online dashboard")] #[poise::command(slash_command)]
#[group("Info")] pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> {
async fn dashboard(ctx: &Context, invoke: &mut CommandInvoke) {
let footer = footer(ctx); let footer = footer(ctx);
let _ = invoke let _ = ctx
.respond( .send(|m| {
ctx.http.clone(), m.embed(|e| {
CreateGenericResponse::new().embed(|e| {
e.title("Dashboard") e.title("Dashboard")
.description("**https://reminder-bot.com/dashboard**") .description("**https://reminder-bot.com/dashboard**")
.footer(footer) .footer(footer)
.color(*THEME_COLOR) .color(*THEME_COLOR)
}), })
) })
.await; .await;
Ok(())
} }
#[command] /// View the current time in a user's selected timezone
#[description("View the current time in your selected timezone")] #[poise::command(slash_command)]
#[group("Info")] pub async fn clock(ctx: Context<'_>) -> Result<(), Error> {
async fn clock(ctx: &Context, invoke: &mut CommandInvoke) { ctx.defer_ephemeral().await?;
let ud = ctx.user_data(&invoke.author_id()).await.unwrap();
let now = Utc::now().with_timezone(&ud.timezone());
let _ = invoke let tz = ctx.timezone().await;
.respond( let now = Utc::now().with_timezone(&tz);
ctx.http.clone(),
CreateGenericResponse::new().content(format!("Current time: {}", now.format("%H:%M"))), ctx.send(|m| {
) m.ephemeral(true).content(format!("Time in **{}**: `{}`", tz, now.format("%H:%M")))
.await; })
.await?;
Ok(())
} }

View File

@ -1,4 +1,4 @@
pub mod info_cmds; pub mod info_cmds;
pub mod moderation_cmds; pub mod moderation_cmds;
pub mod reminder_cmds; // pub mod reminder_cmds;
pub mod todo_cmds; // pub mod todo_cmds;

View File

@ -1,54 +1,64 @@
use chrono::offset::Utc; use chrono::offset::Utc;
use chrono_tz::{Tz, TZ_VARIANTS}; use chrono_tz::{Tz, TZ_VARIANTS};
use levenshtein::levenshtein; use levenshtein::levenshtein;
use regex_command_attr::command; use poise::CreateReply;
use serenity::client::Context;
use crate::{ use crate::{
component_models::pager::{MacroPager, Pager},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
framework::{CommandInvoke, CommandOptions, CreateGenericResponse, OptionValue}, hooks::guild_only,
hooks::{CHECK_GUILD_PERMISSIONS_HOOK, GUILD_ONLY_HOOK},
models::{command_macro::CommandMacro, CtxData}, models::{command_macro::CommandMacro, CtxData},
PopularTimezones, RecordingMacros, RegexFramework, SQLPool, Context, Error,
}; };
#[command("timezone")] async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
#[description("Select your timezone")] if partial.is_empty() {
#[arg( ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
name = "timezone", } else {
description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee", TZ_VARIANTS
kind = "String", .iter()
required = false .filter(|tz| {
)] partial.contains(&tz.to_string())
async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) { || tz.to_string().contains(&partial)
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); || levenshtein(&tz.to_string(), &partial) < 4
let mut user_data = ctx.user_data(invoke.author_id()).await.unwrap(); })
.take(25)
.map(|t| t.to_string())
.collect::<Vec<String>>()
}
}
/// Select your timezone
#[poise::command(slash_command)]
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); 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>() { match timezone.parse::<Tz>() {
Ok(tz) => { Ok(tz) => {
user_data.timezone = timezone.clone(); 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 now = Utc::now().with_timezone(&tz);
let _ = invoke ctx.send(|m| {
.respond( m.embed(|e| {
ctx.http.clone(), e.title("Timezone Set")
CreateGenericResponse::new().embed(|e| { .description(format!(
e.title("Timezone Set") "Timezone has been set to **{}**. Your current time should be `{}`",
.description(format!( timezone,
"Timezone has been set to **{}**. Your current time should be `{}`", now.format("%H:%M").to_string()
timezone, ))
now.format("%H:%M").to_string() .color(*THEME_COLOR)
)) })
.color(*THEME_COLOR) })
}), .await?;
)
.await;
} }
Err(_) => { Err(_) => {
@ -56,8 +66,8 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption
.iter() .iter()
.filter(|tz| { .filter(|tz| {
timezone.contains(&tz.to_string()) timezone.contains(&tz.to_string())
|| tz.to_string().contains(timezone) || tz.to_string().contains(&timezone)
|| levenshtein(&tz.to_string(), timezone) < 4 || levenshtein(&tz.to_string(), &timezone) < 4
}) })
.take(25) .take(25)
.map(|t| t.to_owned()) .map(|t| t.to_owned())
@ -74,25 +84,21 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption
) )
}); });
let _ = invoke ctx.send(|m| {
.respond( m.embed(|e| {
ctx.http.clone(), e.title("Timezone Not Recognized")
CreateGenericResponse::new().embed(|e| { .description("Possibly you meant one of the following timezones, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):")
e.title("Timezone Not Recognized") .color(*THEME_COLOR)
.description("Possibly you meant one of the following timezones, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):") .fields(fields)
.color(*THEME_COLOR) .footer(|f| f.text(footer_text))
.fields(fields) .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
.footer(|f| f.text(footer_text)) })
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee") })
}), .await?;
)
.await;
} }
} }
} else { } else {
let popular_timezones = ctx.data.read().await.get::<PopularTimezones>().cloned().unwrap(); let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| {
let popular_timezones_iter = popular_timezones.iter().map(|t| {
( (
t.to_string(), t.to_string(),
format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()), format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()),
@ -100,276 +106,274 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption
) )
}); });
let _ = invoke ctx.send(|m| {
.respond( m.embed(|e| {
ctx.http.clone(), e.title("Timezone Usage")
CreateGenericResponse::new().embed(|e| { .description(
e.title("Timezone Usage") "**Usage:**
.description(
"**Usage:**
`/timezone Name` `/timezone Name`
**Example:** **Example:**
`/timezone Europe/London` `/timezone Europe/London`
You may want to use one of the popular timezones below, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):", You may want to use one of the popular timezones below, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):",
) )
.color(*THEME_COLOR) .color(*THEME_COLOR)
.fields(popular_timezones_iter) .fields(popular_timezones_iter)
.footer(|f| f.text(footer_text)) .footer(|f| f.text(footer_text))
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee") .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
}), })
) })
.await; .await?;
} }
Ok(())
} }
#[command("macro")] async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
#[description("Record and replay command sequences")] sqlx::query!(
#[subcommand("record")] "
#[description("Start recording up to 5 commands to replay")] SELECT name
#[arg(name = "name", description = "Name for the new macro", kind = "String", required = true)] FROM macro
#[arg( WHERE
name = "description", guild_id = (SELECT id FROM guilds WHERE guild = ?)
description = "Description for the new macro", AND name LIKE CONCAT(?, '%')",
kind = "String", ctx.guild_id().unwrap().0,
required = false partial,
)] )
#[subcommand("finish")] .fetch_all(&ctx.data().database)
#[description("Finish current recording")] .await
#[subcommand("list")] .unwrap_or(vec![])
#[description("List recorded macros")] .iter()
#[subcommand("run")] .map(|s| s.name.clone())
#[description("Run a recorded macro")] .collect()
#[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 and replay command sequences
"record" => { #[poise::command(slash_command, rename = "macro", check = "guild_only")]
let guild_id = invoke.guild_id().unwrap(); pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
let name = args.get("name").unwrap().to_string(); /// Start recording up to 5 commands to replay
#[poise::command(slash_command, rename = "record", check = "guild_only")]
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!( let row = sqlx::query!(
"SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", "
guild_id.0, SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
name guild_id.0,
) name
.fetch_one(&pool) )
.await; .fetch_one(&ctx.data().database)
.await;
if row.is_ok() { if row.is_ok() {
let _ = invoke ctx.send(|m| {
.respond( m.ephemeral(true).embed(|e| {
&ctx, e.title("Unique Name Required")
CreateGenericResponse::new().ephemeral().embed(|e| { .description(
e "A macro already exists under this name.
.title("Unique Name Required") Please select a unique name for your macro.",
.description("A macro already exists under this name. Please select a unique name for your macro.")
.color(*THEME_COLOR)
}),
) )
.await; .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 { } else {
let macro_buffer = ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap(); lock.insert(
(guild_id, ctx.author().id),
CommandMacro { guild_id, name, description, commands: vec![] },
);
true
}
};
let okay = { if okay {
let mut lock = macro_buffer.write().await; ctx.send(|m| {
m.ephemeral(true).embed(|e| {
if lock.contains_key(&(guild_id, invoke.author_id())) { e.title("Macro Recording Started")
false .description(
} else { "Run up to 5 commands, or type `/macro finish` to stop at any point.
lock.insert( Any commands ran as part of recording will be inconsequential",
(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; .color(*THEME_COLOR)
} else { })
let _ = invoke })
.respond( .await?;
&ctx, } else {
CreateGenericResponse::new().ephemeral().embed(|e| { ctx.send(|m| {
e.title("Macro Already Recording") m.ephemeral(true).embed(|e| {
.description( e.title("Macro Already Recording")
"You are already recording a macro in this server. .description(
"You are already recording a macro in this server.
Please use `/macro finish` to end this recording before starting another.", 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(())
}
/// Finish current macro recording
#[poise::command(
slash_command,
rename = "finish",
check = "guild_only",
identifying_name = "macro_finish"
)]
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", check = "guild_only")]
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
let macros = CommandMacro::from_guild(&ctx.data().database, ctx.guild_id().unwrap()).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", check = "guild_only")]
pub async fn run_macro(
ctx: Context<'_>,
#[description = "Name of macro to run"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match sqlx::query!(
"
SELECT commands 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) => {
ctx.defer().await?;
// TODO TODO TODO!!!!!!!! RUN COMMAND FROM MACRO
}
Err(sqlx::Error::RowNotFound) => {
ctx.say(format!("Macro \"{}\" not found", name)).await?;
}
Err(e) => {
panic!("{}", e);
}
}
Ok(())
}
/// Delete a recorded macro
#[poise::command(slash_command, rename = "delete", check = "guild_only")]
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(macros: &[CommandMacro]) -> usize { pub fn max_macro_page(macros: &[CommandMacro]) -> usize {
@ -396,15 +400,30 @@ pub fn max_macro_page(macros: &[CommandMacro]) -> usize {
}) })
} }
pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericResponse { pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateReply {
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)
});
reply
/*
let pager = MacroPager::new(page); let pager = MacroPager::new(page);
if macros.is_empty() { if macros.is_empty() {
return CreateGenericResponse::new().embed(|e| { let mut reply = CreateReply::default();
reply.embed(|e| {
e.title("Macros") e.title("Macros")
.description("No Macros Set Up. Use `/macro record` to get started.") .description("No Macros Set Up. Use `/macro record` to get started.")
.color(*THEME_COLOR) .color(*THEME_COLOR)
}); });
return reply;
} }
let pages = max_macro_page(macros); let pages = max_macro_page(macros);
@ -447,7 +466,9 @@ pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericRes
let display = display_vec.join("\n"); let display = display_vec.join("\n");
CreateGenericResponse::new() let mut reply = CreateReply::default();
reply
.embed(|e| { .embed(|e| {
e.title("Macros") e.title("Macros")
.description(display) .description(display)
@ -458,5 +479,8 @@ pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericRes
pager.create_button_row(pages, comp); pager.create_button_row(pages, comp);
comp comp
}) });
reply
*/
} }

View File

@ -3,9 +3,7 @@ pub(crate) mod pager;
use std::io::Cursor; use std::io::Cursor;
use chrono_tz::Tz; use chrono_tz::Tz;
use rmp_serde::Serializer; use poise::serenity::{
use serde::{Deserialize, Serialize};
use serenity::{
builder::CreateEmbed, builder::CreateEmbed,
client::Context, client::Context,
model::{ model::{
@ -14,18 +12,14 @@ use serenity::{
prelude::InteractionApplicationCommandCallbackDataFlags, prelude::InteractionApplicationCommandCallbackDataFlags,
}, },
}; };
use rmp_serde::Serializer;
use serde::{Deserialize, Serialize};
use crate::{ use crate::{
commands::{ self,
moderation_cmds::{max_macro_page, show_macro_page},
reminder_cmds::{max_delete_page, show_delete_page},
todo_cmds::{max_todo_page, show_todo_page},
},
component_models::pager::{DelPager, LookPager, MacroPager, Pager, TodoPager}, component_models::pager::{DelPager, LookPager, MacroPager, Pager, TodoPager},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
framework::CommandInvoke,
models::{command_macro::CommandMacro, reminder::Reminder}, models::{command_macro::CommandMacro, reminder::Reminder},
SQLPool,
}; };
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]

View File

@ -1,8 +1,10 @@
// todo split pager out into a single struct // todo split pager out into a single struct
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::serenity::{
builder::CreateComponents, model::interactions::message_component::ButtonStyle,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_repr::*; use serde_repr::*;
use serenity::{builder::CreateComponents, model::interactions::message_component::ButtonStyle};
use crate::{component_models::ComponentDataModel, models::reminder::look_flags::LookFlags}; use crate::{component_models::ComponentDataModel, models::reminder::look_flags::LookFlags};

View File

@ -4,14 +4,16 @@ pub const MINUTE: u64 = 60;
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000; pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000;
pub const SELECT_MAX_ENTRIES: usize = 25; pub const SELECT_MAX_ENTRIES: usize = 25;
pub const MACRO_MAX_COMMANDS: usize = 5;
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
const THEME_COLOR_FALLBACK: u32 = 0x8fb677; const THEME_COLOR_FALLBACK: u32 = 0x8fb677;
use std::{collections::HashSet, env, iter::FromIterator}; use std::{collections::HashSet, env, iter::FromIterator};
use poise::serenity::model::prelude::AttachmentType;
use regex::Regex; use regex::Regex;
use serenity::http::AttachmentType;
lazy_static! { lazy_static! {
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (

83
src/event_handlers.rs Normal file
View File

@ -0,0 +1,83 @@
use std::{collections::HashMap, env};
use poise::serenity::{client::Context, model::interactions::Interaction, utils::shard_id};
use crate::{Data, Error};
pub async fn listener(ctx: &Context, event: &poise::Event<'_>, data: &Data) -> Result<(), Error> {
match event {
poise::Event::ChannelDelete { channel } => {
sqlx::query!(
"
DELETE FROM channels WHERE channel = ?
",
channel.id.as_u64()
)
.execute(&data.database)
.await
.unwrap();
}
poise::Event::GuildCreate { guild, is_new } => {
if *is_new {
let guild_id = guild.id.as_u64().to_owned();
sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id)
.execute(&data.database)
.await
.unwrap();
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
let shard_count = ctx.cache.shard_count();
let current_shard_id = shard_id(guild_id, shard_count);
let guild_count = ctx
.cache
.guilds()
.iter()
.filter(|g| {
shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id
})
.count() as u64;
let mut hm = HashMap::new();
hm.insert("server_count", guild_count);
hm.insert("shard_id", current_shard_id);
hm.insert("shard_count", shard_count);
let response = data
.http
.post(
format!(
"https://top.gg/api/bots/{}/stats",
ctx.cache.current_user_id().as_u64()
)
.as_str(),
)
.header("Authorization", token)
.json(&hm)
.send()
.await;
if let Err(res) = response {
println!("DiscordBots Response: {:?}", res);
}
}
}
}
poise::Event::GuildDelete { incomplete, full } => {
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;
}
_ => {}
},
_ => {}
}
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,91 +1,77 @@
use regex_command_attr::check; use poise::{serenity::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction};
use serenity::{client::Context, model::channel::Channel};
use crate::{ use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::CommandOptions, Context, Error};
framework::{CommandInvoke, CommandOptions, CreateGenericResponse, HookResult},
moderation_cmds, RecordingMacros,
};
#[check] pub async fn guild_only(ctx: Context<'_>) -> Result<bool, Error> {
pub async fn guild_only( if ctx.guild_id().is_some() {
ctx: &Context, Ok(true)
invoke: &mut CommandInvoke,
_args: &CommandOptions,
) -> HookResult {
if invoke.guild_id().is_some() {
HookResult::Continue
} else { } else {
let _ = invoke let _ = ctx.say("This command can only be used in servers").await;
.respond(
&ctx,
CreateGenericResponse::new().content("This command can only be used in servers"),
)
.await;
HookResult::Halt Ok(false)
} }
} }
#[check] async fn macro_check(ctx: Context<'_>) -> bool {
pub async fn macro_check( if let Context::Application(app_ctx) = ctx {
ctx: &Context, if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(interaction) =
invoke: &mut CommandInvoke, app_ctx.interaction
args: &CommandOptions, {
) -> HookResult { if let Some(guild_id) = ctx.guild_id() {
if let Some(guild_id) = invoke.guild_id() { if ctx.command().identifying_name != "macro_finish" {
if args.command != moderation_cmds::MACRO_CMD_COMMAND.names[0] { let mut lock = ctx.data().recording_macros.write().await;
let active_recordings =
ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap();
let mut lock = active_recordings.write().await;
if let Some(command_macro) = lock.get_mut(&(guild_id, invoke.author_id())) { if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) {
if command_macro.commands.len() >= 5 { if command_macro.commands.len() >= MACRO_MAX_COMMANDS {
let _ = invoke let _ = ctx.send(|m| {
.respond( m.ephemeral(false).content(
&ctx, "5 commands already recorded. Please use `/macro finish` to end recording.",
CreateGenericResponse::new().content("5 commands already recorded. Please use `/macro finish` to end recording."), )
) })
.await; .await;
} else {
let mut command_options = CommandOptions::new(&ctx.command().name);
command_options.populate(&interaction);
command_macro.commands.push(command_options);
let _ = ctx
.send(|m| m.ephemeral(false).content("Command recorded to macro"))
.await;
}
false
} else {
true
}
} else { } else {
command_macro.commands.push(args.clone()); true
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content("Command recorded to macro"),
)
.await;
} }
HookResult::Halt
} else { } else {
HookResult::Continue true
} }
} else { } else {
HookResult::Continue true
} }
} else { } else {
HookResult::Continue true
} }
} }
#[check] async fn check_self_permissions(ctx: Context<'_>) -> bool {
pub async fn check_self_permissions( if let Some(guild) = ctx.guild() {
ctx: &Context, let user_id = ctx.discord().cache.current_user_id();
invoke: &mut CommandInvoke,
_args: &CommandOptions,
) -> HookResult {
if let Some(guild) = invoke.guild(&ctx) {
let user_id = ctx.cache.current_user_id();
let manage_webhooks = let manage_webhooks = guild
guild.member_permissions(&ctx, user_id).await.map_or(false, |p| p.manage_webhooks()); .member_permissions(&ctx.discord(), user_id)
let (view_channel, send_messages, embed_links) = invoke .await
.map_or(false, |p| p.manage_webhooks());
let (view_channel, send_messages, embed_links) = ctx
.channel_id() .channel_id()
.to_channel_cached(&ctx) .to_channel_cached(&ctx.discord())
.map(|c| { .map(|c| {
if let Channel::Guild(channel) = 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 { } else {
None None
} }
@ -96,12 +82,11 @@ pub async fn check_self_permissions(
}); });
if manage_webhooks && send_messages && embed_links { if manage_webhooks && send_messages && embed_links {
HookResult::Continue true
} else { } else {
let _ = invoke let _ = ctx
.respond( .send(|m| {
&ctx, m.content(format!(
CreateGenericResponse::new().content(format!(
"Please ensure the bot has the correct permissions: "Please ensure the bot has the correct permissions:
{} **View Channel** {} **View Channel**
@ -112,41 +97,17 @@ pub async fn check_self_permissions(
if send_messages { "" } else { "" }, if send_messages { "" } else { "" },
if manage_webhooks { "" } else { "" }, if manage_webhooks { "" } else { "" },
if embed_links { "" } else { "" }, if embed_links { "" } else { "" },
)), ))
) })
.await; .await;
HookResult::Halt false
} }
} else { } else {
HookResult::Continue true
} }
} }
#[check] pub async fn all_checks(ctx: Context<'_>) -> Result<bool, Error> {
pub async fn check_guild_permissions( Ok(macro_check(ctx).await && check_self_permissions(ctx).await)
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
}
} }

View File

@ -3,179 +3,45 @@
extern crate lazy_static; extern crate lazy_static;
mod commands; mod commands;
mod component_models; // mod component_models;
mod consts; mod consts;
mod framework; mod event_handlers;
mod hooks; mod hooks;
mod models; mod models;
mod time_parser; mod time_parser;
mod utils;
use std::{collections::HashMap, env, sync::Arc}; use std::{collections::HashMap, env};
use chrono_tz::Tz; use chrono_tz::Tz;
use dotenv::dotenv; use dotenv::dotenv;
use log::info; use poise::serenity::model::{
use serenity::{ gateway::{Activity, GatewayIntents},
async_trait, id::{GuildId, UserId},
client::{bridge::gateway::GatewayIntents, Client},
http::{client::Http, CacheHttp},
model::{
channel::GuildChannel,
gateway::{Activity, Ready},
guild::{Guild, GuildUnavailable},
id::{GuildId, UserId},
interactions::Interaction,
},
prelude::{Context, EventHandler, TypeMapKey},
utils::shard_id,
}; };
use sqlx::mysql::MySqlPool; use sqlx::{MySql, Pool};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::{ use crate::{
commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, commands::{info_cmds, moderation_cmds},
component_models::ComponentDataModel, consts::THEME_COLOR,
consts::{CNC_GUILD, SUBSCRIPTION_ROLES, THEME_COLOR}, event_handlers::listener,
framework::RegexFramework, hooks::all_checks,
models::command_macro::CommandMacro, models::command_macro::CommandMacro,
utils::register_application_commands,
}; };
struct SQLPool; type Database = MySql;
impl TypeMapKey for SQLPool { pub struct Data {
type Value = MySqlPool; database: Pool<Database>,
http: reqwest::Client,
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro>>,
popular_timezones: Vec<Tz>,
} }
struct ReqwestClient; type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>;
impl TypeMapKey for ReqwestClient {
type Value = Arc<reqwest::Client>;
}
struct PopularTimezones;
impl TypeMapKey for PopularTimezones {
type Value = Arc<Vec<Tz>>;
}
struct RecordingMacros;
impl TypeMapKey for RecordingMacros {
type Value = Arc<RwLock<HashMap<(GuildId, UserId), CommandMacro>>>;
}
struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn channel_delete(&self, ctx: Context, channel: &GuildChannel) {
let pool = ctx
.data
.read()
.await
.get::<SQLPool>()
.cloned()
.expect("Could not get SQLPool from data");
sqlx::query!(
"
DELETE FROM channels WHERE channel = ?
",
channel.id.as_u64()
)
.execute(&pool)
.await
.unwrap();
}
async fn guild_create(&self, ctx: Context, guild: Guild, is_new: bool) {
if is_new {
let guild_id = guild.id.as_u64().to_owned();
{
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let _ = sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id)
.execute(&pool)
.await;
}
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
let shard_count = ctx.cache.shard_count();
let current_shard_id = shard_id(guild_id, shard_count);
let guild_count = ctx
.cache
.guilds()
.iter()
.filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id)
.count() as u64;
let mut hm = HashMap::new();
hm.insert("server_count", guild_count);
hm.insert("shard_id", current_shard_id);
hm.insert("shard_count", shard_count);
let client = ctx
.data
.read()
.await
.get::<ReqwestClient>()
.cloned()
.expect("Could not get ReqwestClient from data");
let response = client
.post(
format!(
"https://top.gg/api/bots/{}/stats",
ctx.cache.current_user_id().as_u64()
)
.as_str(),
)
.header("Authorization", token)
.json(&hm)
.send()
.await;
if let Err(res) = response {
println!("DiscordBots Response: {:?}", res);
}
}
}
}
async fn guild_delete(&self, ctx: Context, incomplete: GuildUnavailable, _full: Option<Guild>) {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0)
.execute(&pool)
.await;
}
async fn ready(&self, ctx: Context, _: Ready) {
ctx.set_activity(Activity::watching("for /remind")).await;
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
match interaction {
Interaction::ApplicationCommand(application_command) => {
let framework = ctx
.data
.read()
.await
.get::<RegexFramework>()
.cloned()
.expect("RegexFramework not found in context");
framework.execute(ctx, application_command).await;
}
Interaction::MessageComponent(component) => {
let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
component_model.act(&ctx, component).await;
}
_ => {}
}
}
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
@ -183,139 +49,75 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
dotenv()?; dotenv()?;
let token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
let http = Http::new_with_token(&token); let options = poise::FrameworkOptions {
commands: vec![
info_cmds::help(),
info_cmds::info(),
info_cmds::donate(),
info_cmds::clock(),
info_cmds::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()
},
],
allowed_mentions: None,
command_check: Some(|ctx| Box::pin(all_checks(ctx))),
listener: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)),
..Default::default()
};
let application_id = http.get_current_application_info().await?.id; let database =
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
let dm_enabled = env::var("DM_ENABLED").map_or(true, |var| var == "1"); 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 = RegexFramework::new() poise::Framework::build()
.ignore_bots(env::var("IGNORE_BOTS").map_or(true, |var| var == "1")) .token(discord_token)
.debug_guild(env::var("DEBUG_GUILD").map_or(None, |g| { .user_data_setup(move |ctx, _bot, framework| {
Some(GuildId(g.parse::<u64>().expect("DEBUG_GUILD must be a guild ID"))) Box::pin(async move {
})) ctx.set_activity(Activity::watching("for /remind")).await;
.dm_enabled(dm_enabled)
// info commands
.add_command(&info_cmds::HELP_COMMAND)
.add_command(&info_cmds::INFO_COMMAND)
.add_command(&info_cmds::DONATE_COMMAND)
.add_command(&info_cmds::DASHBOARD_COMMAND)
.add_command(&info_cmds::CLOCK_COMMAND)
// reminder commands
.add_command(&reminder_cmds::TIMER_COMMAND)
.add_command(&reminder_cmds::REMIND_COMMAND)
// management commands
.add_command(&reminder_cmds::DELETE_COMMAND)
.add_command(&reminder_cmds::LOOK_COMMAND)
.add_command(&reminder_cmds::PAUSE_COMMAND)
.add_command(&reminder_cmds::OFFSET_COMMAND)
.add_command(&reminder_cmds::NUDGE_COMMAND)
// to-do commands
.add_command(&todo_cmds::TODO_COMMAND)
// moderation commands
.add_command(&moderation_cmds::TIMEZONE_COMMAND)
.add_command(&moderation_cmds::MACRO_CMD_COMMAND)
.add_hook(&hooks::CHECK_SELF_PERMISSIONS_HOOK)
.add_hook(&hooks::MACRO_CHECK_HOOK);
let framework_arc = Arc::new(framework); register_application_commands(
ctx,
framework,
env::var("DEBUG_GUILD")
.map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid")))
.ok(),
)
.await
.unwrap();
let mut client = Client::builder(&token) Ok(Data {
.intents(GatewayIntents::GUILDS) http: reqwest::Client::new(),
.application_id(application_id.0) database,
.event_handler(Handler) popular_timezones,
.await recording_macros: Default::default(),
.expect("Error occurred creating client"); })
})
{ })
let pool = MySqlPool::connect( .options(options)
&env::var("DATABASE_URL").expect("Missing DATABASE_URL from environment"), .client_settings(move |client_builder| client_builder.intents(GatewayIntents::GUILDS))
) .run_autosharded()
.await .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;
if let Ok((Some(lower), Some(upper))) = env::var("SHARD_RANGE").map(|sr| {
let mut split =
sr.split(',').map(|val| val.parse::<u64>().expect("SHARD_RANGE not an integer"));
(split.next(), split.next())
}) {
let total_shards = env::var("SHARD_COUNT")
.map(|shard_count| shard_count.parse::<u64>().ok())
.ok()
.flatten()
.expect("No SHARD_COUNT provided, but SHARD_RANGE was provided");
assert!(lower < upper, "SHARD_RANGE lower limit is not less than the upper limit");
info!("Starting client fragment with shards {}-{}/{}", lower, upper, total_shards);
client.start_shard_range([lower, upper], total_shards).await?;
} else if let Ok(total_shards) = env::var("SHARD_COUNT")
.map(|shard_count| shard_count.parse::<u64>().expect("SHARD_COUNT not an integer"))
{
info!("Starting client with {} shards", total_shards);
client.start_shards(total_shards).await?;
} else {
info!("Starting client as autosharded");
client.start_autosharded().await?;
}
Ok(()) Ok(())
} }
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
if let Ok(member) = guild_member {
for role in member.roles {
if SUBSCRIPTION_ROLES.contains(role.as_u64()) {
return true;
}
}
}
false
} else {
true
}
}
pub async fn check_guild_subscription(
cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>,
) -> bool {
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
let owner = guild.owner_id;
check_subscription(&cache_http, owner).await
} else {
false
}
}

View File

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

View File

@ -1,6 +1,16 @@
use serenity::{client::Context, model::id::GuildId}; use std::collections::HashMap;
use crate::{framework::CommandOptions, SQLPool}; use poise::serenity::model::{
id::{ChannelId, GuildId, RoleId, UserId},
interactions::application_command::{
ApplicationCommandInteraction, ApplicationCommandInteractionDataOption,
ApplicationCommandOptionType,
},
};
use serde::{Deserialize, Serialize};
use sqlx::Executor;
use crate::Database;
pub struct CommandMacro { pub struct CommandMacro {
pub guild_id: GuildId, pub guild_id: GuildId,
@ -10,15 +20,17 @@ pub struct CommandMacro {
} }
impl CommandMacro { impl CommandMacro {
pub async fn from_guild(ctx: &Context, guild_id: impl Into<GuildId>) -> Vec<Self> { pub async fn from_guild(
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); db_pool: impl Executor<'_, Database = Database>,
guild_id: impl Into<GuildId>,
) -> Vec<Self> {
let guild_id = guild_id.into(); let guild_id = guild_id.into();
sqlx::query!( sqlx::query!(
"SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", "SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
guild_id.0 guild_id.0
) )
.fetch_all(&pool) .fetch_all(db_pool)
.await .await
.unwrap() .unwrap()
.iter() .iter()
@ -31,3 +43,170 @@ impl CommandMacro {
.collect::<Vec<Self>>() .collect::<Vec<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 new(command: impl ToString) -> Self {
Self {
command: command.to_string(),
subcommand: None,
subcommand_group: None,
options: Default::default(),
}
}
pub fn populate(&mut self, interaction: &ApplicationCommandInteraction) {
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(), self)
}
}
}

View File

@ -5,62 +5,47 @@ pub mod timer;
pub mod user_data; pub mod user_data;
use chrono_tz::Tz; use chrono_tz::Tz;
use serenity::{ use poise::serenity::{async_trait, model::id::UserId};
async_trait,
model::id::{ChannelId, UserId},
prelude::Context,
};
use crate::{ use crate::{
models::{channel_data::ChannelData, user_data::UserData}, models::{channel_data::ChannelData, user_data::UserData},
SQLPool, Context,
}; };
#[async_trait] #[async_trait]
pub trait CtxData { pub trait CtxData {
async fn user_data<U: Into<UserId> + Send + Sync>( async fn user_data<U: Into<UserId> + Send>(
&self, &self,
user_id: U, user_id: U,
) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>>; ) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>>;
async fn timezone<U: Into<UserId> + Send + Sync>(&self, user_id: U) -> Tz; async fn author_data(&self) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>>;
async fn channel_data<C: Into<ChannelId> + Send + Sync>( async fn timezone(&self) -> Tz;
&self,
channel_id: C, async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>>;
) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>>;
} }
#[async_trait] #[async_trait]
impl CtxData for Context { impl CtxData for Context<'_> {
async fn user_data<U: Into<UserId> + Send + Sync>( async fn user_data<U: Into<UserId> + Send>(
&self, &self,
user_id: U, user_id: U,
) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> { ) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> {
let user_id = user_id.into(); UserData::from_user(user_id, &self.discord(), &self.data().database).await
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
} }
async fn timezone<U: Into<UserId> + Send + Sync>(&self, user_id: U) -> Tz { async fn author_data(&self) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> {
let user_id = user_id.into(); UserData::from_user(&self.author().id, &self.discord(), &self.data().database).await
let pool = self.data.read().await.get::<SQLPool>().cloned().unwrap();
UserData::timezone_of(user_id, &pool).await
} }
async fn channel_data<C: Into<ChannelId> + Send + Sync>( async fn timezone(&self) -> Tz {
&self, UserData::timezone_of(self.author().id, &self.data().database).await
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();
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
} }
} }

View File

@ -2,8 +2,7 @@ use std::{collections::HashSet, fmt::Display};
use chrono::{Duration, NaiveDateTime, Utc}; use chrono::{Duration, NaiveDateTime, Utc};
use chrono_tz::Tz; use chrono_tz::Tz;
use serenity::{ use poise::serenity::{
client::Context,
http::CacheHttp, http::CacheHttp,
model::{ model::{
channel::GuildChannel, channel::GuildChannel,
@ -15,14 +14,13 @@ use serenity::{
use sqlx::MySqlPool; use sqlx::MySqlPool;
use crate::{ use crate::{
consts, consts::{DEFAULT_AVATAR, MAX_TIME, MIN_INTERVAL},
consts::{MAX_TIME, MIN_INTERVAL},
models::{ models::{
channel_data::ChannelData, channel_data::ChannelData,
reminder::{content::Content, errors::ReminderError, helper::generate_uid, Reminder}, reminder::{content::Content, errors::ReminderError, helper::generate_uid, Reminder},
user_data::UserData, user_data::UserData,
}, },
SQLPool, Context,
}; };
async fn create_webhook( async fn create_webhook(
@ -30,7 +28,7 @@ async fn create_webhook(
channel: GuildChannel, channel: GuildChannel,
name: impl Display, name: impl Display,
) -> SerenityResult<Webhook> { ) -> 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)] #[derive(Hash, PartialEq, Eq)]
@ -140,12 +138,12 @@ pub struct MultiReminderBuilder<'a> {
expires: Option<NaiveDateTime>, expires: Option<NaiveDateTime>,
content: Content, content: Content,
set_by: Option<u32>, set_by: Option<u32>,
ctx: &'a Context, ctx: &'a Context<'a>,
guild_id: Option<GuildId>, guild_id: Option<GuildId>,
} }
impl<'a> MultiReminderBuilder<'a> { impl<'a> MultiReminderBuilder<'a> {
pub fn new(ctx: &'a Context, guild_id: Option<GuildId>) -> Self { pub fn new(ctx: &'a Context<'a>, guild_id: Option<GuildId>) -> Self {
MultiReminderBuilder { MultiReminderBuilder {
scopes: vec![], scopes: vec![],
utc_time: Utc::now().naive_utc(), utc_time: Utc::now().naive_utc(),
@ -199,7 +197,7 @@ impl<'a> MultiReminderBuilder<'a> {
} }
pub async fn build(self) -> (HashSet<ReminderError>, HashSet<ReminderScope>) { pub async fn build(self) -> (HashSet<ReminderError>, HashSet<ReminderScope>) {
let pool = self.ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); let pool = self.ctx.data().database.clone();
let mut errors = HashSet::new(); let mut errors = HashSet::new();
@ -213,12 +211,13 @@ impl<'a> MultiReminderBuilder<'a> {
for scope in self.scopes { for scope in self.scopes {
let db_channel_id = match scope { let db_channel_id = match scope {
ReminderScope::User(user_id) => { ReminderScope::User(user_id) => {
if let Ok(user) = UserId(user_id).to_user(&self.ctx).await { if let Ok(user) = UserId(user_id).to_user(&self.ctx.discord()).await {
let user_data = let user_data = UserData::from_user(&user, &self.ctx.discord(), &pool)
UserData::from_user(&user, &self.ctx, &pool).await.unwrap(); .await
.unwrap();
if let Some(guild_id) = self.guild_id { 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) Err(ReminderError::InvalidTag)
} else { } else {
Ok(user_data.dm_channel) Ok(user_data.dm_channel)
@ -231,7 +230,8 @@ impl<'a> MultiReminderBuilder<'a> {
} }
} }
ReminderScope::Channel(channel_id) => { 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 let Some(guild_channel) = channel.clone().guild() {
if Some(guild_channel.guild_id) != self.guild_id { if Some(guild_channel.guild_id) != self.guild_id {
@ -243,7 +243,12 @@ impl<'a> MultiReminderBuilder<'a> {
if channel_data.webhook_id.is_none() if channel_data.webhook_id.is_none()
|| channel_data.webhook_token.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) => { Ok(webhook) => {
channel_data.webhook_id = channel_data.webhook_id =

View File

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

View File

@ -6,18 +6,15 @@ pub mod look_flags;
use chrono::{NaiveDateTime, TimeZone}; use chrono::{NaiveDateTime, TimeZone};
use chrono_tz::Tz; use chrono_tz::Tz;
use serenity::{ use poise::serenity::model::id::{ChannelId, GuildId, UserId};
client::Context, use sqlx::{Executor, MySqlPool};
model::id::{ChannelId, GuildId, UserId},
};
use sqlx::MySqlPool;
use crate::{ use crate::{
models::reminder::{ models::reminder::{
helper::longhand_displacement, helper::longhand_displacement,
look_flags::{LookFlags, TimeDisplayType}, look_flags::{LookFlags, TimeDisplayType},
}, },
SQLPool, Context, Database,
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -71,12 +68,10 @@ WHERE
} }
pub async fn from_channel<C: Into<ChannelId>>( pub async fn from_channel<C: Into<ChannelId>>(
ctx: &Context, db_pool: impl Executor<'_, Database = Database>,
channel_id: C, channel_id: C,
flags: &LookFlags, flags: &LookFlags,
) -> Vec<Self> { ) -> Vec<Self> {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let enabled = if flags.show_disabled { "0,1" } else { "1" }; let enabled = if flags.show_disabled { "0,1" } else { "1" };
let channel_id = channel_id.into(); let channel_id = channel_id.into();
@ -113,16 +108,21 @@ ORDER BY
channel_id.as_u64(), channel_id.as_u64(),
enabled, enabled,
) )
.fetch_all(&pool) .fetch_all(db_pool)
.await .await
.unwrap() .unwrap()
} }
pub async fn from_guild(ctx: &Context, guild_id: Option<GuildId>, user: UserId) -> Vec<Self> { pub async fn from_guild(
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap(); ctx: &Context<'_>,
guild_id: Option<GuildId>,
user: UserId,
) -> Vec<Self> {
// todo: see if this can be moved to just extract from the context
let pool = ctx.data().database.clone();
if let Some(guild_id) = guild_id { if let Some(guild_id) = guild_id {
let guild_opt = guild_id.to_guild_cached(&ctx); let guild_opt = guild_id.to_guild_cached(&ctx.discord());
if let Some(guild) = guild_opt { if let Some(guild) = guild_opt {
let channels = guild let channels = guild

View File

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

67
src/utils.rs Normal file
View File

@ -0,0 +1,67 @@
use poise::serenity::{
builder::CreateApplicationCommands,
http::CacheHttp,
model::id::{GuildId, UserId},
};
use crate::{
consts::{CNC_GUILD, SUBSCRIPTION_ROLES},
Data, Error,
};
pub async fn register_application_commands(
ctx: &poise::serenity::client::Context,
framework: &poise::Framework<Data, Error>,
guild_id: Option<GuildId>,
) -> Result<(), poise::serenity::Error> {
let mut commands_builder = CreateApplicationCommands::default();
let commands = &framework.options().commands;
for command in commands {
if let Some(slash_command) = command.create_as_slash_command() {
commands_builder.add_application_command(slash_command);
}
if let Some(context_menu_command) = command.create_as_context_menu_command() {
commands_builder.add_application_command(context_menu_command);
}
}
let commands_builder = poise::serenity::json::Value::Array(commands_builder.0);
if let Some(guild_id) = guild_id {
ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?;
} else {
ctx.http.create_global_application_commands(&commands_builder).await?;
}
Ok(())
}
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
if let Ok(member) = guild_member {
for role in member.roles {
if SUBSCRIPTION_ROLES.contains(role.as_u64()) {
return true;
}
}
}
false
} else {
true
}
}
pub async fn check_guild_subscription(
cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>,
) -> bool {
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
let owner = guild.owner_id;
check_subscription(&cache_http, owner).await
} else {
false
}
}