Compare commits
143 Commits
next
...
jude/remov
Author | SHA1 | Date | |
---|---|---|---|
d7e90614c8 | |||
b5dbfe336d | |||
218be2f0b1 | |||
d7515f3611 | |||
6ae1096d79 | |||
1f0d7adae3 | |||
fc96ae526f | |||
8881ef0f85 | |||
5e82a687f9 | |||
de4ecf8dd6 | |||
064efd4386 | |||
65b8ba3b47 | |||
9d452ed8cb | |||
441419b92b | |||
aecf2c15be | |||
79da56c794 | |||
ef10902c1e | |||
c277f85c2a | |||
035653c7fa | |||
6358bc3deb | |||
9f5066f982 | |||
1d06999e41 | |||
1cf707140c | |||
e38c63f5ba | |||
d52b8b26f2 | |||
bb2128a7ed | |||
5e99a6f9de | |||
5406e6b8ec | |||
4ee0bc4e37 | |||
b99bb7dcbf | |||
98f925dc84 | |||
24e316b12f | |||
4063334953 | |||
e128b9848f | |||
9989ab3b35 | |||
b951db3f55 | |||
884a47bf36 | |||
b0f932445c | |||
2861cdda0b | |||
7ba8fcd6b7 | |||
850f0fad57 | |||
a770a17ee7 | |||
d15a66d9d9 | |||
30f011fcd5 | |||
15dbed2f0f | |||
18cac0345b | |||
334b1bc084 | |||
ba3c76c25f | |||
67b6f30c62 | |||
8ae311190f | |||
016164affb | |||
2c0aeef700 | |||
ecd75d6f55 | |||
4a80d42f86 | |||
075fde71df | |||
55136aecdc | |||
63fc2cdcbc | |||
3190738fc5 | |||
8f4810b532 | |||
a5e6c41fa5 | |||
5f0aa0f834 | |||
dbe8e8e358 | |||
85a114e55c | |||
329492b244 | |||
66135ecd08 | |||
382c2a5a1e | |||
b91245a3f7 | |||
6f0bdf9852 | |||
dcee9e0d2a | |||
8e6e1a18b7 | |||
72af0532fa | |||
e83b643d86 | |||
0e0ab053f3 | |||
8c2296b9c8 | |||
1c6103142f | |||
328127c55e | |||
b0e37b56c0 | |||
45f5b6261a | |||
5f6326179c | |||
6254f91841 | |||
60b90a61d4 | |||
90f05758d0 | |||
74b7b5d711 | |||
90550dc2c7 | |||
79e6498245 | |||
a8ef3d03f9 | |||
53e13844f9 | |||
dd7e681285 | |||
6c20bf2a0f | |||
15aa9ccffd | |||
525471bcad | |||
86d53b63b6 | |||
d8f266852a | |||
76a286076b | |||
5e39e16060 | |||
c1305cfb36 | |||
4823754955 | |||
eb92eacb90 | |||
d0833b7bca | |||
b81c3c80c1 | |||
2f6d035efe | |||
96012ce43c | |||
fa7ec8731b | |||
def43bfa78 | |||
e4e9af2bb4 | |||
cce0de7c75 | |||
e7803b98e8 | |||
7aae246388 | |||
a2d442bc54 | |||
59982df827 | |||
7a6372ed02 | |||
14a54471f7 | |||
5d3b77f1cd | |||
1d64c8bb79 | |||
8ba0f02b98 | |||
d36438c6ce | |||
e0c60e2ce3 | |||
e7160215b0 | |||
6eaa6f0f28 | |||
9db0fa2513 | |||
ca13fd4fa7 | |||
55acc8fd16 | |||
145711fa5d | |||
5524215786 | |||
e8bd05893f | |||
e3d3418f99 | |||
2681280a39 | |||
00579428a1 | |||
b8ef999710 | |||
e8f84e281a | |||
8ddff698e5 | |||
541633270c | |||
25286da5e0 | |||
4bad1324b9 | |||
bd1462a00c | |||
56ffc43616 | |||
52cf642455 | |||
0bf578357a | |||
6e9eccb62e | |||
6ea28284ce | |||
a6525f3052 | |||
348639270d | |||
37177c2431 |
29
.gitignore
vendored
@ -1,5 +1,30 @@
|
|||||||
/target
|
target
|
||||||
.env
|
.env
|
||||||
/venv
|
/venv
|
||||||
.cargo
|
.cargo
|
||||||
/.idea
|
.idea
|
||||||
|
web/static/index.html
|
||||||
|
web/static/assets
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
2240
Cargo.lock
generated
45
Cargo.toml
@ -1,22 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "reminder-rs"
|
name = "reminder-rs"
|
||||||
version = "1.6.40"
|
version = "1.7.24"
|
||||||
authors = ["Jude Southworth <judesouthworth@pm.me>"]
|
authors = ["Jude Southworth <judesouthworth@pm.me>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "AGPL-3.0 only"
|
license = "AGPL-3.0 only"
|
||||||
description = "Reminder Bot for Discord, now in Rust"
|
description = "Reminder Bot for Discord, now in Rust"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
poise = "0.5"
|
poise = "0.6.1"
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
tokio = { version = "1", features = ["process", "full"] }
|
tokio = { version = "1", features = ["process", "full"] }
|
||||||
reqwest = "0.11"
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
lazy-regex = "3.0"
|
regex = "1.10"
|
||||||
regex = "1.9"
|
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.10"
|
env_logger = "0.11"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
chrono-tz = { version = "0.8", features = ["serde"] }
|
chrono-tz = { version = "0.9", features = ["serde"] }
|
||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
num-integer = "0.1"
|
num-integer = "0.1"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
@ -26,13 +25,22 @@ rmp-serde = "1.1"
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
levenshtein = "1.0"
|
levenshtein = "1.0"
|
||||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] }
|
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] }
|
||||||
base64 = "0.21.0"
|
base64 = "0.22"
|
||||||
|
secrecy = "0.8.0"
|
||||||
|
futures = "0.3.30"
|
||||||
|
prometheus = "0.13.3"
|
||||||
|
rocket = { version = "0.5.0", features = ["tls", "secrets", "json"] }
|
||||||
|
rocket_dyn_templates = { version = "0.1.0", features = ["tera"] }
|
||||||
|
serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
||||||
|
oauth2 = "4"
|
||||||
|
csv = "1.2"
|
||||||
|
sd-notify = "0.4.1"
|
||||||
|
|
||||||
[dependencies.postman]
|
[dependencies.extract_derive]
|
||||||
path = "postman"
|
path = "extract_derive"
|
||||||
|
|
||||||
[dependencies.reminder_web]
|
[dependencies.recordable_derive]
|
||||||
path = "web"
|
path = "recordable_derive"
|
||||||
|
|
||||||
[package.metadata.deb]
|
[package.metadata.deb]
|
||||||
depends = "$auto, python3-dateparser (>= 1.0.0)"
|
depends = "$auto, python3-dateparser (>= 1.0.0)"
|
||||||
@ -40,12 +48,17 @@ suggests = "mysql-server-8.0, nginx"
|
|||||||
maintainer-scripts = "debian"
|
maintainer-scripts = "debian"
|
||||||
assets = [
|
assets = [
|
||||||
["target/release/reminder-rs", "usr/bin/reminder-rs", "755"],
|
["target/release/reminder-rs", "usr/bin/reminder-rs", "755"],
|
||||||
|
["static/css/*", "lib/reminder-rs/static/css", "644"],
|
||||||
|
["static/favicon/*", "lib/reminder-rs/static/favicon", "644"],
|
||||||
|
["static/img/*", "lib/reminder-rs/static/img", "644"],
|
||||||
|
["static/js/*", "lib/reminder-rs/static/js", "644"],
|
||||||
|
["static/webfonts/*", "lib/reminder-rs/static/webfonts", "644"],
|
||||||
|
["static/site.webmanifest", "lib/reminder-rs/static/site.webmanifest", "644"],
|
||||||
|
["templates/**/*", "lib/reminder-rs/templates", "644"],
|
||||||
|
["reminder-dashboard/dist/static/assets/*", "lib/reminder-rs/static/assets", "644"],
|
||||||
|
["reminder-dashboard/dist/index.html", "lib/reminder-rs/static/index.html", "644"],
|
||||||
["conf/default.env", "etc/reminder-rs/config.env", "600"],
|
["conf/default.env", "etc/reminder-rs/config.env", "600"],
|
||||||
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
|
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
|
||||||
["web/static/**/*", "lib/reminder-rs/static", "644"],
|
|
||||||
["web/templates/**/*", "lib/reminder-rs/templates", "644"],
|
|
||||||
["healthcheck", "lib/reminder-rs/healthcheck", "755"],
|
|
||||||
["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"],
|
|
||||||
# ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"]
|
# ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"]
|
||||||
]
|
]
|
||||||
conf-files = [
|
conf-files = [
|
||||||
|
@ -4,6 +4,6 @@ ENV RUSTUP_HOME=/usr/local/rustup \
|
|||||||
CARGO_HOME=/usr/local/cargo \
|
CARGO_HOME=/usr/local/cargo \
|
||||||
PATH=/usr/local/cargo/bin:$PATH
|
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 apt update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib cmake pkg-config libssl-dev curl mysql-client-8.0 npm
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain nightly
|
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
|
RUN cargo install cargo-deb
|
||||||
|
22
Rocket.toml
@ -1,28 +1,28 @@
|
|||||||
[default]
|
[default]
|
||||||
address = "0.0.0.0"
|
address = "0.0.0.0"
|
||||||
port = 18920
|
port = 18920
|
||||||
template_dir = "web/templates"
|
template_dir = "templates"
|
||||||
limits = { json = "10MiB" }
|
limits = { json = "10MiB" }
|
||||||
|
|
||||||
[debug]
|
[debug]
|
||||||
secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="
|
secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="
|
||||||
|
|
||||||
[debug.tls]
|
[debug.tls]
|
||||||
certs = "web/private/rsa_sha256_cert.pem"
|
certs = "private/rsa_sha256_cert.pem"
|
||||||
key = "web/private/rsa_sha256_key.pem"
|
key = "private/rsa_sha256_key.pem"
|
||||||
|
|
||||||
[debug.rsa_sha256.tls]
|
[debug.rsa_sha256.tls]
|
||||||
certs = "web/private/rsa_sha256_cert.pem"
|
certs = "private/rsa_sha256_cert.pem"
|
||||||
key = "web/private/rsa_sha256_key.pem"
|
key = "private/rsa_sha256_key.pem"
|
||||||
|
|
||||||
[debug.ecdsa_nistp256_sha256.tls]
|
[debug.ecdsa_nistp256_sha256.tls]
|
||||||
certs = "web/private/ecdsa_nistp256_sha256_cert.pem"
|
certs = "private/ecdsa_nistp256_sha256_cert.pem"
|
||||||
key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem"
|
key = "private/ecdsa_nistp256_sha256_key_pkcs8.pem"
|
||||||
|
|
||||||
[debug.ecdsa_nistp384_sha384.tls]
|
[debug.ecdsa_nistp384_sha384.tls]
|
||||||
certs = "web/private/ecdsa_nistp384_sha384_cert.pem"
|
certs = "private/ecdsa_nistp384_sha384_cert.pem"
|
||||||
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
|
key = "private/ecdsa_nistp384_sha384_key_pkcs8.pem"
|
||||||
|
|
||||||
[debug.ed25519.tls]
|
[debug.ed25519.tls]
|
||||||
certs = "web/private/ed25519_cert.pem"
|
certs = "private/ed25519_cert.pem"
|
||||||
key = "eb/private/ed25519_key.pem"
|
key = "private/ed25519_key.pem"
|
||||||
|
10
build.rs
@ -1,3 +1,13 @@
|
|||||||
|
use std::{path::Path, process::Command};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
println!("cargo:rerun-if-changed=migrations");
|
println!("cargo:rerun-if-changed=migrations");
|
||||||
|
println!("cargo:rerun-if-changed=reminder-dashboard");
|
||||||
|
|
||||||
|
Command::new("npm")
|
||||||
|
.arg("run")
|
||||||
|
.arg("build")
|
||||||
|
.current_dir(Path::new("reminder-dashboard"))
|
||||||
|
.spawn()
|
||||||
|
.expect("Failed to build NPM");
|
||||||
}
|
}
|
||||||
|
46
extract_derive/Cargo.lock
generated
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 3
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "extract_macro"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.78"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.49"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "915aea9e586f80826ee59f8453c1101f9d1c4b3964cd2460185ee8e299ada496"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
|
11
extract_derive/Cargo.toml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "extract_derive"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
proc-macro = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
quote = "1.0.35"
|
||||||
|
syn = { version = "2.0.49", features = ["full"] }
|
53
extract_derive/src/lib.rs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
use proc_macro::TokenStream;
|
||||||
|
use syn::{spanned::Spanned, Data, Fields};
|
||||||
|
|
||||||
|
#[proc_macro_derive(Extract)]
|
||||||
|
pub fn extract(input: TokenStream) -> TokenStream {
|
||||||
|
let ast = syn::parse_macro_input!(input);
|
||||||
|
|
||||||
|
impl_extract(&ast)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn impl_extract(ast: &syn::DeriveInput) -> TokenStream {
|
||||||
|
let name = &ast.ident;
|
||||||
|
|
||||||
|
match &ast.data {
|
||||||
|
// Dispatch over struct: extract args directly from context
|
||||||
|
Data::Struct(st) => match &st.fields {
|
||||||
|
Fields::Named(fields) => {
|
||||||
|
let extracted = fields.named.iter().map(|field| {
|
||||||
|
let ident = &field.ident;
|
||||||
|
let ty = &field.ty;
|
||||||
|
|
||||||
|
quote::quote_spanned! {field.span()=>
|
||||||
|
#ident : crate::utils::extract_arg!(ctx, #ident, #ty)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
TokenStream::from(quote::quote! {
|
||||||
|
impl Extract for #name {
|
||||||
|
fn extract(ctx: crate::ApplicationContext) -> Self {
|
||||||
|
Self {
|
||||||
|
#(#extracted,)*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Fields::Unit => TokenStream::from(quote::quote! {
|
||||||
|
impl Extract for #name {
|
||||||
|
fn extract(ctx: crate::ApplicationContext) -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
_ => {
|
||||||
|
panic!("Only named/unit structs can derive Extract");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
panic!("Only structs can derive Extract");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
healthcheck
@ -1,13 +0,0 @@
|
|||||||
#!/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,19 +0,0 @@
|
|||||||
-- Drop existing constraint
|
|
||||||
ALTER TABLE `reminders` DROP CONSTRAINT `reminders_ibfk_1`;
|
|
||||||
|
|
||||||
ALTER TABLE `reminders` MODIFY COLUMN `channel_id` INT UNSIGNED;
|
|
||||||
ALTER TABLE `reminders` ADD COLUMN `guild_id` INT UNSIGNED;
|
|
||||||
|
|
||||||
ALTER TABLE `reminders`
|
|
||||||
ADD CONSTRAINT `guild_id_fk`
|
|
||||||
FOREIGN KEY (`guild_id`)
|
|
||||||
REFERENCES `guilds`(`id`)
|
|
||||||
ON DELETE CASCADE;
|
|
||||||
|
|
||||||
ALTER TABLE `reminders`
|
|
||||||
ADD CONSTRAINT `channel_id_fk`
|
|
||||||
FOREIGN KEY (`channel_id`)
|
|
||||||
REFERENCES `channels`(`id`)
|
|
||||||
ON DELETE SET NULL;
|
|
||||||
|
|
||||||
UPDATE `reminders` SET `guild_id` = (SELECT guilds.`id` FROM `channels` INNER JOIN `guilds` ON channels.guild_id = guilds.id WHERE reminders.channel_id = channels.id);
|
|
@ -1,4 +0,0 @@
|
|||||||
ALTER TABLE reminders ADD COLUMN `status_change_time` DATETIME;
|
|
||||||
|
|
||||||
-- This is a best-guess as to the status change time.
|
|
||||||
UPDATE reminders SET `status_change_time` = `utc_time` WHERE `status` != 'pending';
|
|
@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `reminder_template` ADD COLUMN `interval_seconds` INT UNSIGNED;
|
||||||
|
ALTER TABLE `reminder_template` ADD COLUMN `interval_days` INT UNSIGNED;
|
||||||
|
ALTER TABLE `reminder_template` ADD COLUMN `interval_months` INT UNSIGNED;
|
50
migrations/20240210133900_macro_restructure.sql
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
CREATE TABLE command_macro (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT,
|
||||||
|
guild_id INT UNSIGNED NOT NULL,
|
||||||
|
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description VARCHAR(100),
|
||||||
|
commands JSON NOT NULL,
|
||||||
|
|
||||||
|
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
# New JSON structure is {command_name: "Remind", "<option name>": "<option value>", ...}
|
||||||
|
INSERT INTO command_macro (guild_id, description, name, commands)
|
||||||
|
SELECT
|
||||||
|
guild_id,
|
||||||
|
description,
|
||||||
|
name,
|
||||||
|
(
|
||||||
|
SELECT JSON_ARRAYAGG(
|
||||||
|
(
|
||||||
|
SELECT JSON_OBJECTAGG(t2.name, t2.value)
|
||||||
|
FROM JSON_TABLE(
|
||||||
|
JSON_ARRAY_APPEND(t1.options, '$', JSON_OBJECT('name', 'command_name', 'value', t1.command_name)),
|
||||||
|
'$[*]' COLUMNS (
|
||||||
|
name VARCHAR(64) PATH '$.name' ERROR ON ERROR,
|
||||||
|
value TEXT PATH '$.value' ERROR ON ERROR
|
||||||
|
)) AS t2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM macro m2
|
||||||
|
JOIN JSON_TABLE(
|
||||||
|
commands,
|
||||||
|
'$[*]' COLUMNS (
|
||||||
|
command_name VARCHAR(64) PATH '$.command_name' ERROR ON ERROR,
|
||||||
|
options JSON PATH '$.options' ERROR ON ERROR
|
||||||
|
)) AS t1
|
||||||
|
WHERE m1.id = m2.id
|
||||||
|
)
|
||||||
|
FROM macro m1;
|
||||||
|
|
||||||
|
# # Check which commands are used in macros
|
||||||
|
# SELECT DISTINCT command_name
|
||||||
|
# FROM macro m2
|
||||||
|
# JOIN JSON_TABLE(
|
||||||
|
# commands,
|
||||||
|
# '$[*]' COLUMNS (
|
||||||
|
# command_name VARCHAR(64) PATH '$.command_name' ERROR ON ERROR,
|
||||||
|
# options JSON PATH '$.options' ERROR ON ERROR
|
||||||
|
# )) AS t1
|
5
migrations/20240303125837_add_indexes.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
ALTER TABLE reminders
|
||||||
|
ADD INDEX `utc_time_index` (`utc_time`);
|
||||||
|
ALTER TABLE reminders
|
||||||
|
ADD INDEX `status_index` (`status`);
|
53
nginx/reminder-bot
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
server {
|
||||||
|
server_name www.reminder-bot.com;
|
||||||
|
|
||||||
|
return 301 https://reminder-bot.com$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name beta.reminder-bot.com;
|
||||||
|
|
||||||
|
return 301 https://reminder-bot.com$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name beta.reminder-bot.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/beta.reminder-bot.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/beta.reminder-bot.com/privkey.pem;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,41 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "postman"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tokio = { version = "1", features = ["process", "full"] }
|
|
||||||
regex = "1.9"
|
|
||||||
log = "0.4"
|
|
||||||
chrono = "0.4"
|
|
||||||
chrono-tz = { version = "0.8", features = ["serde"] }
|
|
||||||
lazy_static = "1.4"
|
|
||||||
num-integer = "0.1"
|
|
||||||
serde = "1.0"
|
|
||||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
|
|
||||||
serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
|
@ -1,800 +0,0 @@
|
|||||||
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: Option<u64>,
|
|
||||||
webhook_id: Option<u64>,
|
|
||||||
webhook_token: Option<String>,
|
|
||||||
|
|
||||||
channel_paused: Option<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
|
|
||||||
LEFT 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', `status_change_time` = NOW()
|
|
||||||
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` = ?, `status_change_time` = NOW()
|
|
||||||
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>) {
|
|
||||||
if let Some(channel_id) = self.channel_id {
|
|
||||||
let _ = http.as_ref().pin_message(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,
|
|
||||||
channel_id: u64,
|
|
||||||
reminder: &Reminder,
|
|
||||||
embed: Option<CreateEmbed>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let channel = ChannelId(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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.channel_id {
|
|
||||||
Some(channel_id) => {
|
|
||||||
if self.enabled
|
|
||||||
&& !(self.channel_paused.unwrap_or(false)
|
|
||||||
&& 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, channel_id, &self, embed).await
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
send_to_channel(cache_http, channel_id, &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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None => {
|
|
||||||
info!("Reminder {} is orphaned", self.id);
|
|
||||||
|
|
||||||
self.log_error(pool, "Orphaned", Option::<u8>::None).await;
|
|
||||||
self.set_failed(pool, "Could not be sent as channel was deleted").await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
11
recordable_derive/Cargo.toml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "recordable_derive"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
proc-macro = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
quote = "1.0.35"
|
||||||
|
syn = { version = "2.0.49", features = ["full"] }
|
42
recordable_derive/src/lib.rs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
use proc_macro::TokenStream;
|
||||||
|
use syn::{spanned::Spanned, Data};
|
||||||
|
|
||||||
|
/// Macro to allow Recordable to be implemented on an enum by dispatching over each variant.
|
||||||
|
#[proc_macro_derive(Recordable)]
|
||||||
|
pub fn extract(input: TokenStream) -> TokenStream {
|
||||||
|
let ast = syn::parse_macro_input!(input);
|
||||||
|
|
||||||
|
impl_recordable(&ast)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn impl_recordable(ast: &syn::DeriveInput) -> TokenStream {
|
||||||
|
let name = &ast.ident;
|
||||||
|
|
||||||
|
match &ast.data {
|
||||||
|
Data::Enum(en) => {
|
||||||
|
let extracted = en.variants.iter().map(|var| {
|
||||||
|
let ident = &var.ident;
|
||||||
|
|
||||||
|
quote::quote_spanned! {var.span()=>
|
||||||
|
Self::#ident (opt) => opt.run(ctx).await?
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
TokenStream::from(quote::quote! {
|
||||||
|
impl Recordable for #name {
|
||||||
|
async fn run(self, ctx: crate::Context<'_>) -> Result<(), crate::Error> {
|
||||||
|
match self {
|
||||||
|
#(#extracted,)*
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
panic!("Only enums can derive Recordable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2
reminder-dashboard/.prettierrc.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
printWidth = 100
|
||||||
|
tabWidth = 4
|
19
reminder-dashboard/README.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# reminder-dashboard
|
||||||
|
|
||||||
|
The re-re-rewrite of the dashboard.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The existing beta variant of the dashboard is written using vanilla JavaScript. This is fine,
|
||||||
|
but annoying to update. This would've been okay if I was more dedicated to updating the vanilla
|
||||||
|
JavaScript too, but I want to experiment with "new" things.
|
||||||
|
|
||||||
|
This also allows me to expand my frontend skills, which is relevant to part of my job.
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
1. Run both `npm run dev` and `cargo run`
|
||||||
|
2. Symlink assets: assuming cloned
|
||||||
|
into `$HOME`, `ln -s $HOME/reminder-bot/reminder-dashboard/dist/index.html $HOME/reminder-bot/web/static/index.html`
|
||||||
|
and
|
||||||
|
`ln -s $HOME/reminder-bot/reminder-dashboard/dist/static/assets $HOME/reminder-bot/web/static/assets`
|
34
reminder-dashboard/index.html
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="EN">
|
||||||
|
<head>
|
||||||
|
<meta name="description" content="The most powerful Discord Reminders Bot">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="yandex-verification" content="bb77b8681eb64a90"/>
|
||||||
|
<meta name="google-site-verification" content="7h7UVTeEe0AOzHiH3cFtsqMULYGN-zCZdMT_YCkW1Ho"/>
|
||||||
|
<!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; font-src fonts.gstatic.com 'self'"> -->
|
||||||
|
|
||||||
|
<!-- favicon -->
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180"
|
||||||
|
href="/static/favicon/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32"
|
||||||
|
href="/static/favicon/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16"
|
||||||
|
href="/static/favicon/favicon-16x16.png">
|
||||||
|
<link rel="manifest" href="/static/site.webmanifest">
|
||||||
|
<meta name="msapplication-TileColor" content="#da532c">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
|
<title>Reminder Bot | Dashboard</title>
|
||||||
|
|
||||||
|
<!-- styles -->
|
||||||
|
<link rel="stylesheet" href="/static/css/bulma.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/fa.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/font.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
5626
reminder-dashboard/package-lock.json
generated
Normal file
32
reminder-dashboard/package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "example",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite build --watch --mode development",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.5.1",
|
||||||
|
"bulma": "^0.9.4",
|
||||||
|
"luxon": "^3.4.3",
|
||||||
|
"preact": "^10.13.1",
|
||||||
|
"react-colorful": "^5.6.1",
|
||||||
|
"react-query": "^3.39.3",
|
||||||
|
"tributejs": "^5.1.3",
|
||||||
|
"use-debounce": "^10.0.0",
|
||||||
|
"wouter": "^3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.5.0",
|
||||||
|
"@types/luxon": "^3.3.2",
|
||||||
|
"eslint": "^8.50.0",
|
||||||
|
"eslint-config-preact": "^1.3.0",
|
||||||
|
"prettier": "^3.0.3",
|
||||||
|
"react-datepicker": "^4.21.0",
|
||||||
|
"sass": "^1.71.1",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.1"
|
||||||
|
}
|
||||||
|
}
|
@ -15,6 +15,10 @@ div.reminderContent.is-collapsed .column.settings {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.reminderContent.is-collapsed .reminder-settings {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
div.reminderContent.is-collapsed .button-row {
|
div.reminderContent.is-collapsed .button-row {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@ -304,11 +308,6 @@ div.dashboard-sidebar {
|
|||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.dashboard-sidebar:not(.mobile-sidebar) {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul.guildList {
|
ul.guildList {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
@ -318,6 +317,9 @@ ul.guildList {
|
|||||||
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
|
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
width: 226px;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.dashboard-sidebar svg {
|
div.dashboard-sidebar svg {
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 762 B |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 323 KiB After Width: | Height: | Size: 323 KiB |
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
@ -18,7 +18,6 @@ const $downloader = document.querySelector("a#downloader");
|
|||||||
const $uploader = document.querySelector("input#uploader");
|
const $uploader = document.querySelector("input#uploader");
|
||||||
|
|
||||||
let channels = [];
|
let channels = [];
|
||||||
let reminderErrors = [];
|
|
||||||
let guildNames = {};
|
let guildNames = {};
|
||||||
let roles = [];
|
let roles = [];
|
||||||
let templates = {};
|
let templates = {};
|
||||||
@ -34,11 +33,7 @@ let globalPatreon = false;
|
|||||||
let guildPatreon = false;
|
let guildPatreon = false;
|
||||||
|
|
||||||
function guildId() {
|
function guildId() {
|
||||||
return document.querySelector("li > a.is-active").parentElement.dataset["guild"];
|
return document.querySelector(".guildList a.is-active").dataset["guild"];
|
||||||
}
|
|
||||||
|
|
||||||
function guildName() {
|
|
||||||
return guildNames[guildId()];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorToInt(r, g, b) {
|
function colorToInt(r, g, b) {
|
||||||
@ -57,7 +52,7 @@ function switch_pane(selector) {
|
|||||||
el.classList.add("is-hidden");
|
el.classList.add("is-hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector(`*[data-name=${selector}]`).classList.remove("is-hidden");
|
document.getElementById(selector).classList.remove("is-hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function update_select(sel) {
|
function update_select(sel) {
|
||||||
@ -228,11 +223,10 @@ async function fetch_reminders(guild_id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function serialize_reminder(node, mode) {
|
async function serialize_reminder(node, mode) {
|
||||||
let interval, utc_time, expiration_time;
|
let utc_time, expiration_time;
|
||||||
|
let interval = get_interval(node);
|
||||||
|
|
||||||
if (mode !== "template") {
|
if (mode !== "template") {
|
||||||
interval = get_interval(node);
|
|
||||||
|
|
||||||
utc_time = luxon.DateTime.fromISO(
|
utc_time = luxon.DateTime.fromISO(
|
||||||
node.querySelector('input[name="time"]').value
|
node.querySelector('input[name="time"]').value
|
||||||
).setZone("UTC");
|
).setZone("UTC");
|
||||||
@ -361,9 +355,9 @@ async function serialize_reminder(node, mode) {
|
|||||||
embed_title: embed_title,
|
embed_title: embed_title,
|
||||||
embed_fields: fields,
|
embed_fields: fields,
|
||||||
expires: expiration_time,
|
expires: expiration_time,
|
||||||
interval_seconds: mode !== "template" ? interval.seconds : null,
|
interval_seconds: interval.seconds,
|
||||||
interval_days: mode !== "template" ? interval.days : null,
|
interval_days: interval.days,
|
||||||
interval_months: mode !== "template" ? interval.months : null,
|
interval_months: interval.months,
|
||||||
name: node.querySelector('input[name="name"]').value,
|
name: node.querySelector('input[name="name"]').value,
|
||||||
tts: node.querySelector('input[name="tts"]').checked,
|
tts: node.querySelector('input[name="tts"]').checked,
|
||||||
username: node.querySelector('input[name="username"]').value,
|
username: node.querySelector('input[name="username"]').value,
|
||||||
@ -425,9 +419,9 @@ function deserialize_reminder(reminder, frame, mode) {
|
|||||||
.insertBefore(embed_field, lastChild);
|
.insertBefore(embed_field, lastChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode !== "template") {
|
|
||||||
if (reminder["interval_seconds"]) update_interval(frame);
|
if (reminder["interval_seconds"]) update_interval(frame);
|
||||||
|
|
||||||
|
if (mode !== "template") {
|
||||||
let $enableBtn = frame.querySelector(".disable-enable");
|
let $enableBtn = frame.querySelector(".disable-enable");
|
||||||
$enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable";
|
$enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable";
|
||||||
|
|
||||||
@ -454,27 +448,21 @@ document.addEventListener("guildSwitched", async (e) => {
|
|||||||
.querySelectorAll(".patreon-only")
|
.querySelectorAll(".patreon-only")
|
||||||
.forEach((el) => el.classList.add("is-locked"));
|
.forEach((el) => el.classList.add("is-locked"));
|
||||||
|
|
||||||
let $li = document.querySelectorAll(`li[data-guild="${e.detail.guild_id}"]`);
|
let $anchor = document.querySelector(
|
||||||
|
`.switch-pane[data-guild="${e.detail.guild_id}"]`
|
||||||
|
);
|
||||||
|
|
||||||
if ($li.length === 0) {
|
let hasError = false;
|
||||||
|
|
||||||
|
if ($anchor === null) {
|
||||||
switch_pane("user-error");
|
switch_pane("user-error");
|
||||||
|
hasError = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch_pane(e.detail.pane);
|
switch_pane($anchor.dataset["pane"]);
|
||||||
reset_guild_pane();
|
reset_guild_pane();
|
||||||
document
|
$anchor.classList.add("is-active");
|
||||||
.querySelectorAll(`li[data-guild="${e.detail.guild_id}"] > a`)
|
|
||||||
.forEach((el) => {
|
|
||||||
el.classList.add("is-active");
|
|
||||||
});
|
|
||||||
document
|
|
||||||
.querySelectorAll(
|
|
||||||
`li[data-guild="${e.detail.guild_id}"] *[data-pane="${e.detail.pane}"]`
|
|
||||||
)
|
|
||||||
.forEach((el) => {
|
|
||||||
el.classList.add("is-active");
|
|
||||||
});
|
|
||||||
|
|
||||||
if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) {
|
if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) {
|
||||||
document
|
document
|
||||||
@ -482,26 +470,15 @@ document.addEventListener("guildSwitched", async (e) => {
|
|||||||
.forEach((el) => el.classList.remove("is-locked"));
|
.forEach((el) => el.classList.remove("is-locked"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = new CustomEvent("paneLoad", {
|
hasError = await fetch_channels(e.detail.guild_id);
|
||||||
detail: {
|
|
||||||
guild_id: e.detail.guild_id,
|
|
||||||
pane: e.detail.pane,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("paneLoad", async (ev) => {
|
|
||||||
const hasError = await fetch_channels(ev.detail.guild_id);
|
|
||||||
if (!hasError) {
|
if (!hasError) {
|
||||||
fetch_roles(ev.detail.guild_id);
|
fetch_roles(e.detail.guild_id);
|
||||||
fetch_templates(ev.detail.guild_id);
|
fetch_templates(e.detail.guild_id);
|
||||||
fetch_reminders(ev.detail.guild_id);
|
fetch_reminders(e.detail.guild_id);
|
||||||
|
|
||||||
document.querySelectorAll("p.pageTitle").forEach((el) => {
|
document.querySelectorAll("p.pageTitle").forEach((el) => {
|
||||||
el.textContent = `${guildName()} Reminders`;
|
el.textContent = `${e.detail.guild_name} Reminders`;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll("select.channel-selector").forEach((el) => {
|
document.querySelectorAll("select.channel-selector").forEach((el) => {
|
||||||
el.addEventListener("change", (e) => {
|
el.addEventListener("change", (e) => {
|
||||||
update_select(e.target);
|
update_select(e.target);
|
||||||
@ -626,6 +603,16 @@ function show_error(error) {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function show_success(error) {
|
||||||
|
document.getElementById("success").querySelector("span.success-message").textContent =
|
||||||
|
error;
|
||||||
|
document.getElementById("success").classList.add("is-active");
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
document.getElementById("success").classList.remove("is-active");
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
$colorPickerInput.value = colorPicker.color.hexString;
|
$colorPickerInput.value = colorPicker.color.hexString;
|
||||||
|
|
||||||
$colorPickerInput.addEventListener("input", () => {
|
$colorPickerInput.addEventListener("input", () => {
|
||||||
@ -706,56 +693,36 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
"%guildname%",
|
"%guildname%",
|
||||||
guild.name
|
guild.name
|
||||||
);
|
);
|
||||||
|
$anchor.dataset["guild"] = guild.id;
|
||||||
$anchor.dataset["name"] = guild.name;
|
$anchor.dataset["name"] = guild.name;
|
||||||
$anchor.href = `/dashboard/${guild.id}/reminders`;
|
$anchor.href = `/dashboard/${guild.id}?name=${guild.name}`;
|
||||||
|
|
||||||
const $li = $anchor.parentElement;
|
$anchor.addEventListener("click", async (e) => {
|
||||||
$li.dataset["guild"] = guild.id;
|
|
||||||
|
|
||||||
$li.querySelectorAll("a").forEach((el) => {
|
|
||||||
el.addEventListener("click", (e) => {
|
|
||||||
const pane = el.dataset["pane"];
|
|
||||||
const slug = el.dataset["slug"];
|
|
||||||
|
|
||||||
if (pane !== undefined && slug !== undefined) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
window.history.pushState({}, "", `/dashboard/${guild.id}`);
|
||||||
switch_pane(pane);
|
|
||||||
|
|
||||||
window.history.pushState(
|
|
||||||
{},
|
|
||||||
"",
|
|
||||||
`/dashboard/${guild.id}/${slug}`
|
|
||||||
);
|
|
||||||
const event = new CustomEvent("guildSwitched", {
|
const event = new CustomEvent("guildSwitched", {
|
||||||
detail: {
|
detail: {
|
||||||
|
guild_name: guild.name,
|
||||||
guild_id: guild.id,
|
guild_id: guild.id,
|
||||||
pane,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
element.append($clone);
|
element.append($clone);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches = window.location.href.match(
|
const matches = window.location.href.match(/dashboard\/(\d+)/);
|
||||||
/dashboard\/(\d+)(\/)?([a-zA-Z\-]+)?/
|
|
||||||
);
|
|
||||||
if (matches) {
|
if (matches) {
|
||||||
let id = matches[1];
|
let id = matches[1];
|
||||||
let kind = matches[3];
|
|
||||||
let name = guildNames[id];
|
let name = guildNames[id];
|
||||||
|
|
||||||
const event = new CustomEvent("guildSwitched", {
|
const event = new CustomEvent("guildSwitched", {
|
||||||
detail: {
|
detail: {
|
||||||
guild_name: name,
|
guild_name: name,
|
||||||
guild_id: id,
|
guild_id: id,
|
||||||
pane: kind,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -796,11 +763,25 @@ $uploader.addEventListener("change", (ev) => {
|
|||||||
fileReader.onload = (e) => resolve(fileReader.result);
|
fileReader.onload = (e) => resolve(fileReader.result);
|
||||||
fileReader.readAsDataURL($uploader.files[0]);
|
fileReader.readAsDataURL($uploader.files[0]);
|
||||||
}).then((dataUrl) => {
|
}).then((dataUrl) => {
|
||||||
|
$importBtn.setAttribute("disabled", true);
|
||||||
|
|
||||||
fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`, {
|
fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ body: dataUrl.split(",")[1] }),
|
body: JSON.stringify({ body: dataUrl.split(",")[1] }),
|
||||||
}).then(() => {
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
$importBtn.removeAttribute("disabled");
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
show_error(data.error);
|
||||||
|
} else {
|
||||||
|
show_success(data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
delete $uploader.files[0];
|
delete $uploader.files[0];
|
||||||
|
fetch_reminders(guild);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
Before Width: | Height: | Size: 712 KiB After Width: | Height: | Size: 712 KiB |
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.5 MiB |
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |