Compare commits
184 Commits
postman-in
...
jude/confi
Author | SHA1 | Date | |
---|---|---|---|
53aa5ebc55 | |||
5a85f1d83a | |||
68ba25886a | |||
e25bf6b828 | |||
5a386daa9d | |||
0d4a02fb1e | |||
e135a74a9b | |||
77f17c8dc2 | |||
6a94f990cf | |||
3aa5bd37aa | |||
fa83fed1af | |||
666cb7fa2f | |||
a5678e15dc | |||
9405cfcee9 | |||
cb25d02cdf | |||
bfe651a125 | |||
dc5e52d9ce | |||
229ada83e1 | |||
13171d6744 | |||
2ad941c94c | |||
924d31e978 | |||
f9a1b23212 | |||
ae5795a7ea | |||
ee36c38eda | |||
eca7df3d9f | |||
902b7e1b4a | |||
db1a53a797 | |||
3605d71b73 | |||
ea2cea573e | |||
d5fa8036e8 | |||
b8707bbc9a | |||
99eea16f62 | |||
88737302f3 | |||
213e3a5100 | |||
8fa1402ecc | |||
e63996bb61 | |||
9ede879630 | |||
88e9826a62 | |||
5d655c7e6d | |||
51c9d8a7ae | |||
90df265114 | |||
e65429aa9c | |||
8d2232f0da | |||
a58b9866ea | |||
b1f25be5d7 | |||
f0f9787326 | |||
302f5835e6 | |||
58c778632e | |||
5671fd462b | |||
5ac9733f15 | |||
01dc0334fd | |||
4a17aac15c | |||
8ce4fc9c6d | |||
b4f07cfc1c | |||
8799089b2d | |||
88c4830209 | |||
4dd3df5cc2 | |||
369a325a46 | |||
1a1a0fdefb | |||
dda8bd3e10 | |||
edbfc92cb9 | |||
6de11f09db | |||
284bfcd9ad | |||
3d627b5bf0 | |||
c3c0dbbbae | |||
64dd81e941 | |||
799298ca34 | |||
fa542bb24f | |||
e025d945cf | |||
bb1c61d0b9 | |||
1519474f93 | |||
9d8622f418 | |||
a66db37b33 | |||
c8c1a171d4 | |||
88cfb829e3 | |||
16be7a328e | |||
04babf7930 | |||
96bc09e8b5 | |||
976fb91ecc | |||
1305b6e64e | |||
cdfe44d958 | |||
c824a36832 | |||
c4bd2c1d18 | |||
561555ab7e | |||
115fbd44cb | |||
aa931328b0 | |||
4b42966284 | |||
523ab7f03a | |||
6e831c8253 | |||
4416e5d175 | |||
734a39a001 | |||
98191d29ee | |||
1c4c4a8b31 | |||
d496c81003 | |||
094d210f64 | |||
314c72e132 | |||
4e0163f2cb | |||
e5b8c418af | |||
3ef8584189 | |||
df2ad09c86 | |||
d70fb24eb1 | |||
3150c7267d | |||
6e65e4ff3d | |||
67a4db2e9a | |||
e9bcb1973f | |||
9b87fd4258 | |||
a49a849917 | |||
aa74a7f9a3 | |||
08e4c6cb57 | |||
6e087bd2dd | |||
e9792e6322 | |||
130504b964 | |||
2a8117d0c1 | |||
94bfd39085 | |||
40cd5f8a36 | |||
133b00a2ce | |||
57336f5c81 | |||
b62d24c024 | |||
8f8235a86e | |||
c8f646a8fa | |||
ecaa382a1e | |||
8991198fd3 | |||
f20b95a482 | |||
8dd7dc6409 | |||
c799d10727 | |||
ceb6fb7b12 | |||
6708abdb0f | |||
a38f6024c1 | |||
7d8748e3ef | |||
bb3386c4e8 | |||
25b84880a5 | |||
7b6e967a5d | |||
2781f2923e | |||
03f08f0a18 | |||
79c86d43f2 | |||
e19af54caf | |||
f4213c6a83 | |||
f56db14720 | |||
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 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -2,6 +2,6 @@
|
|||||||
.env
|
.env
|
||||||
/venv
|
/venv
|
||||||
.cargo
|
.cargo
|
||||||
assets
|
|
||||||
out.json
|
|
||||||
/.idea
|
/.idea
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
2
.prettierrc.toml
Normal file
2
.prettierrc.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
printWidth = 90
|
||||||
|
tabWidth = 4
|
2866
Cargo.lock
generated
2866
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
70
Cargo.toml
70
Cargo.toml
@ -1,46 +1,58 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "reminder_rs"
|
name = "reminder-rs"
|
||||||
version = "1.6.0-beta3"
|
version = "1.6.36"
|
||||||
authors = ["jellywx <judesouthworth@pm.me>"]
|
authors = ["Jude Southworth <judesouthworth@pm.me>"]
|
||||||
edition = "2018"
|
edition = "2021"
|
||||||
|
license = "AGPL-3.0 only"
|
||||||
|
description = "Reminder Bot for Discord, now in Rust"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
poise = "0.5.5"
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
humantime = "2.1"
|
|
||||||
tokio = { version = "1", features = ["process", "full"] }
|
tokio = { version = "1", features = ["process", "full"] }
|
||||||
reqwest = "0.11"
|
reqwest = "0.11"
|
||||||
regex = "1.4"
|
lazy-regex = "2.3.0"
|
||||||
|
regex = "1.6"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.8"
|
env_logger = "0.10"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
chrono-tz = { version = "0.5", features = ["serde"] }
|
chrono-tz = { version = "0.8", features = ["serde"] }
|
||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
num-integer = "0.1"
|
num-integer = "0.1"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde_repr = "0.1"
|
serde_repr = "0.1"
|
||||||
rmp-serde = "0.15"
|
rmp-serde = "1.1"
|
||||||
rand = "0.7"
|
rand = "0.8"
|
||||||
levenshtein = "1.0"
|
levenshtein = "1.0"
|
||||||
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
|
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]}
|
||||||
base64 = "0.13.0"
|
base64 = "0.21.0"
|
||||||
|
|
||||||
[dependencies.regex_command_attr]
|
[dependencies.postman]
|
||||||
path = "command_attributes"
|
path = "postman"
|
||||||
|
|
||||||
[dependencies.serenity]
|
[dependencies.reminder_web]
|
||||||
git = "https://github.com/serenity-rs/serenity"
|
path = "web"
|
||||||
branch = "next"
|
|
||||||
default-features = false
|
[package.metadata.deb]
|
||||||
features = [
|
depends = "$auto, python3-dateparser (>= 1.0.0)"
|
||||||
"builder",
|
suggests = "mysql-server-8.0, nginx"
|
||||||
"client",
|
maintainer-scripts = "debian"
|
||||||
"cache",
|
assets = [
|
||||||
"gateway",
|
["target/release/reminder-rs", "usr/bin/reminder-rs", "755"],
|
||||||
"http",
|
["conf/default.env", "etc/reminder-rs/config.env", "600"],
|
||||||
"model",
|
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
|
||||||
"utils",
|
["$OUT_DIR/web/static/**/*", "lib/reminder-rs/static", "644"],
|
||||||
"rustls_backend",
|
["web/templates/**/*", "lib/reminder-rs/templates", "644"],
|
||||||
"collector",
|
["healthcheck", "lib/reminder-rs/healthcheck", "755"],
|
||||||
"unstable_discord_api"
|
["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"],
|
||||||
|
# ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"]
|
||||||
]
|
]
|
||||||
|
conf-files = [
|
||||||
|
"/etc/reminder-rs/config.env",
|
||||||
|
"/etc/reminder-rs/Rocket.toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.deb.systemd-units]
|
||||||
|
unit-scripts = "systemd"
|
||||||
|
start = false
|
||||||
|
9
Containerfile
Normal file
9
Containerfile
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
FROM ubuntu:20.04
|
||||||
|
|
||||||
|
ENV RUSTUP_HOME=/usr/local/rustup \
|
||||||
|
CARGO_HOME=/usr/local/cargo \
|
||||||
|
PATH=/usr/local/cargo/bin:$PATH
|
||||||
|
|
||||||
|
RUN apt update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib cmake pkg-config libssl-dev curl mysql-client-8.0
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain nightly
|
||||||
|
RUN cargo install cargo-deb
|
47
README.md
47
README.md
@ -2,23 +2,41 @@
|
|||||||
Reminder Bot for Discord.
|
Reminder Bot for Discord.
|
||||||
|
|
||||||
## How do I use it?
|
## 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.
|
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)
|
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
|
### Build APT package
|
||||||
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.
|
|
||||||
|
|
||||||
#### Compilation environment variables
|
Recommended method.
|
||||||
These environment variables must be provided when compiling the bot
|
|
||||||
|
By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a different platform. These instructions are written using `podman`, but `docker` should work too.
|
||||||
|
|
||||||
|
1. Install container software: `sudo apt install podman`.
|
||||||
|
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
|
||||||
|
3. Install SQLx CLI: `cargo install sqlx-cli`
|
||||||
|
4. From the source code directory, execute `sqlx migrate run`
|
||||||
|
5. Build container image: `podman build -t reminder-rs .`
|
||||||
|
6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
|
||||||
|
|
||||||
|
|
||||||
|
### Compiling for other target
|
||||||
|
|
||||||
|
1. Install requirements:
|
||||||
|
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser`
|
||||||
|
2. Install rustup from https://rustup.rs
|
||||||
|
3. Install the nightly toolchain: `rustup toolchain default nightly`
|
||||||
|
4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`.
|
||||||
|
5. Install `sqlx-cli`: `cargo install sqlx-cli`.
|
||||||
|
6. Run migrations: `sqlx migrate run`.
|
||||||
|
7. Set environment variables:
|
||||||
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
|
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
|
||||||
* `WEBHOOK_AVATAR` - accepts the name of an image file located in `$CARGO_MANIFEST_DIR/assets/` to be used as the avatar when creating webhooks. **IMPORTANT: image file must be 128x128 or smaller in size**
|
8. Build: `cargo build --release`
|
||||||
|
|
||||||
### Setting up Python
|
|
||||||
Reminder Bot by default looks for a venv within it's working directory to run Python out of. To set up a venv, install `python3-venv` and run `python3 -m venv venv`. Then, run `source venv/bin/activate` to activate the venv, and do `pip install dateparser` to install the required library
|
|
||||||
|
|
||||||
### Environment Variables
|
### Configuring
|
||||||
|
|
||||||
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
|
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
|
||||||
|
|
||||||
__Required Variables__
|
__Required Variables__
|
||||||
@ -30,14 +48,5 @@ __Other Variables__
|
|||||||
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
|
* `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
|
* `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
|
* `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 `/usr/bin/python3`. Can be changed if your Python executable is located somewhere else
|
||||||
* `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
|
* `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
|
|
||||||
|
28
Rocket.toml
Normal file
28
Rocket.toml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
[default]
|
||||||
|
address = "0.0.0.0"
|
||||||
|
port = 18920
|
||||||
|
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"
|
||||||
|
|
||||||
|
[debug.rsa_sha256.tls]
|
||||||
|
certs = "web/private/rsa_sha256_cert.pem"
|
||||||
|
key = "web/private/rsa_sha256_key.pem"
|
||||||
|
|
||||||
|
[debug.ecdsa_nistp256_sha256.tls]
|
||||||
|
certs = "web/private/ecdsa_nistp256_sha256_cert.pem"
|
||||||
|
key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem"
|
||||||
|
|
||||||
|
[debug.ecdsa_nistp384_sha384.tls]
|
||||||
|
certs = "web/private/ecdsa_nistp384_sha384_cert.pem"
|
||||||
|
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
|
||||||
|
|
||||||
|
[debug.ed25519.tls]
|
||||||
|
certs = "web/private/ed25519_cert.pem"
|
||||||
|
key = "eb/private/ed25519_key.pem"
|
BIN
assets/webhook.jpg
Normal file
BIN
assets/webhook.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
99
build.rs
Normal file
99
build.rs
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
fs::{create_dir_all, DirEntry, File},
|
||||||
|
io,
|
||||||
|
io::Write,
|
||||||
|
path::Path,
|
||||||
|
process::Command,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
fn visit_dirs(dir: &Path, cb: &dyn Fn(&DirEntry)) -> io::Result<()> {
|
||||||
|
if dir.is_dir() {
|
||||||
|
for entry in fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
visit_dirs(&path, cb)?;
|
||||||
|
} else {
|
||||||
|
cb(&entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
fn process_static(file: &DirEntry) {
|
||||||
|
let out_dir = env::var("OUT_DIR").unwrap();
|
||||||
|
let path = file.path();
|
||||||
|
|
||||||
|
let in_path = path.to_str().unwrap();
|
||||||
|
let art_path = format!("{}/{}", out_dir, in_path);
|
||||||
|
let art_dir = format!("{}/{}", out_dir, path.parent().unwrap().to_str().unwrap());
|
||||||
|
|
||||||
|
match path.extension().map(|o| o.to_str()).flatten() {
|
||||||
|
Some("ts") => {}
|
||||||
|
Some("js") => {
|
||||||
|
create_dir_all(art_dir).unwrap();
|
||||||
|
|
||||||
|
if art_path.ends_with(".min.js") {
|
||||||
|
Command::new("cp").arg(in_path).arg(art_path).spawn().expect("Could not copy");
|
||||||
|
} else {
|
||||||
|
let minified = Command::new("npx")
|
||||||
|
.arg("minify")
|
||||||
|
.arg(in_path)
|
||||||
|
.output()
|
||||||
|
.expect("Could not minify");
|
||||||
|
|
||||||
|
let mut fh = File::create(art_path).expect("Couldn't create file");
|
||||||
|
fh.write(&minified.stdout).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("css") => {
|
||||||
|
create_dir_all(art_dir).unwrap();
|
||||||
|
|
||||||
|
if art_path.ends_with(".min.css") {
|
||||||
|
Command::new("cp").arg(in_path).arg(art_path).spawn().expect("Could not copy");
|
||||||
|
} else {
|
||||||
|
let minified = Command::new("npx")
|
||||||
|
.arg("minify")
|
||||||
|
.arg(in_path)
|
||||||
|
.output()
|
||||||
|
.expect("Could not minify");
|
||||||
|
|
||||||
|
let mut fh = File::create(art_path).expect("Couldn't create file");
|
||||||
|
fh.write(&minified.stdout).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
create_dir_all(art_dir).unwrap();
|
||||||
|
|
||||||
|
Command::new("cp").arg(in_path).arg(art_path).spawn().expect("Could not copy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fn compile_tsc(file: &DirEntry) {
|
||||||
|
// if path.extension() == Some("ts") {
|
||||||
|
// let out_dir = env::var("OUT_DIR").unwrap();
|
||||||
|
// let path = file.path();
|
||||||
|
//
|
||||||
|
// Command::new("npx")
|
||||||
|
// .arg("tsc")
|
||||||
|
// .arg(in_path)
|
||||||
|
// .arg(art_path)
|
||||||
|
// .spawn()
|
||||||
|
// .expect("Could not compile");
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=migrations");
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
visit_dirs("web/static".as_ref(), &process_static).unwrap();
|
||||||
|
|
||||||
|
// visit_dirs("web/static".as_ref(), &compile_tsc).unwrap();
|
||||||
|
}
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
8
conf/Rocket.toml
Normal file
8
conf/Rocket.toml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[default]
|
||||||
|
address = "127.0.0.1"
|
||||||
|
port = 18920
|
||||||
|
template_dir = "/lib/reminder-rs/templates"
|
||||||
|
limits = { json = "10MiB" }
|
||||||
|
|
||||||
|
[release]
|
||||||
|
# secret_key = ""
|
19
conf/default.env
Normal file
19
conf/default.env
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
|
DISCORD_TOKEN=
|
||||||
|
PATREON_GUILD_ID=
|
||||||
|
PATREON_ROLE_ID=
|
||||||
|
|
||||||
|
LOCAL_TIMEZONE=
|
||||||
|
MIN_INTERVAL=
|
||||||
|
PYTHON_LOCATION=/usr/bin/python3
|
||||||
|
DONTRUN=
|
||||||
|
SECRET_KEY=
|
||||||
|
|
||||||
|
REMIND_INTERVAL=
|
||||||
|
OAUTH2_DISCORD_CALLBACK=
|
||||||
|
OAUTH2_CLIENT_ID=
|
||||||
|
OAUTH2_CLIENT_SECRET=
|
||||||
|
|
||||||
|
REPORT_EMAIL=
|
||||||
|
LOG_TO_DATABASE=1
|
1
cron.d/reminder_health
Normal file
1
cron.d/reminder_health
Normal file
@ -0,0 +1 @@
|
|||||||
|
*/10 * * * * reminder /lib/reminder-rs/healthcheck
|
9
debian/postinst
vendored
Normal file
9
debian/postinst
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
id -u reminder &>/dev/null || useradd -r -M reminder
|
||||||
|
|
||||||
|
chown -R reminder /etc/reminder-rs
|
||||||
|
|
||||||
|
#DEBHELPER#
|
7
debian/postrm
vendored
Normal file
7
debian/postrm
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
id -u reminder &>/dev/null || userdel reminder
|
||||||
|
|
||||||
|
#DEBHELPER#
|
13
healthcheck
Executable file
13
healthcheck
Executable file
@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
export $(grep -v '^#' /etc/reminder-rs/config.env | xargs -d '\n')
|
||||||
|
|
||||||
|
REGEX='mysql://([A-Za-z]+)@(.+)/(.+)'
|
||||||
|
[[ $DATABASE_URL =~ $REGEX ]]
|
||||||
|
|
||||||
|
VAR=$(mysql -u "${BASH_REMATCH[1]}" -h "${BASH_REMATCH[2]}" -N -D "${BASH_REMATCH[3]}" -e "SELECT COUNT(1) FROM reminders WHERE utc_time < NOW() - INTERVAL 10 MINUTE AND enabled = 1 AND status = 'pending'")
|
||||||
|
|
||||||
|
if [ "$VAR" -gt 0 ]
|
||||||
|
then
|
||||||
|
echo "This is to inform that there is a reminder backlog which must be resolved." | mail -s "Backlog: $VAR" "$REPORT_EMAIL"
|
||||||
|
fi
|
@ -1,10 +1,6 @@
|
|||||||
CREATE DATABASE IF NOT EXISTS reminders;
|
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS=0;
|
SET FOREIGN_KEY_CHECKS=0;
|
||||||
|
|
||||||
USE reminders;
|
CREATE TABLE guilds (
|
||||||
|
|
||||||
CREATE TABLE reminders.guilds (
|
|
||||||
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
||||||
guild BIGINT UNSIGNED UNIQUE NOT NULL,
|
guild BIGINT UNSIGNED UNIQUE NOT NULL,
|
||||||
|
|
||||||
@ -18,10 +14,10 @@ CREATE TABLE reminders.guilds (
|
|||||||
default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' 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),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (default_channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL
|
FOREIGN KEY (default_channel_id) REFERENCES channels(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.channels (
|
CREATE TABLE channels (
|
||||||
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
||||||
channel BIGINT UNSIGNED UNIQUE NOT NULL,
|
channel BIGINT UNSIGNED UNIQUE NOT NULL,
|
||||||
|
|
||||||
@ -39,10 +35,10 @@ CREATE TABLE reminders.channels (
|
|||||||
guild_id INT UNSIGNED,
|
guild_id INT UNSIGNED,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE
|
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.users (
|
CREATE TABLE users (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
user BIGINT UNSIGNED UNIQUE NOT NULL,
|
user BIGINT UNSIGNED UNIQUE NOT NULL,
|
||||||
|
|
||||||
@ -59,10 +55,10 @@ CREATE TABLE reminders.users (
|
|||||||
patreon BOOLEAN NOT NULL DEFAULT 0,
|
patreon BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (dm_channel) REFERENCES reminders.channels(id) ON DELETE RESTRICT
|
FOREIGN KEY (dm_channel) REFERENCES channels(id) ON DELETE RESTRICT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.roles (
|
CREATE TABLE roles (
|
||||||
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
||||||
role BIGINT UNSIGNED UNIQUE NOT NULL,
|
role BIGINT UNSIGNED UNIQUE NOT NULL,
|
||||||
|
|
||||||
@ -71,10 +67,10 @@ CREATE TABLE reminders.roles (
|
|||||||
guild_id INT UNSIGNED NOT NULL,
|
guild_id INT UNSIGNED NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE
|
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.embeds (
|
CREATE TABLE embeds (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
|
|
||||||
title VARCHAR(256) NOT NULL DEFAULT '',
|
title VARCHAR(256) NOT NULL DEFAULT '',
|
||||||
@ -91,7 +87,7 @@ CREATE TABLE reminders.embeds (
|
|||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.embed_fields (
|
CREATE TABLE embed_fields (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
|
|
||||||
title VARCHAR(256) NOT NULL DEFAULT '',
|
title VARCHAR(256) NOT NULL DEFAULT '',
|
||||||
@ -100,10 +96,10 @@ CREATE TABLE reminders.embed_fields (
|
|||||||
embed_id INT UNSIGNED NOT NULL,
|
embed_id INT UNSIGNED NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE CASCADE
|
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.messages (
|
CREATE TABLE messages (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
|
|
||||||
content VARCHAR(2048) NOT NULL DEFAULT '',
|
content VARCHAR(2048) NOT NULL DEFAULT '',
|
||||||
@ -114,10 +110,10 @@ CREATE TABLE reminders.messages (
|
|||||||
attachment_name VARCHAR(260),
|
attachment_name VARCHAR(260),
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE SET NULL
|
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.reminders (
|
CREATE TABLE reminders (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
uid VARCHAR(64) UNIQUE NOT NULL,
|
uid VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
|
||||||
@ -140,20 +136,20 @@ CREATE TABLE reminders.reminders (
|
|||||||
set_by INT UNSIGNED,
|
set_by INT UNSIGNED,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (message_id) REFERENCES reminders.messages(id) ON DELETE RESTRICT,
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE RESTRICT,
|
||||||
FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE CASCADE,
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (set_by) REFERENCES reminders.users(id) ON DELETE SET NULL
|
FOREIGN KEY (set_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TRIGGER message_cleanup AFTER DELETE ON reminders.reminders
|
CREATE TRIGGER message_cleanup AFTER DELETE ON reminders
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
DELETE FROM reminders.messages WHERE id = OLD.message_id;
|
DELETE FROM messages WHERE id = OLD.message_id;
|
||||||
|
|
||||||
CREATE TRIGGER embed_cleanup AFTER DELETE ON reminders.messages
|
CREATE TRIGGER embed_cleanup AFTER DELETE ON messages
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
DELETE FROM reminders.embeds WHERE id = OLD.embed_id;
|
DELETE FROM embeds WHERE id = OLD.embed_id;
|
||||||
|
|
||||||
CREATE TABLE reminders.todos (
|
CREATE TABLE todos (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
user_id INT UNSIGNED,
|
user_id INT UNSIGNED,
|
||||||
guild_id INT UNSIGNED,
|
guild_id INT UNSIGNED,
|
||||||
@ -161,23 +157,23 @@ CREATE TABLE reminders.todos (
|
|||||||
value VARCHAR(2000) NOT NULL,
|
value VARCHAR(2000) NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
|
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.command_restrictions (
|
CREATE TABLE command_restrictions (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
|
|
||||||
role_id INT UNSIGNED NOT NULL,
|
role_id INT UNSIGNED NOT NULL,
|
||||||
command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
|
command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (role_id) REFERENCES reminders.roles(id) ON DELETE CASCADE,
|
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
UNIQUE KEY (`role_id`, `command`)
|
UNIQUE KEY (`role_id`, `command`)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.timers (
|
CREATE TABLE timers (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
start_time TIMESTAMP NOT NULL DEFAULT NOW(),
|
start_time TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
name VARCHAR(32) NOT NULL,
|
name VARCHAR(32) NOT NULL,
|
||||||
@ -186,7 +182,7 @@ CREATE TABLE reminders.timers (
|
|||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.events (
|
CREATE TABLE events (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
`time` TIMESTAMP NOT NULL DEFAULT NOW(),
|
`time` TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
@ -198,12 +194,12 @@ CREATE TABLE reminders.events (
|
|||||||
reminder_id INT UNSIGNED,
|
reminder_id INT UNSIGNED,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
|
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (reminder_id) REFERENCES reminders.reminders(id) ON DELETE SET NULL
|
FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.command_aliases (
|
CREATE TABLE command_aliases (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
|
|
||||||
guild_id INT UNSIGNED NOT NULL,
|
guild_id INT UNSIGNED NOT NULL,
|
||||||
@ -212,22 +208,22 @@ CREATE TABLE reminders.command_aliases (
|
|||||||
command VARCHAR(2048) NOT NULL,
|
command VARCHAR(2048) NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
|
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||||
UNIQUE KEY (`guild_id`, `name`)
|
UNIQUE KEY (`guild_id`, `name`)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.guild_users (
|
CREATE TABLE guild_users (
|
||||||
guild INT UNSIGNED NOT NULL,
|
guild INT UNSIGNED NOT NULL,
|
||||||
user INT UNSIGNED NOT NULL,
|
user INT UNSIGNED NOT NULL,
|
||||||
|
|
||||||
can_access BOOL NOT NULL DEFAULT 0,
|
can_access BOOL NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
FOREIGN KEY (guild) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
|
FOREIGN KEY (guild) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user) REFERENCES reminders.users(id) ON DELETE CASCADE,
|
FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
UNIQUE KEY (guild, user)
|
UNIQUE KEY (guild, user)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE EVENT reminders.event_cleanup
|
CREATE EVENT event_cleanup
|
||||||
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
|
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
|
||||||
ON COMPLETION PRESERVE
|
ON COMPLETION PRESERVE
|
||||||
DO DELETE FROM reminders.events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);
|
DO DELETE FROM events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);
|
@ -1,5 +1,3 @@
|
|||||||
USE reminders;
|
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 0;
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS reminders_new;
|
DROP TABLE IF EXISTS reminders_new;
|
||||||
@ -157,4 +155,9 @@ CREATE TABLE events (
|
|||||||
FOREIGN KEY (reminder_id) REFERENCES reminders_new(id) ON DELETE SET NULL
|
FOREIGN KEY (reminder_id) REFERENCES reminders_new(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
DROP TABLE reminders;
|
||||||
|
DROP TABLE embed_fields;
|
||||||
|
RENAME TABLE reminders_new TO reminders;
|
||||||
|
RENAME TABLE embed_fields_new TO embed_fields;
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
SET FOREIGN_KEY_CHECKS = 1;
|
@ -1,5 +1,3 @@
|
|||||||
USE reminders;
|
|
||||||
|
|
||||||
CREATE TABLE macro (
|
CREATE TABLE macro (
|
||||||
id INT UNSIGNED AUTO_INCREMENT,
|
id INT UNSIGNED AUTO_INCREMENT,
|
||||||
guild_id INT UNSIGNED NOT NULL,
|
guild_id INT UNSIGNED NOT NULL,
|
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE reminders RENAME COLUMN `interval` TO `interval_seconds`;
|
||||||
|
ALTER TABLE reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;
|
49
migrations/20220211000000_reminder_templates.sql
Normal file
49
migrations/20220211000000_reminder_templates.sql
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
CREATE TABLE reminder_template (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
|
||||||
|
`name` VARCHAR(24) NOT NULL DEFAULT 'Reminder',
|
||||||
|
|
||||||
|
`guild_id` INT UNSIGNED NOT NULL,
|
||||||
|
|
||||||
|
`username` VARCHAR(32) DEFAULT NULL,
|
||||||
|
`avatar` VARCHAR(512) DEFAULT NULL,
|
||||||
|
|
||||||
|
`content` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||||
|
`tts` BOOL NOT NULL DEFAULT 0,
|
||||||
|
`attachment` MEDIUMBLOB,
|
||||||
|
`attachment_name` VARCHAR(260),
|
||||||
|
|
||||||
|
`embed_title` VARCHAR(256) NOT NULL DEFAULT '',
|
||||||
|
`embed_description` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||||
|
`embed_image_url` VARCHAR(512),
|
||||||
|
`embed_thumbnail_url` VARCHAR(512),
|
||||||
|
`embed_footer` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||||
|
`embed_footer_url` VARCHAR(512),
|
||||||
|
`embed_author` VARCHAR(256) NOT NULL DEFAULT '',
|
||||||
|
`embed_author_url` VARCHAR(512),
|
||||||
|
`embed_color` INT UNSIGNED NOT NULL DEFAULT 0x0,
|
||||||
|
`embed_fields` JSON,
|
||||||
|
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
|
||||||
|
FOREIGN KEY (`guild_id`) REFERENCES guilds (`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE reminders ADD COLUMN embed_fields JSON;
|
||||||
|
|
||||||
|
update reminders
|
||||||
|
inner join embed_fields as E
|
||||||
|
on E.reminder_id = reminders.id
|
||||||
|
set embed_fields = (
|
||||||
|
select JSON_ARRAYAGG(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'title', E.title,
|
||||||
|
'value', E.value,
|
||||||
|
'inline',
|
||||||
|
if(inline = 1, cast(TRUE as json), cast(FALSE as json))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
from embed_fields
|
||||||
|
group by reminder_id
|
||||||
|
having reminder_id = reminders.id
|
||||||
|
);
|
1
migrations/20221210000000_reminder_daily_intervals.sql
Normal file
1
migrations/20221210000000_reminder_daily_intervals.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE reminders ADD COLUMN `interval_days` INT UNSIGNED DEFAULT NULL;
|
1
migrations/20230511125236_reminder_threads.sql
Normal file
1
migrations/20230511125236_reminder_threads.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE reminders ADD COLUMN `thread_id` BIGINT DEFAULT NULL;
|
1
migrations/20230511180231_ephemeral_confirmations.sql
Normal file
1
migrations/20230511180231_ephemeral_confirmations.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE guilds ADD COLUMN ephemeral_confirmations BOOL NOT NULL DEFAULT 0;
|
2
migrations/20230722130906_increase_reminder_name.sql
Normal file
2
migrations/20230722130906_increase_reminder_name.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE reminders MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder';
|
||||||
|
ALTER TABLE reminder_template MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder';
|
9
migrations/20230730134827_stats.sql
Normal file
9
migrations/20230730134827_stats.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE stat (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
`utc_time` DATETIME NOT NULL DEFAULT NOW(),
|
||||||
|
`type` ENUM('reminder_sent', 'reminder_failed'),
|
||||||
|
`reminder_id` INT UNSIGNED,
|
||||||
|
`message` TEXT,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
);
|
2
migrations/20230731170452_reminder_archive.sql
Normal file
2
migrations/20230731170452_reminder_archive.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE reminders ADD COLUMN `status` ENUM ('pending', 'sent', 'failed', 'deleted') NOT NULL DEFAULT 'pending';
|
||||||
|
ALTER TABLE reminders ADD COLUMN `status_message` TEXT;
|
41
nginx/reminder-rs
Normal file
41
nginx/reminder-rs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
server {
|
||||||
|
server_name www.reminder-bot.com;
|
||||||
|
|
||||||
|
return 301 $scheme://reminder-bot.com$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name reminder-bot.com;
|
||||||
|
|
||||||
|
return 301 https://reminder-bot.com$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name reminder-bot.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/reminder-bot.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/reminder-bot.com/privkey.pem;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
|
proxy_buffer_size 128k;
|
||||||
|
proxy_buffers 4 256k;
|
||||||
|
proxy_busy_buffers_size 256k;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:18920;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static {
|
||||||
|
alias /var/www/reminder-rs/static;
|
||||||
|
expires 30d;
|
||||||
|
}
|
||||||
|
}
|
485
package-lock.json
generated
Normal file
485
package-lock.json
generated
Normal file
@ -0,0 +1,485 @@
|
|||||||
|
{
|
||||||
|
"name": "reminder-rs",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"devDependencies": {
|
||||||
|
"minify": "^10.3.0",
|
||||||
|
"prettier": "^3.0.1",
|
||||||
|
"tsc": "^2.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
|
"version": "0.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
|
||||||
|
"integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/set-array": "^1.0.1",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.10",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.9"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/set-array": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/source-map": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.0",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
|
"version": "1.4.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
|
||||||
|
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz",
|
||||||
|
"integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@putout/minify": {
|
||||||
|
"version": "1.49.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@putout/minify/-/minify-1.49.0.tgz",
|
||||||
|
"integrity": "sha512-T/eS9rJC0tgq/s8uLpB0cpbsUaY7KSML3UbvPri2qjVCcEK/qwi8+lNWdp8VSyOWiC25Ntrt/DewOu6dXRX1ng==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/acorn": {
|
||||||
|
"version": "8.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
|
||||||
|
"integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"acorn": "bin/acorn"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/camel-case": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"pascal-case": "^3.1.2",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/clean-css": {
|
||||||
|
"version": "5.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz",
|
||||||
|
"integrity": "sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"source-map": "~0.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "10.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||||
|
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-b64-images": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-b64-images/-/css-b64-images-0.2.5.tgz",
|
||||||
|
"integrity": "sha512-TgQBEdP07adhrDfXvI5o6bHGukKBNMzp2Ngckc/6d09zpjD2gc1Hl3Ca1CKgb8FXjHi88+Phv2Uegs2kTL4zjg==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"css-b64-images": "bin/css-b64-images"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||||
|
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dot-case": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"no-case": "^3.0.4",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/find-up": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^7.1.0",
|
||||||
|
"path-exists": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/html-minifier-terser": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"camel-case": "^4.1.2",
|
||||||
|
"clean-css": "~5.3.2",
|
||||||
|
"commander": "^10.0.0",
|
||||||
|
"entities": "^4.4.0",
|
||||||
|
"param-case": "^3.0.4",
|
||||||
|
"relateurl": "^0.2.7",
|
||||||
|
"terser": "^5.15.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"html-minifier-terser": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.13.1 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jju": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/locate-path": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lower-case": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minify": {
|
||||||
|
"version": "10.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/minify/-/minify-10.3.0.tgz",
|
||||||
|
"integrity": "sha512-eRkx2J1ykkGBVi1gI2sksmovWFzts+GYi2u3Jd/S5eNIkzj0pidciICsWRWdTKTLZVFUP7b6IvoAzasvQkMicg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@putout/minify": "^1.0.4",
|
||||||
|
"clean-css": "^5.0.1",
|
||||||
|
"css-b64-images": "~0.2.5",
|
||||||
|
"debug": "^4.1.0",
|
||||||
|
"find-up": "^6.1.0",
|
||||||
|
"html-minifier-terser": "^7.1.0",
|
||||||
|
"readjson": "^2.2.2",
|
||||||
|
"simport": "^1.2.0",
|
||||||
|
"try-catch": "^3.0.0",
|
||||||
|
"try-to-catch": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"minify": "bin/minify.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/no-case": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"lower-case": "^2.0.2",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-limit": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"yocto-queue": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-locate": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/param-case": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"dot-case": "^3.0.4",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pascal-case": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"no-case": "^3.0.4",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-exists": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-fcOWSnnpCrovBsmFZIGIy9UqK2FaI7Hqax+DIO0A9UxeVoY4iweyaFjS5TavZN97Hfehph0nhsZnjlVKzEQSrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readjson": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readjson/-/readjson-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-PdeC9tsmLWBiL8vMhJvocq+OezQ3HhsH2HrN7YkhfYcTjQSa/iraB15A7Qvt7Xpr0Yd2rDNt6GbFwVQDg3HcAw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"jju": "^1.4.0",
|
||||||
|
"try-catch": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/relateurl": {
|
||||||
|
"version": "0.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
|
||||||
|
"integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/simport": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/simport/-/simport-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-85Bm7pKsqiiQ8rmYCaPDdlXZjJvuW6/k/FY8MTtLFMgU7f8S00CgTHfRtWB6KwSb6ek4p9YyG2enG1+yJbl+CA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"readjson": "^2.2.0",
|
||||||
|
"try-to-catch": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map": {
|
||||||
|
"version": "0.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map-support": {
|
||||||
|
"version": "0.5.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||||
|
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"source-map": "^0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/terser": {
|
||||||
|
"version": "5.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.19.2.tgz",
|
||||||
|
"integrity": "sha512-qC5+dmecKJA4cpYxRa5aVkKehYsQKc+AHeKl0Oe62aYjBL8ZA33tTljktDHJSaxxMnbI5ZYw+o/S2DxxLu8OfA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
|
"acorn": "^8.8.2",
|
||||||
|
"commander": "^2.20.0",
|
||||||
|
"source-map-support": "~0.5.20"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"terser": "bin/terser"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/terser/node_modules/commander": {
|
||||||
|
"version": "2.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
|
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/try-catch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/try-catch/-/try-catch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-91yfXw1rr/P6oLpHSyHDOHm0vloVvUoo9FVdw8YwY05QjJQG9OT0LUxe2VRAzmHG+0CUOmI3nhxDUMLxDN/NEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/try-to-catch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/try-to-catch/-/try-to-catch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-hOY83V84Hx/1sCzDSaJA+Xz2IIQOHRvjxzt+F0OjbQGPZ6yLPLArMA0gw/484MlfUkQbCpKYMLX3VDCAjWKfzQ==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsc": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsc/-/tsc-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-fzoSieZI5KKJVBYGvwbVZs/J5za84f2lSTLPYf6AGiIf43tZ3GNrI1QzTLcjtyDDP4aLxd46RTZq1nQxe7+k5Q==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/yocto-queue": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
7
package.json
Normal file
7
package.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"minify": "^10.3.0",
|
||||||
|
"prettier": "^3.0.1",
|
||||||
|
"tsc": "^2.0.4"
|
||||||
|
}
|
||||||
|
}
|
16
postman/Cargo.toml
Normal file
16
postman/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "postman"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1", features = ["process", "full"] }
|
||||||
|
regex = "1.4"
|
||||||
|
log = "0.4"
|
||||||
|
chrono = "0.4"
|
||||||
|
chrono-tz = { version = "0.5", features = ["serde"] }
|
||||||
|
lazy_static = "1.4"
|
||||||
|
num-integer = "0.1"
|
||||||
|
serde = "1.0"
|
||||||
|
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
|
||||||
|
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
50
postman/src/lib.rs
Normal file
50
postman/src/lib.rs
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
mod sender;
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use log::{info, warn};
|
||||||
|
use serenity::client::Context;
|
||||||
|
use sqlx::{Executor, MySql};
|
||||||
|
use tokio::{
|
||||||
|
sync::broadcast::Receiver,
|
||||||
|
time::{sleep_until, Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
type Database = MySql;
|
||||||
|
|
||||||
|
pub async fn initialize(
|
||||||
|
mut kill: Receiver<()>,
|
||||||
|
ctx: Context,
|
||||||
|
pool: impl Executor<'_, Database = Database> + Copy,
|
||||||
|
) -> Result<(), &'static str> {
|
||||||
|
tokio::select! {
|
||||||
|
output = _initialize(ctx, pool) => Ok(output),
|
||||||
|
_ = kill.recv() => {
|
||||||
|
warn!("Received terminate signal. Goodbye");
|
||||||
|
Err("Received terminate signal. Goodbye")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||||
|
let remind_interval = env::var("REMIND_INTERVAL")
|
||||||
|
.map(|inner| inner.parse::<u64>().ok())
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or(10);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let sleep_to = Instant::now() + Duration::from_secs(remind_interval);
|
||||||
|
let reminders = sender::Reminder::fetch_reminders(pool).await;
|
||||||
|
|
||||||
|
if reminders.len() > 0 {
|
||||||
|
info!("Preparing to send {} reminders.", reminders.len());
|
||||||
|
|
||||||
|
for reminder in reminders {
|
||||||
|
reminder.send(pool, ctx.clone()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep_until(sleep_to).await;
|
||||||
|
}
|
||||||
|
}
|
745
postman/src/sender.rs
Normal file
745
postman/src/sender.rs
Normal file
@ -0,0 +1,745 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Days, Duration, Months};
|
||||||
|
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},
|
||||||
|
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();
|
||||||
|
pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
match NaiveDateTime::from_timestamp_opt(final_time, 0) {
|
||||||
|
Some(dt) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
} 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(|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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: DateTime<Utc>,
|
||||||
|
timezone: String,
|
||||||
|
restartable: bool,
|
||||||
|
expires: Option<DateTime<Utc>>,
|
||||||
|
interval_seconds: Option<u32>,
|
||||||
|
interval_days: 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_days` AS 'interval_days',
|
||||||
|
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.`status` = 'pending' AND
|
||||||
|
reminders.`id` IN (
|
||||||
|
SELECT
|
||||||
|
MIN(id)
|
||||||
|
FROM
|
||||||
|
reminders
|
||||||
|
WHERE
|
||||||
|
reminders.`utc_time` <= NOW() AND
|
||||||
|
`status` = 'pending' AND
|
||||||
|
(
|
||||||
|
reminders.`interval_seconds` IS NOT NULL
|
||||||
|
OR reminders.`interval_months` IS NOT NULL
|
||||||
|
OR reminders.`interval_days` IS NOT NULL
|
||||||
|
OR reminders.enabled
|
||||||
|
)
|
||||||
|
GROUP BY channel_id
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.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()
|
||||||
|
|| self.interval_days.is_some()
|
||||||
|
{
|
||||||
|
// If all intervals are zero then dont care
|
||||||
|
if self.interval_seconds == Some(0)
|
||||||
|
&& self.interval_days == Some(0)
|
||||||
|
&& self.interval_months == Some(0)
|
||||||
|
{
|
||||||
|
self.set_sent(pool).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut updated_reminder_time =
|
||||||
|
self.utc_time.with_timezone(&self.timezone.parse().unwrap_or(Tz::UTC));
|
||||||
|
let mut fail_count = 0;
|
||||||
|
|
||||||
|
while updated_reminder_time < now && fail_count < 4 {
|
||||||
|
if let Some(interval) = self.interval_months {
|
||||||
|
if interval != 0 {
|
||||||
|
updated_reminder_time = updated_reminder_time
|
||||||
|
.checked_add_months(Months::new(interval))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
warn!(
|
||||||
|
"{}: Could not add {} months to a reminder",
|
||||||
|
interval, self.id
|
||||||
|
);
|
||||||
|
fail_count += 1;
|
||||||
|
|
||||||
|
updated_reminder_time
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(interval) = self.interval_days {
|
||||||
|
if interval != 0 {
|
||||||
|
updated_reminder_time = updated_reminder_time
|
||||||
|
.checked_add_days(Days::new(interval as u64))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
warn!("{}: Could not add {} days to a reminder", self.id, interval);
|
||||||
|
fail_count += 1;
|
||||||
|
|
||||||
|
updated_reminder_time
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(interval) = self.interval_seconds {
|
||||||
|
updated_reminder_time += Duration::seconds(interval as i64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fail_count >= 4 {
|
||||||
|
self.log_error(
|
||||||
|
pool,
|
||||||
|
"Failed to update 4 times and so is being deleted",
|
||||||
|
None::<&'static str>,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.set_failed(pool, "Failed to update 4 times and so is being deleted").await;
|
||||||
|
} else if self.expires.map_or(false, |expires| updated_reminder_time > expires) {
|
||||||
|
self.set_sent(pool).await;
|
||||||
|
} else {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE reminders SET `utc_time` = ? WHERE `id` = ?",
|
||||||
|
updated_reminder_time.with_timezone(&Utc),
|
||||||
|
self.id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect(&format!("Could not update time on Reminder {}", self.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.set_sent(pool).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn log_error(
|
||||||
|
&self,
|
||||||
|
pool: impl Executor<'_, Database = Database> + Copy,
|
||||||
|
error: &'static str,
|
||||||
|
debug_info: Option<impl std::fmt::Debug>,
|
||||||
|
) {
|
||||||
|
let message = match debug_info {
|
||||||
|
Some(info) => format!(
|
||||||
|
"{}
|
||||||
|
{:?}",
|
||||||
|
error, info
|
||||||
|
),
|
||||||
|
|
||||||
|
None => error.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
error!("[Reminder {}] {}", self.id, message);
|
||||||
|
|
||||||
|
if *LOG_TO_DATABASE {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO stat (type, reminder_id, message) VALUES ('reminder_failed', ?, ?)",
|
||||||
|
self.id,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("Could not log error to database");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn log_success(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||||
|
if *LOG_TO_DATABASE {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO stat (type, reminder_id) VALUES ('reminder_sent', ?)",
|
||||||
|
self.id,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("Could not log success to database");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||||
|
sqlx::query!("UPDATE reminders SET `status` = 'sent' WHERE `id` = ?", self.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect(&format!("Could not delete Reminder {}", self.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_failed(
|
||||||
|
&self,
|
||||||
|
pool: impl Executor<'_, Database = Database> + Copy,
|
||||||
|
message: &'static str,
|
||||||
|
) {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE reminders SET `status` = 'failed', `status_message` = ? WHERE `id` = ?",
|
||||||
|
message,
|
||||||
|
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 {
|
||||||
|
if !username.is_empty() {
|
||||||
|
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 for reminder {}: {:?}", self.id, 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 {
|
||||||
|
if let Error::Http(error) = e {
|
||||||
|
if let HttpError::UnsuccessfulRequest(http_error) = *error {
|
||||||
|
match http_error.error.code {
|
||||||
|
10003 => {
|
||||||
|
self.log_error(
|
||||||
|
pool,
|
||||||
|
"Could not be sent as channel does not exist",
|
||||||
|
None::<&'static str>,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.set_failed(
|
||||||
|
pool,
|
||||||
|
"Could not be sent as channel does not exist",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
10004 => {
|
||||||
|
self.log_error(
|
||||||
|
pool,
|
||||||
|
"Could not be sent as guild does not exist",
|
||||||
|
None::<&'static str>,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.set_failed(pool, "Could not be sent as guild does not exist")
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
50001 => {
|
||||||
|
self.log_error(
|
||||||
|
pool,
|
||||||
|
"Could not be sent as missing access",
|
||||||
|
None::<&'static str>,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.set_failed(pool, "Could not be sent as missing access").await;
|
||||||
|
}
|
||||||
|
50007 => {
|
||||||
|
self.log_error(
|
||||||
|
pool,
|
||||||
|
"Could not be sent as user has DMs disabled",
|
||||||
|
None::<&'static str>,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.set_failed(pool, "Could not be sent as user has DMs disabled")
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
50013 => {
|
||||||
|
self.log_error(
|
||||||
|
pool,
|
||||||
|
"Could not be sent as permissions are invalid",
|
||||||
|
None::<&'static str>,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.set_failed(
|
||||||
|
pool,
|
||||||
|
"Could not be sent as permissions are invalid",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.log_error(
|
||||||
|
pool,
|
||||||
|
"HTTP error sending reminder",
|
||||||
|
Some(http_error),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
self.refresh(pool).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.log_error(pool, "(Likely) a parsing error", Some(error)).await;
|
||||||
|
self.refresh(pool).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.log_error(pool, "Non-HTTP error", Some(e)).await;
|
||||||
|
self.refresh(pool).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.log_success(pool).await;
|
||||||
|
self.refresh(pool).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info!("Reminder {} is paused", self.id);
|
||||||
|
|
||||||
|
self.refresh(pool).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
117
src/commands/autocomplete.rs
Normal file
117
src/commands/autocomplete.rs
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use chrono_tz::TZ_VARIANTS;
|
||||||
|
use poise::AutocompleteChoice;
|
||||||
|
|
||||||
|
use crate::{models::CtxData, time_parser::natural_parser, Context};
|
||||||
|
|
||||||
|
pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> 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>>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn time_hint_autocomplete(
|
||||||
|
ctx: Context<'_>,
|
||||||
|
partial: &str,
|
||||||
|
) -> Vec<AutocompleteChoice<String>> {
|
||||||
|
if partial.is_empty() {
|
||||||
|
vec![AutocompleteChoice {
|
||||||
|
name: "Start typing a time...".to_string(),
|
||||||
|
value: "now".to_string(),
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
match natural_parser(partial, &ctx.timezone().await.to_string()).await {
|
||||||
|
Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||||
|
Ok(now) => {
|
||||||
|
let diff = timestamp - now.as_secs() as i64;
|
||||||
|
|
||||||
|
if diff < 0 {
|
||||||
|
vec![AutocompleteChoice {
|
||||||
|
name: "Time is in the past".to_string(),
|
||||||
|
value: "1 year ago".to_string(),
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
if diff > 86400 {
|
||||||
|
vec![
|
||||||
|
AutocompleteChoice {
|
||||||
|
name: partial.to_string(),
|
||||||
|
value: partial.to_string(),
|
||||||
|
},
|
||||||
|
AutocompleteChoice {
|
||||||
|
name: format!(
|
||||||
|
"In approximately {} days, {} hours",
|
||||||
|
diff / 86400,
|
||||||
|
(diff % 86400) / 3600
|
||||||
|
),
|
||||||
|
value: partial.to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} else if diff > 3600 {
|
||||||
|
vec![
|
||||||
|
AutocompleteChoice {
|
||||||
|
name: partial.to_string(),
|
||||||
|
value: partial.to_string(),
|
||||||
|
},
|
||||||
|
AutocompleteChoice {
|
||||||
|
name: format!("In approximately {} hours", diff / 3600),
|
||||||
|
value: partial.to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
AutocompleteChoice {
|
||||||
|
name: partial.to_string(),
|
||||||
|
value: partial.to_string(),
|
||||||
|
},
|
||||||
|
AutocompleteChoice {
|
||||||
|
name: format!("In approximately {} minutes", diff / 60),
|
||||||
|
value: partial.to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
vec![AutocompleteChoice {
|
||||||
|
name: partial.to_string(),
|
||||||
|
value: partial.to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
None => {
|
||||||
|
vec![AutocompleteChoice {
|
||||||
|
name: "Time not recognised".to_string(),
|
||||||
|
value: "now".to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
46
src/commands/command_macro/delete.rs
Normal file
46
src/commands/command_macro/delete.rs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
use super::super::autocomplete::macro_name_autocomplete;
|
||||||
|
use crate::{Context, Error};
|
||||||
|
|
||||||
|
/// 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(())
|
||||||
|
}
|
89
src/commands/command_macro/list.rs
Normal file
89
src/commands/command_macro/list.rs
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
use poise::CreateReply;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
component_models::pager::{MacroPager, Pager},
|
||||||
|
consts::THEME_COLOR,
|
||||||
|
models::{command_macro::CommandMacro, CtxData},
|
||||||
|
Context, Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
|
||||||
|
((macros.len() as f64) / 25.0).ceil() as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
|
||||||
|
let pager = MacroPager::new(page);
|
||||||
|
|
||||||
|
if macros.is_empty() {
|
||||||
|
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);
|
||||||
|
|
||||||
|
let mut page = page;
|
||||||
|
if page >= pages {
|
||||||
|
page = pages - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = (page * 25).min(macros.len());
|
||||||
|
let upper = ((page + 1) * 25).min(macros.len());
|
||||||
|
|
||||||
|
let fields = macros[lower..upper].iter().map(|m| {
|
||||||
|
if let Some(description) = &m.description {
|
||||||
|
(
|
||||||
|
m.name.clone(),
|
||||||
|
format!("*{}*\n- Has {} commands", description, m.commands.len()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(m.name.clone(), format!("- Has {} commands", m.commands.len()), true)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut reply = CreateReply::default();
|
||||||
|
|
||||||
|
reply
|
||||||
|
.embed(|e| {
|
||||||
|
e.title("Macros")
|
||||||
|
.fields(fields)
|
||||||
|
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
.components(|comp| {
|
||||||
|
pager.create_button_row(pages, comp);
|
||||||
|
|
||||||
|
comp
|
||||||
|
});
|
||||||
|
|
||||||
|
reply
|
||||||
|
}
|
229
src/commands/command_macro/migrate.rs
Normal file
229
src/commands/command_macro/migrate.rs
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
use lazy_regex::regex;
|
||||||
|
use poise::serenity_prelude::command::CommandOptionType;
|
||||||
|
use regex::Captures;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{models::command_macro::RawCommandMacro, Context, Error, GuildId};
|
||||||
|
|
||||||
|
struct Alias {
|
||||||
|
name: String,
|
||||||
|
command: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate old $alias reminder commands to macros. Only macro names that are not taken will be used.
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "migrate",
|
||||||
|
guild_only = true,
|
||||||
|
default_member_permissions = "MANAGE_GUILD",
|
||||||
|
identifying_name = "migrate_macro"
|
||||||
|
)]
|
||||||
|
pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let guild_id = ctx.guild_id().unwrap();
|
||||||
|
let mut transaction = ctx.data().database.begin().await?;
|
||||||
|
|
||||||
|
let aliases = sqlx::query_as!(
|
||||||
|
Alias,
|
||||||
|
"SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||||
|
guild_id.0
|
||||||
|
)
|
||||||
|
.fetch_all(&mut transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut added_aliases = 0;
|
||||||
|
|
||||||
|
for alias in aliases {
|
||||||
|
match parse_text_command(guild_id, alias.name, &alias.command) {
|
||||||
|
Some(cmd_macro) => {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
|
||||||
|
cmd_macro.guild_id.0,
|
||||||
|
cmd_macro.name,
|
||||||
|
cmd_macro.description,
|
||||||
|
cmd_macro.commands
|
||||||
|
)
|
||||||
|
.execute(&mut transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
added_aliases += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.commit().await?;
|
||||||
|
|
||||||
|
ctx.send(|b| b.content(format!("Added {} macros.", added_aliases))).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_text_command(
|
||||||
|
guild_id: GuildId,
|
||||||
|
alias_name: String,
|
||||||
|
command: &str,
|
||||||
|
) -> Option<RawCommandMacro> {
|
||||||
|
match command.split_once(" ") {
|
||||||
|
Some((command_word, args)) => {
|
||||||
|
let command_word = command_word.to_lowercase();
|
||||||
|
|
||||||
|
if command_word == "r"
|
||||||
|
|| command_word == "i"
|
||||||
|
|| command_word == "remind"
|
||||||
|
|| command_word == "interval"
|
||||||
|
{
|
||||||
|
let matcher = regex!(
|
||||||
|
r#"(?P<mentions>(?:<@\d+>\s+|<@!\d+>\s+|<#\d+>\s+)*)(?P<time>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+)(?:\s+(?P<interval>(?:(?:\d+)(?:s|m|h|d|))+))?(?:\s+(?P<expires>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+))?\s+(?P<content>.*)"#s
|
||||||
|
);
|
||||||
|
|
||||||
|
match matcher.captures(&args) {
|
||||||
|
Some(captures) => {
|
||||||
|
let mut args: Vec<Value> = vec![];
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("time") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "time",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("content") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "content",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("interval") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "interval",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("expires") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "expires",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("mentions") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "channels",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(RawCommandMacro {
|
||||||
|
guild_id,
|
||||||
|
name: alias_name,
|
||||||
|
description: None,
|
||||||
|
commands: json!([
|
||||||
|
{
|
||||||
|
"command_name": "remind",
|
||||||
|
"options": args,
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else if command_word == "n" || command_word == "natural" {
|
||||||
|
let matcher_primary = regex!(
|
||||||
|
r#"(?P<time>.*?)(?:\s+)(?:send|say)(?:\s+)(?P<content>.*?)(?:(?:\s+)to(?:\s+)(?P<mentions>((?:<@\d+>)|(?:<@!\d+>)|(?:<#\d+>)|(?:\s+))+))?$"#s
|
||||||
|
);
|
||||||
|
let matcher_secondary = regex!(
|
||||||
|
r#"(?P<msg>.*)(?:\s+)every(?:\s+)(?P<interval>.*?)(?:(?:\s+)(?:until|for)(?:\s+)(?P<expires>.*?))?$"#s
|
||||||
|
);
|
||||||
|
|
||||||
|
match matcher_primary.captures(&args) {
|
||||||
|
Some(captures) => {
|
||||||
|
let captures_secondary = matcher_secondary.captures(&args);
|
||||||
|
|
||||||
|
let mut args: Vec<Value> = vec![];
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("time") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "time",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("content") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "content",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) =
|
||||||
|
captures_secondary.as_ref().and_then(|c: &Captures| c.name("interval"))
|
||||||
|
{
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "interval",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) =
|
||||||
|
captures_secondary.and_then(|c: Captures| c.name("expires"))
|
||||||
|
{
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "expires",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("mentions") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "channels",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(RawCommandMacro {
|
||||||
|
guild_id,
|
||||||
|
name: alias_name,
|
||||||
|
description: None,
|
||||||
|
commands: json!([
|
||||||
|
{
|
||||||
|
"command_name": "remind",
|
||||||
|
"options": args,
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
19
src/commands/command_macro/mod.rs
Normal file
19
src/commands/command_macro/mod.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
use crate::{Context, Error};
|
||||||
|
|
||||||
|
pub mod delete;
|
||||||
|
pub mod list;
|
||||||
|
pub mod migrate;
|
||||||
|
pub mod record;
|
||||||
|
pub mod run;
|
||||||
|
|
||||||
|
/// 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(())
|
||||||
|
}
|
151
src/commands/command_macro/record.rs
Normal file
151
src/commands/command_macro/record.rs
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
use std::collections::hash_map::Entry;
|
||||||
|
|
||||||
|
use crate::{consts::THEME_COLOR, models::command_macro::CommandMacro, Context, Error};
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
if name.len() > 100 {
|
||||||
|
ctx.say("Name must be less than 100 characters").await?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if description.as_ref().map_or(0, |d| d.len()) > 100 {
|
||||||
|
ctx.say("Description must be less than 100 characters").await?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.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?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finish current macro recording
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "finish",
|
||||||
|
guild_only = true,
|
||||||
|
default_member_permissions = "MANAGE_GUILD",
|
||||||
|
identifying_name = "finish_macro"
|
||||||
|
)]
|
||||||
|
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let key = (ctx.guild_id().unwrap(), ctx.author().id);
|
||||||
|
|
||||||
|
{
|
||||||
|
let lock = 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(())
|
||||||
|
}
|
56
src/commands/command_macro/run.rs
Normal file
56
src/commands/command_macro/run.rs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
use super::super::autocomplete::macro_name_autocomplete;
|
||||||
|
use crate::{models::command_macro::guild_command_macro, Context, Data, Error, THEME_COLOR};
|
||||||
|
|
||||||
|
/// 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) => {
|
||||||
|
Context::Application(ctx)
|
||||||
|
.send(|b| {
|
||||||
|
b.embed(|e| {
|
||||||
|
e.title("Running Macro").color(*THEME_COLOR).description(format!(
|
||||||
|
"Running macro {} ({} commands)",
|
||||||
|
command_macro.name,
|
||||||
|
command_macro.commands.len()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.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(())
|
||||||
|
}
|
@ -1,16 +1,13 @@
|
|||||||
use chrono::offset::Utc;
|
use chrono::offset::Utc;
|
||||||
use regex_command_attr::command;
|
use poise::{serenity_prelude as serenity, serenity_prelude::Mentionable};
|
||||||
use serenity::{builder::CreateEmbedFooter, client::Context};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{models::CtxData, Context, Error, THEME_COLOR};
|
||||||
framework::{CommandInvoke, CreateGenericResponse},
|
|
||||||
models::CtxData,
|
|
||||||
THEME_COLOR,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter {
|
fn footer(
|
||||||
let shard_count = ctx.cache.shard_count();
|
ctx: Context<'_>,
|
||||||
let shard = ctx.shard_id;
|
) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
|
||||||
|
let shard_count = ctx.serenity_context().cache.shard_count();
|
||||||
|
let shard = ctx.serenity_context().shard_id;
|
||||||
|
|
||||||
move |f| {
|
move |f| {
|
||||||
f.text(format!(
|
f.text(format!(
|
||||||
@ -22,15 +19,13 @@ fn footer(ctx: &Context) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
/// Get an overview of bot commands
|
||||||
#[description("Get an overview of the bot commands")]
|
#[poise::command(slash_command)]
|
||||||
async fn help(ctx: &Context, invoke: &mut CommandInvoke) {
|
pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
let footer = footer(ctx);
|
let footer = footer(ctx);
|
||||||
|
|
||||||
let _ = invoke
|
ctx.send(|m| {
|
||||||
.respond(
|
m.ephemeral(true).embed(|e| {
|
||||||
&ctx,
|
|
||||||
CreateGenericResponse::new().embed(|e| {
|
|
||||||
e.title("Help")
|
e.title("Help")
|
||||||
.color(*THEME_COLOR)
|
.color(*THEME_COLOR)
|
||||||
.description(
|
.description(
|
||||||
@ -54,29 +49,30 @@ __Todo Commands__
|
|||||||
|
|
||||||
__Setup Commands__
|
__Setup Commands__
|
||||||
`/timezone` - Set your timezone (necessary for `/remind` to work properly)
|
`/timezone` - Set your timezone (necessary for `/remind` to work properly)
|
||||||
|
`/dm allow/block` - Change your DM settings for reminders.
|
||||||
|
|
||||||
__Advanced Commands__
|
__Advanced Commands__
|
||||||
`/macro` - Record and replay command sequences
|
`/macro` - Record and replay command sequences
|
||||||
",
|
",
|
||||||
)
|
)
|
||||||
.footer(footer)
|
.footer(footer)
|
||||||
}),
|
})
|
||||||
)
|
})
|
||||||
.await;
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
/// Get information about the bot
|
||||||
#[aliases("invite")]
|
#[poise::command(slash_command)]
|
||||||
#[description("Get information about the bot")]
|
pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
async fn info(ctx: &Context, invoke: &mut CommandInvoke) {
|
|
||||||
let footer = footer(ctx);
|
let footer = footer(ctx);
|
||||||
|
|
||||||
let _ = invoke
|
let _ = ctx
|
||||||
.respond(
|
.send(|m| {
|
||||||
ctx.http.clone(),
|
m.ephemeral(true).embed(|e| {
|
||||||
CreateGenericResponse::new().embed(|e| {
|
|
||||||
e.title("Info")
|
e.title("Info")
|
||||||
.description(format!(
|
.description(
|
||||||
"Help: `/help`
|
"Help: `/help`
|
||||||
|
|
||||||
**Welcome to Reminder Bot!**
|
**Welcome to Reminder Bot!**
|
||||||
@ -86,26 +82,25 @@ Find me on https://discord.jellywx.com and on https://github.com/JellyWX :)
|
|||||||
|
|
||||||
Invite the bot: https://invite.reminder-bot.com/
|
Invite the bot: https://invite.reminder-bot.com/
|
||||||
Use our dashboard: https://reminder-bot.com/",
|
Use our dashboard: https://reminder-bot.com/",
|
||||||
))
|
)
|
||||||
.footer(footer)
|
.footer(footer)
|
||||||
.color(*THEME_COLOR)
|
.color(*THEME_COLOR)
|
||||||
}),
|
})
|
||||||
)
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
/// Details on supporting the bot and Patreon benefits
|
||||||
#[description("Details on supporting the bot and Patreon benefits")]
|
#[poise::command(slash_command)]
|
||||||
#[group("Info")]
|
pub async fn donate(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
async fn donate(ctx: &Context, invoke: &mut CommandInvoke) {
|
|
||||||
let footer = footer(ctx);
|
let footer = footer(ctx);
|
||||||
|
|
||||||
let _ = invoke
|
ctx.send(|m| m.embed(|e| {
|
||||||
.respond(
|
|
||||||
ctx.http.clone(),
|
|
||||||
CreateGenericResponse::new().embed(|e| {
|
|
||||||
e.title("Donate")
|
e.title("Donate")
|
||||||
.description("Thinking of adding a monthly contribution? Click below for my Patreon and official bot server :)
|
.description("Thinking of adding a monthly contribution?
|
||||||
|
Click below for my Patreon and official bot server :)
|
||||||
|
|
||||||
**https://www.patreon.com/jellywx/**
|
**https://www.patreon.com/jellywx/**
|
||||||
**https://discord.jellywx.com/**
|
**https://discord.jellywx.com/**
|
||||||
@ -124,39 +119,63 @@ Just $2 USD/month!
|
|||||||
.color(*THEME_COLOR)
|
.color(*THEME_COLOR)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
/// Get the link to the online dashboard
|
||||||
#[description("Get the link to the online dashboard")]
|
#[poise::command(slash_command)]
|
||||||
#[group("Info")]
|
pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
async fn dashboard(ctx: &Context, invoke: &mut CommandInvoke) {
|
|
||||||
let footer = footer(ctx);
|
let footer = footer(ctx);
|
||||||
|
|
||||||
let _ = invoke
|
ctx.send(|m| {
|
||||||
.respond(
|
m.ephemeral(true).embed(|e| {
|
||||||
ctx.http.clone(),
|
|
||||||
CreateGenericResponse::new().embed(|e| {
|
|
||||||
e.title("Dashboard")
|
e.title("Dashboard")
|
||||||
.description("**https://reminder-bot.com/dashboard**")
|
.description("**https://reminder-bot.com/dashboard**")
|
||||||
.footer(footer)
|
.footer(footer)
|
||||||
.color(*THEME_COLOR)
|
.color(*THEME_COLOR)
|
||||||
}),
|
})
|
||||||
)
|
})
|
||||||
.await;
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
/// View the current time in your selected timezone
|
||||||
#[description("View the current time in your selected timezone")]
|
#[poise::command(slash_command)]
|
||||||
#[group("Info")]
|
pub async fn clock(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
async fn clock(ctx: &Context, invoke: &mut CommandInvoke) {
|
ctx.defer_ephemeral().await?;
|
||||||
let ud = ctx.user_data(&invoke.author_id()).await.unwrap();
|
|
||||||
let now = Utc::now().with_timezone(&ud.timezone());
|
|
||||||
|
|
||||||
let _ = invoke
|
let tz = ctx.timezone().await;
|
||||||
.respond(
|
let now = Utc::now().with_timezone(&tz);
|
||||||
ctx.http.clone(),
|
|
||||||
CreateGenericResponse::new().content(format!("Current time: {}", now.format("%H:%M"))),
|
ctx.send(|m| {
|
||||||
)
|
m.ephemeral(true).content(format!("Time in **{}**: `{}`", tz, now.format("%H:%M")))
|
||||||
.await;
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View 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(())
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
mod autocomplete;
|
||||||
|
pub mod command_macro;
|
||||||
pub mod info_cmds;
|
pub mod info_cmds;
|
||||||
pub mod moderation_cmds;
|
pub mod moderation_cmds;
|
||||||
pub mod reminder_cmds;
|
pub mod reminder_cmds;
|
||||||
|
@ -1,54 +1,43 @@
|
|||||||
use chrono::offset::Utc;
|
use chrono::offset::Utc;
|
||||||
use chrono_tz::{Tz, TZ_VARIANTS};
|
use chrono_tz::{Tz, TZ_VARIANTS};
|
||||||
use levenshtein::levenshtein;
|
use levenshtein::levenshtein;
|
||||||
use regex_command_attr::command;
|
use log::warn;
|
||||||
use serenity::client::Context;
|
|
||||||
|
|
||||||
use crate::{
|
use super::autocomplete::timezone_autocomplete;
|
||||||
component_models::pager::{MacroPager, Pager},
|
use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[command("timezone")]
|
/// Select your timezone
|
||||||
#[description("Select your timezone")]
|
#[poise::command(slash_command, identifying_name = "timezone")]
|
||||||
#[arg(
|
pub async fn timezone(
|
||||||
name = "timezone",
|
ctx: Context<'_>,
|
||||||
description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee",
|
#[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"]
|
||||||
kind = "String",
|
#[autocomplete = "timezone_autocomplete"]
|
||||||
required = false
|
timezone: Option<String>,
|
||||||
)]
|
) -> Result<(), Error> {
|
||||||
async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
|
let mut user_data = ctx.author_data().await.unwrap();
|
||||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
|
||||||
let mut user_data = ctx.user_data(invoke.author_id()).await.unwrap();
|
|
||||||
|
|
||||||
let footer_text = format!("Current timezone: {}", user_data.timezone);
|
let footer_text = format!("Current timezone: {}", user_data.timezone);
|
||||||
|
|
||||||
if let Some(OptionValue::String(timezone)) = args.get("timezone") {
|
if let Some(timezone) = timezone {
|
||||||
match timezone.parse::<Tz>() {
|
match timezone.parse::<Tz>() {
|
||||||
Ok(tz) => {
|
Ok(tz) => {
|
||||||
user_data.timezone = timezone.clone();
|
user_data.timezone = timezone.clone();
|
||||||
user_data.commit_changes(&pool).await;
|
user_data.commit_changes(&ctx.data().database).await;
|
||||||
|
|
||||||
let now = Utc::now().with_timezone(&tz);
|
let now = Utc::now().with_timezone(&tz);
|
||||||
|
|
||||||
let _ = invoke
|
ctx.send(|m| {
|
||||||
.respond(
|
m.embed(|e| {
|
||||||
ctx.http.clone(),
|
|
||||||
CreateGenericResponse::new().embed(|e| {
|
|
||||||
e.title("Timezone Set")
|
e.title("Timezone Set")
|
||||||
.description(format!(
|
.description(format!(
|
||||||
"Timezone has been set to **{}**. Your current time should be `{}`",
|
"Timezone has been set to **{}**. Your current time should be `{}`",
|
||||||
timezone,
|
timezone,
|
||||||
now.format("%H:%M").to_string()
|
now.format("%H:%M")
|
||||||
))
|
))
|
||||||
.color(*THEME_COLOR)
|
.color(*THEME_COLOR)
|
||||||
}),
|
})
|
||||||
)
|
})
|
||||||
.await;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@ -56,8 +45,8 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|tz| {
|
.filter(|tz| {
|
||||||
timezone.contains(&tz.to_string())
|
timezone.contains(&tz.to_string())
|
||||||
|| tz.to_string().contains(timezone)
|
|| tz.to_string().contains(&timezone)
|
||||||
|| levenshtein(&tz.to_string(), timezone) < 4
|
|| levenshtein(&tz.to_string(), &timezone) < 4
|
||||||
})
|
})
|
||||||
.take(25)
|
.take(25)
|
||||||
.map(|t| t.to_owned())
|
.map(|t| t.to_owned())
|
||||||
@ -66,44 +55,31 @@ async fn timezone(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOption
|
|||||||
let fields = filtered_tz.iter().map(|tz| {
|
let fields = filtered_tz.iter().map(|tz| {
|
||||||
(
|
(
|
||||||
tz.to_string(),
|
tz.to_string(),
|
||||||
format!(
|
format!("🕗 `{}`", Utc::now().with_timezone(tz).format("%H:%M")),
|
||||||
"🕗 `{}`",
|
|
||||||
Utc::now().with_timezone(tz).format("%H:%M").to_string()
|
|
||||||
),
|
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = invoke
|
ctx.send(|m| {
|
||||||
.respond(
|
m.embed(|e| {
|
||||||
ctx.http.clone(),
|
|
||||||
CreateGenericResponse::new().embed(|e| {
|
|
||||||
e.title("Timezone Not Recognized")
|
e.title("Timezone Not Recognized")
|
||||||
.description("Possibly you meant one of the following timezones, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):")
|
.description("Possibly you meant one of the following timezones, otherwise click [here](https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee):")
|
||||||
.color(*THEME_COLOR)
|
.color(*THEME_COLOR)
|
||||||
.fields(fields)
|
.fields(fields)
|
||||||
.footer(|f| f.text(footer_text))
|
.footer(|f| f.text(footer_text))
|
||||||
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
|
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
|
||||||
}),
|
})
|
||||||
)
|
})
|
||||||
.await;
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let popular_timezones = ctx.data.read().await.get::<PopularTimezones>().cloned().unwrap();
|
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 popular_timezones_iter = popular_timezones.iter().map(|t| {
|
|
||||||
(
|
|
||||||
t.to_string(),
|
|
||||||
format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = invoke
|
ctx.send(|m| {
|
||||||
.respond(
|
m.embed(|e| {
|
||||||
ctx.http.clone(),
|
|
||||||
CreateGenericResponse::new().embed(|e| {
|
|
||||||
e.title("Timezone Usage")
|
e.title("Timezone Usage")
|
||||||
.description(
|
.description(
|
||||||
"**Usage:**
|
"**Usage:**
|
||||||
@ -118,345 +94,162 @@ You may want to use one of the popular timezones below, otherwise click [here](h
|
|||||||
.fields(popular_timezones_iter)
|
.fields(popular_timezones_iter)
|
||||||
.footer(|f| f.text(footer_text))
|
.footer(|f| f.text(footer_text))
|
||||||
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
|
.url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee")
|
||||||
}),
|
})
|
||||||
)
|
})
|
||||||
.await;
|
.await?;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command("macro")]
|
Ok(())
|
||||||
#[description("Record and replay command sequences")]
|
}
|
||||||
#[subcommand("record")]
|
|
||||||
#[description("Start recording up to 5 commands to replay")]
|
/// Configure server settings
|
||||||
#[arg(name = "name", description = "Name for the new macro", kind = "String", required = true)]
|
#[poise::command(
|
||||||
#[arg(
|
slash_command,
|
||||||
name = "description",
|
rename = "settings",
|
||||||
description = "Description for the new macro",
|
identifying_name = "settings",
|
||||||
kind = "String",
|
guild_only = true
|
||||||
required = false
|
|
||||||
)]
|
)]
|
||||||
#[subcommand("finish")]
|
pub async fn settings(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
#[description("Finish current recording")]
|
Ok(())
|
||||||
#[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 {
|
/// Configure ephemeral setup
|
||||||
let _ = invoke
|
#[poise::command(
|
||||||
.respond(
|
slash_command,
|
||||||
&ctx,
|
rename = "ephemeral",
|
||||||
CreateGenericResponse::new().ephemeral().embed(|e| {
|
identifying_name = "ephemeral_confirmations",
|
||||||
e
|
guild_only = true
|
||||||
.title("Macro Recording Started")
|
)]
|
||||||
|
pub async fn ephemeral_confirmations(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set reminder confirmations to be sent "ephemerally" (private and cleared automatically)
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "on",
|
||||||
|
identifying_name = "set_ephemeral_confirmations",
|
||||||
|
guild_only = true
|
||||||
|
)]
|
||||||
|
pub async fn set_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let mut guild_data = ctx.guild_data().await.unwrap()?;
|
||||||
|
guild_data.ephemeral_confirmations = true;
|
||||||
|
guild_data.commit_changes(&ctx.data().database).await;
|
||||||
|
|
||||||
|
ctx.send(|r| {
|
||||||
|
r.ephemeral(true).embed(|e| {
|
||||||
|
e.title("Confirmations ephemeral")
|
||||||
|
.description("Reminder confirmations will be sent privately, and removed when your client restarts.")
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set reminder confirmations to persist indefinitely
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "off",
|
||||||
|
identifying_name = "unset_ephemeral_confirmations",
|
||||||
|
guild_only = true
|
||||||
|
)]
|
||||||
|
pub async fn unset_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let mut guild_data = ctx.guild_data().await.unwrap()?;
|
||||||
|
guild_data.ephemeral_confirmations = false;
|
||||||
|
guild_data.commit_changes(&ctx.data().database).await;
|
||||||
|
|
||||||
|
ctx.send(|r| {
|
||||||
|
r.ephemeral(true).embed(|e| {
|
||||||
|
e.title("Confirmations public")
|
||||||
.description(
|
.description(
|
||||||
"Run up to 5 commands, or type `/macro finish` to stop at any point.
|
"Reminder confirmations will be sent as regular messages, and won't be removed automatically.",
|
||||||
Any commands ran as part of recording will be inconsequential")
|
|
||||||
.color(*THEME_COLOR)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.await;
|
.color(*THEME_COLOR)
|
||||||
} else {
|
})
|
||||||
let _ = invoke
|
})
|
||||||
.respond(
|
.await?;
|
||||||
&ctx,
|
|
||||||
CreateGenericResponse::new().ephemeral().embed(|e| {
|
Ok(())
|
||||||
e.title("Macro Already Recording")
|
}
|
||||||
|
|
||||||
|
/// Configure whether other users can set reminders to your direct messages
|
||||||
|
#[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")]
|
||||||
|
pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allow other users to set reminders in your direct messages
|
||||||
|
#[poise::command(slash_command, rename = "allow", identifying_name = "set_allowed_dm")]
|
||||||
|
pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let mut user_data = ctx.author_data().await?;
|
||||||
|
user_data.allowed_dm = true;
|
||||||
|
user_data.commit_changes(&ctx.data().database).await;
|
||||||
|
|
||||||
|
ctx.send(|r| {
|
||||||
|
r.ephemeral(true).embed(|e| {
|
||||||
|
e.title("DMs permitted")
|
||||||
|
.description("You will receive a message if a user sets a DM reminder for you.")
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block other users from setting reminders in your direct messages
|
||||||
|
#[poise::command(slash_command, rename = "block", identifying_name = "unset_allowed_dm")]
|
||||||
|
pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let mut user_data = ctx.author_data().await?;
|
||||||
|
user_data.allowed_dm = false;
|
||||||
|
user_data.commit_changes(&ctx.data().database).await;
|
||||||
|
|
||||||
|
ctx.send(|r| {
|
||||||
|
r.ephemeral(true).embed(|e| {
|
||||||
|
e.title("DMs blocked")
|
||||||
.description(
|
.description(
|
||||||
"You are already recording a macro in this server.
|
"You can still set DM reminders for yourself or for users with DMs enabled.",
|
||||||
Please use `/macro finish` to end this recording before starting another.",
|
|
||||||
)
|
)
|
||||||
.color(*THEME_COLOR)
|
.color(*THEME_COLOR)
|
||||||
}),
|
})
|
||||||
)
|
})
|
||||||
.await;
|
.await?;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"finish" => {
|
|
||||||
let key = (invoke.guild_id().unwrap(), invoke.author_id());
|
|
||||||
let macro_buffer = ctx.data.read().await.get::<RecordingMacros>().cloned().unwrap();
|
|
||||||
|
|
||||||
{
|
Ok(())
|
||||||
let lock = macro_buffer.read().await;
|
}
|
||||||
let contained = lock.get(&key);
|
|
||||||
|
|
||||||
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
|
/// View the webhook being used to send reminders to this channel
|
||||||
let _ = invoke
|
#[poise::command(
|
||||||
.respond(
|
slash_command,
|
||||||
&ctx,
|
identifying_name = "webhook_url",
|
||||||
CreateGenericResponse::new().embed(|e| {
|
required_permissions = "ADMINISTRATOR"
|
||||||
e.title("No Macro Recorded")
|
)]
|
||||||
.description("Use `/macro record` to start recording a macro")
|
pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
.color(*THEME_COLOR)
|
match ctx.channel_data().await {
|
||||||
}),
|
Ok(data) => {
|
||||||
)
|
if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
|
||||||
.await;
|
ctx.send(|b| {
|
||||||
|
b.ephemeral(true).content(format!(
|
||||||
|
"**Warning!**
|
||||||
|
This link can be used by users to anonymously send messages, with or without permissions.
|
||||||
|
Do not share it!
|
||||||
|
|| https://discord.com/api/webhooks/{}/{} ||",
|
||||||
|
id, token,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
} else {
|
} else {
|
||||||
let command_macro = contained.unwrap();
|
ctx.say("No webhook configured on this channel.").await?;
|
||||||
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) => {
|
Err(e) => {
|
||||||
panic!("{}", e);
|
warn!("Error fetching channel data: {:?}", e);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"delete" => {
|
|
||||||
let macro_name = args.get("name").unwrap().to_string();
|
|
||||||
|
|
||||||
match sqlx::query!(
|
ctx.say("No webhook configured on this channel.").await?;
|
||||||
"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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn max_macro_page(macros: &[CommandMacro]) -> usize {
|
Ok(())
|
||||||
let mut skipped_char_count = 0;
|
|
||||||
|
|
||||||
macros
|
|
||||||
.iter()
|
|
||||||
.map(|m| {
|
|
||||||
if let Some(description) = &m.description {
|
|
||||||
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
|
|
||||||
} else {
|
|
||||||
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.fold(1, |mut pages, p| {
|
|
||||||
skipped_char_count += p.len();
|
|
||||||
|
|
||||||
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
|
|
||||||
skipped_char_count = p.len();
|
|
||||||
pages += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
pages
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show_macro_page(macros: &[CommandMacro], page: usize) -> CreateGenericResponse {
|
|
||||||
let pager = MacroPager::new(page);
|
|
||||||
|
|
||||||
if macros.is_empty() {
|
|
||||||
return CreateGenericResponse::new().embed(|e| {
|
|
||||||
e.title("Macros")
|
|
||||||
.description("No Macros Set Up. Use `/macro record` to get started.")
|
|
||||||
.color(*THEME_COLOR)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let pages = max_macro_page(macros);
|
|
||||||
|
|
||||||
let mut page = page;
|
|
||||||
if page >= pages {
|
|
||||||
page = pages - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut char_count = 0;
|
|
||||||
let mut skipped_char_count = 0;
|
|
||||||
|
|
||||||
let mut skipped_pages = 0;
|
|
||||||
|
|
||||||
let display_vec: Vec<String> = macros
|
|
||||||
.iter()
|
|
||||||
.map(|m| {
|
|
||||||
if let Some(description) = &m.description {
|
|
||||||
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
|
|
||||||
} else {
|
|
||||||
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.skip_while(|p| {
|
|
||||||
skipped_char_count += p.len();
|
|
||||||
|
|
||||||
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
|
|
||||||
skipped_char_count = p.len();
|
|
||||||
skipped_pages += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
skipped_pages < page
|
|
||||||
})
|
|
||||||
.take_while(|p| {
|
|
||||||
char_count += p.len();
|
|
||||||
|
|
||||||
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
|
||||||
})
|
|
||||||
.collect::<Vec<String>>();
|
|
||||||
|
|
||||||
let display = display_vec.join("\n");
|
|
||||||
|
|
||||||
CreateGenericResponse::new()
|
|
||||||
.embed(|e| {
|
|
||||||
e.title("Macros")
|
|
||||||
.description(display)
|
|
||||||
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
|
|
||||||
.color(*THEME_COLOR)
|
|
||||||
})
|
|
||||||
.components(|comp| {
|
|
||||||
pager.create_button_row(pages, comp);
|
|
||||||
|
|
||||||
comp
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,4 @@
|
|||||||
use regex_command_attr::command;
|
use poise::CreateReply;
|
||||||
use serenity::client::Context;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
component_models::{
|
component_models::{
|
||||||
@ -7,134 +6,222 @@ use crate::{
|
|||||||
ComponentDataModel, TodoSelector,
|
ComponentDataModel, TodoSelector,
|
||||||
},
|
},
|
||||||
consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR},
|
consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR},
|
||||||
framework::{CommandInvoke, CommandOptions, CreateGenericResponse},
|
models::CtxData,
|
||||||
hooks::CHECK_GUILD_PERMISSIONS_HOOK,
|
Context, Error,
|
||||||
SQLPool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[command]
|
/// Manage todo lists
|
||||||
#[description("Manage todo lists")]
|
#[poise::command(
|
||||||
#[subcommandgroup("server")]
|
slash_command,
|
||||||
#[description("Manage the server todo list")]
|
rename = "todo",
|
||||||
#[subcommand("add")]
|
identifying_name = "todo_base",
|
||||||
#[description("Add an item to the server todo list")]
|
default_member_permissions = "MANAGE_GUILD"
|
||||||
#[arg(
|
|
||||||
name = "task",
|
|
||||||
description = "The task to add to the todo list",
|
|
||||||
kind = "String",
|
|
||||||
required = true
|
|
||||||
)]
|
)]
|
||||||
#[subcommand("view")]
|
pub async fn todo_base(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
#[description("View and remove from the server todo list")]
|
Ok(())
|
||||||
#[subcommandgroup("channel")]
|
}
|
||||||
#[description("Manage the channel todo list")]
|
|
||||||
#[subcommand("add")]
|
/// Manage the server todo list
|
||||||
#[description("Add to the channel todo list")]
|
#[poise::command(
|
||||||
#[arg(
|
slash_command,
|
||||||
name = "task",
|
rename = "server",
|
||||||
description = "The task to add to the todo list",
|
guild_only = true,
|
||||||
kind = "String",
|
identifying_name = "todo_guild_base",
|
||||||
required = true
|
default_member_permissions = "MANAGE_GUILD"
|
||||||
)]
|
)]
|
||||||
#[subcommand("view")]
|
pub async fn todo_guild_base(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
#[description("View and remove from the channel todo list")]
|
Ok(())
|
||||||
#[subcommandgroup("user")]
|
}
|
||||||
#[description("Manage your personal todo list")]
|
|
||||||
#[subcommand("add")]
|
/// Add an item to the server todo list
|
||||||
#[description("Add to your personal todo list")]
|
#[poise::command(
|
||||||
#[arg(
|
slash_command,
|
||||||
name = "task",
|
rename = "add",
|
||||||
description = "The task to add to the todo list",
|
guild_only = true,
|
||||||
kind = "String",
|
identifying_name = "todo_guild_add",
|
||||||
required = true
|
default_member_permissions = "MANAGE_GUILD"
|
||||||
)]
|
)]
|
||||||
#[subcommand("view")]
|
pub async fn todo_guild_add(
|
||||||
#[description("View and remove from your personal todo list")]
|
ctx: Context<'_>,
|
||||||
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
|
#[description = "The task to add to the todo list"] task: String,
|
||||||
async fn todo(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
|
) -> Result<(), Error> {
|
||||||
if invoke.guild_id().is_none() && args.subcommand_group != Some("user".to_string()) {
|
|
||||||
let _ = invoke
|
|
||||||
.respond(
|
|
||||||
&ctx,
|
|
||||||
CreateGenericResponse::new().content("Please use `/todo user` in direct messages"),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
} else {
|
|
||||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
|
||||||
|
|
||||||
let keys = match args.subcommand_group.as_ref().unwrap().as_str() {
|
|
||||||
"server" => (None, None, invoke.guild_id().map(|g| g.0)),
|
|
||||||
"channel" => (None, Some(invoke.channel_id().0), invoke.guild_id().map(|g| g.0)),
|
|
||||||
_ => (Some(invoke.author_id().0), None, None),
|
|
||||||
};
|
|
||||||
|
|
||||||
match args.get("task") {
|
|
||||||
Some(task) => {
|
|
||||||
let task = task.to_string();
|
|
||||||
|
|
||||||
sqlx::query!(
|
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 = ?), ?)",
|
"INSERT INTO todos (guild_id, value)
|
||||||
keys.0,
|
VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)",
|
||||||
keys.1,
|
ctx.guild_id().unwrap().0,
|
||||||
keys.2,
|
|
||||||
task
|
task
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&ctx.data().database)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let _ = invoke
|
ctx.say("Item added to todo list").await?;
|
||||||
.respond(&ctx, CreateGenericResponse::new().content("Item added to todo list"))
|
|
||||||
.await;
|
Ok(())
|
||||||
}
|
}
|
||||||
None => {
|
|
||||||
let values = if let Some(uid) = keys.0 {
|
/// View and remove from the server todo list
|
||||||
sqlx::query!(
|
#[poise::command(
|
||||||
"SELECT todos.id, value FROM todos
|
slash_command,
|
||||||
INNER JOIN users ON todos.user_id = users.id
|
rename = "view",
|
||||||
WHERE users.user = ?",
|
guild_only = true,
|
||||||
uid,
|
identifying_name = "todo_guild_view",
|
||||||
)
|
default_member_permissions = "MANAGE_GUILD"
|
||||||
.fetch_all(&pool)
|
)]
|
||||||
.await
|
pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
.unwrap()
|
let values = sqlx::query!(
|
||||||
.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
|
"SELECT todos.id, value FROM todos
|
||||||
INNER JOIN guilds ON todos.guild_id = guilds.id
|
INNER JOIN guilds ON todos.guild_id = guilds.id
|
||||||
WHERE guilds.guild = ?",
|
WHERE guilds.guild = ?",
|
||||||
keys.2,
|
ctx.guild_id().unwrap().0,
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&ctx.data().database)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| (row.id as usize, row.value.clone()))
|
.map(|row| (row.id as usize, row.value.clone()))
|
||||||
.collect::<Vec<(usize, String)>>()
|
.collect::<Vec<(usize, String)>>();
|
||||||
};
|
|
||||||
|
|
||||||
let resp = show_todo_page(&values, 0, keys.0, keys.1, keys.2);
|
let resp = show_todo_page(&values, 0, None, None, ctx.guild_id().map(|g| g.0));
|
||||||
|
|
||||||
invoke.respond(&ctx, resp).await.unwrap();
|
ctx.send(|r| {
|
||||||
|
*r = resp;
|
||||||
|
r
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Manage the channel todo list
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "channel",
|
||||||
|
guild_only = true,
|
||||||
|
identifying_name = "todo_channel_base",
|
||||||
|
default_member_permissions = "MANAGE_GUILD"
|
||||||
|
)]
|
||||||
|
pub async fn todo_channel_base(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add an item to the channel todo list
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "add",
|
||||||
|
guild_only = true,
|
||||||
|
identifying_name = "todo_channel_add",
|
||||||
|
default_member_permissions = "MANAGE_GUILD"
|
||||||
|
)]
|
||||||
|
pub async fn todo_channel_add(
|
||||||
|
ctx: Context<'_>,
|
||||||
|
#[description = "The task to add to the todo list"] task: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
// ensure channel is cached
|
||||||
|
let _ = ctx.channel_data().await;
|
||||||
|
|
||||||
|
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 {
|
pub fn max_todo_page(todo_values: &[(usize, String)]) -> usize {
|
||||||
@ -164,7 +251,7 @@ pub fn show_todo_page(
|
|||||||
user_id: Option<u64>,
|
user_id: Option<u64>,
|
||||||
channel_id: Option<u64>,
|
channel_id: Option<u64>,
|
||||||
guild_id: Option<u64>,
|
guild_id: Option<u64>,
|
||||||
) -> CreateGenericResponse {
|
) -> CreateReply {
|
||||||
let pager = TodoPager::new(page, user_id, channel_id, guild_id);
|
let pager = TodoPager::new(page, user_id, channel_id, guild_id);
|
||||||
|
|
||||||
let pages = max_todo_page(todo_values);
|
let pages = max_todo_page(todo_values);
|
||||||
@ -219,17 +306,23 @@ pub fn show_todo_page(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if todo_ids.is_empty() {
|
if todo_ids.is_empty() {
|
||||||
CreateGenericResponse::new().embed(|e| {
|
let mut reply = CreateReply::default();
|
||||||
|
|
||||||
|
reply.embed(|e| {
|
||||||
e.title(format!("{} Todo List", title))
|
e.title(format!("{} Todo List", title))
|
||||||
.description("Todo List Empty!")
|
.description("Todo List Empty!")
|
||||||
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
|
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
|
||||||
.color(*THEME_COLOR)
|
.color(*THEME_COLOR)
|
||||||
})
|
});
|
||||||
|
|
||||||
|
reply
|
||||||
} else {
|
} else {
|
||||||
let todo_selector =
|
let todo_selector =
|
||||||
ComponentDataModel::TodoSelector(TodoSelector { page, user_id, channel_id, guild_id });
|
ComponentDataModel::TodoSelector(TodoSelector { page, user_id, channel_id, guild_id });
|
||||||
|
|
||||||
CreateGenericResponse::new()
|
let mut reply = CreateReply::default();
|
||||||
|
|
||||||
|
reply
|
||||||
.embed(|e| {
|
.embed(|e| {
|
||||||
e.title(format!("{} Todo List", title))
|
e.title(format!("{} Todo List", title))
|
||||||
.description(display)
|
.description(display)
|
||||||
@ -247,7 +340,18 @@ pub fn show_todo_page(
|
|||||||
opt.create_option(|o| {
|
opt.create_option(|o| {
|
||||||
o.label(format!("Mark {} complete", count + first_num))
|
o.label(format!("Mark {} complete", count + first_num))
|
||||||
.value(id)
|
.value(id)
|
||||||
.description(disp.split_once(" ").unwrap_or(("", "")).1)
|
.description({
|
||||||
|
let c = disp.split_once(' ').unwrap_or(("", "")).1;
|
||||||
|
|
||||||
|
if c.len() > 100 {
|
||||||
|
format!(
|
||||||
|
"{}...",
|
||||||
|
c.chars().take(97).collect::<String>()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
c.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,6 +359,8 @@ pub fn show_todo_page(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
});
|
||||||
|
|
||||||
|
reply
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,31 +2,37 @@ pub(crate) mod pager;
|
|||||||
|
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose, Engine};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use num_integer::Integer;
|
use log::warn;
|
||||||
use rmp_serde::Serializer;
|
use poise::{
|
||||||
use serde::{Deserialize, Serialize};
|
serenity_prelude as serenity,
|
||||||
use serenity::{
|
serenity_prelude::{
|
||||||
builder::CreateEmbed,
|
builder::CreateEmbed,
|
||||||
client::Context,
|
|
||||||
model::{
|
model::{
|
||||||
|
application::interaction::{
|
||||||
|
message_component::MessageComponentInteraction, InteractionResponseType,
|
||||||
|
MessageFlags,
|
||||||
|
},
|
||||||
channel::Channel,
|
channel::Channel,
|
||||||
interactions::{message_component::MessageComponentInteraction, InteractionResponseType},
|
},
|
||||||
prelude::InteractionApplicationCommandCallbackDataFlags,
|
Context,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use rmp_serde::Serializer;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
commands::{
|
commands::{
|
||||||
moderation_cmds::{max_macro_page, show_macro_page},
|
command_macro::list::{max_macro_page, show_macro_page},
|
||||||
reminder_cmds::{max_delete_page, show_delete_page},
|
reminder_cmds::{max_delete_page, show_delete_page},
|
||||||
todo_cmds::{max_todo_page, show_todo_page},
|
todo_cmds::{max_todo_page, show_todo_page},
|
||||||
},
|
},
|
||||||
component_models::pager::{DelPager, LookPager, MacroPager, Pager, TodoPager},
|
component_models::pager::{DelPager, LookPager, MacroPager, Pager, TodoPager},
|
||||||
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
|
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
|
||||||
framework::CommandInvoke,
|
models::reminder::Reminder,
|
||||||
models::{command_macro::CommandMacro, reminder::Reminder},
|
utils::send_as_initial_response,
|
||||||
SQLPool,
|
Data,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
@ -39,24 +45,26 @@ pub enum ComponentDataModel {
|
|||||||
DelSelector(DelSelector),
|
DelSelector(DelSelector),
|
||||||
TodoSelector(TodoSelector),
|
TodoSelector(TodoSelector),
|
||||||
MacroPager(MacroPager),
|
MacroPager(MacroPager),
|
||||||
|
UndoReminder(UndoReminder),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ComponentDataModel {
|
impl ComponentDataModel {
|
||||||
pub fn to_custom_id(&self) -> String {
|
pub fn to_custom_id(&self) -> String {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
self.serialize(&mut Serializer::new(&mut buf)).unwrap();
|
self.serialize(&mut Serializer::new(&mut buf)).unwrap();
|
||||||
base64::encode(buf)
|
general_purpose::STANDARD.encode(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_custom_id(data: &String) -> Self {
|
pub fn from_custom_id(data: &String) -> Self {
|
||||||
let buf = base64::decode(data)
|
let buf = general_purpose::STANDARD
|
||||||
|
.decode(data)
|
||||||
.map_err(|e| format!("Could not decode `custom_id' {}: {:?}", data, e))
|
.map_err(|e| format!("Could not decode `custom_id' {}: {:?}", data, e))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cur = Cursor::new(buf);
|
let cur = Cursor::new(buf);
|
||||||
rmp_serde::from_read(cur).unwrap()
|
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 {
|
match self {
|
||||||
ComponentDataModel::LookPager(pager) => {
|
ComponentDataModel::LookPager(pager) => {
|
||||||
let flags = pager.flags;
|
let flags = pager.flags;
|
||||||
@ -73,13 +81,13 @@ impl ComponentDataModel {
|
|||||||
component.channel_id
|
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
|
let pages = reminders
|
||||||
.iter()
|
.iter()
|
||||||
.map(|reminder| reminder.display(&flags, &pager.timezone))
|
.map(|reminder| reminder.display(&flags, &pager.timezone))
|
||||||
.fold(0, |t, r| t + r.len())
|
.fold(0, |t, r| t + r.len())
|
||||||
.div_ceil(&EMBED_DESCRIPTION_MAX_LENGTH);
|
.div_ceil(EMBED_DESCRIPTION_MAX_LENGTH);
|
||||||
|
|
||||||
let channel_name =
|
let channel_name =
|
||||||
if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) {
|
if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) {
|
||||||
@ -107,7 +115,7 @@ impl ComponentDataModel {
|
|||||||
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
||||||
})
|
})
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
.join("\n");
|
.join("");
|
||||||
|
|
||||||
let mut embed = CreateEmbed::default();
|
let mut embed = CreateEmbed::default();
|
||||||
embed
|
embed
|
||||||
@ -123,7 +131,7 @@ impl ComponentDataModel {
|
|||||||
.create_interaction_response(&ctx, |r| {
|
.create_interaction_response(&ctx, |r| {
|
||||||
r.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|
r.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|
||||||
|response| {
|
|response| {
|
||||||
response.embeds(vec![embed]).components(|comp| {
|
response.set_embeds(vec![embed]).components(|comp| {
|
||||||
pager.create_button_row(pages, comp);
|
pager.create_button_row(pages, comp);
|
||||||
|
|
||||||
comp
|
comp
|
||||||
@ -134,37 +142,63 @@ impl ComponentDataModel {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
ComponentDataModel::DelPager(pager) => {
|
ComponentDataModel::DelPager(pager) => {
|
||||||
let reminders =
|
let reminders = Reminder::from_guild(
|
||||||
Reminder::from_guild(ctx, component.guild_id, component.user.id).await;
|
&ctx,
|
||||||
|
&data.database,
|
||||||
|
component.guild_id,
|
||||||
|
component.user.id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let max_pages = max_delete_page(&reminders, &pager.timezone);
|
let max_pages = max_delete_page(&reminders, &pager.timezone);
|
||||||
|
|
||||||
let resp = show_delete_page(&reminders, pager.next_page(max_pages), pager.timezone);
|
let resp = show_delete_page(&reminders, pager.next_page(max_pages), pager.timezone);
|
||||||
|
|
||||||
let mut invoke = CommandInvoke::component(component);
|
let _ = component
|
||||||
let _ = invoke.respond(&ctx, resp).await;
|
.create_interaction_response(&ctx, |f| {
|
||||||
|
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|
||||||
|
|d| {
|
||||||
|
send_as_initial_response(resp, d);
|
||||||
|
d
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
ComponentDataModel::DelSelector(selector) => {
|
ComponentDataModel::DelSelector(selector) => {
|
||||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
|
||||||
let selected_id = component.data.values.join(",");
|
let selected_id = component.data.values.join(",");
|
||||||
|
|
||||||
sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id)
|
sqlx::query!(
|
||||||
.execute(&pool)
|
"UPDATE reminders SET `status` = 'pending' WHERE FIND_IN_SET(id, ?)",
|
||||||
|
selected_id
|
||||||
|
)
|
||||||
|
.execute(&data.database)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let reminders =
|
let reminders = Reminder::from_guild(
|
||||||
Reminder::from_guild(ctx, component.guild_id, component.user.id).await;
|
&ctx,
|
||||||
|
&data.database,
|
||||||
|
component.guild_id,
|
||||||
|
component.user.id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let resp = show_delete_page(&reminders, selector.page, selector.timezone);
|
let resp = show_delete_page(&reminders, selector.page, selector.timezone);
|
||||||
|
|
||||||
let mut invoke = CommandInvoke::component(component);
|
let _ = component
|
||||||
let _ = invoke.respond(&ctx, resp).await;
|
.create_interaction_response(&ctx, |f| {
|
||||||
|
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|
||||||
|
|d| {
|
||||||
|
send_as_initial_response(resp, d);
|
||||||
|
d
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
ComponentDataModel::TodoPager(pager) => {
|
ComponentDataModel::TodoPager(pager) => {
|
||||||
if Some(component.user.id.0) == pager.user_id || pager.user_id.is_none() {
|
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 {
|
let values = if let Some(uid) = pager.user_id {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"SELECT todos.id, value FROM todos
|
"SELECT todos.id, value FROM todos
|
||||||
@ -172,7 +206,7 @@ impl ComponentDataModel {
|
|||||||
WHERE users.user = ?",
|
WHERE users.user = ?",
|
||||||
uid,
|
uid,
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&data.database)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
@ -185,7 +219,7 @@ impl ComponentDataModel {
|
|||||||
WHERE channels.channel = ?",
|
WHERE channels.channel = ?",
|
||||||
cid,
|
cid,
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&data.database)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
@ -198,7 +232,7 @@ impl ComponentDataModel {
|
|||||||
WHERE guilds.guild = ?",
|
WHERE guilds.guild = ?",
|
||||||
pager.guild_id,
|
pager.guild_id,
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&data.database)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
@ -216,15 +250,22 @@ impl ComponentDataModel {
|
|||||||
pager.guild_id,
|
pager.guild_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut invoke = CommandInvoke::component(component);
|
let _ = component
|
||||||
let _ = invoke.respond(&ctx, resp).await;
|
.create_interaction_response(&ctx, |f| {
|
||||||
|
f.kind(InteractionResponseType::UpdateMessage)
|
||||||
|
.interaction_response_data(|d| {
|
||||||
|
send_as_initial_response(resp, d);
|
||||||
|
d
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
} else {
|
} else {
|
||||||
let _ = component
|
let _ = component
|
||||||
.create_interaction_response(&ctx, |r| {
|
.create_interaction_response(&ctx, |r| {
|
||||||
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||||
.interaction_response_data(|d| {
|
.interaction_response_data(|d| {
|
||||||
d.flags(
|
d.flags(
|
||||||
InteractionApplicationCommandCallbackDataFlags::EPHEMERAL,
|
MessageFlags::EPHEMERAL,
|
||||||
)
|
)
|
||||||
.content("Only the user who performed the command can use these components")
|
.content("Only the user who performed the command can use these components")
|
||||||
})
|
})
|
||||||
@ -234,11 +275,10 @@ impl ComponentDataModel {
|
|||||||
}
|
}
|
||||||
ComponentDataModel::TodoSelector(selector) => {
|
ComponentDataModel::TodoSelector(selector) => {
|
||||||
if Some(component.user.id.0) == selector.user_id || selector.user_id.is_none() {
|
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(",");
|
let selected_id = component.data.values.join(",");
|
||||||
|
|
||||||
sqlx::query!("DELETE FROM todos WHERE FIND_IN_SET(id, ?)", selected_id)
|
sqlx::query!("DELETE FROM todos WHERE FIND_IN_SET(id, ?)", selected_id)
|
||||||
.execute(&pool)
|
.execute(&data.database)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@ -249,7 +289,7 @@ impl ComponentDataModel {
|
|||||||
selector.channel_id,
|
selector.channel_id,
|
||||||
selector.guild_id,
|
selector.guild_id,
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&data.database)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
@ -264,15 +304,22 @@ impl ComponentDataModel {
|
|||||||
selector.guild_id,
|
selector.guild_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut invoke = CommandInvoke::component(component);
|
let _ = component
|
||||||
let _ = invoke.respond(&ctx, resp).await;
|
.create_interaction_response(&ctx, |f| {
|
||||||
|
f.kind(InteractionResponseType::UpdateMessage)
|
||||||
|
.interaction_response_data(|d| {
|
||||||
|
send_as_initial_response(resp, d);
|
||||||
|
d
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
} else {
|
} else {
|
||||||
let _ = component
|
let _ = component
|
||||||
.create_interaction_response(&ctx, |r| {
|
.create_interaction_response(&ctx, |r| {
|
||||||
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||||
.interaction_response_data(|d| {
|
.interaction_response_data(|d| {
|
||||||
d.flags(
|
d.flags(
|
||||||
InteractionApplicationCommandCallbackDataFlags::EPHEMERAL,
|
MessageFlags::EPHEMERAL,
|
||||||
)
|
)
|
||||||
.content("Only the user who performed the command can use these components")
|
.content("Only the user who performed the command can use these components")
|
||||||
})
|
})
|
||||||
@ -281,15 +328,87 @@ impl ComponentDataModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ComponentDataModel::MacroPager(pager) => {
|
ComponentDataModel::MacroPager(pager) => {
|
||||||
let mut invoke = CommandInvoke::component(component);
|
let macros = data.command_macros(component.guild_id.unwrap()).await.unwrap();
|
||||||
|
|
||||||
let macros = CommandMacro::from_guild(ctx, invoke.guild_id().unwrap()).await;
|
|
||||||
|
|
||||||
let max_page = max_macro_page(¯os);
|
let max_page = max_macro_page(¯os);
|
||||||
let page = pager.next_page(max_page);
|
let page = pager.next_page(max_page);
|
||||||
|
|
||||||
let resp = show_macro_page(¯os, 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -308,3 +427,9 @@ pub struct TodoSelector {
|
|||||||
pub channel_id: Option<u64>,
|
pub channel_id: Option<u64>,
|
||||||
pub guild_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
|
// todo split pager out into a single struct
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
|
use poise::serenity_prelude::{
|
||||||
|
builder::CreateComponents, model::application::component::ButtonStyle,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_repr::*;
|
use serde_repr::*;
|
||||||
use serenity::{builder::CreateComponents, model::interactions::message_component::ButtonStyle};
|
|
||||||
|
|
||||||
use crate::{component_models::ComponentDataModel, models::reminder::look_flags::LookFlags};
|
use crate::{component_models::ComponentDataModel, models::reminder::look_flags::LookFlags};
|
||||||
|
|
||||||
|
@ -1,36 +1,29 @@
|
|||||||
pub const DAY: u64 = 86_400;
|
pub const DAY: u64 = 86_400;
|
||||||
pub const HOUR: u64 = 3_600;
|
pub const HOUR: u64 = 3_600;
|
||||||
pub const MINUTE: u64 = 60;
|
pub const MINUTE: u64 = 60;
|
||||||
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000;
|
|
||||||
|
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4096;
|
||||||
pub const SELECT_MAX_ENTRIES: usize = 25;
|
pub const SELECT_MAX_ENTRIES: usize = 25;
|
||||||
|
|
||||||
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
|
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
|
||||||
|
|
||||||
const THEME_COLOR_FALLBACK: u32 = 0x8fb677;
|
const THEME_COLOR_FALLBACK: u32 = 0x8fb677;
|
||||||
|
pub const MACRO_MAX_COMMANDS: usize = 5;
|
||||||
|
|
||||||
use std::{collections::HashSet, env, iter::FromIterator};
|
use std::{collections::HashSet, env, iter::FromIterator};
|
||||||
|
|
||||||
|
use poise::serenity_prelude::model::prelude::AttachmentType;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serenity::http::AttachmentType;
|
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref REMIND_INTERVAL: u64 = env::var("REMIND_INTERVAL")
|
|
||||||
.map(|inner| inner.parse::<u64>().ok())
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.unwrap_or(10);
|
|
||||||
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
|
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
|
||||||
include_bytes!(concat!(
|
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/webhook.jpg")) as &[u8],
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
"webhook.jpg",
|
||||||
"/assets/",
|
|
||||||
env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation")
|
|
||||||
)) as &[u8],
|
|
||||||
env!("WEBHOOK_AVATAR"),
|
|
||||||
)
|
)
|
||||||
.into();
|
.into();
|
||||||
pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap();
|
pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap();
|
||||||
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
||||||
env::var("SUBSCRIPTION_ROLES")
|
env::var("PATREON_ROLE_ID")
|
||||||
.map(|var| var
|
.map(|var| var
|
||||||
.split(',')
|
.split(',')
|
||||||
.filter_map(|item| { item.parse::<u64>().ok() })
|
.filter_map(|item| { item.parse::<u64>().ok() })
|
||||||
@ -38,16 +31,12 @@ lazy_static! {
|
|||||||
.unwrap_or_else(|_| Vec::new())
|
.unwrap_or_else(|_| Vec::new())
|
||||||
);
|
);
|
||||||
pub static ref CNC_GUILD: Option<u64> =
|
pub static ref CNC_GUILD: Option<u64> =
|
||||||
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
|
env::var("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten();
|
||||||
pub static ref MIN_INTERVAL: i64 = env::var("MIN_INTERVAL")
|
pub static ref MIN_INTERVAL: i64 =
|
||||||
.ok()
|
env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
|
||||||
.map(|inner| inner.parse::<i64>().ok())
|
|
||||||
.flatten()
|
|
||||||
.unwrap_or(600);
|
|
||||||
pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
|
pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|inner| inner.parse::<i64>().ok())
|
.and_then(|inner| inner.parse::<i64>().ok())
|
||||||
.flatten()
|
|
||||||
.unwrap_or(60 * 60 * 24 * 365 * 50);
|
.unwrap_or(60 * 60 * 24 * 365 * 50);
|
||||||
pub static ref LOCAL_TIMEZONE: String =
|
pub static ref LOCAL_TIMEZONE: String =
|
||||||
env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string());
|
env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string());
|
||||||
@ -55,5 +44,5 @@ lazy_static! {
|
|||||||
.map_or(THEME_COLOR_FALLBACK, |inner| u32::from_str_radix(&inner, 16)
|
.map_or(THEME_COLOR_FALLBACK, |inner| u32::from_str_radix(&inner, 16)
|
||||||
.unwrap_or(THEME_COLOR_FALLBACK));
|
.unwrap_or(THEME_COLOR_FALLBACK));
|
||||||
pub static ref PYTHON_LOCATION: String =
|
pub static ref PYTHON_LOCATION: String =
|
||||||
env::var("PYTHON_LOCATION").unwrap_or_else(|_| "venv/bin/python3".to_string());
|
env::var("PYTHON_LOCATION").unwrap_or_else(|_| "/usr/bin/python3".to_string());
|
||||||
}
|
}
|
||||||
|
114
src/event_handlers.rs
Normal file
114
src/event_handlers.rs
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
use std::{collections::HashMap, env};
|
||||||
|
|
||||||
|
use log::error;
|
||||||
|
use poise::{
|
||||||
|
serenity_prelude as serenity,
|
||||||
|
serenity_prelude::{model::application::interaction::Interaction, utils::shard_id},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{component_models::ComponentDataModel, Data, Error, THEME_COLOR};
|
||||||
|
|
||||||
|
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::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 IGNORE INTO guilds (guild) VALUES (?)", guild_id)
|
||||||
|
.execute(&data.database)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Err(e) = post_guild_count(ctx, &data.http, guild_id).await {
|
||||||
|
error!("DiscordBotList: {:?}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_channel = guild.default_channel_guaranteed();
|
||||||
|
|
||||||
|
if let Some(default_channel) = default_channel {
|
||||||
|
default_channel
|
||||||
|
.send_message(&ctx, |m| {
|
||||||
|
m.embed(|e| {
|
||||||
|
e.title("Thank you for adding Reminder Bot!").description(
|
||||||
|
"To get started:
|
||||||
|
• Set your timezone with `/timezone`
|
||||||
|
• Set up permissions in Server Settings 🠚 Integrations 🠚 Reminder Bot (desktop only)
|
||||||
|
• Create your first reminder with `/remind`
|
||||||
|
|
||||||
|
__Support__
|
||||||
|
If you need any support, please come and ask us! Join our [Discord](https://discord.jellywx.com).
|
||||||
|
|
||||||
|
__Updates__
|
||||||
|
To stay up to date on the latest features and fixes, join our [Discord](https://discord.jellywx.com).
|
||||||
|
",
|
||||||
|
).color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_guild_count(
|
||||||
|
ctx: &serenity::Context,
|
||||||
|
http: &reqwest::Client,
|
||||||
|
guild_id: u64,
|
||||||
|
) -> Result<(), reqwest::Error> {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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
|
||||||
|
.map(|_| ())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
692
src/framework.rs
692
src/framework.rs
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
166
src/hooks.rs
166
src/hooks.rs
@ -1,107 +1,79 @@
|
|||||||
use regex_command_attr::check;
|
use poise::{
|
||||||
use serenity::{client::Context, model::channel::Channel};
|
serenity_prelude::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction,
|
||||||
|
|
||||||
use crate::{
|
|
||||||
framework::{CommandInvoke, CommandOptions, CreateGenericResponse, HookResult},
|
|
||||||
moderation_cmds, RecordingMacros,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[check]
|
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
|
||||||
pub async fn guild_only(
|
|
||||||
ctx: &Context,
|
async fn macro_check(ctx: Context<'_>) -> bool {
|
||||||
invoke: &mut CommandInvoke,
|
if let Context::Application(app_ctx) = ctx {
|
||||||
_args: &CommandOptions,
|
if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) =
|
||||||
) -> HookResult {
|
app_ctx.interaction
|
||||||
if invoke.guild_id().is_some() {
|
{
|
||||||
HookResult::Continue
|
if let Some(guild_id) = ctx.guild_id() {
|
||||||
} else {
|
if ctx.command().identifying_name != "finish_macro" {
|
||||||
let _ = invoke
|
let mut lock = ctx.data().recording_macros.write().await;
|
||||||
.respond(
|
|
||||||
&ctx,
|
if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) {
|
||||||
CreateGenericResponse::new().content("This command can only be used in servers"),
|
if command_macro.commands.len() >= MACRO_MAX_COMMANDS {
|
||||||
)
|
let _ = ctx.send(|m| {
|
||||||
.await;
|
m.ephemeral(true).content(
|
||||||
|
format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS),
|
||||||
HookResult::Halt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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;
|
|
||||||
|
|
||||||
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;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
command_macro.commands.push(args.clone());
|
let recorded = RecordedCommand {
|
||||||
|
action: None,
|
||||||
|
command_name: ctx.command().identifying_name.clone(),
|
||||||
|
options: Vec::from(app_ctx.args),
|
||||||
|
};
|
||||||
|
|
||||||
let _ = invoke
|
command_macro.commands.push(recorded);
|
||||||
.respond(
|
|
||||||
&ctx,
|
let _ = ctx
|
||||||
CreateGenericResponse::new().content("Command recorded to macro"),
|
.send(|m| m.ephemeral(true).content("Command recorded to macro"))
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
HookResult::Halt
|
return false;
|
||||||
} else {
|
}
|
||||||
HookResult::Continue
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
HookResult::Continue
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
HookResult::Continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[check]
|
true
|
||||||
pub async fn check_self_permissions(
|
}
|
||||||
ctx: &Context,
|
|
||||||
invoke: &mut CommandInvoke,
|
async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
||||||
_args: &CommandOptions,
|
if let Some(guild) = ctx.guild() {
|
||||||
) -> HookResult {
|
let user_id = ctx.serenity_context().cache.current_user_id();
|
||||||
if let Some(guild) = invoke.guild(&ctx) {
|
|
||||||
let user_id = ctx.cache.current_user_id();
|
|
||||||
|
|
||||||
let manage_webhooks =
|
let manage_webhooks =
|
||||||
guild.member_permissions(&ctx, user_id).await.map_or(false, |p| p.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 (view_channel, send_messages, embed_links) = ctx
|
||||||
.channel_id()
|
.channel_id()
|
||||||
.to_channel_cached(&ctx)
|
.to_channel(&ctx)
|
||||||
.map(|c| {
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|c| {
|
||||||
if let Channel::Guild(channel) = c {
|
if let Channel::Guild(channel) = c {
|
||||||
channel.permissions_for_user(ctx, user_id).ok()
|
let perms = channel.permissions_for_user(&ctx, user_id).ok()?;
|
||||||
|
|
||||||
|
Some((perms.view_channel(), perms.send_messages(), perms.embed_links()))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.flatten()
|
.unwrap_or((false, false, false));
|
||||||
.map_or((false, false, false), |p| {
|
|
||||||
(p.read_messages(), p.send_messages(), p.embed_links())
|
|
||||||
});
|
|
||||||
|
|
||||||
if manage_webhooks && send_messages && embed_links {
|
if manage_webhooks && send_messages && embed_links {
|
||||||
HookResult::Continue
|
true
|
||||||
} else {
|
} else {
|
||||||
let _ = invoke
|
let _ = ctx
|
||||||
.respond(
|
.send(|m| {
|
||||||
&ctx,
|
m.content(format!(
|
||||||
CreateGenericResponse::new().content(format!(
|
|
||||||
"Please ensure the bot has the correct permissions:
|
"Please ensure the bot has the correct permissions:
|
||||||
|
|
||||||
{} **View Channel**
|
{} **View Channel**
|
||||||
@ -110,43 +82,19 @@ pub async fn check_self_permissions(
|
|||||||
{} **Manage Webhooks**",
|
{} **Manage Webhooks**",
|
||||||
if view_channel { "✅" } else { "❌" },
|
if view_channel { "✅" } else { "❌" },
|
||||||
if send_messages { "✅" } else { "❌" },
|
if send_messages { "✅" } else { "❌" },
|
||||||
if manage_webhooks { "✅" } else { "❌" },
|
|
||||||
if embed_links { "✅" } else { "❌" },
|
if embed_links { "✅" } else { "❌" },
|
||||||
)),
|
if manage_webhooks { "✅" } else { "❌" },
|
||||||
)
|
))
|
||||||
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
HookResult::Halt
|
false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HookResult::Continue
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[check]
|
pub async fn all_checks(ctx: Context<'_>) -> Result<bool, Error> {
|
||||||
pub async fn check_guild_permissions(
|
Ok(macro_check(ctx).await && check_self_permissions(ctx).await)
|
||||||
ctx: &Context,
|
|
||||||
invoke: &mut CommandInvoke,
|
|
||||||
_args: &CommandOptions,
|
|
||||||
) -> HookResult {
|
|
||||||
if let Some(guild) = invoke.guild(&ctx) {
|
|
||||||
let permissions = guild.member_permissions(&ctx, invoke.author_id()).await.unwrap();
|
|
||||||
|
|
||||||
if !permissions.manage_guild() {
|
|
||||||
let _ = invoke
|
|
||||||
.respond(
|
|
||||||
&ctx,
|
|
||||||
CreateGenericResponse::new().content(
|
|
||||||
"You must have the \"Manage Server\" permission to use this command",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
HookResult::Halt
|
|
||||||
} else {
|
|
||||||
HookResult::Continue
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HookResult::Continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
327
src/interval_parser.rs
Normal file
327
src/interval_parser.rs
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
/*
|
||||||
|
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 day: u64,
|
||||||
|
pub sec: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Parser<'a> {
|
||||||
|
iter: Chars<'a>,
|
||||||
|
src: &'a str,
|
||||||
|
current: (u64, 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 day, mut sec, nsec) = match &self.src[start..end] {
|
||||||
|
"nanos" | "nsec" | "ns" => (0, 0u64, 0u64, n),
|
||||||
|
"usec" | "us" => (0, 0, 0u64, n.mul(1000)?),
|
||||||
|
"millis" | "msec" | "ms" => (0, 0, 0u64, n.mul(1_000_000)?),
|
||||||
|
"seconds" | "second" | "secs" | "sec" | "s" => (0, 0, n, 0),
|
||||||
|
"minutes" | "minute" | "min" | "mins" | "m" => (0, 0, n.mul(60)?, 0),
|
||||||
|
"hours" | "hour" | "hr" | "hrs" | "h" => (0, 0, n.mul(3600)?, 0),
|
||||||
|
"days" | "day" | "d" => (0, n, 0, 0),
|
||||||
|
"weeks" | "week" | "w" => (0, n.mul(7)?, 0, 0),
|
||||||
|
"months" | "month" | "M" => (n, 0, 0, 0),
|
||||||
|
"years" | "year" | "y" => (n.mul(12)?, 0, 0, 0),
|
||||||
|
_ => {
|
||||||
|
return Err(Error::UnknownUnit {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
unit: self.src[start..end].to_string(),
|
||||||
|
value: n,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut nsec = self.current.3 + nsec;
|
||||||
|
if nsec > 1_000_000_000 {
|
||||||
|
sec += nsec / 1_000_000_000;
|
||||||
|
nsec %= 1_000_000_000;
|
||||||
|
}
|
||||||
|
sec += self.current.2;
|
||||||
|
day += self.current.1;
|
||||||
|
month += self.current.0;
|
||||||
|
|
||||||
|
self.current = (month, day, 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,
|
||||||
|
day: self.current.1,
|
||||||
|
sec: self.current.2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse duration object `1hour 12min 5s`
|
||||||
|
///
|
||||||
|
/// The duration object is a concatenation of time spans. Where each time
|
||||||
|
/// span is an integer number and a suffix. Supported suffixes:
|
||||||
|
///
|
||||||
|
/// * `nsec`, `ns` -- nanoseconds
|
||||||
|
/// * `usec`, `us` -- microseconds
|
||||||
|
/// * `msec`, `ms` -- milliseconds
|
||||||
|
/// * `seconds`, `second`, `sec`, `s`
|
||||||
|
/// * `minutes`, `minute`, `min`, `m`
|
||||||
|
/// * `hours`, `hour`, `hr`, `h`
|
||||||
|
/// * `days`, `day`, `d`
|
||||||
|
/// * `weeks`, `week`, `w`
|
||||||
|
/// * `months`, `month`, `M` -- defined as 30.44 days
|
||||||
|
/// * `years`, `year`, `y` -- defined as 365.25 days
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use std::time::Duration;
|
||||||
|
/// use humantime::parse_duration;
|
||||||
|
///
|
||||||
|
/// assert_eq!(parse_duration("2h 37min"), Ok(Duration::new(9420, 0)));
|
||||||
|
/// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000)));
|
||||||
|
/// ```
|
||||||
|
pub fn parse_duration(s: &str) -> Result<Interval, Error> {
|
||||||
|
Parser { iter: s.chars(), src: s, current: (0, 0, 0, 0) }.parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_seconds() {
|
||||||
|
let interval = parse_duration("10 seconds").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 10);
|
||||||
|
assert_eq!(interval.day, 0);
|
||||||
|
assert_eq!(interval.month, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_minutes() {
|
||||||
|
let interval = parse_duration("10 minutes").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 600);
|
||||||
|
assert_eq!(interval.day, 0);
|
||||||
|
assert_eq!(interval.month, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_hours() {
|
||||||
|
let interval = parse_duration("10 hours").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 36_000);
|
||||||
|
assert_eq!(interval.day, 0);
|
||||||
|
assert_eq!(interval.month, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_days() {
|
||||||
|
let interval = parse_duration("10 days").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 0);
|
||||||
|
assert_eq!(interval.day, 10);
|
||||||
|
assert_eq!(interval.month, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_weeks() {
|
||||||
|
let interval = parse_duration("10 weeks").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 0);
|
||||||
|
assert_eq!(interval.day, 70);
|
||||||
|
assert_eq!(interval.month, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_months() {
|
||||||
|
let interval = parse_duration("10 months").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 0);
|
||||||
|
assert_eq!(interval.day, 0);
|
||||||
|
assert_eq!(interval.month, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_years() {
|
||||||
|
let interval = parse_duration("10 years").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 0);
|
||||||
|
assert_eq!(interval.day, 0);
|
||||||
|
assert_eq!(interval.month, 120);
|
||||||
|
}
|
||||||
|
}
|
505
src/main.rs
505
src/main.rs
@ -1,366 +1,269 @@
|
|||||||
#![feature(int_roundings)]
|
#![feature(int_roundings)]
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate lazy_static;
|
extern crate lazy_static;
|
||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
mod component_models;
|
mod component_models;
|
||||||
mod consts;
|
mod consts;
|
||||||
mod framework;
|
mod event_handlers;
|
||||||
mod hooks;
|
mod hooks;
|
||||||
|
mod interval_parser;
|
||||||
mod models;
|
mod models;
|
||||||
mod sender;
|
|
||||||
mod time_parser;
|
mod time_parser;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
env,
|
env,
|
||||||
sync::{
|
error::Error as StdError,
|
||||||
atomic::{AtomicBool, Ordering},
|
fmt::{Debug, Display, Formatter},
|
||||||
Arc,
|
path::Path,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use dotenv::dotenv;
|
use log::{error, warn};
|
||||||
use log::info;
|
use poise::serenity_prelude::model::{
|
||||||
use serenity::{
|
gateway::GatewayIntents,
|
||||||
async_trait,
|
|
||||||
client::{bridge::gateway::GatewayIntents, Client},
|
|
||||||
http::{client::Http, CacheHttp},
|
|
||||||
model::{
|
|
||||||
channel::GuildChannel,
|
|
||||||
gateway::{Activity, Ready},
|
|
||||||
guild::{Guild, GuildUnavailable},
|
|
||||||
id::{GuildId, UserId},
|
id::{GuildId, UserId},
|
||||||
interactions::Interaction,
|
|
||||||
},
|
|
||||||
prelude::{Context, EventHandler, TypeMapKey},
|
|
||||||
utils::shard_id,
|
|
||||||
};
|
|
||||||
use sqlx::mysql::MySqlPool;
|
|
||||||
use tokio::{
|
|
||||||
sync::RwLock,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
};
|
||||||
|
use sqlx::{MySql, Pool};
|
||||||
|
use tokio::sync::{broadcast, broadcast::Sender, RwLock};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
|
commands::{command_macro, info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
|
||||||
component_models::ComponentDataModel,
|
consts::THEME_COLOR,
|
||||||
consts::{CNC_GUILD, REMIND_INTERVAL, SUBSCRIPTION_ROLES, THEME_COLOR},
|
event_handlers::listener,
|
||||||
framework::RegexFramework,
|
hooks::all_checks,
|
||||||
models::command_macro::CommandMacro,
|
models::command_macro::CommandMacro,
|
||||||
|
utils::register_application_commands,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct SQLPool;
|
type Database = MySql;
|
||||||
|
|
||||||
impl TypeMapKey for SQLPool {
|
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||||
type Value = MySqlPool;
|
type Context<'a> = poise::Context<'a, Data, Error>;
|
||||||
|
type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>;
|
||||||
|
|
||||||
|
pub struct Data {
|
||||||
|
database: Pool<Database>,
|
||||||
|
http: reqwest::Client,
|
||||||
|
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>,
|
||||||
|
popular_timezones: Vec<Tz>,
|
||||||
|
_broadcast: Sender<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ReqwestClient;
|
impl Debug for Data {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
impl TypeMapKey for ReqwestClient {
|
write!(f, "Data {{ .. }}")
|
||||||
type Value = Arc<reqwest::Client>;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PopularTimezones;
|
|
||||||
|
|
||||||
impl TypeMapKey for PopularTimezones {
|
|
||||||
type Value = Arc<Vec<Tz>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RecordingMacros;
|
|
||||||
|
|
||||||
impl TypeMapKey for RecordingMacros {
|
|
||||||
type Value = Arc<RwLock<HashMap<(GuildId, UserId), CommandMacro>>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Handler {
|
|
||||||
is_loop_running: AtomicBool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl EventHandler for Handler {
|
|
||||||
async fn cache_ready(&self, ctx_base: Context, _guilds: Vec<GuildId>) {
|
|
||||||
info!("Cache Ready!");
|
|
||||||
info!("Preparing to send reminders");
|
|
||||||
|
|
||||||
if !self.is_loop_running.load(Ordering::Relaxed) {
|
|
||||||
let ctx = ctx_base.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let sleep_until = 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.clone(), ctx.clone()).await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::time::sleep_until(sleep_until).await;
|
struct Ended;
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
self.is_loop_running.swap(true, Ordering::Relaxed);
|
impl Debug for Ended {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("Process ended.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn channel_delete(&self, ctx: Context, channel: &GuildChannel) {
|
impl Display for Ended {
|
||||||
let pool = ctx
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
.data
|
f.write_str("Process ended.")
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get::<SQLPool>()
|
|
||||||
.cloned()
|
|
||||||
.expect("Could not get SQLPool from data");
|
|
||||||
|
|
||||||
sqlx::query!(
|
|
||||||
"
|
|
||||||
DELETE FROM channels WHERE channel = ?
|
|
||||||
",
|
|
||||||
channel.id.as_u64()
|
|
||||||
)
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn guild_create(&self, ctx: Context, guild: Guild, is_new: bool) {
|
|
||||||
if is_new {
|
|
||||||
let guild_id = guild.id.as_u64().to_owned();
|
|
||||||
|
|
||||||
{
|
|
||||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
|
||||||
|
|
||||||
let _ = sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
|
|
||||||
let shard_count = ctx.cache.shard_count();
|
|
||||||
let current_shard_id = shard_id(guild_id, shard_count);
|
|
||||||
|
|
||||||
let guild_count = ctx
|
|
||||||
.cache
|
|
||||||
.guilds()
|
|
||||||
.iter()
|
|
||||||
.filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id)
|
|
||||||
.count() as u64;
|
|
||||||
|
|
||||||
let mut hm = HashMap::new();
|
|
||||||
hm.insert("server_count", guild_count);
|
|
||||||
hm.insert("shard_id", current_shard_id);
|
|
||||||
hm.insert("shard_count", shard_count);
|
|
||||||
|
|
||||||
let client = ctx
|
|
||||||
.data
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get::<ReqwestClient>()
|
|
||||||
.cloned()
|
|
||||||
.expect("Could not get ReqwestClient from data");
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.post(
|
|
||||||
format!(
|
|
||||||
"https://top.gg/api/bots/{}/stats",
|
|
||||||
ctx.cache.current_user_id().as_u64()
|
|
||||||
)
|
|
||||||
.as_str(),
|
|
||||||
)
|
|
||||||
.header("Authorization", token)
|
|
||||||
.json(&hm)
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(res) = response {
|
|
||||||
println!("DiscordBots Response: {:?}", res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn guild_delete(&self, ctx: Context, incomplete: GuildUnavailable, _full: Option<Guild>) {
|
impl StdError for Ended {}
|
||||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
|
||||||
let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0)
|
|
||||||
.execute(&pool)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ready(&self, ctx: Context, _: Ready) {
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
ctx.set_activity(Activity::watching("for /remind")).await;
|
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||||
}
|
let (tx, mut rx) = broadcast::channel(16);
|
||||||
|
|
||||||
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
|
tokio::select! {
|
||||||
match interaction {
|
output = _main(tx) => output,
|
||||||
Interaction::ApplicationCommand(application_command) => {
|
_ = rx.recv() => Err(Box::new(Ended) as Box<dyn StdError + Send + Sync>)
|
||||||
let framework = ctx
|
|
||||||
.data
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get::<RegexFramework>()
|
|
||||||
.cloned()
|
|
||||||
.expect("RegexFramework not found in context");
|
|
||||||
|
|
||||||
framework.execute(ctx, application_command).await;
|
|
||||||
}
|
|
||||||
Interaction::MessageComponent(component) => {
|
|
||||||
let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
|
|
||||||
component_model.act(&ctx, component).await;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
dotenv()?;
|
if Path::new("/etc/reminder-rs/config.env").exists() {
|
||||||
|
dotenv::from_path("/etc/reminder-rs/config.env")?;
|
||||||
|
} else {
|
||||||
|
let _ = dotenv::dotenv();
|
||||||
|
}
|
||||||
|
|
||||||
let token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
|
let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
|
||||||
|
|
||||||
let application_id = {
|
let options = poise::FrameworkOptions {
|
||||||
let http = Http::new_with_token(&token);
|
commands: vec![
|
||||||
|
info_cmds::help(),
|
||||||
http.get_current_application_info().await?.id
|
info_cmds::info(),
|
||||||
|
info_cmds::donate(),
|
||||||
|
info_cmds::clock(),
|
||||||
|
info_cmds::clock_context_menu(),
|
||||||
|
info_cmds::dashboard(),
|
||||||
|
moderation_cmds::timezone(),
|
||||||
|
poise::Command {
|
||||||
|
subcommands: vec![
|
||||||
|
moderation_cmds::set_allowed_dm(),
|
||||||
|
moderation_cmds::unset_allowed_dm(),
|
||||||
|
],
|
||||||
|
..moderation_cmds::allowed_dm()
|
||||||
|
},
|
||||||
|
poise::Command {
|
||||||
|
subcommands: vec![poise::Command {
|
||||||
|
subcommands: vec![
|
||||||
|
moderation_cmds::set_ephemeral_confirmations(),
|
||||||
|
moderation_cmds::unset_ephemeral_confirmations(),
|
||||||
|
],
|
||||||
|
..moderation_cmds::ephemeral_confirmations()
|
||||||
|
}],
|
||||||
|
..moderation_cmds::settings()
|
||||||
|
},
|
||||||
|
moderation_cmds::webhook(),
|
||||||
|
poise::Command {
|
||||||
|
subcommands: vec![
|
||||||
|
command_macro::delete::delete_macro(),
|
||||||
|
command_macro::record::finish_macro(),
|
||||||
|
command_macro::list::list_macro(),
|
||||||
|
command_macro::record::record_macro(),
|
||||||
|
command_macro::run::run_macro(),
|
||||||
|
command_macro::migrate::migrate_macro(),
|
||||||
|
],
|
||||||
|
..command_macro::macro_base()
|
||||||
|
},
|
||||||
|
reminder_cmds::pause(),
|
||||||
|
reminder_cmds::offset(),
|
||||||
|
reminder_cmds::nudge(),
|
||||||
|
reminder_cmds::look(),
|
||||||
|
reminder_cmds::delete(),
|
||||||
|
poise::Command {
|
||||||
|
subcommands: vec![
|
||||||
|
reminder_cmds::list_timer(),
|
||||||
|
reminder_cmds::start_timer(),
|
||||||
|
reminder_cmds::delete_timer(),
|
||||||
|
],
|
||||||
|
..reminder_cmds::timer_base()
|
||||||
|
},
|
||||||
|
reminder_cmds::multiline(),
|
||||||
|
reminder_cmds::remind(),
|
||||||
|
poise::Command {
|
||||||
|
subcommands: vec![
|
||||||
|
poise::Command {
|
||||||
|
subcommands: vec![
|
||||||
|
todo_cmds::todo_guild_add(),
|
||||||
|
todo_cmds::todo_guild_view(),
|
||||||
|
],
|
||||||
|
..todo_cmds::todo_guild_base()
|
||||||
|
},
|
||||||
|
poise::Command {
|
||||||
|
subcommands: vec![
|
||||||
|
todo_cmds::todo_channel_add(),
|
||||||
|
todo_cmds::todo_channel_view(),
|
||||||
|
],
|
||||||
|
..todo_cmds::todo_channel_base()
|
||||||
|
},
|
||||||
|
poise::Command {
|
||||||
|
subcommands: vec![todo_cmds::todo_user_add(), todo_cmds::todo_user_view()],
|
||||||
|
..todo_cmds::todo_user_base()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..todo_cmds::todo_base()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allowed_mentions: None,
|
||||||
|
command_check: Some(|ctx| Box::pin(all_checks(ctx))),
|
||||||
|
event_handler: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)),
|
||||||
|
on_error: |error| {
|
||||||
|
Box::pin(async move {
|
||||||
|
match error {
|
||||||
|
poise::FrameworkError::CommandCheckFailed { .. } => {
|
||||||
|
// suppress error
|
||||||
|
}
|
||||||
|
error => {
|
||||||
|
if let Err(e) = poise::builtins::on_error(error).await {
|
||||||
|
log::error!("Error while handling error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let dm_enabled = env::var("DM_ENABLED").map_or(true, |var| var == "1");
|
let database =
|
||||||
|
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
|
||||||
|
|
||||||
let framework = RegexFramework::new()
|
sqlx::migrate!().run(&database).await?;
|
||||||
.ignore_bots(env::var("IGNORE_BOTS").map_or(true, |var| var == "1"))
|
|
||||||
.debug_guild(env::var("DEBUG_GUILD").map_or(None, |g| {
|
|
||||||
Some(GuildId(g.parse::<u64>().expect("DEBUG_GUILD must be a guild ID")))
|
|
||||||
}))
|
|
||||||
.dm_enabled(dm_enabled)
|
|
||||||
// info commands
|
|
||||||
.add_command(&info_cmds::HELP_COMMAND)
|
|
||||||
.add_command(&info_cmds::INFO_COMMAND)
|
|
||||||
.add_command(&info_cmds::DONATE_COMMAND)
|
|
||||||
.add_command(&info_cmds::DASHBOARD_COMMAND)
|
|
||||||
.add_command(&info_cmds::CLOCK_COMMAND)
|
|
||||||
// reminder commands
|
|
||||||
.add_command(&reminder_cmds::TIMER_COMMAND)
|
|
||||||
.add_command(&reminder_cmds::REMIND_COMMAND)
|
|
||||||
// management commands
|
|
||||||
.add_command(&reminder_cmds::DELETE_COMMAND)
|
|
||||||
.add_command(&reminder_cmds::LOOK_COMMAND)
|
|
||||||
.add_command(&reminder_cmds::PAUSE_COMMAND)
|
|
||||||
.add_command(&reminder_cmds::OFFSET_COMMAND)
|
|
||||||
.add_command(&reminder_cmds::NUDGE_COMMAND)
|
|
||||||
// to-do commands
|
|
||||||
.add_command(&todo_cmds::TODO_COMMAND)
|
|
||||||
// moderation commands
|
|
||||||
.add_command(&moderation_cmds::TIMEZONE_COMMAND)
|
|
||||||
.add_command(&moderation_cmds::MACRO_CMD_COMMAND)
|
|
||||||
.add_hook(&hooks::CHECK_SELF_PERMISSIONS_HOOK)
|
|
||||||
.add_hook(&hooks::MACRO_CHECK_HOOK);
|
|
||||||
|
|
||||||
let framework_arc = Arc::new(framework);
|
|
||||||
|
|
||||||
let mut client = Client::builder(&token)
|
|
||||||
.intents(GatewayIntents::GUILDS)
|
|
||||||
.application_id(application_id.0)
|
|
||||||
.event_handler(Handler { is_loop_running: AtomicBool::from(false) })
|
|
||||||
.await
|
|
||||||
.expect("Error occurred creating client");
|
|
||||||
|
|
||||||
{
|
|
||||||
let pool = MySqlPool::connect(
|
|
||||||
&env::var("DATABASE_URL").expect("Missing DATABASE_URL from environment"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let popular_timezones = sqlx::query!(
|
let popular_timezones = sqlx::query!(
|
||||||
"SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21"
|
"SELECT IFNULL(timezone, 'UTC') AS timezone
|
||||||
|
FROM users
|
||||||
|
WHERE timezone IS NOT NULL
|
||||||
|
GROUP BY timezone
|
||||||
|
ORDER BY COUNT(timezone) DESC
|
||||||
|
LIMIT 21"
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&database)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| t.timezone.parse::<Tz>().unwrap())
|
.map(|t| t.timezone.parse::<Tz>().unwrap())
|
||||||
.collect::<Vec<Tz>>();
|
.collect::<Vec<Tz>>();
|
||||||
|
|
||||||
let mut data = client.data.write().await;
|
poise::Framework::builder()
|
||||||
|
.token(discord_token)
|
||||||
|
.setup(move |ctx, _bot, framework| {
|
||||||
|
Box::pin(async move {
|
||||||
|
register_application_commands(ctx, framework, None).await.unwrap();
|
||||||
|
|
||||||
data.insert::<SQLPool>(pool);
|
let kill_tx = tx.clone();
|
||||||
data.insert::<PopularTimezones>(Arc::new(popular_timezones));
|
let kill_recv = tx.subscribe();
|
||||||
data.insert::<ReqwestClient>(Arc::new(reqwest::Client::new()));
|
|
||||||
data.insert::<RegexFramework>(framework_arc.clone());
|
let ctx1 = ctx.clone();
|
||||||
data.insert::<RecordingMacros>(Arc::new(RwLock::new(HashMap::new())));
|
let ctx2 = ctx.clone();
|
||||||
|
|
||||||
|
let pool1 = database.clone();
|
||||||
|
let pool2 = 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);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
framework_arc.build_slash(&client.cache_and_http.http).await;
|
});
|
||||||
|
|
||||||
if let Ok((Some(lower), Some(upper))) = env::var("SHARD_RANGE").map(|sr| {
|
|
||||||
let mut split =
|
|
||||||
sr.split(',').map(|val| val.parse::<u64>().expect("SHARD_RANGE not an integer"));
|
|
||||||
|
|
||||||
(split.next(), split.next())
|
|
||||||
}) {
|
|
||||||
let total_shards = env::var("SHARD_COUNT")
|
|
||||||
.map(|shard_count| shard_count.parse::<u64>().ok())
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.expect("No SHARD_COUNT provided, but SHARD_RANGE was provided");
|
|
||||||
|
|
||||||
assert!(lower < upper, "SHARD_RANGE lower limit is not less than the upper limit");
|
|
||||||
|
|
||||||
info!("Starting client fragment with shards {}-{}/{}", lower, upper, total_shards);
|
|
||||||
|
|
||||||
client.start_shard_range([lower, upper], total_shards).await?;
|
|
||||||
} else if let Ok(total_shards) = env::var("SHARD_COUNT")
|
|
||||||
.map(|shard_count| shard_count.parse::<u64>().expect("SHARD_COUNT not an integer"))
|
|
||||||
{
|
|
||||||
info!("Starting client with {} shards", total_shards);
|
|
||||||
|
|
||||||
client.start_shards(total_shards).await?;
|
|
||||||
} else {
|
} else {
|
||||||
info!("Starting client as autosharded");
|
warn!("Not running postman");
|
||||||
|
|
||||||
client.start_autosharded().await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !run_settings.contains("web") {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
warn!("Not running web");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Data {
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
database,
|
||||||
|
popular_timezones,
|
||||||
|
recording_macros: Default::default(),
|
||||||
|
_broadcast: tx,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.options(options)
|
||||||
|
.intents(GatewayIntents::GUILDS)
|
||||||
|
.run_autosharded()
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
|
|
||||||
if let Some(subscription_guild) = *CNC_GUILD {
|
|
||||||
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
|
|
||||||
|
|
||||||
if let Ok(member) = guild_member {
|
|
||||||
for role in member.roles {
|
|
||||||
if SUBSCRIPTION_ROLES.contains(role.as_u64()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn check_guild_subscription(
|
|
||||||
cache_http: impl CacheHttp,
|
|
||||||
guild_id: impl Into<GuildId>,
|
|
||||||
) -> bool {
|
|
||||||
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
|
|
||||||
let owner = guild.owner_id;
|
|
||||||
|
|
||||||
check_subscription(&cache_http, owner).await
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use serenity::model::channel::Channel;
|
use poise::serenity_prelude::model::channel::Channel;
|
||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
pub struct ChannelData {
|
pub struct ChannelData {
|
||||||
@ -22,9 +22,7 @@ impl ChannelData {
|
|||||||
|
|
||||||
if let Ok(c) = sqlx::query_as_unchecked!(
|
if let Ok(c) = sqlx::query_as_unchecked!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ?",
|
||||||
SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ?
|
|
||||||
",
|
|
||||||
channel_id
|
channel_id
|
||||||
)
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
@ -37,9 +35,7 @@ SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_u
|
|||||||
let (guild_id, channel_name) = if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) };
|
let (guild_id, channel_name) = if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) };
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))",
|
||||||
INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))
|
|
||||||
",
|
|
||||||
channel_id,
|
channel_id,
|
||||||
channel_name,
|
channel_name,
|
||||||
guild_id
|
guild_id
|
||||||
|
@ -1,33 +1,77 @@
|
|||||||
use serenity::{client::Context, model::id::GuildId};
|
use poise::serenity_prelude::model::{
|
||||||
|
application::interaction::application_command::CommandDataOption, id::GuildId,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{framework::CommandOptions, SQLPool};
|
use crate::{Context, Data, Error};
|
||||||
|
|
||||||
pub struct CommandMacro {
|
type Func<U, E> = for<'a> fn(
|
||||||
|
poise::ApplicationContext<'a, U, E>,
|
||||||
|
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>;
|
||||||
|
|
||||||
|
fn default_none<U, E>() -> Option<Func<U, E>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct RecordedCommand<U, E> {
|
||||||
|
#[serde(skip)]
|
||||||
|
#[serde(default = "default_none::<U, E>")]
|
||||||
|
pub action: Option<Func<U, E>>,
|
||||||
|
pub command_name: String,
|
||||||
|
pub options: Vec<CommandDataOption>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CommandMacro<U, E> {
|
||||||
pub guild_id: GuildId,
|
pub guild_id: GuildId,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub commands: Vec<CommandOptions>,
|
pub commands: Vec<RecordedCommand<U, E>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandMacro {
|
pub struct RawCommandMacro {
|
||||||
pub async fn from_guild(ctx: &Context, guild_id: impl Into<GuildId>) -> Vec<Self> {
|
pub guild_id: GuildId,
|
||||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
pub name: String,
|
||||||
let guild_id = guild_id.into();
|
pub description: Option<String>,
|
||||||
|
pub commands: Value,
|
||||||
|
}
|
||||||
|
|
||||||
sqlx::query!(
|
pub async fn guild_command_macro(
|
||||||
"SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
ctx: &Context<'_>,
|
||||||
guild_id.0
|
name: &str,
|
||||||
|
) -> Option<CommandMacro<Data, Error>> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"
|
||||||
|
SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?
|
||||||
|
",
|
||||||
|
ctx.guild_id().unwrap().0,
|
||||||
|
name
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_one(&ctx.data().database)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.ok()?;
|
||||||
|
|
||||||
|
let mut commands: Vec<RecordedCommand<Data, Error>> =
|
||||||
|
serde_json::from_str(&row.commands).unwrap();
|
||||||
|
|
||||||
|
for recorded_command in &mut commands {
|
||||||
|
let command = &ctx
|
||||||
|
.framework()
|
||||||
|
.options()
|
||||||
|
.commands
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| Self {
|
.find(|c| c.identifying_name == recorded_command.command_name);
|
||||||
guild_id,
|
|
||||||
name: row.name.clone(),
|
recorded_command.action = command.map(|c| c.slash_action).flatten();
|
||||||
description: row.description.clone(),
|
|
||||||
commands: serde_json::from_str(&row.commands).unwrap(),
|
|
||||||
})
|
|
||||||
.collect::<Vec<Self>>()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let command_macro = CommandMacro {
|
||||||
|
guild_id: ctx.guild_id().unwrap(),
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
commands,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(command_macro)
|
||||||
}
|
}
|
||||||
|
48
src/models/guild_data.rs
Normal file
48
src/models/guild_data.rs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
use poise::serenity_prelude::GuildId;
|
||||||
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
|
pub struct GuildData {
|
||||||
|
pub ephemeral_confirmations: bool,
|
||||||
|
pub id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GuildData {
|
||||||
|
pub async fn from_guild(
|
||||||
|
guild_id: GuildId,
|
||||||
|
pool: &MySqlPool,
|
||||||
|
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
|
||||||
|
if let Ok(c) = sqlx::query_as_unchecked!(
|
||||||
|
Self,
|
||||||
|
"SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?",
|
||||||
|
guild_id.0
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(c)
|
||||||
|
} else {
|
||||||
|
sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id.0)
|
||||||
|
.execute(&pool.clone())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(sqlx::query_as_unchecked!(
|
||||||
|
Self,
|
||||||
|
"SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?",
|
||||||
|
guild_id.0
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn commit_changes(&self, pool: &MySqlPool) {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE guilds SET ephemeral_confirmations = ? WHERE id = ?",
|
||||||
|
self.ephemeral_confirmations,
|
||||||
|
self.id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
@ -1,66 +1,97 @@
|
|||||||
pub mod channel_data;
|
pub mod channel_data;
|
||||||
pub mod command_macro;
|
pub mod command_macro;
|
||||||
|
pub mod guild_data;
|
||||||
pub mod reminder;
|
pub mod reminder;
|
||||||
pub mod timer;
|
pub mod timer;
|
||||||
pub mod user_data;
|
pub mod user_data;
|
||||||
|
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use serenity::{
|
use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelType};
|
||||||
async_trait,
|
|
||||||
model::id::{ChannelId, UserId},
|
|
||||||
prelude::Context,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::{channel_data::ChannelData, user_data::UserData},
|
models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData},
|
||||||
SQLPool,
|
CommandMacro, Context, Data, Error, GuildId,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait CtxData {
|
pub trait CtxData {
|
||||||
async fn user_data<U: Into<UserId> + Send + Sync>(
|
async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error>;
|
||||||
&self,
|
|
||||||
user_id: U,
|
|
||||||
) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>>;
|
|
||||||
|
|
||||||
async fn timezone<U: Into<UserId> + Send + Sync>(&self, user_id: U) -> Tz;
|
async fn author_data(&self) -> Result<UserData, Error>;
|
||||||
|
|
||||||
async fn channel_data<C: Into<ChannelId> + Send + Sync>(
|
async fn guild_data(&self) -> Option<Result<GuildData, Error>>;
|
||||||
&self,
|
|
||||||
channel_id: C,
|
async fn timezone(&self) -> Tz;
|
||||||
) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>>;
|
|
||||||
|
async fn channel_data(&self) -> Result<ChannelData, Error>;
|
||||||
|
|
||||||
|
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl CtxData for Context {
|
impl CtxData for Context<'_> {
|
||||||
async fn user_data<U: Into<UserId> + Send + Sync>(
|
async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error> {
|
||||||
|
UserData::from_user(user_id, &self.serenity_context(), &self.data().database).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn author_data(&self) -> Result<UserData, Error> {
|
||||||
|
UserData::from_user(&self.author().id, &self.serenity_context(), &self.data().database)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn guild_data(&self) -> Option<Result<GuildData, Error>> {
|
||||||
|
if let Some(guild_id) = self.guild_id() {
|
||||||
|
Some(GuildData::from_guild(guild_id, &self.data().database).await)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn timezone(&self) -> Tz {
|
||||||
|
UserData::timezone_of(self.author().id, &self.data().database).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> {
|
||||||
|
// If we're in a thread, get the parent channel.
|
||||||
|
let recv_channel = self.channel_id().to_channel(&self).await?;
|
||||||
|
|
||||||
|
let channel = match recv_channel.guild() {
|
||||||
|
Some(guild_channel) => {
|
||||||
|
if guild_channel.kind == ChannelType::PublicThread {
|
||||||
|
guild_channel.parent_id.unwrap().to_channel_cached(&self).unwrap()
|
||||||
|
} else {
|
||||||
|
self.channel_id().to_channel_cached(&self).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None => self.channel_id().to_channel_cached(&self).unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
|
ChannelData::from_channel(&channel, &self.data().database).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error> {
|
||||||
|
self.data().command_macros(self.guild_id().unwrap()).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Data {
|
||||||
|
pub(crate) async fn command_macros(
|
||||||
&self,
|
&self,
|
||||||
user_id: U,
|
guild_id: GuildId,
|
||||||
) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> {
|
) -> Result<Vec<CommandMacro<Data, Error>>, Error> {
|
||||||
let user_id = user_id.into();
|
let rows = sqlx::query!(
|
||||||
let pool = self.data.read().await.get::<SQLPool>().cloned().unwrap();
|
"SELECT name, description, commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||||
|
guild_id.0
|
||||||
|
)
|
||||||
|
.fetch_all(&self.database)
|
||||||
|
.await?.iter().map(|row| CommandMacro {
|
||||||
|
guild_id,
|
||||||
|
name: row.name.clone(),
|
||||||
|
description: row.description.clone(),
|
||||||
|
commands: serde_json::from_str(&row.commands).unwrap(),
|
||||||
|
}).collect();
|
||||||
|
|
||||||
let user = user_id.to_user(self).await.unwrap();
|
Ok(rows)
|
||||||
|
|
||||||
UserData::from_user(&user, &self, &pool).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn timezone<U: Into<UserId> + Send + Sync>(&self, user_id: U) -> Tz {
|
|
||||||
let user_id = user_id.into();
|
|
||||||
let pool = self.data.read().await.get::<SQLPool>().cloned().unwrap();
|
|
||||||
|
|
||||||
UserData::timezone_of(user_id, &pool).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn channel_data<C: Into<ChannelId> + Send + Sync>(
|
|
||||||
&self,
|
|
||||||
channel_id: C,
|
|
||||||
) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> {
|
|
||||||
let channel_id = channel_id.into();
|
|
||||||
let pool = self.data.read().await.get::<SQLPool>().cloned().unwrap();
|
|
||||||
|
|
||||||
let channel = channel_id.to_channel_cached(&self).unwrap();
|
|
||||||
|
|
||||||
ChannelData::from_channel(&channel, &pool).await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,27 +2,26 @@ use std::{collections::HashSet, fmt::Display};
|
|||||||
|
|
||||||
use chrono::{Duration, NaiveDateTime, Utc};
|
use chrono::{Duration, NaiveDateTime, Utc};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use serenity::{
|
use poise::serenity_prelude::{
|
||||||
client::Context,
|
|
||||||
http::CacheHttp,
|
http::CacheHttp,
|
||||||
model::{
|
model::{
|
||||||
channel::GuildChannel,
|
channel::GuildChannel,
|
||||||
id::{ChannelId, GuildId, UserId},
|
id::{ChannelId, GuildId, UserId},
|
||||||
webhook::Webhook,
|
webhook::Webhook,
|
||||||
},
|
},
|
||||||
Result as SerenityResult,
|
ChannelType, Result as SerenityResult,
|
||||||
};
|
};
|
||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
consts,
|
consts::{DAY, DEFAULT_AVATAR, MAX_TIME, MIN_INTERVAL},
|
||||||
consts::{MAX_TIME, MIN_INTERVAL},
|
interval_parser::Interval,
|
||||||
models::{
|
models::{
|
||||||
channel_data::ChannelData,
|
channel_data::ChannelData,
|
||||||
reminder::{content::Content, errors::ReminderError, helper::generate_uid, Reminder},
|
reminder::{content::Content, errors::ReminderError, helper::generate_uid, Reminder},
|
||||||
user_data::UserData,
|
user_data::UserData,
|
||||||
},
|
},
|
||||||
SQLPool,
|
Context,
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn create_webhook(
|
async fn create_webhook(
|
||||||
@ -30,7 +29,7 @@ async fn create_webhook(
|
|||||||
channel: GuildChannel,
|
channel: GuildChannel,
|
||||||
name: impl Display,
|
name: impl Display,
|
||||||
) -> SerenityResult<Webhook> {
|
) -> SerenityResult<Webhook> {
|
||||||
channel.create_webhook_with_avatar(ctx.http(), name, consts::DEFAULT_AVATAR.clone()).await
|
channel.create_webhook_with_avatar(ctx.http(), name, DEFAULT_AVATAR.clone()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Hash, PartialEq, Eq)]
|
#[derive(Hash, PartialEq, Eq)]
|
||||||
@ -52,9 +51,12 @@ pub struct ReminderBuilder {
|
|||||||
pool: MySqlPool,
|
pool: MySqlPool,
|
||||||
uid: String,
|
uid: String,
|
||||||
channel: u32,
|
channel: u32,
|
||||||
|
thread_id: Option<u64>,
|
||||||
utc_time: NaiveDateTime,
|
utc_time: NaiveDateTime,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
interval: Option<i64>,
|
interval_seconds: Option<i64>,
|
||||||
|
interval_days: Option<i64>,
|
||||||
|
interval_months: Option<i64>,
|
||||||
expires: Option<NaiveDateTime>,
|
expires: Option<NaiveDateTime>,
|
||||||
content: String,
|
content: String,
|
||||||
tts: bool,
|
tts: bool,
|
||||||
@ -86,7 +88,9 @@ INSERT INTO reminders (
|
|||||||
`channel_id`,
|
`channel_id`,
|
||||||
`utc_time`,
|
`utc_time`,
|
||||||
`timezone`,
|
`timezone`,
|
||||||
`interval`,
|
`interval_seconds`,
|
||||||
|
`interval_days`,
|
||||||
|
`interval_months`,
|
||||||
`expires`,
|
`expires`,
|
||||||
`content`,
|
`content`,
|
||||||
`tts`,
|
`tts`,
|
||||||
@ -104,6 +108,8 @@ INSERT INTO reminders (
|
|||||||
?,
|
?,
|
||||||
?,
|
?,
|
||||||
?,
|
?,
|
||||||
|
?,
|
||||||
|
?,
|
||||||
?
|
?
|
||||||
)
|
)
|
||||||
",
|
",
|
||||||
@ -111,7 +117,9 @@ INSERT INTO reminders (
|
|||||||
self.channel,
|
self.channel,
|
||||||
utc_time,
|
utc_time,
|
||||||
self.timezone,
|
self.timezone,
|
||||||
self.interval,
|
self.interval_seconds,
|
||||||
|
self.interval_days,
|
||||||
|
self.interval_months,
|
||||||
self.expires,
|
self.expires,
|
||||||
self.content,
|
self.content,
|
||||||
self.tts,
|
self.tts,
|
||||||
@ -123,7 +131,7 @@ INSERT INTO reminders (
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
Ok(Reminder::from_uid(&self.pool, self.uid).await.unwrap())
|
Ok(Reminder::from_uid(&self.pool, &self.uid).await.unwrap())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,11 +144,11 @@ pub struct MultiReminderBuilder<'a> {
|
|||||||
scopes: Vec<ReminderScope>,
|
scopes: Vec<ReminderScope>,
|
||||||
utc_time: NaiveDateTime,
|
utc_time: NaiveDateTime,
|
||||||
timezone: Tz,
|
timezone: Tz,
|
||||||
interval: Option<i64>,
|
interval: Option<Interval>,
|
||||||
expires: Option<NaiveDateTime>,
|
expires: Option<NaiveDateTime>,
|
||||||
content: Content,
|
content: Content,
|
||||||
set_by: Option<u32>,
|
set_by: Option<u32>,
|
||||||
ctx: &'a Context,
|
ctx: &'a Context<'a>,
|
||||||
guild_id: Option<GuildId>,
|
guild_id: Option<GuildId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,6 +167,12 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn timezone(mut self, timezone: Tz) -> Self {
|
||||||
|
self.timezone = timezone;
|
||||||
|
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn content(mut self, content: Content) -> Self {
|
pub fn content(mut self, content: Content) -> Self {
|
||||||
self.content = content;
|
self.content = content;
|
||||||
|
|
||||||
@ -166,17 +180,15 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn time<T: Into<i64>>(mut self, time: T) -> Self {
|
pub fn time<T: Into<i64>>(mut self, time: T) -> Self {
|
||||||
self.utc_time = NaiveDateTime::from_timestamp(time.into(), 0);
|
if let Some(utc_time) = NaiveDateTime::from_timestamp_opt(time.into(), 0) {
|
||||||
|
self.utc_time = utc_time;
|
||||||
|
}
|
||||||
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self {
|
pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self {
|
||||||
if let Some(t) = time {
|
self.expires = time.map(|t| NaiveDateTime::from_timestamp_opt(t.into(), 0)).flatten();
|
||||||
self.expires = Some(NaiveDateTime::from_timestamp(t.into(), 0));
|
|
||||||
} else {
|
|
||||||
self.expires = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@ -188,7 +200,7 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn interval(mut self, interval: Option<i64>) -> Self {
|
pub fn interval(mut self, interval: Option<Interval>) -> Self {
|
||||||
self.interval = interval;
|
self.interval = interval;
|
||||||
|
|
||||||
self
|
self
|
||||||
@ -198,28 +210,42 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
self.scopes = scopes;
|
self.scopes = scopes;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn build(self) -> (HashSet<ReminderError>, HashSet<ReminderScope>) {
|
pub async fn build(self) -> (HashSet<ReminderError>, HashSet<(Reminder, ReminderScope)>) {
|
||||||
let pool = self.ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
|
||||||
|
|
||||||
let mut errors = HashSet::new();
|
let mut errors = HashSet::new();
|
||||||
|
|
||||||
let mut ok_locs = HashSet::new();
|
let mut ok_locs = HashSet::new();
|
||||||
|
|
||||||
if self.interval.map_or(false, |i| (i as i64) < *MIN_INTERVAL) {
|
if self
|
||||||
|
.interval
|
||||||
|
.map_or(false, |i| ((i.sec + i.day * DAY + i.month * 30 * DAY) as i64) < *MIN_INTERVAL)
|
||||||
|
{
|
||||||
errors.insert(ReminderError::ShortInterval);
|
errors.insert(ReminderError::ShortInterval);
|
||||||
} else if self.interval.map_or(false, |i| (i as i64) > *MAX_TIME) {
|
} else if self
|
||||||
|
.interval
|
||||||
|
.map_or(false, |i| ((i.sec + i.day * DAY + i.month * 30 * DAY) as i64) > *MAX_TIME)
|
||||||
|
{
|
||||||
errors.insert(ReminderError::LongInterval);
|
errors.insert(ReminderError::LongInterval);
|
||||||
} else {
|
} else {
|
||||||
for scope in self.scopes {
|
for scope in self.scopes {
|
||||||
|
let thread_id = None;
|
||||||
let db_channel_id = match scope {
|
let db_channel_id = match scope {
|
||||||
ReminderScope::User(user_id) => {
|
ReminderScope::User(user_id) => {
|
||||||
if let Ok(user) = UserId(user_id).to_user(&self.ctx).await {
|
if let Ok(user) = UserId(user_id).to_user(&self.ctx).await {
|
||||||
let user_data =
|
let user_data = UserData::from_user(
|
||||||
UserData::from_user(&user, &self.ctx, &pool).await.unwrap();
|
&user,
|
||||||
|
&self.ctx.serenity_context(),
|
||||||
|
&self.ctx.data().database,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
if let Some(guild_id) = self.guild_id {
|
if let Some(guild_id) = self.guild_id {
|
||||||
if guild_id.member(&self.ctx, user).await.is_err() {
|
if guild_id.member(&self.ctx, user).await.is_err() {
|
||||||
Err(ReminderError::InvalidTag)
|
Err(ReminderError::InvalidTag)
|
||||||
|
} else if self.set_by.map_or(true, |i| i != user_data.id)
|
||||||
|
&& !user_data.allowed_dm
|
||||||
|
{
|
||||||
|
Err(ReminderError::UserBlockedDm)
|
||||||
} else {
|
} else {
|
||||||
Ok(user_data.dm_channel)
|
Ok(user_data.dm_channel)
|
||||||
}
|
}
|
||||||
@ -233,12 +259,29 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
ReminderScope::Channel(channel_id) => {
|
ReminderScope::Channel(channel_id) => {
|
||||||
let channel = ChannelId(channel_id).to_channel(&self.ctx).await.unwrap();
|
let channel = ChannelId(channel_id).to_channel(&self.ctx).await.unwrap();
|
||||||
|
|
||||||
if let Some(guild_channel) = channel.clone().guild() {
|
if let Some(mut guild_channel) = channel.clone().guild() {
|
||||||
if Some(guild_channel.guild_id) != self.guild_id {
|
if Some(guild_channel.guild_id) != self.guild_id {
|
||||||
Err(ReminderError::InvalidTag)
|
Err(ReminderError::InvalidTag)
|
||||||
} else {
|
} else {
|
||||||
let mut channel_data =
|
let mut channel_data = if guild_channel.kind
|
||||||
ChannelData::from_channel(&channel, &pool).await.unwrap();
|
== ChannelType::PublicThread
|
||||||
|
{
|
||||||
|
// fixme jesus christ
|
||||||
|
let parent = guild_channel
|
||||||
|
.parent_id
|
||||||
|
.unwrap()
|
||||||
|
.to_channel(&self.ctx)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
guild_channel = parent.clone().guild().unwrap();
|
||||||
|
ChannelData::from_channel(&parent, &self.ctx.data().database)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
} else {
|
||||||
|
ChannelData::from_channel(&channel, &self.ctx.data().database)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
if channel_data.webhook_id.is_none()
|
if channel_data.webhook_id.is_none()
|
||||||
|| channel_data.webhook_token.is_none()
|
|| channel_data.webhook_token.is_none()
|
||||||
@ -250,7 +293,9 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
Some(webhook.id.as_u64().to_owned());
|
Some(webhook.id.as_u64().to_owned());
|
||||||
channel_data.webhook_token = webhook.token;
|
channel_data.webhook_token = webhook.token;
|
||||||
|
|
||||||
channel_data.commit_changes(&pool).await;
|
channel_data
|
||||||
|
.commit_changes(&self.ctx.data().database)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(channel_data.id)
|
Ok(channel_data.id)
|
||||||
}
|
}
|
||||||
@ -270,12 +315,15 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
match db_channel_id {
|
match db_channel_id {
|
||||||
Ok(c) => {
|
Ok(c) => {
|
||||||
let builder = ReminderBuilder {
|
let builder = ReminderBuilder {
|
||||||
pool: pool.clone(),
|
pool: self.ctx.data().database.clone(),
|
||||||
uid: generate_uid(),
|
uid: generate_uid(),
|
||||||
channel: c,
|
channel: c,
|
||||||
|
thread_id,
|
||||||
utc_time: self.utc_time,
|
utc_time: self.utc_time,
|
||||||
timezone: self.timezone.to_string(),
|
timezone: self.timezone.to_string(),
|
||||||
interval: self.interval,
|
interval_seconds: self.interval.map(|i| i.sec as i64),
|
||||||
|
interval_days: self.interval.map(|i| i.day as i64),
|
||||||
|
interval_months: self.interval.map(|i| i.month as i64),
|
||||||
expires: self.expires,
|
expires: self.expires,
|
||||||
content: self.content.content.clone(),
|
content: self.content.content.clone(),
|
||||||
tts: self.content.tts,
|
tts: self.content.tts,
|
||||||
@ -285,8 +333,8 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match builder.build().await {
|
match builder.build().await {
|
||||||
Ok(_) => {
|
Ok(r) => {
|
||||||
ok_locs.insert(scope);
|
ok_locs.insert((r, scope));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
errors.insert(e);
|
errors.insert(e);
|
||||||
|
@ -7,6 +7,7 @@ pub enum ReminderError {
|
|||||||
PastTime,
|
PastTime,
|
||||||
ShortInterval,
|
ShortInterval,
|
||||||
InvalidTag,
|
InvalidTag,
|
||||||
|
UserBlockedDm,
|
||||||
DiscordError(String),
|
DiscordError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +31,9 @@ impl ToString for ReminderError {
|
|||||||
ReminderError::InvalidTag => {
|
ReminderError::InvalidTag => {
|
||||||
"Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string()
|
"Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string()
|
||||||
}
|
}
|
||||||
|
ReminderError::UserBlockedDm => {
|
||||||
|
"User has DM reminders disabled".to_string()
|
||||||
|
}
|
||||||
ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s),
|
ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,6 @@
|
|||||||
use num_integer::Integer;
|
|
||||||
use rand::{rngs::OsRng, seq::IteratorRandom};
|
use rand::{rngs::OsRng, seq::IteratorRandom};
|
||||||
|
|
||||||
use crate::consts::{CHARACTERS, DAY, HOUR, MINUTE};
|
use crate::consts::CHARACTERS;
|
||||||
|
|
||||||
pub fn longhand_displacement(seconds: u64) -> String {
|
|
||||||
let (days, seconds) = seconds.div_rem(&DAY);
|
|
||||||
let (hours, seconds) = seconds.div_rem(&HOUR);
|
|
||||||
let (minutes, seconds) = seconds.div_rem(&MINUTE);
|
|
||||||
|
|
||||||
let mut sections = vec![];
|
|
||||||
|
|
||||||
for (var, name) in
|
|
||||||
[days, hours, minutes, seconds].iter().zip(["days", "hours", "minutes", "seconds"].iter())
|
|
||||||
{
|
|
||||||
if *var > 0 {
|
|
||||||
sections.push(format!("{} {}", var, name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sections.join(", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn generate_uid() -> String {
|
pub fn generate_uid() -> String {
|
||||||
let mut generator: OsRng = Default::default();
|
let mut generator: OsRng = Default::default();
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
|
use poise::serenity_prelude::model::id::ChannelId;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_repr::*;
|
use serde_repr::*;
|
||||||
use serenity::model::id::ChannelId;
|
|
||||||
|
|
||||||
#[derive(Serialize_repr, Deserialize_repr, Copy, Clone, Debug)]
|
#[derive(Serialize_repr, Deserialize_repr, Copy, Clone, Debug)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
|
@ -4,20 +4,19 @@ pub mod errors;
|
|||||||
mod helper;
|
mod helper;
|
||||||
pub mod look_flags;
|
pub mod look_flags;
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, TimeZone};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use serenity::{
|
use poise::serenity_prelude::{
|
||||||
client::Context,
|
|
||||||
model::id::{ChannelId, GuildId, UserId},
|
model::id::{ChannelId, GuildId, UserId},
|
||||||
|
Cache,
|
||||||
};
|
};
|
||||||
use sqlx::MySqlPool;
|
use sqlx::Executor;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::reminder::{
|
models::reminder::look_flags::{LookFlags, TimeDisplayType},
|
||||||
helper::longhand_displacement,
|
Database,
|
||||||
look_flags::{LookFlags, TimeDisplayType},
|
|
||||||
},
|
|
||||||
SQLPool,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -25,8 +24,10 @@ pub struct Reminder {
|
|||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
pub channel: u64,
|
pub channel: u64,
|
||||||
pub utc_time: NaiveDateTime,
|
pub utc_time: DateTime<Utc>,
|
||||||
pub interval: Option<u32>,
|
pub interval_seconds: Option<u32>,
|
||||||
|
pub interval_days: Option<u32>,
|
||||||
|
pub interval_months: Option<u32>,
|
||||||
pub expires: Option<NaiveDateTime>,
|
pub expires: Option<NaiveDateTime>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
@ -34,8 +35,22 @@ pub struct Reminder {
|
|||||||
pub set_by: Option<u64>,
|
pub set_by: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Hash for Reminder {
|
||||||
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
|
self.uid.hash(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq<Self> for Reminder {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.uid == other.uid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for Reminder {}
|
||||||
|
|
||||||
impl Reminder {
|
impl Reminder {
|
||||||
pub async fn from_uid(pool: &MySqlPool, uid: String) -> Option<Self> {
|
pub async fn from_uid(pool: impl Executor<'_, Database = Database>, uid: &str) -> Option<Self> {
|
||||||
sqlx::query_as_unchecked!(
|
sqlx::query_as_unchecked!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
@ -44,7 +59,9 @@ SELECT
|
|||||||
reminders.uid,
|
reminders.uid,
|
||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
@ -70,16 +87,7 @@ WHERE
|
|||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn from_channel<C: Into<ChannelId>>(
|
pub async fn from_id(pool: impl Executor<'_, Database = Database>, id: u32) -> Option<Self> {
|
||||||
ctx: &Context,
|
|
||||||
channel_id: C,
|
|
||||||
flags: &LookFlags,
|
|
||||||
) -> Vec<Self> {
|
|
||||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
|
||||||
|
|
||||||
let enabled = if flags.show_disabled { "0,1" } else { "1" };
|
|
||||||
let channel_id = channel_id.into();
|
|
||||||
|
|
||||||
sqlx::query_as_unchecked!(
|
sqlx::query_as_unchecked!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
@ -88,7 +96,9 @@ SELECT
|
|||||||
reminders.uid,
|
reminders.uid,
|
||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
@ -105,6 +115,51 @@ LEFT JOIN
|
|||||||
ON
|
ON
|
||||||
reminders.set_by = users.id
|
reminders.set_by = users.id
|
||||||
WHERE
|
WHERE
|
||||||
|
reminders.id = ?
|
||||||
|
",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn from_channel<C: Into<ChannelId>>(
|
||||||
|
pool: impl Executor<'_, Database = Database>,
|
||||||
|
channel_id: C,
|
||||||
|
flags: &LookFlags,
|
||||||
|
) -> Vec<Self> {
|
||||||
|
let enabled = if flags.show_disabled { "0,1" } else { "1" };
|
||||||
|
let channel_id = channel_id.into();
|
||||||
|
|
||||||
|
sqlx::query_as_unchecked!(
|
||||||
|
Self,
|
||||||
|
"
|
||||||
|
SELECT
|
||||||
|
reminders.id,
|
||||||
|
reminders.uid,
|
||||||
|
channels.channel,
|
||||||
|
reminders.utc_time,
|
||||||
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
|
reminders.interval_months,
|
||||||
|
reminders.expires,
|
||||||
|
reminders.enabled,
|
||||||
|
reminders.content,
|
||||||
|
reminders.embed_description,
|
||||||
|
users.user AS set_by
|
||||||
|
FROM
|
||||||
|
reminders
|
||||||
|
INNER JOIN
|
||||||
|
channels
|
||||||
|
ON
|
||||||
|
reminders.channel_id = channels.id
|
||||||
|
LEFT JOIN
|
||||||
|
users
|
||||||
|
ON
|
||||||
|
reminders.set_by = users.id
|
||||||
|
WHERE
|
||||||
|
`status` = 'pending' AND
|
||||||
channels.channel = ? AND
|
channels.channel = ? AND
|
||||||
FIND_IN_SET(reminders.enabled, ?)
|
FIND_IN_SET(reminders.enabled, ?)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@ -113,16 +168,19 @@ ORDER BY
|
|||||||
channel_id.as_u64(),
|
channel_id.as_u64(),
|
||||||
enabled,
|
enabled,
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn from_guild(ctx: &Context, guild_id: Option<GuildId>, user: UserId) -> Vec<Self> {
|
pub async fn from_guild(
|
||||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
cache: impl AsRef<Cache>,
|
||||||
|
pool: impl Executor<'_, Database = Database>,
|
||||||
|
guild_id: Option<GuildId>,
|
||||||
|
user: UserId,
|
||||||
|
) -> Vec<Self> {
|
||||||
if let Some(guild_id) = guild_id {
|
if let Some(guild_id) = guild_id {
|
||||||
let guild_opt = guild_id.to_guild_cached(&ctx);
|
let guild_opt = guild_id.to_guild_cached(cache);
|
||||||
|
|
||||||
if let Some(guild) = guild_opt {
|
if let Some(guild) = guild_opt {
|
||||||
let channels = guild
|
let channels = guild
|
||||||
@ -141,7 +199,9 @@ SELECT
|
|||||||
reminders.uid,
|
reminders.uid,
|
||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
@ -158,11 +218,12 @@ LEFT JOIN
|
|||||||
ON
|
ON
|
||||||
reminders.set_by = users.id
|
reminders.set_by = users.id
|
||||||
WHERE
|
WHERE
|
||||||
|
`status` = 'pending' AND
|
||||||
FIND_IN_SET(channels.channel, ?)
|
FIND_IN_SET(channels.channel, ?)
|
||||||
",
|
",
|
||||||
channels
|
channels
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as_unchecked!(
|
sqlx::query_as_unchecked!(
|
||||||
@ -173,7 +234,9 @@ SELECT
|
|||||||
reminders.uid,
|
reminders.uid,
|
||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
@ -190,11 +253,12 @@ LEFT JOIN
|
|||||||
ON
|
ON
|
||||||
reminders.set_by = users.id
|
reminders.set_by = users.id
|
||||||
WHERE
|
WHERE
|
||||||
|
`status` = 'pending' AND
|
||||||
channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)
|
channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)
|
||||||
",
|
",
|
||||||
guild_id.as_u64()
|
guild_id.as_u64()
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -206,7 +270,9 @@ SELECT
|
|||||||
reminders.uid,
|
reminders.uid,
|
||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
reminders.content,
|
reminders.content,
|
||||||
@ -223,16 +289,27 @@ LEFT JOIN
|
|||||||
ON
|
ON
|
||||||
reminders.set_by = users.id
|
reminders.set_by = users.id
|
||||||
WHERE
|
WHERE
|
||||||
|
`status` = 'pending' AND
|
||||||
channels.id = (SELECT dm_channel FROM users WHERE user = ?)
|
channels.id = (SELECT dm_channel FROM users WHERE user = ?)
|
||||||
",
|
",
|
||||||
user.as_u64()
|
user.as_u64()
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete(
|
||||||
|
&self,
|
||||||
|
db: impl Executor<'_, Database = Database>,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", self.uid)
|
||||||
|
.execute(db)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn display_content(&self) -> &str {
|
pub fn display_content(&self) -> &str {
|
||||||
if self.content.is_empty() {
|
if self.content.is_empty() {
|
||||||
&self.embed_description
|
&self.embed_description
|
||||||
@ -247,34 +324,32 @@ WHERE
|
|||||||
count + 1,
|
count + 1,
|
||||||
self.display_content(),
|
self.display_content(),
|
||||||
self.channel,
|
self.channel,
|
||||||
timezone
|
self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S")
|
||||||
.timestamp(self.utc_time.timestamp(), 0)
|
|
||||||
.format("%Y-%m-%d %H:%M:%S")
|
|
||||||
.to_string()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn display(&self, flags: &LookFlags, timezone: &Tz) -> String {
|
pub fn display(&self, flags: &LookFlags, timezone: &Tz) -> String {
|
||||||
let time_display = match flags.time_display {
|
let time_display = match flags.time_display {
|
||||||
TimeDisplayType::Absolute => timezone
|
TimeDisplayType::Absolute => {
|
||||||
.timestamp(self.utc_time.timestamp(), 0)
|
self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S").to_string()
|
||||||
.format("%Y-%m-%d %H:%M:%S")
|
}
|
||||||
.to_string(),
|
|
||||||
|
|
||||||
TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()),
|
TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(interval) = self.interval {
|
if self.interval_seconds.is_some()
|
||||||
|
|| self.interval_days.is_some()
|
||||||
|
|| self.interval_months.is_some()
|
||||||
|
{
|
||||||
format!(
|
format!(
|
||||||
"'{}' *occurs next at* **{}**, repeating every **{}** (set by {})",
|
"'{}' *occurs next at* **{}**, repeating (set by {})\n",
|
||||||
self.display_content(),
|
self.display_content(),
|
||||||
time_display,
|
time_display,
|
||||||
longhand_displacement(interval as u64),
|
|
||||||
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
|
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"'{}' *occurs next at* **{}** (set by {})",
|
"'{}' *occurs next at* **{}** (set by {})\n",
|
||||||
self.display_content(),
|
self.display_content(),
|
||||||
time_display,
|
time_display,
|
||||||
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
|
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::{DateTime, Utc};
|
||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
pub struct Timer {
|
pub struct Timer {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub start_time: NaiveDateTime,
|
pub start_time: DateTime<Utc>,
|
||||||
pub owner: u64,
|
pub owner: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use log::error;
|
use log::error;
|
||||||
use serenity::{
|
use poise::serenity_prelude::{http::CacheHttp, model::id::UserId};
|
||||||
http::CacheHttp,
|
|
||||||
model::{id::UserId, user::User},
|
|
||||||
};
|
|
||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
use crate::consts::LOCAL_TIMEZONE;
|
use crate::consts::LOCAL_TIMEZONE;
|
||||||
@ -11,9 +8,9 @@ use crate::consts::LOCAL_TIMEZONE;
|
|||||||
pub struct UserData {
|
pub struct UserData {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub user: u64,
|
pub user: u64,
|
||||||
pub name: String,
|
|
||||||
pub dm_channel: u32,
|
pub dm_channel: u32,
|
||||||
pub timezone: String,
|
pub timezone: String,
|
||||||
|
pub allowed_dm: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserData {
|
impl UserData {
|
||||||
@ -25,7 +22,7 @@ impl UserData {
|
|||||||
|
|
||||||
match sqlx::query!(
|
match sqlx::query!(
|
||||||
"
|
"
|
||||||
SELECT timezone FROM users WHERE user = ?
|
SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?
|
||||||
",
|
",
|
||||||
user_id
|
user_id
|
||||||
)
|
)
|
||||||
@ -40,20 +37,20 @@ SELECT timezone FROM users WHERE user = ?
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn from_user(
|
pub async fn from_user<U: Into<UserId>>(
|
||||||
user: &User,
|
user: U,
|
||||||
ctx: impl CacheHttp,
|
ctx: impl CacheHttp,
|
||||||
pool: &MySqlPool,
|
pool: &MySqlPool,
|
||||||
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
|
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
|
||||||
let user_id = user.id.as_u64().to_owned();
|
let user_id = user.into();
|
||||||
|
|
||||||
match sqlx::query_as_unchecked!(
|
match sqlx::query_as_unchecked!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
SELECT id, user, name, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ?
|
SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone, allowed_dm FROM users WHERE user = ?
|
||||||
",
|
",
|
||||||
*LOCAL_TIMEZONE,
|
*LOCAL_TIMEZONE,
|
||||||
user_id
|
user_id.0
|
||||||
)
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
@ -61,27 +58,24 @@ SELECT id, user, name, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone
|
|||||||
Ok(c) => Ok(c),
|
Ok(c) => Ok(c),
|
||||||
|
|
||||||
Err(sqlx::Error::RowNotFound) => {
|
Err(sqlx::Error::RowNotFound) => {
|
||||||
let dm_channel = user.create_dm_channel(ctx).await?;
|
let dm_channel = user_id.create_dm_channel(ctx).await?;
|
||||||
let dm_id = dm_channel.id.as_u64().to_owned();
|
|
||||||
|
|
||||||
let pool_c = pool.clone();
|
let pool_c = pool.clone();
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
INSERT IGNORE INTO channels (channel) VALUES (?)
|
INSERT IGNORE INTO channels (channel) VALUES (?)
|
||||||
",
|
",
|
||||||
dm_id
|
dm_channel.id.0
|
||||||
)
|
)
|
||||||
.execute(&pool_c)
|
.execute(&pool_c)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
INSERT INTO users (user, name, dm_channel, timezone) VALUES (?, ?, (SELECT id FROM channels WHERE channel = ?), ?)
|
INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id FROM channels WHERE channel = ?), ?)
|
||||||
",
|
",
|
||||||
user_id,
|
user_id.0,
|
||||||
user.name,
|
dm_channel.id.0,
|
||||||
dm_id,
|
|
||||||
*LOCAL_TIMEZONE
|
*LOCAL_TIMEZONE
|
||||||
)
|
)
|
||||||
.execute(&pool_c)
|
.execute(&pool_c)
|
||||||
@ -90,9 +84,9 @@ INSERT INTO users (user, name, dm_channel, timezone) VALUES (?, ?, (SELECT id FR
|
|||||||
Ok(sqlx::query_as_unchecked!(
|
Ok(sqlx::query_as_unchecked!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
SELECT id, user, name, dm_channel, timezone FROM users WHERE user = ?
|
SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ?
|
||||||
",
|
",
|
||||||
user_id
|
user_id.0
|
||||||
)
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?)
|
.await?)
|
||||||
@ -109,10 +103,10 @@ SELECT id, user, name, dm_channel, timezone FROM users WHERE user = ?
|
|||||||
pub async fn commit_changes(&self, pool: &MySqlPool) {
|
pub async fn commit_changes(&self, pool: &MySqlPool) {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
UPDATE users SET name = ?, timezone = ? WHERE id = ?
|
UPDATE users SET timezone = ?, allowed_dm = ? WHERE id = ?
|
||||||
",
|
",
|
||||||
self.name,
|
|
||||||
self.timezone,
|
self.timezone,
|
||||||
|
self.allowed_dm,
|
||||||
self.id
|
self.id
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
552
src/sender.rs
552
src/sender.rs
@ -1,552 +0,0 @@
|
|||||||
use chrono::Duration;
|
|
||||||
use chrono_tz::Tz;
|
|
||||||
use log::{error, info, warn};
|
|
||||||
use num_integer::Integer;
|
|
||||||
use regex::{Captures, Regex};
|
|
||||||
use serenity::{
|
|
||||||
builder::CreateEmbed,
|
|
||||||
http::{CacheHttp, Http, StatusCode},
|
|
||||||
model::{
|
|
||||||
channel::{Channel, Embed as SerenityEmbed},
|
|
||||||
id::ChannelId,
|
|
||||||
webhook::Webhook,
|
|
||||||
},
|
|
||||||
Error, Result,
|
|
||||||
};
|
|
||||||
use sqlx::{
|
|
||||||
types::chrono::{NaiveDateTime, Utc},
|
|
||||||
MySqlPool,
|
|
||||||
};
|
|
||||||
|
|
||||||
lazy_static! {
|
|
||||||
pub static ref TIMEFROM_REGEX: Regex =
|
|
||||||
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
|
|
||||||
pub static ref TIMENOW_REGEX: Regex =
|
|
||||||
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fmt_displacement(format: &str, seconds: u64) -> String {
|
|
||||||
let mut seconds = seconds;
|
|
||||||
let mut days: u64 = 0;
|
|
||||||
let mut hours: u64 = 0;
|
|
||||||
let mut minutes: u64 = 0;
|
|
||||||
|
|
||||||
for (rep, time_type, div) in
|
|
||||||
[("%d", &mut days, 86400), ("%h", &mut hours, 3600), ("%m", &mut minutes, 60)].iter_mut()
|
|
||||||
{
|
|
||||||
if format.contains(*rep) {
|
|
||||||
let (divided, new_seconds) = seconds.div_rem(&div);
|
|
||||||
|
|
||||||
**time_type = divided;
|
|
||||||
seconds = new_seconds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
format
|
|
||||||
.replace("%s", &seconds.to_string())
|
|
||||||
.replace("%m", &minutes.to_string())
|
|
||||||
.replace("%h", &hours.to_string())
|
|
||||||
.replace("%d", &days.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn substitute(string: &str) -> String {
|
|
||||||
let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| {
|
|
||||||
let final_time = caps.name("time").unwrap().as_str();
|
|
||||||
let format = caps.name("format").unwrap().as_str();
|
|
||||||
|
|
||||||
if let Ok(final_time) = final_time.parse::<i64>() {
|
|
||||||
let dt = NaiveDateTime::from_timestamp(final_time, 0);
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
|
|
||||||
let difference = {
|
|
||||||
if now < dt {
|
|
||||||
dt - Utc::now().naive_utc()
|
|
||||||
} else {
|
|
||||||
Utc::now().naive_utc() - dt
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fmt_displacement(format, difference.num_seconds() as u64)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
TIMENOW_REGEX
|
|
||||||
.replace(&new, |caps: &Captures| {
|
|
||||||
let timezone = caps.name("timezone").unwrap().as_str();
|
|
||||||
|
|
||||||
println!("{}", timezone);
|
|
||||||
|
|
||||||
if let Ok(tz) = timezone.parse::<Tz>() {
|
|
||||||
let format = caps.name("format").unwrap().as_str();
|
|
||||||
let now = Utc::now().with_timezone(&tz);
|
|
||||||
|
|
||||||
now.format(format).to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Embed {
|
|
||||||
inner: EmbedInner,
|
|
||||||
fields: Vec<EmbedField>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct EmbedInner {
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct EmbedField {
|
|
||||||
title: String,
|
|
||||||
value: String,
|
|
||||||
inline: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Embed {
|
|
||||||
pub async fn from_id(pool: &MySqlPool, id: u32) -> Option<Self> {
|
|
||||||
let mut inner = sqlx::query_as_unchecked!(
|
|
||||||
EmbedInner,
|
|
||||||
"
|
|
||||||
SELECT
|
|
||||||
`embed_title` AS title,
|
|
||||||
`embed_description` AS description,
|
|
||||||
`embed_image_url` AS image_url,
|
|
||||||
`embed_thumbnail_url` AS thumbnail_url,
|
|
||||||
`embed_footer` AS footer,
|
|
||||||
`embed_footer_url` AS footer_url,
|
|
||||||
`embed_author` AS author,
|
|
||||||
`embed_author_url` AS author_url,
|
|
||||||
`embed_color` AS color
|
|
||||||
FROM
|
|
||||||
reminders
|
|
||||||
WHERE
|
|
||||||
`id` = ?
|
|
||||||
",
|
|
||||||
id
|
|
||||||
)
|
|
||||||
.fetch_one(&pool.clone())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
inner.title = substitute(&inner.title);
|
|
||||||
inner.description = substitute(&inner.description);
|
|
||||||
inner.footer = substitute(&inner.footer);
|
|
||||||
|
|
||||||
let mut fields = sqlx::query_as_unchecked!(
|
|
||||||
EmbedField,
|
|
||||||
"
|
|
||||||
SELECT
|
|
||||||
title,
|
|
||||||
value,
|
|
||||||
inline
|
|
||||||
FROM
|
|
||||||
embed_fields
|
|
||||||
WHERE
|
|
||||||
reminder_id = ?
|
|
||||||
",
|
|
||||||
id
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
fields.iter_mut().for_each(|mut field| {
|
|
||||||
field.title = substitute(&field.title);
|
|
||||||
field.value = substitute(&field.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
let e = Embed { inner, fields };
|
|
||||||
|
|
||||||
if e.has_content() {
|
|
||||||
Some(e)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_content(&self) -> bool {
|
|
||||||
if self.inner.title.is_empty()
|
|
||||||
&& self.inner.description.is_empty()
|
|
||||||
&& self.inner.image_url.is_none()
|
|
||||||
&& self.inner.thumbnail_url.is_none()
|
|
||||||
&& self.inner.footer.is_empty()
|
|
||||||
&& self.inner.footer_url.is_none()
|
|
||||||
&& self.inner.author.is_empty()
|
|
||||||
&& self.inner.author_url.is_none()
|
|
||||||
&& self.fields.is_empty()
|
|
||||||
{
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Into<CreateEmbed> for Embed {
|
|
||||||
fn into(self) -> CreateEmbed {
|
|
||||||
let mut c = CreateEmbed::default();
|
|
||||||
|
|
||||||
c.title(&self.inner.title)
|
|
||||||
.description(&self.inner.description)
|
|
||||||
.color(self.inner.color)
|
|
||||||
.author(|a| {
|
|
||||||
a.name(&self.inner.author);
|
|
||||||
|
|
||||||
if let Some(author_icon) = &self.inner.author_url {
|
|
||||||
a.icon_url(author_icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
a
|
|
||||||
})
|
|
||||||
.footer(|f| {
|
|
||||||
f.text(&self.inner.footer);
|
|
||||||
|
|
||||||
if let Some(footer_icon) = &self.inner.footer_url {
|
|
||||||
f.icon_url(footer_icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
f
|
|
||||||
});
|
|
||||||
|
|
||||||
for field in &self.fields {
|
|
||||||
c.field(&field.title, &field.value, field.inline);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(image_url) = &self.inner.image_url {
|
|
||||||
c.image(image_url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(thumbnail_url) = &self.inner.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: Option<u32>,
|
|
||||||
|
|
||||||
avatar: Option<String>,
|
|
||||||
username: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Reminder {
|
|
||||||
pub async fn fetch_reminders(pool: &MySqlPool) -> Vec<Self> {
|
|
||||||
sqlx::query_as_unchecked!(
|
|
||||||
Reminder,
|
|
||||||
"
|
|
||||||
SELECT
|
|
||||||
reminders.`id` AS id,
|
|
||||||
|
|
||||||
channels.`channel` AS channel_id,
|
|
||||||
channels.`webhook_id` AS webhook_id,
|
|
||||||
channels.`webhook_token` AS webhook_token,
|
|
||||||
|
|
||||||
channels.`paused` AS channel_paused,
|
|
||||||
channels.`paused_until` AS channel_paused_until,
|
|
||||||
reminders.`enabled` AS enabled,
|
|
||||||
|
|
||||||
reminders.`tts` AS tts,
|
|
||||||
reminders.`pin` AS pin,
|
|
||||||
reminders.`content` AS content,
|
|
||||||
reminders.`attachment` AS attachment,
|
|
||||||
reminders.`attachment_name` AS attachment_name,
|
|
||||||
|
|
||||||
reminders.`utc_time` AS 'utc_time',
|
|
||||||
reminders.`timezone` AS timezone,
|
|
||||||
reminders.`restartable` AS restartable,
|
|
||||||
reminders.`expires` AS expires,
|
|
||||||
reminders.`interval` AS 'interval',
|
|
||||||
|
|
||||||
reminders.`avatar` AS avatar,
|
|
||||||
reminders.`username` AS username
|
|
||||||
FROM
|
|
||||||
reminders
|
|
||||||
INNER JOIN
|
|
||||||
channels
|
|
||||||
ON
|
|
||||||
reminders.channel_id = channels.id
|
|
||||||
WHERE
|
|
||||||
reminders.`utc_time` < NOW()
|
|
||||||
",
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.into_iter()
|
|
||||||
.map(|mut rem| {
|
|
||||||
rem.content = substitute(&rem.content);
|
|
||||||
|
|
||||||
rem
|
|
||||||
})
|
|
||||||
.collect::<Vec<Self>>()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn reset_webhook(&self, pool: &MySqlPool) {
|
|
||||||
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: &MySqlPool) {
|
|
||||||
if let Some(interval) = self.interval {
|
|
||||||
let now = Utc::now().naive_local();
|
|
||||||
let mut updated_reminder_time = self.utc_time;
|
|
||||||
|
|
||||||
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: &MySqlPool) {
|
|
||||||
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: MySqlPool, 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.clone())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let embed = Embed::from_id(&pool.clone(), 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.clone()).await;
|
|
||||||
send_to_channel(cache_http, &self, embed).await
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
send_to_channel(cache_http, &self, embed).await
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
error!("Error sending {:?}: {:?}", self, e);
|
|
||||||
|
|
||||||
if let Error::Http(error) = e {
|
|
||||||
if error.status_code() == Some(StatusCode::from_u16(404).unwrap()) {
|
|
||||||
error!("Seeing channel is deleted. Removing reminder");
|
|
||||||
self.force_delete(&pool).await;
|
|
||||||
} else {
|
|
||||||
self.refresh(&pool).await;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.refresh(&pool).await;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.refresh(&pool).await;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
info!("Reminder {} is paused", self.id);
|
|
||||||
|
|
||||||
self.refresh(&pool).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -211,14 +211,12 @@ pub async fn natural_parser(time: &str, timezone: &str) -> Option<i64> {
|
|||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.map(|inner| {
|
.and_then(|inner| {
|
||||||
if inner.status.success() {
|
if inner.status.success() {
|
||||||
Some(from_utf8(&*inner.stdout).unwrap().parse::<i64>().unwrap())
|
Some(from_utf8(&*inner.stdout).unwrap().parse::<i64>().unwrap())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.flatten()
|
.and_then(|inner| if inner < 0 { None } else { Some(inner) })
|
||||||
.map(|inner| if inner < 0 { None } else { Some(inner) })
|
|
||||||
.flatten()
|
|
||||||
}
|
}
|
||||||
|
108
src/utils.rs
Normal file
108
src/utils.rs
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
use poise::{
|
||||||
|
serenity_prelude as serenity,
|
||||||
|
serenity_prelude::{
|
||||||
|
builder::CreateApplicationCommands,
|
||||||
|
http::CacheHttp,
|
||||||
|
interaction::MessageFlags,
|
||||||
|
model::id::{GuildId, UserId},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
consts::{CNC_GUILD, SUBSCRIPTION_ROLES},
|
||||||
|
Data, Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn register_application_commands(
|
||||||
|
ctx: &serenity::Context,
|
||||||
|
framework: &poise::Framework<Data, Error>,
|
||||||
|
guild_id: Option<GuildId>,
|
||||||
|
) -> Result<(), serenity::Error> {
|
||||||
|
let mut commands_builder = CreateApplicationCommands::default();
|
||||||
|
let commands = &framework.options().commands;
|
||||||
|
for command in commands {
|
||||||
|
if let Some(slash_command) = command.create_as_slash_command() {
|
||||||
|
commands_builder.add_application_command(slash_command);
|
||||||
|
}
|
||||||
|
if let Some(context_menu_command) = command.create_as_context_menu_command() {
|
||||||
|
commands_builder.add_application_command(context_menu_command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let commands_builder = poise::serenity_prelude::json::Value::Array(commands_builder.0);
|
||||||
|
|
||||||
|
if let Some(guild_id) = guild_id {
|
||||||
|
ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?;
|
||||||
|
} else {
|
||||||
|
ctx.http.create_global_application_commands(&commands_builder).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
|
||||||
|
if let Some(subscription_guild) = *CNC_GUILD {
|
||||||
|
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
|
||||||
|
|
||||||
|
if let Ok(member) = guild_member {
|
||||||
|
for role in member.roles {
|
||||||
|
if SUBSCRIPTION_ROLES.contains(role.as_u64()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_guild_subscription(
|
||||||
|
cache_http: impl CacheHttp,
|
||||||
|
guild_id: impl Into<GuildId>,
|
||||||
|
) -> bool {
|
||||||
|
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
|
||||||
|
let owner = guild.owner_id;
|
||||||
|
|
||||||
|
check_subscription(&cache_http, owner).await
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the message, specified via [`crate::CreateReply`], to the interaction initial response
|
||||||
|
/// endpoint
|
||||||
|
pub fn send_as_initial_response(
|
||||||
|
data: poise::CreateReply<'_>,
|
||||||
|
f: &mut serenity::CreateInteractionResponseData,
|
||||||
|
) {
|
||||||
|
let poise::CreateReply {
|
||||||
|
content,
|
||||||
|
embeds,
|
||||||
|
attachments: _, // serenity doesn't support attachments in initial response yet
|
||||||
|
components,
|
||||||
|
ephemeral,
|
||||||
|
allowed_mentions,
|
||||||
|
reply: _,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if let Some(content) = content {
|
||||||
|
f.content(content);
|
||||||
|
}
|
||||||
|
f.set_embeds(embeds);
|
||||||
|
if let Some(allowed_mentions) = allowed_mentions {
|
||||||
|
f.allowed_mentions(|f| {
|
||||||
|
*f = allowed_mentions.clone();
|
||||||
|
f
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(components) = components {
|
||||||
|
f.components(|f| {
|
||||||
|
f.0 = components.0;
|
||||||
|
f
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ephemeral {
|
||||||
|
f.flags(MessageFlags::EPHEMERAL);
|
||||||
|
}
|
||||||
|
}
|
14
systemd/reminder-rs.service
Normal file
14
systemd/reminder-rs.service
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Reminder Bot
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=reminder
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/reminder-rs
|
||||||
|
WorkingDirectory=/etc/reminder-rs
|
||||||
|
Restart=always
|
||||||
|
RestartSec=4
|
||||||
|
Environment="reminder_rs=warn,postman=warn"
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
21
web/Cargo.toml
Normal file
21
web/Cargo.toml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "reminder_web"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["jellywx <judesouthworth@pm.me>"]
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] }
|
||||||
|
rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] }
|
||||||
|
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
||||||
|
oauth2 = "4"
|
||||||
|
log = "0.4"
|
||||||
|
reqwest = "0.11"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
|
||||||
|
chrono = "0.4"
|
||||||
|
chrono-tz = "0.5"
|
||||||
|
lazy_static = "1.4.0"
|
||||||
|
rand = "0.7"
|
||||||
|
base64 = "0.13"
|
||||||
|
csv = "1.1"
|
32
web/private/ca_cert.pem
Normal file
32
web/private/ca_cert.pem
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFbzCCA1egAwIBAgIUUY2fYP5h41dtqcuNLVUS99N7+jAwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
|
||||||
|
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
|
||||||
|
MDUxNTE5MDIyNFowRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQK
|
||||||
|
DAlSb2NrZXQgQ0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMIICIjANBgkqhkiG
|
||||||
|
9w0BAQEFAAOCAg8AMIICCgKCAgEA3MmVOUxbZ0rtBBbXjWkkM2n2IoPuqfiENdrM
|
||||||
|
NH9IcIbEgFb7AQ/c2vGBLWWw2RFtXxe2ubbgcHyAySQ+ENGEf+W2yox5JpcOOBP+
|
||||||
|
/KrUQ4ROqxLBWOD6+fA/q5m9/CXXJosWXly3DFE9nUEUlNXcaSxJm/bxIqnxwEMQ
|
||||||
|
NESWdG1qlsGVhHLi5sflgwAcYH+nZzdtINLyvQ5AGIs8s6LpmvUVbC3I+5oNNSwW
|
||||||
|
rRsIfCFaYLuUx7jZMaOk8Dfz8MbFL68+7OCGwmhduSHoGW8jVzDXwXv/crAmFtau
|
||||||
|
zmu43/vPDwKa4w/kQJLNr1QmiGz9FBt8DXVJ0s5bokOIlodEgAvjsL3xwnrKak0F
|
||||||
|
8ak74IBetfDdVvuC9WWmJ9/dj3z91KvCWLdZGm8ijlYZ2OuaiiJ/oC6iebaPFHEY
|
||||||
|
IQmF5wzT/nZLIbQsti9wFnOpptgkhDAcKIAk3+pncuMwZ9WRvWO2IFt+ZCqKCCDU
|
||||||
|
JliDZmm3ow/zob8DD6Txex5OkJGUF6iu8o7fjI9BxsjSc76bLrxIhvR43UXjzoHl
|
||||||
|
t3lvDH8SzSN/kxzQMXVl48h3xydJ3+ZyDOLle1bqMga8Kcin9GcEUqmL1JcyFafe
|
||||||
|
CUE/Nk1pHyrlnhpMCYIDVI3pmNLB0/m/IEWLckSr9+hXyUPJQyWiyrixwC86SPxZ
|
||||||
|
AonU2aUCAwEAAaNTMFEwHQYDVR0OBBYEFJ5uQa9kD5fioNeS/Ff/mxDcMAzhMB8G
|
||||||
|
A1UdIwQYMBaAFJ5uQa9kD5fioNeS/Ff/mxDcMAzhMA8GA1UdEwEB/wQFMAMBAf8w
|
||||||
|
DQYJKoZIhvcNAQELBQADggIBABf7adF1J40/8EjfSJ5XdG7OBUrZTas3+nuULh8B
|
||||||
|
6h1igAq0BgX9v3awmPCaxqDLqwJCsFZCk0s9Vgd62McKVKasyW1TQchWbdvlqeAB
|
||||||
|
QQfZpJtAZK12dcMl6iznC/JBTPvYl9q1FKS8kYtg19in/xlhxiiEJaL5z+slpTgT
|
||||||
|
cN12650W2FqjT9sD9jshZI/a8o53d2yUBXBBecCZ49djceBGLbxbteEi/sb9qM4f
|
||||||
|
IUxeSrvK6owxKMZ5okUqZWaFseFCkdMKpMg9eecyfSTweac8yLlZmeqX5D/H85Hr
|
||||||
|
hnWHjI5NeVZAoDkdmNeaDbAIv4f3prqC8uFP3ub8D0rG/WiPdOAd79KNgqbUUFyp
|
||||||
|
NbjSiAesx4Rm1WIgjHljFQKXJdtnxt+v1VkKV9entXJX2tye7+Z1+zurNYyUyM1J
|
||||||
|
COdXeV4jpq2mP8cLpAqX7ip1tNx9YMy5ctbAE1WsUw7lPyO91+VN2Q6MN5OyemY3
|
||||||
|
4nBzRJU8X1nsDgeNQWjzb/ZC7eoS916Gywk8Hu6GoIwvYMm3zea25n6mAAOX17vE
|
||||||
|
1YdvSzUlnVbwnbgd4BgFRGUfBVjO7qvFC7ZWLngWhBzIIYIGUJfO2rippgkxAoHH
|
||||||
|
dgWYk1MNnk2yM8WSo0VKoe4yuF9NwPlfPqeCE683JHdwGEJKZWMocszzHrGZ2KX2
|
||||||
|
I4/u
|
||||||
|
-----END CERTIFICATE-----
|
51
web/private/ca_key.pem
Normal file
51
web/private/ca_key.pem
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIJKAIBAAKCAgEA3MmVOUxbZ0rtBBbXjWkkM2n2IoPuqfiENdrMNH9IcIbEgFb7
|
||||||
|
AQ/c2vGBLWWw2RFtXxe2ubbgcHyAySQ+ENGEf+W2yox5JpcOOBP+/KrUQ4ROqxLB
|
||||||
|
WOD6+fA/q5m9/CXXJosWXly3DFE9nUEUlNXcaSxJm/bxIqnxwEMQNESWdG1qlsGV
|
||||||
|
hHLi5sflgwAcYH+nZzdtINLyvQ5AGIs8s6LpmvUVbC3I+5oNNSwWrRsIfCFaYLuU
|
||||||
|
x7jZMaOk8Dfz8MbFL68+7OCGwmhduSHoGW8jVzDXwXv/crAmFtauzmu43/vPDwKa
|
||||||
|
4w/kQJLNr1QmiGz9FBt8DXVJ0s5bokOIlodEgAvjsL3xwnrKak0F8ak74IBetfDd
|
||||||
|
VvuC9WWmJ9/dj3z91KvCWLdZGm8ijlYZ2OuaiiJ/oC6iebaPFHEYIQmF5wzT/nZL
|
||||||
|
IbQsti9wFnOpptgkhDAcKIAk3+pncuMwZ9WRvWO2IFt+ZCqKCCDUJliDZmm3ow/z
|
||||||
|
ob8DD6Txex5OkJGUF6iu8o7fjI9BxsjSc76bLrxIhvR43UXjzoHlt3lvDH8SzSN/
|
||||||
|
kxzQMXVl48h3xydJ3+ZyDOLle1bqMga8Kcin9GcEUqmL1JcyFafeCUE/Nk1pHyrl
|
||||||
|
nhpMCYIDVI3pmNLB0/m/IEWLckSr9+hXyUPJQyWiyrixwC86SPxZAonU2aUCAwEA
|
||||||
|
AQKCAgBVSXlfXOOiDwtnnPs/IPJe+fuecaBsABfyRcbEMLbm4OhfOzpSurHx0YC4
|
||||||
|
7KNX9qdtKFfpfX9NdIq7KEjhbk3kqfPmYkUaZxeTCgZhzAua2S0aYHBXyPCqQ+gU
|
||||||
|
fZsqH+Pwe6H0aZQ8KdXHPTCaHdK6veThXo7feQ5t2noT9rq31txpx/Xd6BNGWsmJ
|
||||||
|
xS0xCZ68/GgnWdVyumKAGKkmKzRaK3pPA5CzwFqBw7ouvFaWvLuQymU6kWk1B6Xb
|
||||||
|
NYIB7IaXWPbRwhnMV0x9C2ABEzFvqOpvT1rqDqloAR4dlvcfbsIZZkQ2mhjt6MeT
|
||||||
|
hsorwQ4yCjvtZvVRfW1gTP4iR7ZpmHgHIYQDJy7raZqRVNe9WgMxKTS/IYsHvEvH
|
||||||
|
MUBOfQEclAQ8FTUOgTg9+Hf9VXJOtjZRxY08gb+OgKxoAQKxRZmLDUZli9SNWzGe
|
||||||
|
R3UECh0gImm/CzWnV3fercLX+qHLTcyJFcb2gclAfG8v8cOT2qu8RtsJAQM2ZSn7
|
||||||
|
L8FaWXewjAwkZwCl8Zl5ky1RbBXUkcuLIkAeLAl0+ffM9xiwLhiIj8gRJoq0/jSr
|
||||||
|
K1pA8CQ/rZC+9RsI8hP4x2Fn1CU8bOyEBPeNFEIDJHBiCrs/Rl6EAzDMJHlhL/HT
|
||||||
|
f7bqssuRjnSbRCKaAdtfGTxQUEjefvqJ11xE3BfHGnKPqoasMQKCAQEA8nY+3MAB
|
||||||
|
eBPlE/H4JeVpj7/9/q+JWLFFH9E3Re25LbObzRzIQOd5bTCJkOPQXYRbRIZ1pMt9
|
||||||
|
+nZzeLKvWn5nuUFgG2S4n+BEJkBDV78LOkUuGIAPdztF2mwVumygEGJFFfZHbYrh
|
||||||
|
XtaGUKdNxn1R3Z4B8W5jWpNNkU+dvoq4Zt0LrL28HB4Sa2yrtHeXbhqSb0BA15+N
|
||||||
|
vO9mlfX2I6Yr8F+L/OBfxB0gaNLISJJsj5NSm302je/QrfoMAwbePoNPjEX1/qd2
|
||||||
|
rgj9JfM+NE2FsVnFhrV+q4DywRUdX4VBFP4+x7fPm8LxvtVUmLVA3/NtGwPdnC6U
|
||||||
|
mQh/+kKVhU2AQwKCAQEA6R2FrxZIUdmk45b/tSjlq7r1ycBYeSztjzXw6hx+w/K3
|
||||||
|
Lf+5OGyoGoKr5bZHMhHFxqoWzP6kiZ2Uyvu+pLAGLfenJPec89ZncN4FvW9yU8yL
|
||||||
|
nE2Sc8Vf2GnOw2KGJcJR11Ew4dDspHfnM3rof2G4gPC/EMZeGojXoc5GHmT4iasD
|
||||||
|
Xwje4qqx5WKvRu1Rw2y+XM5h4gDn3QLLojDOvBpt22yaqSTAupY7OliGFO9h2WPL
|
||||||
|
r9TL6va87nXNepCdtzuSlxqTVtgMu2wVu3CnQyw9RiD2M31YnUtBDLNy/UNFj/5Z
|
||||||
|
6uXYuakh8vSFFvjgCUyQwR1xCTJur/6LdpAnt9Bz9wKCAQAUqoN9KVh2tatm4c72
|
||||||
|
2/D9ca3ikW+xgZqUta5yZWrNPGvhNbzT22b8KZDwKprN/cQRuSw52aZpPMNm3EQa
|
||||||
|
AIAyyCG69ADQj7r/T6btybjZRKBDMlcfIIw5q9DGTQ/vlZCx6IX6DkZbYQmdwkTc
|
||||||
|
0D20GA2uWGxbggawhgq5/PTuv5SJKrrn4qBLS73u6eqcVeN5XA6q0kywd+9UhNxv
|
||||||
|
+W/xUxOJgE5pVto2VREBLonWSwZVfnyx6GjvC0sOzv0Ocv7KxAPNqtRwzQ9Wtr7s
|
||||||
|
klb84Nv3OW0MjTcjwfr481Cyy2DqgP5PFnSogWJuibR34jXAgbnX4BiGWrUdzaMU
|
||||||
|
86AlAoIBABnw9Bh41VFuc9/zxL7nLy++HW33HqFVc5Y1PXr/8sdhcisHQxhZVxek
|
||||||
|
JPbqIuAahDTIZsMnLy41QAKaoyt2fymMXqhJecjUuiwgOOlMxp82qu6Y30xM0Y6m
|
||||||
|
r6CkjSMUjcD1QwhOFJd01GCxM8BBIqQOpmR6fqxbQAu8hacKO3IuerCPryXwMt3A
|
||||||
|
7ppo/GlP55sySEg7K5I3pmuFHOxn0IPTgR6DfYMGBs9GXJ1lyjDD3z3Q42RhUsMC
|
||||||
|
jvwtra9fTL/N8EmAv2H39C8oqSRbfvIX5u3x6/ONFU8RhSFT5CDTADSYoVZ/0MxV
|
||||||
|
k53r0hqWz6D94r9QQmsJW4G1JwZYhx8CggEBANUybXsJRgDC5ZOlwbaq0FeTOpZ4
|
||||||
|
pSKx1RkVV+G93hK5XdXIVu365pSt3T68MgF+4wWlbC4MuKuzJltFnJBapzz5ygcU
|
||||||
|
jw+Q+l/jq+mJj8uvUfo5TAy9thuggj1a7SCs/dm5DBwYA7bfmamLbbBpA0qywMsF
|
||||||
|
/vL1bpePIFhbGMhBK7uiEzOAOZVuceZWqcUByjUYy925K/an0ANsQAch5PVeAsEv
|
||||||
|
wXC3soaCzfzUyv+eAYBy7LOcz+hpdRj1l4c9Zx6hZeBxMc5LSRoTs+HfE6GgTuI2
|
||||||
|
cCfCZNFw7DhDy1TBd3XjQ0AFykeaesImZVR6gp0NYH1kionsMtR5rl4C2tw=
|
||||||
|
-----END RSA PRIVATE KEY-----
|
21
web/private/ecdsa_nistp256_sha256_cert.pem
Normal file
21
web/private/ecdsa_nistp256_sha256_cert.pem
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDYTCCAUmgAwIBAgIUA/EaCcXKgOxBiuaMNTackeF9DgcwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
|
||||||
|
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
|
||||||
|
MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK
|
||||||
|
DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDBZMBMGByqGSM49AgEGCCqGSM49
|
||||||
|
AwEHA0IABOEVdmVd218y3Yy+ocjtt5siaFsNR7tknvS21pzKzxsFc05UrF74YqRx
|
||||||
|
Ic6/AQq56C48x6gDhUCdf+nMzD0NWTijGDAWMBQGA1UdEQQNMAuCCWxvY2FsaG9z
|
||||||
|
dDANBgkqhkiG9w0BAQsFAAOCAgEAW32kadHWEIsN5WXacjtgE9jbFii/rWJjrAO/
|
||||||
|
GWuARwLvjWeEFM5xSTny1cTDEz/7Bmfd9GRRpfgxKCFBGaU7zTmOV/AokrjUay+s
|
||||||
|
KPj0EV9ZE/84KVfr+MOuhwXhSSv+NvxduFw1sdhTUHs+Wopwjte+bnOUBXmnQH97
|
||||||
|
ITSQuUvEamPUS4tJD/kQ0qSGJKNv3CShhbe3XkjUd0UKK7lZzn6hY2fy3CrkkJDT
|
||||||
|
GyzFCRf3ZDfjW5/fGXN/TNoEK65pPQVr5taK/bovDesEO7rR4Y4YgtnGlJ7YToWh
|
||||||
|
E6I77CbsJCJdmgpjPNwRlwQ8upoWAfxOHqwg32s6uWq20v4TXZTOnEKOPEJG74bh
|
||||||
|
JHtwqsy2fIdGz4kZksKGerzJffyQPhX4f3qxxrijT9Hj2EoFIQ4u/jTRpK31IW3R
|
||||||
|
gEeNB8O4iyg3crx0oHRwc6zX63O6wBJCZ+0uxLoyjwtjKCxVYbJpccjoQh6zO3UO
|
||||||
|
pqAO+NKxQ469QDBV3uRyyQ7DRPu6p67/8gn1E/o8kyU1P7eLFIhBp4T4wCaMQBG6
|
||||||
|
IpL5W7eCyuOcqfdm471N07Z4KAqiV8eBJcKaAE+WzNIvrFvCZ/zq7Gj8p07nkjK8
|
||||||
|
+ZGDUieiY0mQEpiXLQZt1xNtT/MmlAzFYrK7t6gSygykQKjO1o4GG4g+eXu3h1YK
|
||||||
|
avsOwtc=
|
||||||
|
-----END CERTIFICATE-----
|
5
web/private/ecdsa_nistp256_sha256_key_pkcs8.pem
Normal file
5
web/private/ecdsa_nistp256_sha256_key_pkcs8.pem
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgeo/7wBIYVax9bj4m
|
||||||
|
1UEe2H+H4YLsbApDCZMslZbubRuhRANCAAThFXZlXdtfMt2MvqHI7bebImhbDUe7
|
||||||
|
ZJ70ttacys8bBXNOVKxe+GKkcSHOvwEKueguPMeoA4VAnX/pzMw9DVk4
|
||||||
|
-----END PRIVATE KEY-----
|
21
web/private/ecdsa_nistp384_sha384_cert.pem
Normal file
21
web/private/ecdsa_nistp384_sha384_cert.pem
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDfjCCAWagAwIBAgIUfmkM99JpQUsQsh8TVILPQDBGOhowDQYJKoZIhvcNAQEM
|
||||||
|
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
|
||||||
|
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
|
||||||
|
MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK
|
||||||
|
DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDB2MBAGByqGSM49AgEGBSuBBAAi
|
||||||
|
A2IABM5rQQNZEGWeDyrg2dVQS15c+JsX/ginN9/ArHpTgGO6xaLfkbekIj7gewUR
|
||||||
|
VQcQrYulwu02HTFDzGVvf1DdCZzIJLJUpl+jagdI05yMPFg2s5lThzGB6HENyw1I
|
||||||
|
hU1dMaMYMBYwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBDAUAA4IC
|
||||||
|
AQAzpXFEeCMvTBG+kzmHN/+esjCK8MO/Grrw3Qoa1stX0yjNWzfhlGHfWk9Mgwnp
|
||||||
|
DnIEFT9lB+nT9uTkKL81Opu2bkWXuL0MyjyYmcCfEgJeDblUbD4HYzPO/s2P7QZu
|
||||||
|
Oa1pNKBiSWe1xq+F/gkn0+nDfICq3QQOOQMzaAmlFmszN3v7pryz+x21MqTFsfuW
|
||||||
|
ymycRcwZ3VyYeceZuBxjOhR2l8I5yZbR+KPj8VS7A3vgqvkS8ixTK0LrxcLwrTIz
|
||||||
|
W9Z1skzVpux4K7iwn3qlWIJ7EbERi9Ydz0MzpjD+CHxGlgHTzT552DGZhPO8D7BE
|
||||||
|
+4IoS0YeXEGLeC2a9Hmiy3FXbM4nt3aIWMiWyhjslXIbvWOJL1Vi5GNpdQCG+rB7
|
||||||
|
lvA0IISp/tAi9duufdONjgRceRDsqf7o6TOBQdB3QxHRt9gCB2QquRh5llMUY8YH
|
||||||
|
PxFJAEzF4X5b0q0k4b50HRVNfDY0SC/XIR7S2xnLDGuoUqrOpUligBzfXV4JHrPv
|
||||||
|
YKHsWSOiVxU9eY1SYKuKqnOsGvXSSQlYqSK1ol94eOKDjNy8wekaVYAQNsdNL7o5
|
||||||
|
QSWZUcbZLkaNMFdD9twWGduAs0eTichVgyvRgPdevjTGDPBHKNSUURZRTYeW4aIJ
|
||||||
|
QzSQUQXwOXsQOmfCkaaTISsDwZAJ0wFqqST1AOhlCWD1OQ==
|
||||||
|
-----END CERTIFICATE-----
|
6
web/private/ecdsa_nistp384_sha384_key_pkcs8.pem
Normal file
6
web/private/ecdsa_nistp384_sha384_key_pkcs8.pem
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCRBt7MeJXyJRq7grGQ
|
||||||
|
jEdBHE31hG5LuKQ5iL8yTVGk75Kc8ISll2LewbvQ3NhR6E+hZANiAATOa0EDWRBl
|
||||||
|
ng8q4NnVUEteXPibF/4IpzffwKx6U4BjusWi35G3pCI+4HsFEVUHEK2LpcLtNh0x
|
||||||
|
Q8xlb39Q3QmcyCSyVKZfo2oHSNOcjDxYNrOZU4cxgehxDcsNSIVNXTE=
|
||||||
|
-----END PRIVATE KEY-----
|
20
web/private/ed25519_cert.pem
Normal file
20
web/private/ed25519_cert.pem
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDMjCCARqgAwIBAgIUSu1CGfaWlNPjjLG7SaEEgxI+AsAwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
|
||||||
|
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
|
||||||
|
MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK
|
||||||
|
DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUGAytlcAMhAKNv1fkv5rxY
|
||||||
|
xGT84C98g2xPpain+jsliHvYrqNkS1zhoxgwFjAUBgNVHREEDTALgglsb2NhbGhv
|
||||||
|
c3QwDQYJKoZIhvcNAQELBQADggIBAMgWZhr3whYbFPNYm5RZxjgEonMY7cH/Rza1
|
||||||
|
UrBkyoMRL9v00YKJTEvjl5Xl4ku7jqxY2qjValacDroD8hYTLhhkkbHxH6u+kJSC
|
||||||
|
cKRQ8QVBZMmoor8qD2Y2BuXZYGWEtgeoXh+DLHWOim7gXDD6GyFPnYFGHnRhNmkE
|
||||||
|
6fPcfw1FZmBrMM9rk31EeK9nLVRabL+vuLDEddX1LH4LwK4OuV51wQMZGYiC3M2b
|
||||||
|
JpmuPAUAUyiyN53Utuw/ubeih3cEglOwCyDPFt6XISYHO0UaQdw25uhpjxs8KTZB
|
||||||
|
qOPJAI4cvGaN3GARXvz2P/QHUiTzsf2XOXXtrO0g+F9P7t6x2xL35c0A8yxKkfsa
|
||||||
|
RKdCveRuVlsLXVLVBikqLE/OgPC6SIhIFvTYzXTaZyNfge6dRy2Wg6qWPcGwseeA
|
||||||
|
QpFKgWiY3cuy7nJm6uybR6zOEMj5GgNWIbICGguQ5161MLnv0bEmKh2d9/jQ/XN5
|
||||||
|
M+nixtGla/G4hL3FQIy9rhHVZzPWwSxl+/eE4qgnInEhmlQ2KrcpgneCt38t0vjJ
|
||||||
|
dwHZSzVv8yX7fE0OSq/DWQXJraWUcT7Ds0g7T7jWkWfS0qStVd9VJ+rYQ8dBbE9Y
|
||||||
|
gcmkm1DMUd+y9xDRDjxby7gMDWFiN2Au9FQgaIpIctTNCj0+agFBPnfXPVSIH6gX
|
||||||
|
10kA2ZVX
|
||||||
|
-----END CERTIFICATE-----
|
3
web/private/ed25519_key.pem
Normal file
3
web/private/ed25519_key.pem
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MC4CAQAwBQYDK2VwBCIEIGtLkaYE4D+P5HLkoc3a/tNhPP+gnhPLF3jEtdYcAtpd
|
||||||
|
-----END PRIVATE KEY-----
|
114
web/private/gen_certs.sh
Normal file
114
web/private/gen_certs.sh
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
#! /bin/bash
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
# ./gen_certs.sh [cert-kind]
|
||||||
|
#
|
||||||
|
# [cert-kind]:
|
||||||
|
# ed25519
|
||||||
|
# rsa_sha256
|
||||||
|
# ecdsa_nistp256_sha256
|
||||||
|
# ecdsa_nistp384_sha384
|
||||||
|
#
|
||||||
|
# Generate a certificate of the [cert-kind] key type, or if no cert-kind is
|
||||||
|
# specified, all of the certificates.
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./gen_certs.sh ed25519
|
||||||
|
# ./gen_certs.sh rsa_sha256
|
||||||
|
|
||||||
|
# TODO: `rustls` (really, `webpki`) doesn't currently use the CN in the subject
|
||||||
|
# to check if a certificate is valid for a server name sent via SNI. It's not
|
||||||
|
# clear if this is intended, since certificates _should_ have a `subjectAltName`
|
||||||
|
# with a DNS name, or if it simply hasn't been implemented yet. See
|
||||||
|
# https://bugzilla.mozilla.org/show_bug.cgi?id=552346 for a bit more info.
|
||||||
|
|
||||||
|
CA_SUBJECT="/C=US/ST=CA/O=Rocket CA/CN=Rocket Root CA"
|
||||||
|
SUBJECT="/C=US/ST=CA/O=Rocket/CN=localhost"
|
||||||
|
ALT="DNS:localhost"
|
||||||
|
|
||||||
|
function gen_ca() {
|
||||||
|
openssl genrsa -out ca_key.pem 4096
|
||||||
|
openssl req -new -x509 -days 3650 -key ca_key.pem \
|
||||||
|
-subj "${CA_SUBJECT}" -out ca_cert.pem
|
||||||
|
}
|
||||||
|
|
||||||
|
function gen_ca_if_non_existent() {
|
||||||
|
if ! [ -f ./ca_cert.pem ]; then gen_ca; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function gen_rsa_sha256() {
|
||||||
|
gen_ca_if_non_existent
|
||||||
|
|
||||||
|
openssl req -newkey rsa:4096 -nodes -sha256 -keyout rsa_sha256_key.pem \
|
||||||
|
-subj "${SUBJECT}" -out server.csr
|
||||||
|
|
||||||
|
openssl x509 -req -sha256 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \
|
||||||
|
-CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \
|
||||||
|
-in server.csr -out rsa_sha256_cert.pem
|
||||||
|
|
||||||
|
rm ca_cert.srl server.csr
|
||||||
|
}
|
||||||
|
|
||||||
|
function gen_ed25519() {
|
||||||
|
gen_ca_if_non_existent
|
||||||
|
|
||||||
|
openssl genpkey -algorithm ED25519 > ed25519_key.pem
|
||||||
|
|
||||||
|
openssl req -new -key ed25519_key.pem -subj "${SUBJECT}" -out server.csr
|
||||||
|
openssl x509 -req -extfile <(printf "subjectAltName=${ALT}") -days 3650 \
|
||||||
|
-CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \
|
||||||
|
-in server.csr -out ed25519_cert.pem
|
||||||
|
|
||||||
|
rm ca_cert.srl server.csr
|
||||||
|
}
|
||||||
|
|
||||||
|
function gen_ecdsa_nistp256_sha256() {
|
||||||
|
gen_ca_if_non_existent
|
||||||
|
|
||||||
|
openssl ecparam -out ecdsa_nistp256_sha256_key.pem -name prime256v1 -genkey
|
||||||
|
|
||||||
|
# Convert to pkcs8 format supported by rustls
|
||||||
|
openssl pkcs8 -topk8 -nocrypt -in ecdsa_nistp256_sha256_key.pem \
|
||||||
|
-out ecdsa_nistp256_sha256_key_pkcs8.pem
|
||||||
|
|
||||||
|
openssl req -new -nodes -sha256 -key ecdsa_nistp256_sha256_key_pkcs8.pem \
|
||||||
|
-subj "${SUBJECT}" -out server.csr
|
||||||
|
|
||||||
|
openssl x509 -req -sha256 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \
|
||||||
|
-CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \
|
||||||
|
-in server.csr -out ecdsa_nistp256_sha256_cert.pem
|
||||||
|
|
||||||
|
rm ca_cert.srl server.csr ecdsa_nistp256_sha256_key.pem
|
||||||
|
}
|
||||||
|
|
||||||
|
function gen_ecdsa_nistp384_sha384() {
|
||||||
|
gen_ca_if_non_existent
|
||||||
|
|
||||||
|
openssl ecparam -out ecdsa_nistp384_sha384_key.pem -name secp384r1 -genkey
|
||||||
|
|
||||||
|
# Convert to pkcs8 format supported by rustls
|
||||||
|
openssl pkcs8 -topk8 -nocrypt -in ecdsa_nistp384_sha384_key.pem \
|
||||||
|
-out ecdsa_nistp384_sha384_key_pkcs8.pem
|
||||||
|
|
||||||
|
openssl req -new -nodes -sha384 -key ecdsa_nistp384_sha384_key_pkcs8.pem \
|
||||||
|
-subj "${SUBJECT}" -out server.csr
|
||||||
|
|
||||||
|
openssl x509 -req -sha384 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \
|
||||||
|
-CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \
|
||||||
|
-in server.csr -out ecdsa_nistp384_sha384_cert.pem
|
||||||
|
|
||||||
|
rm ca_cert.srl server.csr ecdsa_nistp384_sha384_key.pem
|
||||||
|
}
|
||||||
|
|
||||||
|
case $1 in
|
||||||
|
ed25519) gen_ed25519 ;;
|
||||||
|
rsa_sha256) gen_rsa_sha256 ;;
|
||||||
|
ecdsa_nistp256_sha256) gen_ecdsa_nistp256_sha256 ;;
|
||||||
|
ecdsa_nistp384_sha384) gen_ecdsa_nistp384_sha384 ;;
|
||||||
|
*)
|
||||||
|
gen_ed25519
|
||||||
|
gen_rsa_sha256
|
||||||
|
gen_ecdsa_nistp256_sha256
|
||||||
|
gen_ecdsa_nistp384_sha384
|
||||||
|
;;
|
||||||
|
esac
|
30
web/private/rsa_sha256_cert.pem
Normal file
30
web/private/rsa_sha256_cert.pem
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFLDCCAxSgAwIBAgIUZ461KXDgTT25LP1S6PK6i9ZKtOYwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
|
||||||
|
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
|
||||||
|
MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK
|
||||||
|
DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQAD
|
||||||
|
ggIPADCCAgoCggIBAKl8Re1dneFB2obYPOuZf3d6BR2xGcMhr2vmHpnVgkeg/xGI
|
||||||
|
cwHhhB04mEg4TqbZ0Q1wdKN4ruNigdrQAXDqnm4y2IB1q/uTdoXVWNsvAZmDq4N4
|
||||||
|
rPUDWagpkilV4HOHDQOI27Lo/+30wJX6H8i3J6Nag1y9spuySkL1F1SgQng9uzvP
|
||||||
|
3SwbsO9R/jS7qTZNEtirnJv3qJlxmT4BCvBuWNALShFlSZYnZk/2csYXEaVkWMUE
|
||||||
|
rnWGmTmzMZEzDOdQdPC3sJDCPQbbgD4SnQANqhOVjPSdJ6Y6joN6XYtB2EP+6LJ8
|
||||||
|
UBTICETnUROWeKqMm215Gsen32cyWkaCwEWtTFn7rJHbPvUY4vPYQZAB2/h07lYq
|
||||||
|
v67W3r5lTq1KuoStD8M/7nCIYIuNhmJLMzzWrSfJrQpYexoMII6kjOxv2kiUgb1y
|
||||||
|
bYKnFlVf4up3pTpi2/0We4SHRgc7xTcEPNvU5z5hc5JwHZIFjmi5dx50ZoAULrXl
|
||||||
|
OUhfdUPut7H38UQg3riJiNAzedUiTubek26po8qH1iS/EMgAi5RboaBovjWhgjwq
|
||||||
|
P/RP0vzpln6OdQIRaRlY9vZiik9HbFybafuA2zEkyc+zc4HqJk3vqX/0hgd9qWeL
|
||||||
|
zgsHr6A86tNUXsUaoInQbzznjpbFE85klxd6HeqasOyVDCeDUs4lWoDNp0DbAgMB
|
||||||
|
AAGjGDAWMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAgEA
|
||||||
|
sS2TUKKYtNYC68TEQb5KAa8/zi6Lhz9lyn9rpBXAcAVeBdxCqrBU0nRN8EjXEff1
|
||||||
|
oBeau9Wi9PwdK6J+4xFuer9YrZZWuWLKUBXJL9ZXEfI+USo5WVz7v/puoKZSUSu2
|
||||||
|
+Ee4+v1eoAsCtaA8IR0Sh1KTc9kg1QZW1nIdBV0zgAERWTzm1iy2Ff+euowM/lUR
|
||||||
|
FczMAJsNq83O2kaH541fRx3gC2iMN/QLwRalBR2y8hTI5wQa0Yr8moC4IsTfRrKQ
|
||||||
|
/SZDjFT1XoA9TnrByIvGQNlxK1Rm2hvK1OQn4OL6sdYK6F+MxfUn/atQNyBQ6CF+
|
||||||
|
oKuvOnE2jces8GGZIU1jYJ2271SQkzp0CW3N1ZKjClSQFAYED+5ERTGyeEL4o3Vr
|
||||||
|
V/BUd0KC7HhbWBlFc2EDmPOoOTp/ID901Kf499M68pe2Fr/63/nCAON6WFk1NPYA
|
||||||
|
+sWMfS25hikU5rOHGQgXxXAz90pwpvpoLQvaq0/azsbHvq9B4ubdcLE0xcoK+DGq
|
||||||
|
+/aOeWlYkalWp93akPYpWN3UJXzDXzzagRID/1LEGS+Ssbh1WVhAhLKVoxzBvkgm
|
||||||
|
ozVAhR3zHXI2QpQ2FEbOmkcRtpxv2CiaTsgHg2MK8cdmPx3G+ufCZjRbQx/VXVdN
|
||||||
|
vaFRdmzr/5EQ7DT5Uy8w32h1to4Fm2sAtbAFYaFLXaM=
|
||||||
|
-----END CERTIFICATE-----
|
52
web/private/rsa_sha256_key.pem
Normal file
52
web/private/rsa_sha256_key.pem
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCpfEXtXZ3hQdqG
|
||||||
|
2DzrmX93egUdsRnDIa9r5h6Z1YJHoP8RiHMB4YQdOJhIOE6m2dENcHSjeK7jYoHa
|
||||||
|
0AFw6p5uMtiAdav7k3aF1VjbLwGZg6uDeKz1A1moKZIpVeBzhw0DiNuy6P/t9MCV
|
||||||
|
+h/ItyejWoNcvbKbskpC9RdUoEJ4Pbs7z90sG7DvUf40u6k2TRLYq5yb96iZcZk+
|
||||||
|
AQrwbljQC0oRZUmWJ2ZP9nLGFxGlZFjFBK51hpk5szGRMwznUHTwt7CQwj0G24A+
|
||||||
|
Ep0ADaoTlYz0nSemOo6Del2LQdhD/uiyfFAUyAhE51ETlniqjJtteRrHp99nMlpG
|
||||||
|
gsBFrUxZ+6yR2z71GOLz2EGQAdv4dO5WKr+u1t6+ZU6tSrqErQ/DP+5wiGCLjYZi
|
||||||
|
SzM81q0nya0KWHsaDCCOpIzsb9pIlIG9cm2CpxZVX+Lqd6U6Ytv9FnuEh0YHO8U3
|
||||||
|
BDzb1Oc+YXOScB2SBY5ouXcedGaAFC615TlIX3VD7rex9/FEIN64iYjQM3nVIk7m
|
||||||
|
3pNuqaPKh9YkvxDIAIuUW6GgaL41oYI8Kj/0T9L86ZZ+jnUCEWkZWPb2YopPR2xc
|
||||||
|
m2n7gNsxJMnPs3OB6iZN76l/9IYHfalni84LB6+gPOrTVF7FGqCJ0G88546WxRPO
|
||||||
|
ZJcXeh3qmrDslQwng1LOJVqAzadA2wIDAQABAoICABT4gHp/Q+K0UEKxDNCl/ISe
|
||||||
|
/3UODb78MwVpws2MAoO0YvsbZAeOjNdEwmrlNK4mc1xzVqtHanROIv0dEaCUFyhR
|
||||||
|
eEJkzPPi6h5jKIxuQ4doKFerHdNvJ6/L/P7KVmxVAII4c96uP8SErTOhcD9Ykjn/
|
||||||
|
IBPgkPH83H1ucAWTksXn9XvQG3CyuHDUN1z0/1ntrXBLw6P0v9LEoI5weJcJQEn1
|
||||||
|
q6N9Yd6HX3xzZP4nqpJJWUZ/bsqx7dGa3340z9rrNJz4TYuLzRtFG5gSm4R/LFUi
|
||||||
|
Av/dViOWST3xbROnAQhgyRAUm6AGpCdKa9i9nI6VuUGRY4PivJy7OTpSQVIdwD2K
|
||||||
|
VFbFauCmsTY7B+Z+DXe9JJV+Tlazvlwop1DApxCgLMNr2pVB/1yPzMrNYDB4Sg1c
|
||||||
|
T3stu4A8PtljTykla2C2LPFrdIijvYv9n6PSPWV4vf1ix9uYs99FhZPQJMgm+CDr
|
||||||
|
n3cFfuofIWIRJpCuu3hn4woGgW2bXor4pyMNg1yCp1T2c8g6eJUYAAIRpse0HDbT
|
||||||
|
ZUifxlNBx819bf9aqv+lhwMU4UFlmWMv3NETcCBqgUn+z4scNJ3kZwO9ZodLRblK
|
||||||
|
SpalZ6SNejTnVukg3zbwJG8w5vIrJvfjBkikAL1O5X6Kn3YqfrXAl38bIvA50ZBe
|
||||||
|
eOFcgDPClLPGlNKxQXiZAoIBAQDh0aArTjWi8Kxt2Ga3Hu4jQ7IXklL9osGAlFCB
|
||||||
|
wZs831/ktLY0c7vY+mbwrY4AtqK21A00MrNSTsp/McHwAUi410DdcTqzkGnm9BBZ
|
||||||
|
FKVO1H9KGg2t344tOXcPsFTl6SR5a0aPqagyvgdH+YWC4ccfYZWMcLELn3sOGoEp
|
||||||
|
a7VBxjLZZ+/b+/oIzhwxEaBi8N0U3IZ3SsC9Lmojnb9+LMwGs14TFfahD3uM1hnU
|
||||||
|
vww1elwPwXafWc+7Ej1bXijWBXbyzkCITzHafmDsAatEZnemHL8zKKtTn2aSTUSj
|
||||||
|
Fl/y94WR7/V4nVEQ0R3fjGC0rKh08BtKzxPjYBqjT5aI7+yfAoIBAQDAIzROr00o
|
||||||
|
65auD5TB2k/pSVKQpB/U6ESGafDxg4a+R9Ng2ESmDN5hUUZDefm0Uxf9AZqS6eno
|
||||||
|
GSAqxNDixWPy2WFzbZ0mUCoyQn+Eh09WBvt0IqDZ2+ngBEXiKA/8dsvXinBHLuHV
|
||||||
|
u+rJdLMzPfhWVo1/iXq7SOjBvDuthKROku5BEt6Q3Cs0nk6uneRIqKtaqa7KIInF
|
||||||
|
BPtUifZtCP09h6iFFGNv2g2OYRYDg8TXvjt3scKy0DUC3kTeTCPvNvHaOObGbMRU
|
||||||
|
Q85HkXburxWfsMto5m/lRsbY3COxddB47WIQ6QNewESfCab/R2lOdlXQeaH8fuxT
|
||||||
|
wWC5DMz4F0ZFAoIBAFm+EjZDnaNEnHIHB0MNIryXAabGev7beKUdzCTVCVmWuChO
|
||||||
|
/P45ZFTlppVNk9qKun2IJjsxTvyN3YHRB27XQ8xZlyiqABcudDfZlMmiH9QFNRUA
|
||||||
|
56DK8Fjetodgn0zDa8BpNqCPXw3TYVdkPX/3NEgvYtxuSJ4C4keHlv8cE+uw1bJ6
|
||||||
|
0OMO754iMyf5BlFrwaCxxyqPZauJT5sZ7Ok66lZbYC6bkukNGx+sUpWu2y5Bk2ab
|
||||||
|
jwXjDmAc7o9qCzaK82upNhI1zu0zPldsjmDfi/tS/1VYe0X/WicYWAesM7N+VPHb
|
||||||
|
eCVX98iEIqgdxKzo1QWsClyfkRrSraNrVLrVBqcCggEAaLIGK6YMNnMBPUGSPnt2
|
||||||
|
NdlVWymDiuExjcimmQOhZYf/33KZHZ4/gunljpklfqQUmzHHh6xcX7NpOsTaSedj
|
||||||
|
Sg43ss0U566g/5gKoi2VBnxxglvoKC5T51SMu+o2o8wb0QxHmBIszulBy5qClzZ6
|
||||||
|
Xpl1Kvy/2tOkuQSXxDpVydb4ao8cpfTCuj5VA4NXxFvcW1/AtbU7PRc02GEA3XMb
|
||||||
|
gu6r3jA46tb3shCnDS09Eo4/Gz7Kp+MaL8Dr5/G3Vv8qlE2TOqZD6OK1wXu7Qd43
|
||||||
|
uzd772I5sMZ7TenOrUFUYsB/QlWmF3hPLBX3YH0KHc4PfrT4lnyWzCDAUrVt7vXH
|
||||||
|
vQKCAQEAz1Q+jW+NG7CAqkOJ19n+KmGn+zddvy8x4txKx8kV0+3XIVNkseS6IT65
|
||||||
|
uemD85Gn7MYwHxcKUHghHSKyJsvCb1vSmB3JtCnrsodmQNMb6Lsq89VlM8Ylc/s3
|
||||||
|
F4AraiOlylqcEwLkanD3XSfPdQZhExZHEtpAW/Rr6zYT9J94VO1nK3+RkFI4a8kl
|
||||||
|
pEbY+tqJj3LGTuaQkshB9rDtcBT5/JsaxlMk783qkKziCPZF47BWqrJb+t7tm3qg
|
||||||
|
5gf+QUInEjdW3k3uZBL4316RP/TJlLvo29PkEwoC8X4R2EgDeHQhhRC2Fi2Wpy4O
|
||||||
|
ce4G+zZOOYXwvWGJLwNhgsve8C3oqg==
|
||||||
|
-----END PRIVATE KEY-----
|
49
web/src/consts.rs
Normal file
49
web/src/consts.rs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/token";
|
||||||
|
pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize";
|
||||||
|
pub const DISCORD_API: &'static str = "https://discord.com/api";
|
||||||
|
|
||||||
|
pub const MAX_NAME_LENGTH: usize = 100;
|
||||||
|
pub const MAX_CONTENT_LENGTH: usize = 2000;
|
||||||
|
pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096;
|
||||||
|
pub const MAX_EMBED_TITLE_LENGTH: usize = 256;
|
||||||
|
pub const MAX_EMBED_AUTHOR_LENGTH: usize = 256;
|
||||||
|
pub const MAX_EMBED_FOOTER_LENGTH: usize = 2048;
|
||||||
|
pub const MAX_URL_LENGTH: usize = 512;
|
||||||
|
pub const MAX_USERNAME_LENGTH: usize = 100;
|
||||||
|
pub const MAX_EMBED_FIELDS: usize = 25;
|
||||||
|
pub const MAX_EMBED_FIELD_TITLE_LENGTH: usize = 256;
|
||||||
|
pub const MAX_EMBED_FIELD_VALUE_LENGTH: usize = 1024;
|
||||||
|
|
||||||
|
pub const MINUTE: usize = 60;
|
||||||
|
pub const HOUR: usize = 60 * MINUTE;
|
||||||
|
pub const DAY: usize = 24 * HOUR;
|
||||||
|
|
||||||
|
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
|
||||||
|
|
||||||
|
use std::{collections::HashSet, env, iter::FromIterator};
|
||||||
|
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use serenity::model::prelude::AttachmentType;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
|
||||||
|
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/webhook.jpg")) as &[u8],
|
||||||
|
"webhook.jpg",
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
||||||
|
env::var("PATREON_ROLE_ID")
|
||||||
|
.map(|var| var
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|item| { item.parse::<u64>().ok() })
|
||||||
|
.collect::<Vec<u64>>())
|
||||||
|
.unwrap_or_else(|_| Vec::new())
|
||||||
|
);
|
||||||
|
pub static ref CNC_GUILD: Option<u64> =
|
||||||
|
env::var("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten();
|
||||||
|
pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL")
|
||||||
|
.ok()
|
||||||
|
.map(|inner| inner.parse::<u32>().ok())
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or(600);
|
||||||
|
}
|
216
web/src/lib.rs
Normal file
216
web/src/lib.rs
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
#[macro_use]
|
||||||
|
extern crate rocket;
|
||||||
|
|
||||||
|
mod consts;
|
||||||
|
#[macro_use]
|
||||||
|
mod macros;
|
||||||
|
mod routes;
|
||||||
|
|
||||||
|
use std::{collections::HashMap, env, path::Path};
|
||||||
|
|
||||||
|
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
|
||||||
|
use rocket::{
|
||||||
|
fs::FileServer,
|
||||||
|
serde::json::{json, Value as JsonValue},
|
||||||
|
tokio::sync::broadcast::Sender,
|
||||||
|
};
|
||||||
|
use rocket_dyn_templates::Template;
|
||||||
|
use serenity::{
|
||||||
|
client::Context,
|
||||||
|
http::CacheHttp,
|
||||||
|
model::id::{GuildId, UserId},
|
||||||
|
};
|
||||||
|
use sqlx::{MySql, Pool};
|
||||||
|
|
||||||
|
use crate::consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES};
|
||||||
|
|
||||||
|
type Database = MySql;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum Error {
|
||||||
|
SQLx(sqlx::Error),
|
||||||
|
Serenity(serenity::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(401)]
|
||||||
|
async fn not_authorized() -> Template {
|
||||||
|
let map: HashMap<String, String> = HashMap::new();
|
||||||
|
Template::render("errors/401", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(403)]
|
||||||
|
async fn forbidden() -> Template {
|
||||||
|
let map: HashMap<String, String> = HashMap::new();
|
||||||
|
Template::render("errors/403", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(404)]
|
||||||
|
async fn not_found() -> Template {
|
||||||
|
let map: HashMap<String, String> = HashMap::new();
|
||||||
|
Template::render("errors/404", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(413)]
|
||||||
|
async fn payload_too_large() -> JsonValue {
|
||||||
|
json!({"error": "Data too large.", "errors": ["Data too large."]})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(422)]
|
||||||
|
async fn unprocessable_entity() -> JsonValue {
|
||||||
|
json!({"error": "Invalid request.", "errors": ["Invalid request."]})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(500)]
|
||||||
|
async fn internal_server_error() -> Template {
|
||||||
|
let map: HashMap<String, String> = HashMap::new();
|
||||||
|
Template::render("errors/500", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn initialize(
|
||||||
|
kill_channel: Sender<()>,
|
||||||
|
serenity_context: Context,
|
||||||
|
db_pool: Pool<Database>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
info!("Checking environment variables...");
|
||||||
|
env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied");
|
||||||
|
env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied");
|
||||||
|
env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied");
|
||||||
|
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied");
|
||||||
|
info!("Done!");
|
||||||
|
|
||||||
|
let oauth2_client = BasicClient::new(
|
||||||
|
ClientId::new(env::var("OAUTH2_CLIENT_ID")?),
|
||||||
|
Some(ClientSecret::new(env::var("OAUTH2_CLIENT_SECRET")?)),
|
||||||
|
AuthUrl::new(DISCORD_OAUTH_AUTHORIZE.to_string())?,
|
||||||
|
Some(TokenUrl::new(DISCORD_OAUTH_TOKEN.to_string())?),
|
||||||
|
)
|
||||||
|
.set_redirect_uri(RedirectUrl::new(env::var("OAUTH2_DISCORD_CALLBACK")?)?);
|
||||||
|
|
||||||
|
let reqwest_client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let static_path =
|
||||||
|
if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" };
|
||||||
|
|
||||||
|
rocket::build()
|
||||||
|
.attach(Template::fairing())
|
||||||
|
.register(
|
||||||
|
"/",
|
||||||
|
catchers![
|
||||||
|
not_authorized,
|
||||||
|
forbidden,
|
||||||
|
not_found,
|
||||||
|
internal_server_error,
|
||||||
|
unprocessable_entity,
|
||||||
|
payload_too_large,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.manage(oauth2_client)
|
||||||
|
.manage(reqwest_client)
|
||||||
|
.manage(serenity_context)
|
||||||
|
.manage(db_pool)
|
||||||
|
.mount("/static", FileServer::from(static_path))
|
||||||
|
.mount(
|
||||||
|
"/",
|
||||||
|
routes![
|
||||||
|
routes::index,
|
||||||
|
routes::cookies,
|
||||||
|
routes::privacy,
|
||||||
|
routes::terms,
|
||||||
|
routes::return_to_same_site,
|
||||||
|
routes::report::report_error,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.mount(
|
||||||
|
"/help",
|
||||||
|
routes![
|
||||||
|
routes::help,
|
||||||
|
routes::help_timezone,
|
||||||
|
routes::help_create_reminder,
|
||||||
|
routes::help_delete_reminder,
|
||||||
|
routes::help_timers,
|
||||||
|
routes::help_todo_lists,
|
||||||
|
routes::help_macros,
|
||||||
|
routes::help_intervals,
|
||||||
|
routes::help_dashboard,
|
||||||
|
routes::help_iemanager,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.mount(
|
||||||
|
"/login",
|
||||||
|
routes![
|
||||||
|
routes::login::discord_login,
|
||||||
|
routes::login::discord_logout,
|
||||||
|
routes::login::discord_callback
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.mount(
|
||||||
|
"/dashboard",
|
||||||
|
routes![
|
||||||
|
routes::dashboard::dashboard,
|
||||||
|
routes::dashboard::dashboard_home,
|
||||||
|
routes::dashboard::user::get_user_info,
|
||||||
|
routes::dashboard::user::update_user_info,
|
||||||
|
routes::dashboard::user::get_user_guilds,
|
||||||
|
routes::dashboard::guild::get_guild_patreon,
|
||||||
|
routes::dashboard::guild::get_guild_channels,
|
||||||
|
routes::dashboard::guild::get_guild_roles,
|
||||||
|
routes::dashboard::guild::get_reminder_templates,
|
||||||
|
routes::dashboard::guild::create_reminder_template,
|
||||||
|
routes::dashboard::guild::delete_reminder_template,
|
||||||
|
routes::dashboard::guild::create_guild_reminder,
|
||||||
|
routes::dashboard::guild::get_reminders,
|
||||||
|
routes::dashboard::guild::edit_reminder,
|
||||||
|
routes::dashboard::guild::delete_reminder,
|
||||||
|
routes::dashboard::export::export_reminders,
|
||||||
|
routes::dashboard::export::export_reminder_templates,
|
||||||
|
routes::dashboard::export::export_todos,
|
||||||
|
routes::dashboard::export::import_reminders,
|
||||||
|
routes::dashboard::export::import_todos,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.mount("/admin", routes![routes::admin::admin_dashboard_home, routes::admin::bot_data])
|
||||||
|
.launch()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
warn!("Exiting rocket runtime");
|
||||||
|
// distribute kill signal
|
||||||
|
match kill_channel.send(()) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to issue kill signal: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
|
||||||
|
if let Some(subscription_guild) = *CNC_GUILD {
|
||||||
|
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
|
||||||
|
|
||||||
|
if let Ok(member) = guild_member {
|
||||||
|
for role in member.roles {
|
||||||
|
if SUBSCRIPTION_ROLES.contains(role.as_u64()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_guild_subscription(
|
||||||
|
cache_http: impl CacheHttp,
|
||||||
|
guild_id: impl Into<GuildId>,
|
||||||
|
) -> bool {
|
||||||
|
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
|
||||||
|
let owner = guild.owner_id;
|
||||||
|
|
||||||
|
check_subscription(&cache_http, owner).await
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
139
web/src/macros.rs
Normal file
139
web/src/macros.rs
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
macro_rules! check_length {
|
||||||
|
($max:ident, $field:expr) => {
|
||||||
|
if $field.len() > $max {
|
||||||
|
return Err(json!({ "error": format!("{} exceeded", stringify!($max)) }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
($max:ident, $field:expr, $($fields:expr),+) => {
|
||||||
|
check_length!($max, $field);
|
||||||
|
check_length!($max, $($fields),+);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! check_length_opt {
|
||||||
|
($max:ident, $field:expr) => {
|
||||||
|
if let Some(field) = &$field {
|
||||||
|
check_length!($max, field);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
($max:ident, $field:expr, $($fields:expr),+) => {
|
||||||
|
check_length_opt!($max, $field);
|
||||||
|
check_length_opt!($max, $($fields),+);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! check_url {
|
||||||
|
($field:expr) => {
|
||||||
|
if !($field.starts_with("http://") || $field.starts_with("https://")) {
|
||||||
|
return Err(json!({ "error": "URL invalid" }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
($field:expr, $($fields:expr),+) => {
|
||||||
|
check_url!($max, $field);
|
||||||
|
check_url!($max, $($fields),+);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! check_url_opt {
|
||||||
|
($field:expr) => {
|
||||||
|
if let Some(field) = &$field {
|
||||||
|
check_url!(field);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
($field:expr, $($fields:expr),+) => {
|
||||||
|
check_url_opt!($field);
|
||||||
|
check_url_opt!($($fields),+);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! check_authorization {
|
||||||
|
($cookies:expr, $ctx:expr, $guild:expr) => {
|
||||||
|
use serenity::model::id::UserId;
|
||||||
|
|
||||||
|
let user_id = $cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
|
||||||
|
|
||||||
|
match user_id {
|
||||||
|
Some(user_id) => {
|
||||||
|
match GuildId($guild).to_guild_cached($ctx) {
|
||||||
|
Some(guild) => {
|
||||||
|
let member_res = guild.member($ctx, UserId(user_id)).await;
|
||||||
|
|
||||||
|
match member_res {
|
||||||
|
Err(_) => {
|
||||||
|
return Err(json!({"error": "User not in guild"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(member) => {
|
||||||
|
let permissions_res = member.permissions($ctx);
|
||||||
|
|
||||||
|
match permissions_res {
|
||||||
|
Err(_) => {
|
||||||
|
return Err(json!({"error": "Couldn't fetch permissions"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(permissions) => {
|
||||||
|
if !(permissions.manage_messages() || permissions.manage_guild() || permissions.administrator()) {
|
||||||
|
return Err(json!({"error": "Incorrect permissions"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None => {
|
||||||
|
return Err(json!({"error": "Bot not in guild"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None => {
|
||||||
|
return Err(json!({"error": "User not authorized"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! update_field {
|
||||||
|
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
|
||||||
|
if let Some(value) = &$reminder.$field {
|
||||||
|
match sqlx::query(concat!(
|
||||||
|
"UPDATE reminders SET `",
|
||||||
|
stringify!($field),
|
||||||
|
"` = ? WHERE uid = ?"
|
||||||
|
))
|
||||||
|
.bind(value)
|
||||||
|
.bind(&$reminder.uid)
|
||||||
|
.execute($pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
concat!(
|
||||||
|
"Error in `update_field!(",
|
||||||
|
stringify!($pool),
|
||||||
|
stringify!($reminder),
|
||||||
|
stringify!($field),
|
||||||
|
")': {:?}"
|
||||||
|
),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
|
||||||
|
$error.push(format!("Error setting field {}", stringify!($field)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
($pool:expr, $error:ident, $reminder:ident.[$field:ident, $($fields:ident),+]) => {
|
||||||
|
update_field!($pool, $error, $reminder.[$field]);
|
||||||
|
update_field!($pool, $error, $reminder.[$($fields),+]);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! json_err {
|
||||||
|
($message:expr) => {
|
||||||
|
Err(json!({ "error": $message }))
|
||||||
|
};
|
||||||
|
}
|
218
web/src/routes/admin.rs
Normal file
218
web/src/routes/admin.rs
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
use std::{collections::HashMap, env};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use rocket::{
|
||||||
|
http::{CookieJar, Status},
|
||||||
|
serde::json::json,
|
||||||
|
State,
|
||||||
|
};
|
||||||
|
use rocket_dyn_templates::Template;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::{MySql, Pool};
|
||||||
|
|
||||||
|
use crate::routes::JsonResult;
|
||||||
|
|
||||||
|
fn is_admin(cookies: &CookieJar<'_>) -> bool {
|
||||||
|
cookies
|
||||||
|
.get_private("userid")
|
||||||
|
.map_or(false, |cookie| Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/")]
|
||||||
|
pub async fn admin_dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Status> {
|
||||||
|
if let Some(cookie) = cookies.get_private("userid") {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
if Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok() {
|
||||||
|
Ok(Template::render("admin_dashboard", &map))
|
||||||
|
} else {
|
||||||
|
Err(Status::Forbidden)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(Status::Unauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct TimeFrame {
|
||||||
|
time_key: DateTime<Utc>,
|
||||||
|
count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/data")]
|
||||||
|
pub async fn bot_data(cookies: &CookieJar<'_>, pool: &State<Pool<MySql>>) -> JsonResult {
|
||||||
|
if !is_admin(cookies) {
|
||||||
|
return json_err!("Not authorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
let backlog = sqlx::query!(
|
||||||
|
"SELECT COUNT(1) AS backlog FROM reminders WHERE `utc_time` < NOW() AND enabled = 1 AND `status` = 'pending'"
|
||||||
|
)
|
||||||
|
.fetch_one(pool.inner())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let schedule_once = sqlx::query_as_unchecked!(
|
||||||
|
TimeFrame,
|
||||||
|
"SELECT
|
||||||
|
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`,
|
||||||
|
COUNT(1) AS `count`
|
||||||
|
FROM reminders
|
||||||
|
WHERE
|
||||||
|
`utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND
|
||||||
|
`utc_time` >= NOW() AND
|
||||||
|
`enabled` = 1 AND
|
||||||
|
`status` = 'pending' AND
|
||||||
|
`interval_seconds` IS NULL AND
|
||||||
|
`interval_months` IS NULL AND
|
||||||
|
`interval_days` IS NULL
|
||||||
|
GROUP BY `time_key`
|
||||||
|
ORDER BY `time_key`"
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let schedule_interval = sqlx::query_as_unchecked!(
|
||||||
|
TimeFrame,
|
||||||
|
"SELECT
|
||||||
|
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`,
|
||||||
|
COUNT(1) AS `count`
|
||||||
|
FROM reminders
|
||||||
|
WHERE
|
||||||
|
`utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND
|
||||||
|
`utc_time` >= NOW() AND
|
||||||
|
`status` = 'pending' AND
|
||||||
|
`enabled` = 1 AND (
|
||||||
|
`interval_seconds` IS NOT NULL OR
|
||||||
|
`interval_months` IS NOT NULL OR
|
||||||
|
`interval_days` IS NOT NULL
|
||||||
|
)
|
||||||
|
GROUP BY `time_key`
|
||||||
|
ORDER BY `time_key`"
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let schedule_once_long = sqlx::query_as_unchecked!(
|
||||||
|
TimeFrame,
|
||||||
|
"SELECT
|
||||||
|
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
|
||||||
|
COUNT(1) AS `count`
|
||||||
|
FROM reminders
|
||||||
|
WHERE
|
||||||
|
`utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND
|
||||||
|
`utc_time` >= NOW() AND
|
||||||
|
`enabled` = 1 AND
|
||||||
|
`status` = 'pending' AND
|
||||||
|
`interval_seconds` IS NULL AND
|
||||||
|
`interval_months` IS NULL AND
|
||||||
|
`interval_days` IS NULL
|
||||||
|
GROUP BY `time_key`
|
||||||
|
ORDER BY `time_key`"
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let schedule_interval_long = sqlx::query_as_unchecked!(
|
||||||
|
TimeFrame,
|
||||||
|
"SELECT
|
||||||
|
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
|
||||||
|
COUNT(1) AS `count`
|
||||||
|
FROM reminders
|
||||||
|
WHERE
|
||||||
|
`utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND
|
||||||
|
`utc_time` >= NOW() AND
|
||||||
|
`status` = 'pending' AND
|
||||||
|
`enabled` = 1 AND (
|
||||||
|
`interval_seconds` IS NOT NULL OR
|
||||||
|
`interval_months` IS NOT NULL OR
|
||||||
|
`interval_days` IS NOT NULL
|
||||||
|
)
|
||||||
|
GROUP BY `time_key`
|
||||||
|
ORDER BY `time_key`"
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let history = sqlx::query_as_unchecked!(
|
||||||
|
TimeFrame,
|
||||||
|
"SELECT
|
||||||
|
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
|
||||||
|
COUNT(1) AS `count`
|
||||||
|
FROM stat
|
||||||
|
WHERE
|
||||||
|
`utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND
|
||||||
|
`type` = 'reminder_sent'
|
||||||
|
GROUP BY `time_key`
|
||||||
|
ORDER BY `time_key`"
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let history_failed = sqlx::query_as_unchecked!(
|
||||||
|
TimeFrame,
|
||||||
|
"SELECT
|
||||||
|
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
|
||||||
|
COUNT(1) AS `count`
|
||||||
|
FROM stat
|
||||||
|
WHERE
|
||||||
|
`utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND
|
||||||
|
`type` = 'reminder_failed'
|
||||||
|
GROUP BY `time_key`
|
||||||
|
ORDER BY `time_key`"
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let interval_count = sqlx::query!(
|
||||||
|
"SELECT COUNT(1) AS count
|
||||||
|
FROM reminders
|
||||||
|
WHERE
|
||||||
|
`status` = 'pending' AND (
|
||||||
|
`interval_seconds` IS NOT NULL OR
|
||||||
|
`interval_months` IS NOT NULL OR
|
||||||
|
`interval_days` IS NOT NULL
|
||||||
|
)"
|
||||||
|
)
|
||||||
|
.fetch_one(pool.inner())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let reminder_count = sqlx::query!(
|
||||||
|
"SELECT COUNT(1) AS count
|
||||||
|
FROM reminders
|
||||||
|
WHERE
|
||||||
|
`status` = 'pending' AND
|
||||||
|
`interval_seconds` IS NULL AND
|
||||||
|
`interval_months` IS NULL AND
|
||||||
|
`interval_days` IS NULL"
|
||||||
|
)
|
||||||
|
.fetch_one(pool.inner())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"backlog": backlog.backlog,
|
||||||
|
"scheduleShort": {
|
||||||
|
"once": schedule_once,
|
||||||
|
"interval": schedule_interval
|
||||||
|
},
|
||||||
|
"scheduleLong": {
|
||||||
|
"once": schedule_once_long,
|
||||||
|
"interval": schedule_interval_long,
|
||||||
|
},
|
||||||
|
"historyLong": {
|
||||||
|
"sent": history,
|
||||||
|
"failed": history_failed,
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"reminders": reminder_count.count,
|
||||||
|
"intervals": interval_count.count,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
428
web/src/routes/dashboard/export.rs
Normal file
428
web/src/routes/dashboard/export.rs
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
use csv::{QuoteStyle, WriterBuilder};
|
||||||
|
use rocket::{
|
||||||
|
http::CookieJar,
|
||||||
|
serde::json::{json, serde_json, Json},
|
||||||
|
State,
|
||||||
|
};
|
||||||
|
use serenity::{
|
||||||
|
client::Context,
|
||||||
|
model::id::{ChannelId, GuildId},
|
||||||
|
};
|
||||||
|
use sqlx::{MySql, Pool};
|
||||||
|
|
||||||
|
use crate::routes::{
|
||||||
|
dashboard::{
|
||||||
|
create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv,
|
||||||
|
TodoCsv,
|
||||||
|
},
|
||||||
|
JsonResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[get("/api/guild/<id>/export/reminders")]
|
||||||
|
pub async fn export_reminders(
|
||||||
|
id: u64,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
|
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
||||||
|
|
||||||
|
let channels_res = GuildId(id).channels(&ctx.inner()).await;
|
||||||
|
|
||||||
|
match channels_res {
|
||||||
|
Ok(channels) => {
|
||||||
|
let channels = channels
|
||||||
|
.keys()
|
||||||
|
.into_iter()
|
||||||
|
.map(|k| k.as_u64().to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
let result = sqlx::query_as_unchecked!(
|
||||||
|
ReminderCsv,
|
||||||
|
"SELECT
|
||||||
|
reminders.attachment,
|
||||||
|
reminders.attachment_name,
|
||||||
|
reminders.avatar,
|
||||||
|
CONCAT('#', channels.channel) AS channel,
|
||||||
|
reminders.content,
|
||||||
|
reminders.embed_author,
|
||||||
|
reminders.embed_author_url,
|
||||||
|
reminders.embed_color,
|
||||||
|
reminders.embed_description,
|
||||||
|
reminders.embed_footer,
|
||||||
|
reminders.embed_footer_url,
|
||||||
|
reminders.embed_image_url,
|
||||||
|
reminders.embed_thumbnail_url,
|
||||||
|
reminders.embed_title,
|
||||||
|
reminders.embed_fields,
|
||||||
|
reminders.enabled,
|
||||||
|
reminders.expires,
|
||||||
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
|
reminders.interval_months,
|
||||||
|
reminders.name,
|
||||||
|
reminders.restartable,
|
||||||
|
reminders.tts,
|
||||||
|
reminders.username,
|
||||||
|
reminders.utc_time
|
||||||
|
FROM reminders
|
||||||
|
LEFT JOIN channels ON channels.id = reminders.channel_id
|
||||||
|
WHERE FIND_IN_SET(channels.channel, ?)",
|
||||||
|
channels
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(reminders) => {
|
||||||
|
reminders.iter().for_each(|reminder| {
|
||||||
|
csv_writer.serialize(reminder).unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
match csv_writer.into_inner() {
|
||||||
|
Ok(inner) => match String::from_utf8(inner) {
|
||||||
|
Ok(encoded) => Ok(json!({ "body": encoded })),
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to write UTF-8: {:?}", e);
|
||||||
|
|
||||||
|
Err(json!({"error": "Failed to write UTF-8"}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to extract CSV: {:?}", e);
|
||||||
|
|
||||||
|
Err(json!({"error": "Failed to extract CSV"}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to complete SQL query: {:?}", e);
|
||||||
|
|
||||||
|
Err(json!({"error": "Failed to query reminders"}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Could not fetch channels from {}: {:?}", id, e);
|
||||||
|
|
||||||
|
Err(json!({"error": "Failed to get guild channels"}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[put("/api/guild/<id>/export/reminders", data = "<body>")]
|
||||||
|
pub async fn import_reminders(
|
||||||
|
id: u64,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
body: Json<ImportBody>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
|
let user_id =
|
||||||
|
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
||||||
|
|
||||||
|
match base64::decode(&body.body) {
|
||||||
|
Ok(body) => {
|
||||||
|
let mut reader = csv::Reader::from_reader(body.as_slice());
|
||||||
|
|
||||||
|
for result in reader.deserialize::<ReminderCsv>() {
|
||||||
|
match result {
|
||||||
|
Ok(record) => {
|
||||||
|
let channel_id = record.channel.split_at(1).1;
|
||||||
|
|
||||||
|
match channel_id.parse::<u64>() {
|
||||||
|
Ok(channel_id) => {
|
||||||
|
let reminder = Reminder {
|
||||||
|
attachment: record.attachment,
|
||||||
|
attachment_name: record.attachment_name,
|
||||||
|
avatar: record.avatar,
|
||||||
|
channel: channel_id,
|
||||||
|
content: record.content,
|
||||||
|
embed_author: record.embed_author,
|
||||||
|
embed_author_url: record.embed_author_url,
|
||||||
|
embed_color: record.embed_color,
|
||||||
|
embed_description: record.embed_description,
|
||||||
|
embed_footer: record.embed_footer,
|
||||||
|
embed_footer_url: record.embed_footer_url,
|
||||||
|
embed_image_url: record.embed_image_url,
|
||||||
|
embed_thumbnail_url: record.embed_thumbnail_url,
|
||||||
|
embed_title: record.embed_title,
|
||||||
|
embed_fields: record
|
||||||
|
.embed_fields
|
||||||
|
.map(|s| serde_json::from_str(&s).ok())
|
||||||
|
.flatten(),
|
||||||
|
enabled: record.enabled,
|
||||||
|
expires: record.expires,
|
||||||
|
interval_seconds: record.interval_seconds,
|
||||||
|
interval_days: record.interval_days,
|
||||||
|
interval_months: record.interval_months,
|
||||||
|
name: record.name,
|
||||||
|
restartable: record.restartable,
|
||||||
|
tts: record.tts,
|
||||||
|
uid: generate_uid(),
|
||||||
|
username: record.username,
|
||||||
|
utc_time: record.utc_time,
|
||||||
|
};
|
||||||
|
|
||||||
|
create_reminder(
|
||||||
|
ctx.inner(),
|
||||||
|
pool.inner(),
|
||||||
|
GuildId(id),
|
||||||
|
UserId(user_id),
|
||||||
|
reminder,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(_) => {
|
||||||
|
return json_err!(format!(
|
||||||
|
"Failed to parse channel {}",
|
||||||
|
channel_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Couldn't deserialize CSV row: {:?}", e);
|
||||||
|
|
||||||
|
return json_err!("Deserialize error. Aborted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(_) => {
|
||||||
|
json_err!("Malformed base64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/guild/<id>/export/todos")]
|
||||||
|
pub async fn export_todos(
|
||||||
|
id: u64,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
|
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
||||||
|
|
||||||
|
match sqlx::query_as_unchecked!(
|
||||||
|
TodoCsv,
|
||||||
|
"SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos
|
||||||
|
LEFT JOIN channels ON todos.channel_id = channels.id
|
||||||
|
INNER JOIN guilds ON todos.guild_id = guilds.id
|
||||||
|
WHERE guilds.guild = ?",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(todos) => {
|
||||||
|
todos.iter().for_each(|todo| {
|
||||||
|
csv_writer.serialize(todo).unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
match csv_writer.into_inner() {
|
||||||
|
Ok(inner) => match String::from_utf8(inner) {
|
||||||
|
Ok(encoded) => Ok(json!({ "body": encoded })),
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to write UTF-8: {:?}", e);
|
||||||
|
|
||||||
|
json_err!("Failed to write UTF-8")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to extract CSV: {:?}", e);
|
||||||
|
|
||||||
|
json_err!("Failed to extract CSV")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Could not fetch templates from {}: {:?}", id, e);
|
||||||
|
|
||||||
|
json_err!("Failed to query templates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[put("/api/guild/<id>/export/todos", data = "<body>")]
|
||||||
|
pub async fn import_todos(
|
||||||
|
id: u64,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
body: Json<ImportBody>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
|
let channels_res = GuildId(id).channels(&ctx.inner()).await;
|
||||||
|
|
||||||
|
match channels_res {
|
||||||
|
Ok(channels) => match base64::decode(&body.body) {
|
||||||
|
Ok(body) => {
|
||||||
|
let mut reader = csv::Reader::from_reader(body.as_slice());
|
||||||
|
|
||||||
|
let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?))";
|
||||||
|
let mut query_params = vec![];
|
||||||
|
|
||||||
|
for result in reader.deserialize::<TodoCsv>() {
|
||||||
|
match result {
|
||||||
|
Ok(record) => match record.channel_id {
|
||||||
|
Some(channel_id) => {
|
||||||
|
let channel_id = channel_id.split_at(1).1;
|
||||||
|
|
||||||
|
match channel_id.parse::<u64>() {
|
||||||
|
Ok(channel_id) => {
|
||||||
|
if channels.contains_key(&ChannelId(channel_id)) {
|
||||||
|
query_params.push((record.value, Some(channel_id), id));
|
||||||
|
} else {
|
||||||
|
return json_err!(format!(
|
||||||
|
"Invalid channel ID {}",
|
||||||
|
channel_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(_) => {
|
||||||
|
return json_err!(format!(
|
||||||
|
"Invalid channel ID {}",
|
||||||
|
channel_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None => {
|
||||||
|
query_params.push((record.value, None, id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Couldn't deserialize CSV row: {:?}", e);
|
||||||
|
|
||||||
|
return json_err!("Deserialize error. Aborted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let query_str = format!(
|
||||||
|
"INSERT INTO todos (value, channel_id, guild_id) VALUES {}",
|
||||||
|
vec![query_placeholder].repeat(query_params.len()).join(",")
|
||||||
|
);
|
||||||
|
let mut query = sqlx::query(&query_str);
|
||||||
|
|
||||||
|
for param in query_params {
|
||||||
|
query = query.bind(param.0).bind(param.1).bind(param.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = query.execute(pool.inner()).await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(_) => Ok(json!({})),
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Couldn't execute todo query: {:?}", e);
|
||||||
|
|
||||||
|
json_err!("An unexpected error occured.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(_) => {
|
||||||
|
json_err!("Malformed base64")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Couldn't fetch channels for guild {}: {:?}", id, e);
|
||||||
|
|
||||||
|
json_err!("Couldn't fetch channels.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/guild/<id>/export/reminder_templates")]
|
||||||
|
pub async fn export_reminder_templates(
|
||||||
|
id: u64,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
|
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
||||||
|
|
||||||
|
match sqlx::query_as_unchecked!(
|
||||||
|
ReminderTemplateCsv,
|
||||||
|
"SELECT
|
||||||
|
name,
|
||||||
|
attachment,
|
||||||
|
attachment_name,
|
||||||
|
avatar,
|
||||||
|
content,
|
||||||
|
embed_author,
|
||||||
|
embed_author_url,
|
||||||
|
embed_color,
|
||||||
|
embed_description,
|
||||||
|
embed_footer,
|
||||||
|
embed_footer_url,
|
||||||
|
embed_image_url,
|
||||||
|
embed_thumbnail_url,
|
||||||
|
embed_title,
|
||||||
|
embed_fields,
|
||||||
|
tts,
|
||||||
|
username
|
||||||
|
FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(templates) => {
|
||||||
|
templates.iter().for_each(|template| {
|
||||||
|
csv_writer.serialize(template).unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
match csv_writer.into_inner() {
|
||||||
|
Ok(inner) => match String::from_utf8(inner) {
|
||||||
|
Ok(encoded) => Ok(json!({ "body": encoded })),
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to write UTF-8: {:?}", e);
|
||||||
|
|
||||||
|
json_err!("Failed to write UTF-8")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to extract CSV: {:?}", e);
|
||||||
|
|
||||||
|
json_err!("Failed to extract CSV")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Could not fetch templates from {}: {:?}", id, e);
|
||||||
|
|
||||||
|
json_err!("Failed to query templates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
617
web/src/routes/dashboard/guild.rs
Normal file
617
web/src/routes/dashboard/guild.rs
Normal file
@ -0,0 +1,617 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
use rocket::{
|
||||||
|
http::CookieJar,
|
||||||
|
serde::json::{json, Json},
|
||||||
|
State,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serenity::{
|
||||||
|
client::Context,
|
||||||
|
model::{
|
||||||
|
channel::GuildChannel,
|
||||||
|
id::{ChannelId, GuildId, RoleId},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use sqlx::{MySql, Pool};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
check_guild_subscription, check_subscription,
|
||||||
|
consts::{
|
||||||
|
MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
|
||||||
|
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
|
||||||
|
MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
|
||||||
|
MIN_INTERVAL,
|
||||||
|
},
|
||||||
|
routes::{
|
||||||
|
dashboard::{
|
||||||
|
create_database_channel, create_reminder, template_name_default, DeleteReminder,
|
||||||
|
DeleteReminderTemplate, PatchReminder, Reminder, ReminderTemplate,
|
||||||
|
},
|
||||||
|
JsonResult,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ChannelInfo {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
webhook_avatar: Option<String>,
|
||||||
|
webhook_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/guild/<id>/patreon")]
|
||||||
|
pub async fn get_guild_patreon(
|
||||||
|
id: u64,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
|
match GuildId(id).to_guild_cached(ctx.inner()) {
|
||||||
|
Some(guild) => {
|
||||||
|
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
|
||||||
|
.member(&ctx.inner(), guild.owner_id)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let patreon = member_res.map_or(false, |member| {
|
||||||
|
member
|
||||||
|
.roles
|
||||||
|
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(json!({ "patreon": patreon }))
|
||||||
|
}
|
||||||
|
|
||||||
|
None => json_err!("Bot not in guild"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/guild/<id>/channels")]
|
||||||
|
pub async fn get_guild_channels(
|
||||||
|
id: u64,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
|
match GuildId(id).to_guild_cached(ctx.inner()) {
|
||||||
|
Some(guild) => {
|
||||||
|
let mut channels = guild
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c)))
|
||||||
|
.filter(|(_, channel)| channel.is_text_based())
|
||||||
|
.collect::<Vec<(ChannelId, GuildChannel)>>();
|
||||||
|
|
||||||
|
channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position));
|
||||||
|
|
||||||
|
let channel_info = channels
|
||||||
|
.iter()
|
||||||
|
.map(|(channel_id, channel)| ChannelInfo {
|
||||||
|
name: channel.name.to_string(),
|
||||||
|
id: channel_id.to_string(),
|
||||||
|
webhook_avatar: None,
|
||||||
|
webhook_name: None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<ChannelInfo>>();
|
||||||
|
|
||||||
|
Ok(json!(channel_info))
|
||||||
|
}
|
||||||
|
|
||||||
|
None => json_err!("Bot not in guild"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RoleInfo {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/guild/<id>/roles")]
|
||||||
|
pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
|
||||||
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
|
let roles_res = ctx.cache.guild_roles(id);
|
||||||
|
|
||||||
|
match roles_res {
|
||||||
|
Some(roles) => {
|
||||||
|
let roles = roles
|
||||||
|
.iter()
|
||||||
|
.map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
|
||||||
|
.collect::<Vec<RoleInfo>>();
|
||||||
|
|
||||||
|
Ok(json!(roles))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
warn!("Could not fetch roles from {}", id);
|
||||||
|
|
||||||
|
json_err!("Could not get roles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/guild/<id>/templates")]
|
||||||
|
pub async fn get_reminder_templates(
|
||||||
|
id: u64,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
|
match sqlx::query_as_unchecked!(
|
||||||
|
ReminderTemplate,
|
||||||
|
"SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(templates) => Ok(json!(templates)),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Could not fetch templates from {}: {:?}", id, e);
|
||||||
|
|
||||||
|
json_err!("Could not get templates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/guild/<id>/templates", data = "<reminder_template>")]
|
||||||
|
pub async fn create_reminder_template(
|
||||||
|
id: u64,
|
||||||
|
reminder_template: Json<ReminderTemplate>,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
|
// validate lengths
|
||||||
|
check_length!(MAX_CONTENT_LENGTH, reminder_template.content);
|
||||||
|
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description);
|
||||||
|
check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title);
|
||||||
|
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author);
|
||||||
|
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer);
|
||||||
|
check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields);
|
||||||
|
if let Some(fields) = &reminder_template.embed_fields {
|
||||||
|
for field in &fields.0 {
|
||||||
|
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
|
||||||
|
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username);
|
||||||
|
check_length_opt!(
|
||||||
|
MAX_URL_LENGTH,
|
||||||
|
reminder_template.embed_footer_url,
|
||||||
|
reminder_template.embed_thumbnail_url,
|
||||||
|
reminder_template.embed_author_url,
|
||||||
|
reminder_template.embed_image_url,
|
||||||
|
reminder_template.avatar
|
||||||
|
);
|
||||||
|
|
||||||
|
// validate urls
|
||||||
|
check_url_opt!(
|
||||||
|
reminder_template.embed_footer_url,
|
||||||
|
reminder_template.embed_thumbnail_url,
|
||||||
|
reminder_template.embed_author_url,
|
||||||
|
reminder_template.embed_image_url,
|
||||||
|
reminder_template.avatar
|
||||||
|
);
|
||||||
|
|
||||||
|
let name = if reminder_template.name.is_empty() {
|
||||||
|
template_name_default()
|
||||||
|
} else {
|
||||||
|
reminder_template.name.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
match sqlx::query!(
|
||||||
|
"INSERT INTO reminder_template
|
||||||
|
(guild_id,
|
||||||
|
name,
|
||||||
|
attachment,
|
||||||
|
attachment_name,
|
||||||
|
avatar,
|
||||||
|
content,
|
||||||
|
embed_author,
|
||||||
|
embed_author_url,
|
||||||
|
embed_color,
|
||||||
|
embed_description,
|
||||||
|
embed_footer,
|
||||||
|
embed_footer_url,
|
||||||
|
embed_image_url,
|
||||||
|
embed_thumbnail_url,
|
||||||
|
embed_title,
|
||||||
|
embed_fields,
|
||||||
|
tts,
|
||||||
|
username
|
||||||
|
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
id, name,
|
||||||
|
reminder_template.attachment,
|
||||||
|
reminder_template.attachment_name,
|
||||||
|
reminder_template.avatar,
|
||||||
|
reminder_template.content,
|
||||||
|
reminder_template.embed_author,
|
||||||
|
reminder_template.embed_author_url,
|
||||||
|
reminder_template.embed_color,
|
||||||
|
reminder_template.embed_description,
|
||||||
|
reminder_template.embed_footer,
|
||||||
|
reminder_template.embed_footer_url,
|
||||||
|
reminder_template.embed_image_url,
|
||||||
|
reminder_template.embed_thumbnail_url,
|
||||||
|
reminder_template.embed_title,
|
||||||
|
reminder_template.embed_fields,
|
||||||
|
reminder_template.tts,
|
||||||
|
reminder_template.username,
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
Ok(json!({}))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Could not create template for {}: {:?}", id, e);
|
||||||
|
|
||||||
|
json_err!("Could not create template")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")]
|
||||||
|
pub async fn delete_reminder_template(
|
||||||
|
id: u64,
|
||||||
|
delete_reminder_template: Json<DeleteReminderTemplate>,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
|
match sqlx::query!(
|
||||||
|
"DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?",
|
||||||
|
id, delete_reminder_template.id
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
Ok(json!({}))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Could not delete template from {}: {:?}", id, e);
|
||||||
|
|
||||||
|
json_err!("Could not delete template")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
|
||||||
|
pub async fn create_guild_reminder(
|
||||||
|
id: u64,
|
||||||
|
reminder: Json<Reminder>,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
serenity_context: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, serenity_context.inner(), id);
|
||||||
|
|
||||||
|
let user_id =
|
||||||
|
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
||||||
|
|
||||||
|
create_reminder(
|
||||||
|
serenity_context.inner(),
|
||||||
|
pool.inner(),
|
||||||
|
GuildId(id),
|
||||||
|
UserId(user_id),
|
||||||
|
reminder.into_inner(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/guild/<id>/reminders")]
|
||||||
|
pub async fn get_reminders(
|
||||||
|
id: u64,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
serenity_context: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, serenity_context.inner(), id);
|
||||||
|
|
||||||
|
let channels_res = GuildId(id).channels(&ctx.inner()).await;
|
||||||
|
|
||||||
|
match channels_res {
|
||||||
|
Ok(channels) => {
|
||||||
|
let channels = channels
|
||||||
|
.keys()
|
||||||
|
.into_iter()
|
||||||
|
.map(|k| k.as_u64().to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
sqlx::query_as_unchecked!(
|
||||||
|
Reminder,
|
||||||
|
"SELECT
|
||||||
|
reminders.attachment,
|
||||||
|
reminders.attachment_name,
|
||||||
|
reminders.avatar,
|
||||||
|
channels.channel,
|
||||||
|
reminders.content,
|
||||||
|
reminders.embed_author,
|
||||||
|
reminders.embed_author_url,
|
||||||
|
reminders.embed_color,
|
||||||
|
reminders.embed_description,
|
||||||
|
reminders.embed_footer,
|
||||||
|
reminders.embed_footer_url,
|
||||||
|
reminders.embed_image_url,
|
||||||
|
reminders.embed_thumbnail_url,
|
||||||
|
reminders.embed_title,
|
||||||
|
IFNULL(reminders.embed_fields, '[]') AS embed_fields,
|
||||||
|
reminders.enabled,
|
||||||
|
reminders.expires,
|
||||||
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
|
reminders.interval_months,
|
||||||
|
reminders.name,
|
||||||
|
reminders.restartable,
|
||||||
|
reminders.tts,
|
||||||
|
reminders.uid,
|
||||||
|
reminders.username,
|
||||||
|
reminders.utc_time
|
||||||
|
FROM reminders
|
||||||
|
LEFT JOIN channels ON channels.id = reminders.channel_id
|
||||||
|
WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?)",
|
||||||
|
channels
|
||||||
|
)
|
||||||
|
.fetch_all(pool.inner())
|
||||||
|
.await
|
||||||
|
.map(|r| Ok(json!(r)))
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
warn!("Failed to complete SQL query: {:?}", e);
|
||||||
|
|
||||||
|
json_err!("Could not load reminders")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Could not fetch channels from {}: {:?}", id, e);
|
||||||
|
|
||||||
|
Ok(json!([]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[patch("/api/guild/<id>/reminders", data = "<reminder>")]
|
||||||
|
pub async fn edit_reminder(
|
||||||
|
id: u64,
|
||||||
|
reminder: Json<PatchReminder>,
|
||||||
|
serenity_context: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, serenity_context.inner(), id);
|
||||||
|
|
||||||
|
let mut error = vec![];
|
||||||
|
|
||||||
|
let user_id =
|
||||||
|
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
||||||
|
|
||||||
|
if reminder.message_ok() {
|
||||||
|
update_field!(pool.inner(), error, reminder.[
|
||||||
|
content,
|
||||||
|
embed_author,
|
||||||
|
embed_description,
|
||||||
|
embed_footer,
|
||||||
|
embed_title,
|
||||||
|
embed_fields,
|
||||||
|
username
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
error.push("Message exceeds limits.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
update_field!(pool.inner(), error, reminder.[
|
||||||
|
attachment,
|
||||||
|
attachment_name,
|
||||||
|
avatar,
|
||||||
|
embed_author_url,
|
||||||
|
embed_color,
|
||||||
|
embed_footer_url,
|
||||||
|
embed_image_url,
|
||||||
|
embed_thumbnail_url,
|
||||||
|
enabled,
|
||||||
|
expires,
|
||||||
|
name,
|
||||||
|
restartable,
|
||||||
|
tts,
|
||||||
|
utc_time
|
||||||
|
]);
|
||||||
|
|
||||||
|
if reminder.interval_days.flatten().is_some()
|
||||||
|
|| reminder.interval_months.flatten().is_some()
|
||||||
|
|| reminder.interval_seconds.flatten().is_some()
|
||||||
|
{
|
||||||
|
if check_guild_subscription(&serenity_context.inner(), id).await
|
||||||
|
|| check_subscription(&serenity_context.inner(), user_id).await
|
||||||
|
{
|
||||||
|
let new_interval_length = match reminder.interval_days {
|
||||||
|
Some(interval) => interval.unwrap_or(0),
|
||||||
|
None => sqlx::query!(
|
||||||
|
"SELECT interval_days AS days FROM reminders WHERE uid = ?",
|
||||||
|
reminder.uid
|
||||||
|
)
|
||||||
|
.fetch_one(pool.inner())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Error updating reminder interval: {:?}", e);
|
||||||
|
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
|
||||||
|
})?
|
||||||
|
.days
|
||||||
|
.unwrap_or(0),
|
||||||
|
} * 86400 + match reminder.interval_months {
|
||||||
|
Some(interval) => interval.unwrap_or(0),
|
||||||
|
None => sqlx::query!(
|
||||||
|
"SELECT interval_months AS months FROM reminders WHERE uid = ?",
|
||||||
|
reminder.uid
|
||||||
|
)
|
||||||
|
.fetch_one(pool.inner())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Error updating reminder interval: {:?}", e);
|
||||||
|
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
|
||||||
|
})?
|
||||||
|
.months
|
||||||
|
.unwrap_or(0),
|
||||||
|
} * 2592000 + match reminder.interval_seconds {
|
||||||
|
Some(interval) => interval.unwrap_or(0),
|
||||||
|
None => sqlx::query!(
|
||||||
|
"SELECT interval_seconds AS seconds FROM reminders WHERE uid = ?",
|
||||||
|
reminder.uid
|
||||||
|
)
|
||||||
|
.fetch_one(pool.inner())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Error updating reminder interval: {:?}", e);
|
||||||
|
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
|
||||||
|
})?
|
||||||
|
.seconds
|
||||||
|
.unwrap_or(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
if new_interval_length < *MIN_INTERVAL {
|
||||||
|
error.push(String::from("New interval is too short."));
|
||||||
|
} else {
|
||||||
|
update_field!(pool.inner(), error, reminder.[
|
||||||
|
interval_days,
|
||||||
|
interval_months,
|
||||||
|
interval_seconds
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if reminder.channel > 0 {
|
||||||
|
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
|
||||||
|
match channel {
|
||||||
|
Some(channel) => {
|
||||||
|
let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id);
|
||||||
|
|
||||||
|
if !channel_matches_guild {
|
||||||
|
warn!(
|
||||||
|
"Error in `edit_reminder`: channel {:?} not found for guild {}",
|
||||||
|
reminder.channel, id
|
||||||
|
);
|
||||||
|
|
||||||
|
return Err(json!({"error": "Channel not found"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = create_database_channel(
|
||||||
|
serenity_context.inner(),
|
||||||
|
ChannelId(reminder.channel),
|
||||||
|
pool.inner(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = channel {
|
||||||
|
warn!("`create_database_channel` returned an error code: {:?}", e);
|
||||||
|
|
||||||
|
return Err(
|
||||||
|
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = channel.unwrap();
|
||||||
|
|
||||||
|
match sqlx::query!(
|
||||||
|
"UPDATE reminders SET channel_id = ? WHERE uid = ?",
|
||||||
|
channel,
|
||||||
|
reminder.uid
|
||||||
|
)
|
||||||
|
.execute(pool.inner())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error setting channel: {:?}", e);
|
||||||
|
|
||||||
|
error.push("Couldn't set channel".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None => {
|
||||||
|
warn!(
|
||||||
|
"Error in `edit_reminder`: channel {:?} not found for guild {}",
|
||||||
|
reminder.channel, id
|
||||||
|
);
|
||||||
|
|
||||||
|
return Err(json!({"error": "Channel not found"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match sqlx::query_as_unchecked!(
|
||||||
|
Reminder,
|
||||||
|
"SELECT reminders.attachment,
|
||||||
|
reminders.attachment_name,
|
||||||
|
reminders.avatar,
|
||||||
|
channels.channel,
|
||||||
|
reminders.content,
|
||||||
|
reminders.embed_author,
|
||||||
|
reminders.embed_author_url,
|
||||||
|
reminders.embed_color,
|
||||||
|
reminders.embed_description,
|
||||||
|
reminders.embed_footer,
|
||||||
|
reminders.embed_footer_url,
|
||||||
|
reminders.embed_image_url,
|
||||||
|
reminders.embed_thumbnail_url,
|
||||||
|
reminders.embed_title,
|
||||||
|
reminders.embed_fields,
|
||||||
|
reminders.enabled,
|
||||||
|
reminders.expires,
|
||||||
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
|
reminders.interval_months,
|
||||||
|
reminders.name,
|
||||||
|
reminders.restartable,
|
||||||
|
reminders.tts,
|
||||||
|
reminders.uid,
|
||||||
|
reminders.username,
|
||||||
|
reminders.utc_time
|
||||||
|
FROM reminders
|
||||||
|
LEFT JOIN channels ON channels.id = reminders.channel_id
|
||||||
|
WHERE uid = ?",
|
||||||
|
reminder.uid
|
||||||
|
)
|
||||||
|
.fetch_one(pool.inner())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error exiting `edit_reminder': {:?}", e);
|
||||||
|
|
||||||
|
Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[delete("/api/guild/<_>/reminders", data = "<reminder>")]
|
||||||
|
pub async fn delete_reminder(
|
||||||
|
reminder: Json<DeleteReminder>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
match sqlx::query!("UPDATE reminders SET `status` = 'deleted' WHERE uid = ?", reminder.uid)
|
||||||
|
.execute(pool.inner())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(json!({})),
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error in `delete_reminder`: {:?}", e);
|
||||||
|
|
||||||
|
Err(json!({"error": "Could not delete reminder"}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
672
web/src/routes/dashboard/mod.rs
Normal file
672
web/src/routes/dashboard/mod.rs
Normal file
@ -0,0 +1,672 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::{naive::NaiveDateTime, Utc};
|
||||||
|
use rand::{rngs::OsRng, seq::IteratorRandom};
|
||||||
|
use rocket::{http::CookieJar, response::Redirect, serde::json::json};
|
||||||
|
use rocket_dyn_templates::Template;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
use serenity::{
|
||||||
|
client::Context,
|
||||||
|
http::Http,
|
||||||
|
model::id::{ChannelId, GuildId, UserId},
|
||||||
|
};
|
||||||
|
use sqlx::{types::Json, Executor};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
check_guild_subscription, check_subscription,
|
||||||
|
consts::{
|
||||||
|
CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH,
|
||||||
|
MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH,
|
||||||
|
MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH,
|
||||||
|
MAX_NAME_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL,
|
||||||
|
},
|
||||||
|
routes::JsonResult,
|
||||||
|
Database, Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod export;
|
||||||
|
pub mod guild;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
|
type Unset<T> = Option<T>;
|
||||||
|
|
||||||
|
fn name_default() -> String {
|
||||||
|
"Reminder".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn template_name_default() -> String {
|
||||||
|
"Template".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channel_default() -> u64 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id_default() -> u32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interval_default() -> Unset<Option<u32>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
T: Deserialize<'de>,
|
||||||
|
{
|
||||||
|
Ok(Some(Option::deserialize(deserializer)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct ReminderTemplate {
|
||||||
|
#[serde(default = "id_default")]
|
||||||
|
id: u32,
|
||||||
|
#[serde(default = "id_default")]
|
||||||
|
guild_id: u32,
|
||||||
|
#[serde(default = "template_name_default")]
|
||||||
|
name: String,
|
||||||
|
attachment: Option<Vec<u8>>,
|
||||||
|
attachment_name: Option<String>,
|
||||||
|
avatar: Option<String>,
|
||||||
|
content: String,
|
||||||
|
embed_author: String,
|
||||||
|
embed_author_url: Option<String>,
|
||||||
|
embed_color: u32,
|
||||||
|
embed_description: String,
|
||||||
|
embed_footer: String,
|
||||||
|
embed_footer_url: Option<String>,
|
||||||
|
embed_image_url: Option<String>,
|
||||||
|
embed_thumbnail_url: Option<String>,
|
||||||
|
embed_title: String,
|
||||||
|
embed_fields: Option<Json<Vec<EmbedField>>>,
|
||||||
|
tts: bool,
|
||||||
|
username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct ReminderTemplateCsv {
|
||||||
|
#[serde(default = "template_name_default")]
|
||||||
|
name: String,
|
||||||
|
attachment: Option<Vec<u8>>,
|
||||||
|
attachment_name: Option<String>,
|
||||||
|
avatar: Option<String>,
|
||||||
|
content: String,
|
||||||
|
embed_author: String,
|
||||||
|
embed_author_url: Option<String>,
|
||||||
|
embed_color: u32,
|
||||||
|
embed_description: String,
|
||||||
|
embed_footer: String,
|
||||||
|
embed_footer_url: Option<String>,
|
||||||
|
embed_image_url: Option<String>,
|
||||||
|
embed_thumbnail_url: Option<String>,
|
||||||
|
embed_title: String,
|
||||||
|
embed_fields: Option<String>,
|
||||||
|
tts: bool,
|
||||||
|
username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DeleteReminderTemplate {
|
||||||
|
id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct EmbedField {
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
inline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Reminder {
|
||||||
|
#[serde(with = "base64s")]
|
||||||
|
attachment: Option<Vec<u8>>,
|
||||||
|
attachment_name: Option<String>,
|
||||||
|
avatar: Option<String>,
|
||||||
|
#[serde(with = "string")]
|
||||||
|
channel: u64,
|
||||||
|
content: String,
|
||||||
|
embed_author: String,
|
||||||
|
embed_author_url: Option<String>,
|
||||||
|
embed_color: u32,
|
||||||
|
embed_description: String,
|
||||||
|
embed_footer: String,
|
||||||
|
embed_footer_url: Option<String>,
|
||||||
|
embed_image_url: Option<String>,
|
||||||
|
embed_thumbnail_url: Option<String>,
|
||||||
|
embed_title: String,
|
||||||
|
embed_fields: Option<Json<Vec<EmbedField>>>,
|
||||||
|
enabled: bool,
|
||||||
|
expires: Option<NaiveDateTime>,
|
||||||
|
interval_seconds: Option<u32>,
|
||||||
|
interval_days: Option<u32>,
|
||||||
|
interval_months: Option<u32>,
|
||||||
|
#[serde(default = "name_default")]
|
||||||
|
name: String,
|
||||||
|
restartable: bool,
|
||||||
|
tts: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
uid: String,
|
||||||
|
username: Option<String>,
|
||||||
|
utc_time: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct ReminderCsv {
|
||||||
|
#[serde(with = "base64s")]
|
||||||
|
attachment: Option<Vec<u8>>,
|
||||||
|
attachment_name: Option<String>,
|
||||||
|
avatar: Option<String>,
|
||||||
|
channel: String,
|
||||||
|
content: String,
|
||||||
|
embed_author: String,
|
||||||
|
embed_author_url: Option<String>,
|
||||||
|
embed_color: u32,
|
||||||
|
embed_description: String,
|
||||||
|
embed_footer: String,
|
||||||
|
embed_footer_url: Option<String>,
|
||||||
|
embed_image_url: Option<String>,
|
||||||
|
embed_thumbnail_url: Option<String>,
|
||||||
|
embed_title: String,
|
||||||
|
embed_fields: Option<String>,
|
||||||
|
enabled: bool,
|
||||||
|
expires: Option<NaiveDateTime>,
|
||||||
|
interval_seconds: Option<u32>,
|
||||||
|
interval_days: Option<u32>,
|
||||||
|
interval_months: Option<u32>,
|
||||||
|
#[serde(default = "name_default")]
|
||||||
|
name: String,
|
||||||
|
restartable: bool,
|
||||||
|
tts: bool,
|
||||||
|
username: Option<String>,
|
||||||
|
utc_time: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PatchReminder {
|
||||||
|
uid: String,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
attachment: Unset<Option<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
attachment_name: Unset<Option<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
avatar: Unset<Option<String>>,
|
||||||
|
#[serde(default = "channel_default")]
|
||||||
|
#[serde(with = "string")]
|
||||||
|
channel: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
content: Unset<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
embed_author: Unset<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
embed_author_url: Unset<Option<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
embed_color: Unset<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
embed_description: Unset<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
embed_footer: Unset<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
embed_footer_url: Unset<Option<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
embed_image_url: Unset<Option<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
embed_thumbnail_url: Unset<Option<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
embed_title: Unset<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
embed_fields: Unset<Json<Vec<EmbedField>>>,
|
||||||
|
#[serde(default)]
|
||||||
|
enabled: Unset<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
expires: Unset<Option<NaiveDateTime>>,
|
||||||
|
#[serde(default = "interval_default")]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
interval_seconds: Unset<Option<u32>>,
|
||||||
|
#[serde(default = "interval_default")]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
interval_days: Unset<Option<u32>>,
|
||||||
|
#[serde(default = "interval_default")]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
interval_months: Unset<Option<u32>>,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Unset<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
restartable: Unset<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
tts: Unset<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
username: Unset<Option<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
utc_time: Unset<NaiveDateTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PatchReminder {
|
||||||
|
fn message_ok(&self) -> bool {
|
||||||
|
self.content.as_ref().map_or(true, |c| c.len() <= MAX_CONTENT_LENGTH)
|
||||||
|
&& self.embed_author.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_AUTHOR_LENGTH)
|
||||||
|
&& self
|
||||||
|
.embed_description
|
||||||
|
.as_ref()
|
||||||
|
.map_or(true, |c| c.len() <= MAX_EMBED_DESCRIPTION_LENGTH)
|
||||||
|
&& self.embed_footer.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_FOOTER_LENGTH)
|
||||||
|
&& self.embed_title.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_TITLE_LENGTH)
|
||||||
|
&& self.embed_fields.as_ref().map_or(true, |c| {
|
||||||
|
c.0.len() <= MAX_EMBED_FIELDS
|
||||||
|
&& c.0.iter().all(|f| {
|
||||||
|
f.title.len() <= MAX_EMBED_FIELD_TITLE_LENGTH
|
||||||
|
&& f.value.len() <= MAX_EMBED_FIELD_VALUE_LENGTH
|
||||||
|
})
|
||||||
|
})
|
||||||
|
&& self
|
||||||
|
.username
|
||||||
|
.as_ref()
|
||||||
|
.map_or(true, |c| c.as_ref().map_or(true, |v| v.len() <= MAX_USERNAME_LENGTH))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_uid() -> String {
|
||||||
|
let mut generator: OsRng = Default::default();
|
||||||
|
|
||||||
|
(0..64)
|
||||||
|
.map(|_| CHARACTERS.chars().choose(&mut generator).unwrap().to_owned().to_string())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://github.com/serde-rs/json/issues/329#issuecomment-305608405
|
||||||
|
mod string {
|
||||||
|
use std::{fmt::Display, str::FromStr};
|
||||||
|
|
||||||
|
use serde::{de, Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
|
pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
T: Display,
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.collect_str(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
|
||||||
|
where
|
||||||
|
T: FromStr,
|
||||||
|
T::Err: Display,
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod base64s {
|
||||||
|
use serde::{de, Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
|
pub fn serialize<S>(value: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
if let Some(opt) = value {
|
||||||
|
serializer.collect_str(&base64::encode(opt))
|
||||||
|
} else {
|
||||||
|
serializer.serialize_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let string = Option::<String>::deserialize(deserializer)?;
|
||||||
|
Some(string.map(|b| base64::decode(b).map_err(de::Error::custom))).flatten().transpose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DeleteReminder {
|
||||||
|
uid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ImportBody {
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct TodoCsv {
|
||||||
|
value: String,
|
||||||
|
channel_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_reminder(
|
||||||
|
ctx: &Context,
|
||||||
|
pool: impl sqlx::Executor<'_, Database = Database> + Copy,
|
||||||
|
guild_id: GuildId,
|
||||||
|
user_id: UserId,
|
||||||
|
reminder: Reminder,
|
||||||
|
) -> JsonResult {
|
||||||
|
// check guild in db
|
||||||
|
match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.0)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Err(sqlx::Error::RowNotFound) => {
|
||||||
|
if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.0)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err(json!({"error": "Guild could not be created"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate channel
|
||||||
|
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx);
|
||||||
|
let channel_exists = channel.is_some();
|
||||||
|
|
||||||
|
let channel_matches_guild =
|
||||||
|
channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id == guild_id));
|
||||||
|
|
||||||
|
if !channel_matches_guild || !channel_exists {
|
||||||
|
warn!(
|
||||||
|
"Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})",
|
||||||
|
reminder.channel, guild_id, channel_exists
|
||||||
|
);
|
||||||
|
|
||||||
|
return Err(json!({"error": "Channel not found"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = create_database_channel(&ctx, ChannelId(reminder.channel), pool).await;
|
||||||
|
|
||||||
|
if let Err(e) = channel {
|
||||||
|
warn!("`create_database_channel` returned an error code: {:?}", e);
|
||||||
|
|
||||||
|
return Err(
|
||||||
|
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = channel.unwrap();
|
||||||
|
|
||||||
|
// validate lengths
|
||||||
|
check_length!(MAX_NAME_LENGTH, reminder.name);
|
||||||
|
check_length!(MAX_CONTENT_LENGTH, reminder.content);
|
||||||
|
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
|
||||||
|
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
|
||||||
|
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
|
||||||
|
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
|
||||||
|
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
|
||||||
|
if let Some(fields) = &reminder.embed_fields {
|
||||||
|
for field in &fields.0 {
|
||||||
|
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
|
||||||
|
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
|
||||||
|
check_length_opt!(
|
||||||
|
MAX_URL_LENGTH,
|
||||||
|
reminder.embed_footer_url,
|
||||||
|
reminder.embed_thumbnail_url,
|
||||||
|
reminder.embed_author_url,
|
||||||
|
reminder.embed_image_url,
|
||||||
|
reminder.avatar
|
||||||
|
);
|
||||||
|
|
||||||
|
// validate urls
|
||||||
|
check_url_opt!(
|
||||||
|
reminder.embed_footer_url,
|
||||||
|
reminder.embed_thumbnail_url,
|
||||||
|
reminder.embed_author_url,
|
||||||
|
reminder.embed_image_url,
|
||||||
|
reminder.avatar
|
||||||
|
);
|
||||||
|
|
||||||
|
// validate time and interval
|
||||||
|
if reminder.utc_time < Utc::now().naive_utc() {
|
||||||
|
return Err(json!({"error": "Time must be in the future"}));
|
||||||
|
}
|
||||||
|
if reminder.interval_seconds.is_some()
|
||||||
|
|| reminder.interval_days.is_some()
|
||||||
|
|| reminder.interval_months.is_some()
|
||||||
|
{
|
||||||
|
if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32
|
||||||
|
+ reminder.interval_days.unwrap_or(0) * DAY as u32
|
||||||
|
+ reminder.interval_seconds.unwrap_or(0)
|
||||||
|
< *MIN_INTERVAL
|
||||||
|
{
|
||||||
|
return Err(json!({"error": "Interval too short"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check patreon if necessary
|
||||||
|
if reminder.interval_seconds.is_some()
|
||||||
|
|| reminder.interval_days.is_some()
|
||||||
|
|| reminder.interval_months.is_some()
|
||||||
|
{
|
||||||
|
if !check_guild_subscription(&ctx, guild_id).await
|
||||||
|
&& !check_subscription(&ctx, user_id).await
|
||||||
|
{
|
||||||
|
return Err(json!({"error": "Patreon is required to set intervals"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64 decode error dropped here
|
||||||
|
let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten();
|
||||||
|
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
|
||||||
|
let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
reminder.username
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_uid = generate_uid();
|
||||||
|
|
||||||
|
// write to db
|
||||||
|
match sqlx::query!(
|
||||||
|
"INSERT INTO reminders (
|
||||||
|
uid,
|
||||||
|
attachment,
|
||||||
|
attachment_name,
|
||||||
|
channel_id,
|
||||||
|
avatar,
|
||||||
|
content,
|
||||||
|
embed_author,
|
||||||
|
embed_author_url,
|
||||||
|
embed_color,
|
||||||
|
embed_description,
|
||||||
|
embed_footer,
|
||||||
|
embed_footer_url,
|
||||||
|
embed_image_url,
|
||||||
|
embed_thumbnail_url,
|
||||||
|
embed_title,
|
||||||
|
embed_fields,
|
||||||
|
enabled,
|
||||||
|
expires,
|
||||||
|
interval_seconds,
|
||||||
|
interval_days,
|
||||||
|
interval_months,
|
||||||
|
name,
|
||||||
|
restartable,
|
||||||
|
tts,
|
||||||
|
username,
|
||||||
|
`utc_time`
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
new_uid,
|
||||||
|
attachment_data,
|
||||||
|
reminder.attachment_name,
|
||||||
|
channel,
|
||||||
|
reminder.avatar,
|
||||||
|
reminder.content,
|
||||||
|
reminder.embed_author,
|
||||||
|
reminder.embed_author_url,
|
||||||
|
reminder.embed_color,
|
||||||
|
reminder.embed_description,
|
||||||
|
reminder.embed_footer,
|
||||||
|
reminder.embed_footer_url,
|
||||||
|
reminder.embed_image_url,
|
||||||
|
reminder.embed_thumbnail_url,
|
||||||
|
reminder.embed_title,
|
||||||
|
reminder.embed_fields,
|
||||||
|
reminder.enabled,
|
||||||
|
reminder.expires,
|
||||||
|
reminder.interval_seconds,
|
||||||
|
reminder.interval_days,
|
||||||
|
reminder.interval_months,
|
||||||
|
name,
|
||||||
|
reminder.restartable,
|
||||||
|
reminder.tts,
|
||||||
|
username,
|
||||||
|
reminder.utc_time,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => sqlx::query_as_unchecked!(
|
||||||
|
Reminder,
|
||||||
|
"SELECT
|
||||||
|
reminders.attachment,
|
||||||
|
reminders.attachment_name,
|
||||||
|
reminders.avatar,
|
||||||
|
channels.channel,
|
||||||
|
reminders.content,
|
||||||
|
reminders.embed_author,
|
||||||
|
reminders.embed_author_url,
|
||||||
|
reminders.embed_color,
|
||||||
|
reminders.embed_description,
|
||||||
|
reminders.embed_footer,
|
||||||
|
reminders.embed_footer_url,
|
||||||
|
reminders.embed_image_url,
|
||||||
|
reminders.embed_thumbnail_url,
|
||||||
|
reminders.embed_title,
|
||||||
|
reminders.embed_fields,
|
||||||
|
reminders.enabled,
|
||||||
|
reminders.expires,
|
||||||
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
|
reminders.interval_months,
|
||||||
|
reminders.name,
|
||||||
|
reminders.restartable,
|
||||||
|
reminders.tts,
|
||||||
|
reminders.uid,
|
||||||
|
reminders.username,
|
||||||
|
reminders.utc_time
|
||||||
|
FROM reminders
|
||||||
|
LEFT JOIN channels ON channels.id = reminders.channel_id
|
||||||
|
WHERE uid = ?",
|
||||||
|
new_uid
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map(|r| Ok(json!(r)))
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
warn!("Failed to complete SQL query: {:?}", e);
|
||||||
|
|
||||||
|
Err(json!({"error": "Could not load reminder"}))
|
||||||
|
}),
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error in `create_reminder`: Could not execute query: {:?}", e);
|
||||||
|
|
||||||
|
Err(json!({"error": "Unknown error"}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_database_channel(
|
||||||
|
ctx: impl AsRef<Http>,
|
||||||
|
channel: ChannelId,
|
||||||
|
pool: impl Executor<'_, Database = Database> + Copy,
|
||||||
|
) -> Result<u32, crate::Error> {
|
||||||
|
let row =
|
||||||
|
sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Ok(row) => {
|
||||||
|
if row.webhook_token.is_none() || row.webhook_id.is_none() {
|
||||||
|
let webhook = channel
|
||||||
|
.create_webhook_with_avatar(&ctx, "Reminder", DEFAULT_AVATAR.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::Serenity(e))?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE channels SET webhook_id = ?, webhook_token = ? WHERE channel = ?",
|
||||||
|
webhook.id.0,
|
||||||
|
webhook.token,
|
||||||
|
channel.0
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::SQLx(e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(sqlx::Error::RowNotFound) => {
|
||||||
|
// create webhook
|
||||||
|
let webhook = channel
|
||||||
|
.create_webhook_with_avatar(&ctx, "Reminder", DEFAULT_AVATAR.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::Serenity(e))?;
|
||||||
|
|
||||||
|
// create database entry
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO channels (
|
||||||
|
webhook_id,
|
||||||
|
webhook_token,
|
||||||
|
channel
|
||||||
|
) VALUES (?, ?, ?)",
|
||||||
|
webhook.id.0,
|
||||||
|
webhook.token,
|
||||||
|
channel.0
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::SQLx(e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => Err(Error::SQLx(e)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::SQLx(e))?;
|
||||||
|
|
||||||
|
Ok(row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/")]
|
||||||
|
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
|
||||||
|
if cookies.get_private("userid").is_some() {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Ok(Template::render("dashboard", &map))
|
||||||
|
} else {
|
||||||
|
Err(Redirect::to("/login/discord"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/<_>")]
|
||||||
|
pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
|
||||||
|
if cookies.get_private("userid").is_some() {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Ok(Template::render("dashboard", &map))
|
||||||
|
} else {
|
||||||
|
Err(Redirect::to("/login/discord"))
|
||||||
|
}
|
||||||
|
}
|
168
web/src/routes/dashboard/user.rs
Normal file
168
web/src/routes/dashboard/user.rs
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
use chrono_tz::Tz;
|
||||||
|
use reqwest::Client;
|
||||||
|
use rocket::{
|
||||||
|
http::CookieJar,
|
||||||
|
serde::json::{json, Json, Value as JsonValue},
|
||||||
|
State,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serenity::{
|
||||||
|
client::Context,
|
||||||
|
model::{
|
||||||
|
id::{GuildId, RoleId},
|
||||||
|
permissions::Permissions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use sqlx::{MySql, Pool};
|
||||||
|
|
||||||
|
use crate::consts::DISCORD_API;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UserInfo {
|
||||||
|
name: String,
|
||||||
|
patreon: bool,
|
||||||
|
timezone: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateUser {
|
||||||
|
timezone: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GuildInfo {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PartialGuild {
|
||||||
|
pub id: GuildId,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner: bool,
|
||||||
|
#[serde(rename = "permissions_new")]
|
||||||
|
pub permissions: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/user")]
|
||||||
|
pub async fn get_user_info(
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonValue {
|
||||||
|
if let Some(user_id) =
|
||||||
|
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
|
||||||
|
{
|
||||||
|
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
|
||||||
|
.member(&ctx.inner(), user_id)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let timezone = sqlx::query!(
|
||||||
|
"SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?",
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.fetch_one(pool.inner())
|
||||||
|
.await
|
||||||
|
.map_or(None, |q| Some(q.timezone));
|
||||||
|
|
||||||
|
let user_info = UserInfo {
|
||||||
|
name: cookies
|
||||||
|
.get_private("username")
|
||||||
|
.map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()),
|
||||||
|
patreon: member_res.map_or(false, |member| {
|
||||||
|
member
|
||||||
|
.roles
|
||||||
|
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
|
||||||
|
}),
|
||||||
|
timezone,
|
||||||
|
};
|
||||||
|
|
||||||
|
json!(user_info)
|
||||||
|
} else {
|
||||||
|
json!({"error": "Not authorized"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[patch("/api/user", data = "<user>")]
|
||||||
|
pub async fn update_user_info(
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
user: Json<UpdateUser>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonValue {
|
||||||
|
if let Some(user_id) =
|
||||||
|
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
|
||||||
|
{
|
||||||
|
if user.timezone.parse::<Tz>().is_ok() {
|
||||||
|
let _ = sqlx::query!(
|
||||||
|
"UPDATE users SET timezone = ? WHERE user = ?",
|
||||||
|
user.timezone,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
.execute(pool.inner())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
json!({})
|
||||||
|
} else {
|
||||||
|
json!({"error": "Timezone not recognized"})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
json!({"error": "Not authorized"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/user/guilds")]
|
||||||
|
pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue {
|
||||||
|
if let Some(access_token) = cookies.get_private("access_token") {
|
||||||
|
let request_res = reqwest_client
|
||||||
|
.get(format!("{}/users/@me/guilds", DISCORD_API))
|
||||||
|
.bearer_auth(access_token.value())
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match request_res {
|
||||||
|
Ok(response) => {
|
||||||
|
let guilds_res = response.json::<Vec<PartialGuild>>().await;
|
||||||
|
|
||||||
|
match guilds_res {
|
||||||
|
Ok(guilds) => {
|
||||||
|
let reduced_guilds = guilds
|
||||||
|
.iter()
|
||||||
|
.filter(|g| {
|
||||||
|
g.owner
|
||||||
|
|| g.permissions.as_ref().map_or(false, |p| {
|
||||||
|
let permissions =
|
||||||
|
Permissions::from_bits_truncate(p.parse().unwrap());
|
||||||
|
|
||||||
|
permissions.manage_messages()
|
||||||
|
|| permissions.manage_guild()
|
||||||
|
|| permissions.administrator()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|g| GuildInfo { id: g.id.to_string(), name: g.name.to_string() })
|
||||||
|
.collect::<Vec<GuildInfo>>();
|
||||||
|
|
||||||
|
json!(reduced_guilds)
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error constructing user from request: {:?}", e);
|
||||||
|
|
||||||
|
json!({"error": "Could not get user details"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error getting user guilds: {:?}", e);
|
||||||
|
|
||||||
|
json!({"error": "Could not reach Discord"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
json!({"error": "Not authorized"})
|
||||||
|
}
|
||||||
|
}
|
157
web/src/routes/login.rs
Normal file
157
web/src/routes/login.rs
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
use log::warn;
|
||||||
|
use oauth2::{
|
||||||
|
basic::BasicClient, reqwest::async_http_client, AuthorizationCode, CsrfToken,
|
||||||
|
PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse,
|
||||||
|
};
|
||||||
|
use reqwest::Client;
|
||||||
|
use rocket::{
|
||||||
|
http::{private::cookie::Expiration, Cookie, CookieJar, SameSite},
|
||||||
|
response::{Flash, Redirect},
|
||||||
|
uri, State,
|
||||||
|
};
|
||||||
|
use serenity::model::user::User;
|
||||||
|
|
||||||
|
use crate::{consts::DISCORD_API, routes};
|
||||||
|
|
||||||
|
#[get("/discord")]
|
||||||
|
pub async fn discord_login(
|
||||||
|
oauth2_client: &State<BasicClient>,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
) -> Redirect {
|
||||||
|
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
|
||||||
|
let (auth_url, csrf_token) = oauth2_client
|
||||||
|
.authorize_url(CsrfToken::new_random)
|
||||||
|
// Set the desired scopes.
|
||||||
|
.add_scope(Scope::new("identify".to_string()))
|
||||||
|
.add_scope(Scope::new("guilds".to_string()))
|
||||||
|
// Set the PKCE code challenge.
|
||||||
|
.set_pkce_challenge(pkce_challenge)
|
||||||
|
.url();
|
||||||
|
|
||||||
|
// store the pkce secret to verify the authorization later
|
||||||
|
cookies.add_private(
|
||||||
|
Cookie::build("verify", pkce_verifier.secret().to_string())
|
||||||
|
.http_only(true)
|
||||||
|
.path("/login")
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.expires(Expiration::Session)
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// store the csrf token to verify no interference
|
||||||
|
cookies.add_private(
|
||||||
|
Cookie::build("csrf", csrf_token.secret().to_string())
|
||||||
|
.http_only(true)
|
||||||
|
.path("/login")
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.expires(Expiration::Session)
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Redirect::to(auth_url.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/discord/logout")]
|
||||||
|
pub async fn discord_logout(cookies: &CookieJar<'_>) -> Redirect {
|
||||||
|
cookies.remove_private(Cookie::named("username"));
|
||||||
|
cookies.remove_private(Cookie::named("userid"));
|
||||||
|
cookies.remove_private(Cookie::named("access_token"));
|
||||||
|
|
||||||
|
Redirect::to(uri!(routes::index))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/discord/authorized?<code>&<state>")]
|
||||||
|
pub async fn discord_callback(
|
||||||
|
code: &str,
|
||||||
|
state: &str,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
oauth2_client: &State<BasicClient>,
|
||||||
|
reqwest_client: &State<Client>,
|
||||||
|
) -> Result<Redirect, Flash<Redirect>> {
|
||||||
|
if let (Some(pkce_secret), Some(csrf_token)) =
|
||||||
|
(cookies.get_private("verify"), cookies.get_private("csrf"))
|
||||||
|
{
|
||||||
|
if state == csrf_token.value() {
|
||||||
|
let token_result = oauth2_client
|
||||||
|
.exchange_code(AuthorizationCode::new(code.to_string()))
|
||||||
|
// Set the PKCE code verifier.
|
||||||
|
.set_pkce_verifier(PkceCodeVerifier::new(pkce_secret.value().to_string()))
|
||||||
|
.request_async(async_http_client)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
cookies.remove_private(Cookie::named("verify"));
|
||||||
|
cookies.remove_private(Cookie::named("csrf"));
|
||||||
|
|
||||||
|
match token_result {
|
||||||
|
Ok(token) => {
|
||||||
|
cookies.add_private(
|
||||||
|
Cookie::build("access_token", token.access_token().secret().to_string())
|
||||||
|
.secure(true)
|
||||||
|
.http_only(true)
|
||||||
|
.path("/dashboard")
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let request_res = reqwest_client
|
||||||
|
.get(format!("{}/users/@me", DISCORD_API))
|
||||||
|
.bearer_auth(token.access_token().secret())
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match request_res {
|
||||||
|
Ok(response) => {
|
||||||
|
let user_res = response.json::<User>().await;
|
||||||
|
|
||||||
|
match user_res {
|
||||||
|
Ok(user) => {
|
||||||
|
let user_name = format!("{}#{}", user.name, user.discriminator);
|
||||||
|
let user_id = user.id.as_u64().to_string();
|
||||||
|
|
||||||
|
cookies.add_private(Cookie::new("username", user_name));
|
||||||
|
cookies.add_private(Cookie::new("userid", user_id));
|
||||||
|
|
||||||
|
Ok(Redirect::to(uri!(super::return_to_same_site("dashboard"))))
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error constructing user from request: {:?}", e);
|
||||||
|
|
||||||
|
Err(Flash::new(
|
||||||
|
Redirect::to(uri!(super::return_to_same_site(""))),
|
||||||
|
"danger",
|
||||||
|
"Failed to contact Discord",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error getting user info: {:?}", e);
|
||||||
|
|
||||||
|
Err(Flash::new(
|
||||||
|
Redirect::to(uri!(super::return_to_same_site(""))),
|
||||||
|
"danger",
|
||||||
|
"Failed to contact Discord",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error in discord callback: {:?}", e);
|
||||||
|
|
||||||
|
Err(Flash::new(
|
||||||
|
Redirect::to(uri!(super::return_to_same_site(""))),
|
||||||
|
"warning",
|
||||||
|
"Your login request was rejected. The server may be misconfigured. Please retry or alert us in Discord.",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "danger", "Your request failed to validate, and so has been rejected (CSRF Validation Failure)"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "warning", "Your request was missing information, and so has been rejected (CSRF Validation Tokens Missing)"))
|
||||||
|
}
|
||||||
|
}
|
110
web/src/routes/mod.rs
Normal file
110
web/src/routes/mod.rs
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
pub mod admin;
|
||||||
|
pub mod dashboard;
|
||||||
|
pub mod login;
|
||||||
|
pub mod report;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use rocket::{request::FlashMessage, serde::json::Value as JsonValue};
|
||||||
|
use rocket_dyn_templates::Template;
|
||||||
|
|
||||||
|
pub type JsonResult = Result<JsonValue, JsonValue>;
|
||||||
|
|
||||||
|
#[get("/")]
|
||||||
|
pub async fn index(flash: Option<FlashMessage<'_>>) -> Template {
|
||||||
|
let mut map: HashMap<&str, String> = HashMap::new();
|
||||||
|
|
||||||
|
if let Some(message) = flash {
|
||||||
|
map.insert("flashed_message", message.message().to_string());
|
||||||
|
map.insert("flashed_grade", message.kind().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Template::render("index", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/ret?<to>")]
|
||||||
|
pub async fn return_to_same_site(to: &str) -> Template {
|
||||||
|
let mut map: HashMap<&str, String> = HashMap::new();
|
||||||
|
|
||||||
|
map.insert("to", to.to_string());
|
||||||
|
|
||||||
|
Template::render("return", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/cookies")]
|
||||||
|
pub async fn cookies() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("cookies", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/privacy")]
|
||||||
|
pub async fn privacy() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("privacy", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/terms")]
|
||||||
|
pub async fn terms() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("terms", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/")]
|
||||||
|
pub async fn help() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("help", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/timezone")]
|
||||||
|
pub async fn help_timezone() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("support/timezone", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/create_reminder")]
|
||||||
|
pub async fn help_create_reminder() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("support/create_reminder", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/delete_reminder")]
|
||||||
|
pub async fn help_delete_reminder() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("support/delete_reminder", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/timers")]
|
||||||
|
pub async fn help_timers() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("support/timers", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/todo_lists")]
|
||||||
|
pub async fn help_todo_lists() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("support/todo_lists", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/macros")]
|
||||||
|
pub async fn help_macros() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("support/macros", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/intervals")]
|
||||||
|
pub async fn help_intervals() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("support/intervals", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/dashboard")]
|
||||||
|
pub async fn help_dashboard() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("support/dashboard", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/iemanager")]
|
||||||
|
pub async fn help_iemanager() -> Template {
|
||||||
|
let map: HashMap<&str, String> = HashMap::new();
|
||||||
|
Template::render("support/iemanager", &map)
|
||||||
|
}
|
48
web/src/routes/report.rs
Normal file
48
web/src/routes/report.rs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
use rocket::{
|
||||||
|
http::CookieJar,
|
||||||
|
serde::{
|
||||||
|
json::{json, Json},
|
||||||
|
Deserialize,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::routes::JsonResult;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ClientError {
|
||||||
|
#[serde(rename = "reporterId")]
|
||||||
|
reporter_id: String,
|
||||||
|
url: String,
|
||||||
|
#[serde(rename = "relativeTimestamp")]
|
||||||
|
relative_timestamp: i64,
|
||||||
|
#[serde(rename = "errorMessage")]
|
||||||
|
error_message: String,
|
||||||
|
#[serde(rename = "errorLine")]
|
||||||
|
error_line: u64,
|
||||||
|
#[serde(rename = "errorFile")]
|
||||||
|
error_file: String,
|
||||||
|
#[serde(rename = "errorType")]
|
||||||
|
error_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/report", data = "<client_error>")]
|
||||||
|
pub async fn report_error(cookies: &CookieJar<'_>, client_error: Json<ClientError>) -> JsonResult {
|
||||||
|
if let Some(user_id) = cookies.get_private("userid") {
|
||||||
|
error!(
|
||||||
|
"User {} reports a client-side error.
|
||||||
|
{}, {}:{} at {}ms
|
||||||
|
{}: {}
|
||||||
|
Chain: {}",
|
||||||
|
user_id,
|
||||||
|
client_error.url,
|
||||||
|
client_error.error_file,
|
||||||
|
client_error.error_line,
|
||||||
|
client_error.relative_timestamp,
|
||||||
|
client_error.error_type,
|
||||||
|
client_error.error_message,
|
||||||
|
client_error.reporter_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({}))
|
||||||
|
}
|
1
web/static/css/bulma.min.css
vendored
Normal file
1
web/static/css/bulma.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
91
web/static/css/dtsel.css
Normal file
91
web/static/css/dtsel.css
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
.date-selector-wrapper {
|
||||||
|
width: 200px;
|
||||||
|
padding: 3px;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 1px 1px 10px 1px #5c5c5c;
|
||||||
|
position: absolute;
|
||||||
|
font-size: 12px;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-khtml-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-o-user-select: none;
|
||||||
|
/* user-select: none; */
|
||||||
|
}
|
||||||
|
.cal-header, .cal-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cal-cell, .cal-nav {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cal-day-names {
|
||||||
|
height: 25px;
|
||||||
|
line-height: 25px;
|
||||||
|
}
|
||||||
|
.cal-day-names .cal-cell {
|
||||||
|
cursor: default;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.cal-cell-prev, .cal-cell-next {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
.cal-months .cal-row, .cal-years .cal-row {
|
||||||
|
height: 60px;
|
||||||
|
line-height: 60px;
|
||||||
|
}
|
||||||
|
.cal-nav-prev, .cal-nav-next {
|
||||||
|
flex: 0.15;
|
||||||
|
}
|
||||||
|
.cal-nav-current {
|
||||||
|
flex: 0.75;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.cal-months .cal-cell, .cal-years .cal-cell {
|
||||||
|
flex: 0.25;
|
||||||
|
}
|
||||||
|
.cal-days .cal-cell {
|
||||||
|
flex: 0.143;
|
||||||
|
}
|
||||||
|
.cal-value {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #286090;
|
||||||
|
}
|
||||||
|
.cal-cell:hover, .cal-nav:hover {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
.cal-value:hover {
|
||||||
|
background-color: #204d74;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* time footer */
|
||||||
|
.cal-time {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
height: 27px;
|
||||||
|
line-height: 27px;
|
||||||
|
}
|
||||||
|
.cal-time-label, .cal-time-value {
|
||||||
|
flex: 0.12;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cal-time-slider {
|
||||||
|
flex: 0.77;
|
||||||
|
background-image: linear-gradient(to right, #d1d8dd, #d1d8dd);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 1px;
|
||||||
|
background-position: left 50%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.cal-time-slider input {
|
||||||
|
width: 100%;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: 0 0;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 100%;
|
||||||
|
outline: 0;
|
||||||
|
user-select: auto;
|
||||||
|
}
|
12749
web/static/css/fa.css
Normal file
12749
web/static/css/fa.css
Normal file
File diff suppressed because it is too large
Load Diff
63
web/static/css/font.css
Normal file
63
web/static/css/font.css
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300;
|
||||||
|
src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkids18E.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7nsDc.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCds18E.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwlxdr.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK3dSBYKcSV-LCoeQqfX1RYOo3qOK7g.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwlxdr.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlxdr.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCs6KVjbNBYlgo6eA.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCv6KVjbNBYlgoCxCvTtw.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user