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