Compare commits
158 Commits
jellywx/ma
...
jude/react
Author | SHA1 | Date | |
---|---|---|---|
1a03c2471b | |||
a476f43f28 | |||
17192b0f89 | |||
0419863afa | |||
827a982a40 | |||
6e435bfc2e | |||
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 | |||
8587bed703 | |||
6c9af1ae8e | |||
7695b7a476 | |||
651da7b28e | |||
eb086146bf | |||
4ebd705e5e | |||
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 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -2,6 +2,6 @@
|
|||||||
.env
|
.env
|
||||||
/venv
|
/venv
|
||||||
.cargo
|
.cargo
|
||||||
assets
|
|
||||||
out.json
|
|
||||||
/.idea
|
/.idea
|
||||||
|
web/static/index.html
|
||||||
|
web/static/assets
|
||||||
|
2462
Cargo.lock
generated
2462
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
48
Cargo.toml
48
Cargo.toml
@ -1,20 +1,22 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "reminder_rs"
|
name = "reminder-rs"
|
||||||
version = "1.6.5"
|
version = "1.6.50"
|
||||||
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.3"
|
poise = "0.5"
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
tokio = { version = "1", features = ["process", "full"] }
|
tokio = { version = "1", features = ["process", "full"] }
|
||||||
reqwest = "0.11"
|
reqwest = "0.11"
|
||||||
lazy-regex = "2.3.0"
|
lazy-regex = "3.0.2"
|
||||||
regex = "1.6"
|
regex = "1.9"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.9"
|
env_logger = "0.10"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
chrono-tz = { version = "0.6", 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"
|
||||||
@ -23,11 +25,35 @@ serde_repr = "0.1"
|
|||||||
rmp-serde = "1.1"
|
rmp-serde = "1.1"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
levenshtein = "1.0"
|
levenshtein = "1.0"
|
||||||
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
|
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]}
|
||||||
base64 = "0.13"
|
base64 = "0.21.0"
|
||||||
|
|
||||||
[dependencies.postman]
|
[dependencies.postman]
|
||||||
path = "postman"
|
path = "postman"
|
||||||
|
|
||||||
[dependencies.reminder_web]
|
[dependencies.reminder_web]
|
||||||
path = "web"
|
path = "web"
|
||||||
|
|
||||||
|
[package.metadata.deb]
|
||||||
|
depends = "$auto, python3-dateparser (>= 1.0.0)"
|
||||||
|
suggests = "mysql-server-8.0, nginx"
|
||||||
|
maintainer-scripts = "debian"
|
||||||
|
assets = [
|
||||||
|
["target/release/reminder-rs", "usr/bin/reminder-rs", "755"],
|
||||||
|
["conf/default.env", "etc/reminder-rs/config.env", "600"],
|
||||||
|
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
|
||||||
|
["web/static/**/*", "lib/reminder-rs/static", "644"],
|
||||||
|
["reminder-dashboard/dist/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"]
|
||||||
|
]
|
||||||
|
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
|
46
README.md
46
README.md
@ -7,25 +7,36 @@ reminders are paid on the hosted version of the bot. Keep reading if you want to
|
|||||||
|
|
||||||
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
|
||||||
Install build requirements:
|
|
||||||
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential`
|
|
||||||
|
|
||||||
Install Rust from https://rustup.rs
|
Recommended method.
|
||||||
|
|
||||||
Reminder Bot can then be built by running `cargo build --release` in the top level directory. It is necessary to create a
|
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.
|
||||||
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
|
1. Install container software: `sudo apt install podman`.
|
||||||
These environment variables must be provided when compiling the bot
|
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
|
||||||
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
|
3. Install SQLx CLI: `cargo install sqlx-cli`
|
||||||
* `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**
|
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`
|
||||||
|
|
||||||
### 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
|
### 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`)
|
||||||
|
8. Build: `cargo build --release`
|
||||||
|
|
||||||
|
|
||||||
|
### 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__
|
||||||
@ -37,10 +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
|
||||||
* `PYTHON_LOCATION` - default `venv/bin/python3`. Can be changed if your Python executable is located somewhere else
|
* `PYTHON_LOCATION` - default `/usr/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
|
||||||
* `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages
|
|
||||||
|
|
||||||
### Todo List
|
|
||||||
|
|
||||||
* Convert aliases to macros
|
|
||||||
|
10
Rocket.toml
10
Rocket.toml
@ -1,6 +1,6 @@
|
|||||||
[default]
|
[default]
|
||||||
address = "0.0.0.0"
|
address = "0.0.0.0"
|
||||||
port = 5000
|
port = 18920
|
||||||
template_dir = "web/templates"
|
template_dir = "web/templates"
|
||||||
limits = { json = "10MiB" }
|
limits = { json = "10MiB" }
|
||||||
|
|
||||||
@ -11,18 +11,18 @@ secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="
|
|||||||
certs = "web/private/rsa_sha256_cert.pem"
|
certs = "web/private/rsa_sha256_cert.pem"
|
||||||
key = "web/private/rsa_sha256_key.pem"
|
key = "web/private/rsa_sha256_key.pem"
|
||||||
|
|
||||||
[rsa_sha256.tls]
|
[debug.rsa_sha256.tls]
|
||||||
certs = "web/private/rsa_sha256_cert.pem"
|
certs = "web/private/rsa_sha256_cert.pem"
|
||||||
key = "web/private/rsa_sha256_key.pem"
|
key = "web/private/rsa_sha256_key.pem"
|
||||||
|
|
||||||
[ecdsa_nistp256_sha256.tls]
|
[debug.ecdsa_nistp256_sha256.tls]
|
||||||
certs = "web/private/ecdsa_nistp256_sha256_cert.pem"
|
certs = "web/private/ecdsa_nistp256_sha256_cert.pem"
|
||||||
key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem"
|
key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem"
|
||||||
|
|
||||||
[ecdsa_nistp384_sha384.tls]
|
[debug.ecdsa_nistp384_sha384.tls]
|
||||||
certs = "web/private/ecdsa_nistp384_sha384_cert.pem"
|
certs = "web/private/ecdsa_nistp384_sha384_cert.pem"
|
||||||
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
|
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
|
||||||
|
|
||||||
[ed25519.tls]
|
[debug.ed25519.tls]
|
||||||
certs = "web/private/ed25519_cert.pem"
|
certs = "web/private/ed25519_cert.pem"
|
||||||
key = "eb/private/ed25519_key.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 |
3
build.rs
Normal file
3
build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=migrations");
|
||||||
|
}
|
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,4 +0,0 @@
|
|||||||
USE reminders;
|
|
||||||
|
|
||||||
ALTER TABLE reminders.reminders RENAME COLUMN `interval` TO `interval_seconds`;
|
|
||||||
ALTER TABLE reminders.reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;
|
|
@ -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;
|
@ -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;
|
@ -1,5 +1,3 @@
|
|||||||
USE reminders;
|
|
||||||
|
|
||||||
CREATE TABLE reminder_template (
|
CREATE TABLE reminder_template (
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
|
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;
|
@ -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;
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -5,12 +5,12 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1", features = ["process", "full"] }
|
tokio = { version = "1", features = ["process", "full"] }
|
||||||
regex = "1.4"
|
regex = "1.9"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
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"
|
||||||
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
|
sqlx = { version = "0.7", 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"] }
|
serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
use chrono::Duration;
|
use std::env;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Days, Duration, Months};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
@ -7,7 +9,7 @@ use regex::{Captures, Regex};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serenity::{
|
use serenity::{
|
||||||
builder::CreateEmbed,
|
builder::CreateEmbed,
|
||||||
http::{CacheHttp, Http, HttpError, StatusCode},
|
http::{CacheHttp, Http, HttpError},
|
||||||
model::{
|
model::{
|
||||||
channel::{Channel, Embed as SerenityEmbed},
|
channel::{Channel, Embed as SerenityEmbed},
|
||||||
id::ChannelId,
|
id::ChannelId,
|
||||||
@ -30,6 +32,7 @@ lazy_static! {
|
|||||||
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
|
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
|
||||||
pub static ref TIMENOW_REGEX: Regex =
|
pub static ref TIMENOW_REGEX: Regex =
|
||||||
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
|
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 {
|
fn fmt_displacement(format: &str, seconds: u64) -> String {
|
||||||
@ -62,7 +65,8 @@ pub fn substitute(string: &str) -> String {
|
|||||||
let format = caps.name("format").map(|m| m.as_str());
|
let format = caps.name("format").map(|m| m.as_str());
|
||||||
|
|
||||||
if let (Some(final_time), Some(format)) = (final_time, format) {
|
if let (Some(final_time), Some(format)) = (final_time, format) {
|
||||||
let dt = NaiveDateTime::from_timestamp(final_time, 0);
|
match NaiveDateTime::from_timestamp_opt(final_time, 0) {
|
||||||
|
Some(dt) => {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
let difference = {
|
let difference = {
|
||||||
@ -74,6 +78,10 @@ pub fn substitute(string: &str) -> String {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fmt_displacement(format, difference.num_seconds() as u64)
|
fmt_displacement(format, difference.num_seconds() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
@ -146,7 +154,7 @@ impl Embed {
|
|||||||
embed.description = substitute(&embed.description);
|
embed.description = substitute(&embed.description);
|
||||||
embed.footer = substitute(&embed.footer);
|
embed.footer = substitute(&embed.footer);
|
||||||
|
|
||||||
embed.fields.iter_mut().for_each(|mut field| {
|
embed.fields.iter_mut().for_each(|field| {
|
||||||
field.title = substitute(&field.title);
|
field.title = substitute(&field.title);
|
||||||
field.value = substitute(&field.value);
|
field.value = substitute(&field.value);
|
||||||
});
|
});
|
||||||
@ -243,11 +251,12 @@ pub struct Reminder {
|
|||||||
attachment: Option<Vec<u8>>,
|
attachment: Option<Vec<u8>>,
|
||||||
attachment_name: Option<String>,
|
attachment_name: Option<String>,
|
||||||
|
|
||||||
utc_time: NaiveDateTime,
|
utc_time: DateTime<Utc>,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
restartable: bool,
|
restartable: bool,
|
||||||
expires: Option<NaiveDateTime>,
|
expires: Option<DateTime<Utc>>,
|
||||||
interval_seconds: Option<u32>,
|
interval_seconds: Option<u32>,
|
||||||
|
interval_days: Option<u32>,
|
||||||
interval_months: Option<u32>,
|
interval_months: Option<u32>,
|
||||||
|
|
||||||
avatar: Option<String>,
|
avatar: Option<String>,
|
||||||
@ -281,6 +290,7 @@ SELECT
|
|||||||
reminders.`restartable` AS restartable,
|
reminders.`restartable` AS restartable,
|
||||||
reminders.`expires` AS 'expires',
|
reminders.`expires` AS 'expires',
|
||||||
reminders.`interval_seconds` AS 'interval_seconds',
|
reminders.`interval_seconds` AS 'interval_seconds',
|
||||||
|
reminders.`interval_days` AS 'interval_days',
|
||||||
reminders.`interval_months` AS 'interval_months',
|
reminders.`interval_months` AS 'interval_months',
|
||||||
|
|
||||||
reminders.`avatar` AS avatar,
|
reminders.`avatar` AS avatar,
|
||||||
@ -292,16 +302,19 @@ INNER JOIN
|
|||||||
ON
|
ON
|
||||||
reminders.channel_id = channels.id
|
reminders.channel_id = channels.id
|
||||||
WHERE
|
WHERE
|
||||||
|
reminders.`status` = 'pending' AND
|
||||||
reminders.`id` IN (
|
reminders.`id` IN (
|
||||||
SELECT
|
SELECT
|
||||||
MIN(id)
|
MIN(id)
|
||||||
FROM
|
FROM
|
||||||
reminders
|
reminders
|
||||||
WHERE
|
WHERE
|
||||||
reminders.`utc_time` <= NOW()
|
reminders.`utc_time` <= NOW() AND
|
||||||
AND (
|
`status` = 'pending' AND
|
||||||
|
(
|
||||||
reminders.`interval_seconds` IS NOT NULL
|
reminders.`interval_seconds` IS NOT NULL
|
||||||
OR reminders.`interval_months` IS NOT NULL
|
OR reminders.`interval_months` IS NOT NULL
|
||||||
|
OR reminders.`interval_days` IS NOT NULL
|
||||||
OR reminders.enabled
|
OR reminders.enabled
|
||||||
)
|
)
|
||||||
GROUP BY channel_id
|
GROUP BY channel_id
|
||||||
@ -330,9 +343,7 @@ WHERE
|
|||||||
|
|
||||||
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||||
let _ = sqlx::query!(
|
let _ = sqlx::query!(
|
||||||
"
|
"UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?",
|
||||||
UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
|
|
||||||
",
|
|
||||||
self.channel_id
|
self.channel_id
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@ -340,56 +351,72 @@ UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||||
if self.interval_seconds.is_some() || self.interval_months.is_some() {
|
if self.interval_seconds.is_some()
|
||||||
let now = Utc::now().naive_local();
|
|| self.interval_months.is_some()
|
||||||
let mut updated_reminder_time = self.utc_time;
|
|| self.interval_days.is_some()
|
||||||
|
|
||||||
if let Some(interval) = self.interval_months {
|
|
||||||
match sqlx::query!(
|
|
||||||
// use the second date_add to force return value to datetime
|
|
||||||
"SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time",
|
|
||||||
updated_reminder_time,
|
|
||||||
interval
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(row) => match row.new_time {
|
// If all intervals are zero then dont care
|
||||||
Some(datetime) => {
|
if self.interval_seconds == Some(0)
|
||||||
updated_reminder_time = datetime;
|
&& self.interval_days == Some(0)
|
||||||
|
&& self.interval_months == Some(0)
|
||||||
|
{
|
||||||
|
self.set_sent(pool).await;
|
||||||
}
|
}
|
||||||
None => {
|
|
||||||
warn!("Could not update interval by months: got NULL");
|
|
||||||
|
|
||||||
updated_reminder_time += Duration::days(30);
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Could not update interval by months: {:?}", e);
|
|
||||||
|
|
||||||
// naively fallback to adding 30 days
|
|
||||||
updated_reminder_time += Duration::days(30);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(interval) = self.interval_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 {
|
if let Some(interval) = self.interval_seconds {
|
||||||
while updated_reminder_time < now {
|
|
||||||
updated_reminder_time += Duration::seconds(interval as i64);
|
updated_reminder_time += Duration::seconds(interval as i64);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.expires.map_or(false, |expires| {
|
if fail_count >= 4 {
|
||||||
NaiveDateTime::from_timestamp(updated_reminder_time.timestamp(), 0) > expires
|
self.log_error(
|
||||||
}) {
|
pool,
|
||||||
self.force_delete(pool).await;
|
"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 {
|
} else {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"UPDATE reminders SET `utc_time` = ? WHERE `id` = ?",
|
||||||
UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
|
updated_reminder_time.with_timezone(&Utc),
|
||||||
",
|
|
||||||
updated_reminder_time,
|
|
||||||
self.id
|
self.id
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@ -397,15 +424,67 @@ UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
|
|||||||
.expect(&format!("Could not update time on Reminder {}", self.id));
|
.expect(&format!("Could not update time on Reminder {}", self.id));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.force_delete(pool).await;
|
self.set_sent(pool).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
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!(
|
sqlx::query!(
|
||||||
"
|
"INSERT INTO stat (type, reminder_id, message) VALUES ('reminder_failed', ?, ?)",
|
||||||
DELETE FROM reminders WHERE `id` = ?
|
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
|
self.id
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@ -504,8 +583,10 @@ DELETE FROM reminders WHERE `id` = ?
|
|||||||
w.content(&reminder.content).tts(reminder.tts);
|
w.content(&reminder.content).tts(reminder.tts);
|
||||||
|
|
||||||
if let Some(username) = &reminder.username {
|
if let Some(username) = &reminder.username {
|
||||||
|
if !username.is_empty() {
|
||||||
w.username(username);
|
w.username(username);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(avatar) = &reminder.avatar {
|
if let Some(avatar) = &reminder.avatar {
|
||||||
w.avatar_url(avatar);
|
w.avatar_url(avatar);
|
||||||
@ -548,9 +629,7 @@ DELETE FROM reminders WHERE `id` = ?
|
|||||||
.map_or(true, |inner| inner >= Utc::now().naive_local()))
|
.map_or(true, |inner| inner >= Utc::now().naive_local()))
|
||||||
{
|
{
|
||||||
let _ = sqlx::query!(
|
let _ = sqlx::query!(
|
||||||
"
|
"UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?",
|
||||||
UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
|
|
||||||
",
|
|
||||||
self.channel_id
|
self.channel_id
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@ -567,7 +646,7 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
|
|||||||
if let Ok(webhook) = webhook_res {
|
if let Ok(webhook) = webhook_res {
|
||||||
send_to_webhook(cache_http, &self, webhook, embed).await
|
send_to_webhook(cache_http, &self, webhook, embed).await
|
||||||
} else {
|
} else {
|
||||||
warn!("Webhook vanished: {:?}", webhook_res);
|
warn!("Webhook vanished for reminder {}: {:?}", self.id, webhook_res);
|
||||||
|
|
||||||
self.reset_webhook(pool).await;
|
self.reset_webhook(pool).await;
|
||||||
send_to_channel(cache_http, &self, embed).await
|
send_to_channel(cache_http, &self, embed).await
|
||||||
@ -577,24 +656,84 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
error!("Error sending reminder {}: {:?}", self.id, e);
|
|
||||||
|
|
||||||
if let Error::Http(error) = e {
|
if let Error::Http(error) = e {
|
||||||
if error.status_code() == Some(StatusCode::NOT_FOUND) {
|
if let HttpError::UnsuccessfulRequest(http_error) = *error {
|
||||||
warn!("Seeing channel is deleted. Removing reminder");
|
match http_error.error.code {
|
||||||
self.force_delete(pool).await;
|
10003 => {
|
||||||
} else if let HttpError::UnsuccessfulRequest(error) = *error {
|
self.log_error(
|
||||||
if error.error.code == 50007 {
|
pool,
|
||||||
warn!("User cannot receive DMs");
|
"Could not be sent as channel does not exist",
|
||||||
self.force_delete(pool).await;
|
None::<&'static str>,
|
||||||
} else {
|
)
|
||||||
|
.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;
|
self.refresh(pool).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
self.log_error(pool, "(Likely) a parsing error", Some(error)).await;
|
||||||
self.refresh(pool).await;
|
self.refresh(pool).await;
|
||||||
}
|
}
|
||||||
} else {
|
} 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;
|
self.refresh(pool).await;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
use chrono_tz::TZ_VARIANTS;
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::Context;
|
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> {
|
pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
|
||||||
if partial.is_empty() {
|
if partial.is_empty() {
|
||||||
@ -33,3 +36,82 @@ WHERE
|
|||||||
.map(|s| s.name.clone())
|
.map(|s| s.name.clone())
|
||||||
.collect()
|
.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(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,38 +0,0 @@
|
|||||||
use poise::serenity_prelude::CommandType;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
commands::autocomplete::macro_name_autocomplete, models::command_macro::guild_command_macro,
|
|
||||||
Context, Error,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Add a macro as a slash-command to this server. Enables controlling permissions per-macro.
|
|
||||||
#[poise::command(
|
|
||||||
slash_command,
|
|
||||||
rename = "install",
|
|
||||||
guild_only = true,
|
|
||||||
default_member_permissions = "MANAGE_GUILD",
|
|
||||||
identifying_name = "install_macro"
|
|
||||||
)]
|
|
||||||
pub async fn install_macro(
|
|
||||||
ctx: Context<'_>,
|
|
||||||
#[description = "Name of macro to install"]
|
|
||||||
#[autocomplete = "macro_name_autocomplete"]
|
|
||||||
name: String,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let guild_id = ctx.guild_id().unwrap();
|
|
||||||
|
|
||||||
if let Some(command_macro) = guild_command_macro(&ctx, &name).await {
|
|
||||||
guild_id
|
|
||||||
.create_application_command(&ctx.discord(), |a| {
|
|
||||||
a.kind(CommandType::ChatInput)
|
|
||||||
.name(command_macro.name)
|
|
||||||
.description(command_macro.description.unwrap_or_else(|| "".to_string()))
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
ctx.send(|r| r.ephemeral(true).content("Macro installed. Go to Server Settings 🠚 Integrations 🠚 Reminder Bot to configure permissions.")).await?;
|
|
||||||
} else {
|
|
||||||
ctx.send(|r| r.ephemeral(true).content("No macro found with that name")).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
@ -2,7 +2,7 @@ use poise::CreateReply;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
component_models::pager::{MacroPager, Pager},
|
component_models::pager::{MacroPager, Pager},
|
||||||
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
|
consts::THEME_COLOR,
|
||||||
models::{command_macro::CommandMacro, CtxData},
|
models::{command_macro::CommandMacro, CtxData},
|
||||||
Context, Error,
|
Context, Error,
|
||||||
};
|
};
|
||||||
@ -30,27 +30,7 @@ pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
|
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
|
||||||
let mut skipped_char_count = 0;
|
((macros.len() as f64) / 25.0).ceil() as usize
|
||||||
|
|
||||||
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<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
|
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
|
||||||
@ -75,45 +55,27 @@ pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> Crea
|
|||||||
page = pages - 1;
|
page = pages - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut char_count = 0;
|
let lower = (page * 25).min(macros.len());
|
||||||
let mut skipped_char_count = 0;
|
let upper = ((page + 1) * 25).min(macros.len());
|
||||||
|
|
||||||
let mut skipped_pages = 0;
|
let fields = macros[lower..upper].iter().map(|m| {
|
||||||
|
|
||||||
let display_vec: Vec<String> = macros
|
|
||||||
.iter()
|
|
||||||
.map(|m| {
|
|
||||||
if let Some(description) = &m.description {
|
if let Some(description) = &m.description {
|
||||||
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
|
(
|
||||||
|
m.name.clone(),
|
||||||
|
format!("*{}*\n- Has {} commands", description, m.commands.len()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
|
(m.name.clone(), format!("- Has {} commands", m.commands.len()), true)
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.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");
|
|
||||||
|
|
||||||
let mut reply = CreateReply::default();
|
let mut reply = CreateReply::default();
|
||||||
|
|
||||||
reply
|
reply
|
||||||
.embed(|e| {
|
.embed(|e| {
|
||||||
e.title("Macros")
|
e.title("Macros")
|
||||||
.description(display)
|
.fields(fields)
|
||||||
.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)
|
||||||
})
|
})
|
||||||
|
@ -27,7 +27,7 @@ pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
"SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
"SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||||
guild_id.0
|
guild_id.0
|
||||||
)
|
)
|
||||||
.fetch_all(&mut transaction)
|
.fetch_all(&mut *transaction)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut added_aliases = 0;
|
let mut added_aliases = 0;
|
||||||
@ -42,7 +42,7 @@ pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
cmd_macro.description,
|
cmd_macro.description,
|
||||||
cmd_macro.commands
|
cmd_macro.commands
|
||||||
)
|
)
|
||||||
.execute(&mut transaction)
|
.execute(&mut *transaction)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
added_aliases += 1;
|
added_aliases += 1;
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
use crate::{Context, Error};
|
use crate::{Context, Error};
|
||||||
|
|
||||||
pub mod delete;
|
pub mod delete;
|
||||||
pub mod install;
|
|
||||||
pub mod list;
|
pub mod list;
|
||||||
pub mod migrate;
|
pub mod migrate;
|
||||||
pub mod record;
|
pub mod record;
|
||||||
|
@ -15,6 +15,18 @@ pub async fn record_macro(
|
|||||||
#[description = "Name for the new macro"] name: String,
|
#[description = "Name for the new macro"] name: String,
|
||||||
#[description = "Description for the new macro"] description: Option<String>,
|
#[description = "Description for the new macro"] description: Option<String>,
|
||||||
) -> Result<(), Error> {
|
) -> 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 guild_id = ctx.guild_id().unwrap();
|
||||||
|
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
use super::super::autocomplete::macro_name_autocomplete;
|
use super::super::autocomplete::macro_name_autocomplete;
|
||||||
use crate::{models::command_macro::guild_command_macro, Context, Data, Error};
|
use crate::{models::command_macro::guild_command_macro, Context, Data, Error, THEME_COLOR};
|
||||||
|
|
||||||
/// Run a recorded macro
|
/// Run a recorded macro
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
@ -17,7 +17,17 @@ pub async fn run_macro(
|
|||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
match guild_command_macro(&Context::Application(ctx), &name).await {
|
match guild_command_macro(&Context::Application(ctx), &name).await {
|
||||||
Some(command_macro) => {
|
Some(command_macro) => {
|
||||||
ctx.defer_response(false).await?;
|
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 {
|
for command in command_macro.commands {
|
||||||
if let Some(action) = command.action {
|
if let Some(action) = command.action {
|
||||||
|
@ -6,8 +6,8 @@ use crate::{models::CtxData, Context, Error, THEME_COLOR};
|
|||||||
fn footer(
|
fn footer(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
|
) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
|
||||||
let shard_count = ctx.discord().cache.shard_count();
|
let shard_count = ctx.serenity_context().cache.shard_count();
|
||||||
let shard = ctx.discord().shard_id;
|
let shard = ctx.serenity_context().shard_id;
|
||||||
|
|
||||||
move |f| {
|
move |f| {
|
||||||
f.text(format!(
|
f.text(format!(
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
pub mod autocomplete;
|
mod autocomplete;
|
||||||
pub mod command_macro;
|
pub mod command_macro;
|
||||||
pub mod info_cmds;
|
pub mod info_cmds;
|
||||||
pub mod moderation_cmds;
|
pub mod moderation_cmds;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
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 log::warn;
|
||||||
|
|
||||||
use super::autocomplete::timezone_autocomplete;
|
use super::autocomplete::timezone_autocomplete;
|
||||||
use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
|
use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
|
||||||
@ -101,6 +102,78 @@ You may want to use one of the popular timezones below, otherwise click [here](h
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configure server settings
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "settings",
|
||||||
|
identifying_name = "settings",
|
||||||
|
guild_only = true
|
||||||
|
)]
|
||||||
|
pub async fn settings(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure ephemeral setup
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "ephemeral",
|
||||||
|
identifying_name = "ephemeral_confirmations",
|
||||||
|
guild_only = true
|
||||||
|
)]
|
||||||
|
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(
|
||||||
|
"Reminder confirmations will be sent as regular messages, and won't be removed automatically.",
|
||||||
|
)
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Configure whether other users can set reminders to your direct messages
|
/// Configure whether other users can set reminders to your direct messages
|
||||||
#[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")]
|
#[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")]
|
||||||
pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
|
pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
@ -108,7 +181,7 @@ pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Allow other users to set reminders in your direct messages
|
/// Allow other users to set reminders in your direct messages
|
||||||
#[poise::command(slash_command, rename = "allow", identifying_name = "allowed_dm")]
|
#[poise::command(slash_command, rename = "allow", identifying_name = "set_allowed_dm")]
|
||||||
pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
let mut user_data = ctx.author_data().await?;
|
let mut user_data = ctx.author_data().await?;
|
||||||
user_data.allowed_dm = true;
|
user_data.allowed_dm = true;
|
||||||
@ -127,7 +200,7 @@ pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Block other users from setting reminders in your direct messages
|
/// Block other users from setting reminders in your direct messages
|
||||||
#[poise::command(slash_command, rename = "block", identifying_name = "allowed_dm")]
|
#[poise::command(slash_command, rename = "block", identifying_name = "unset_allowed_dm")]
|
||||||
pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
let mut user_data = ctx.author_data().await?;
|
let mut user_data = ctx.author_data().await?;
|
||||||
user_data.allowed_dm = false;
|
user_data.allowed_dm = false;
|
||||||
@ -157,8 +230,7 @@ pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
match ctx.channel_data().await {
|
match ctx.channel_data().await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
|
if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
|
||||||
let _ = ctx
|
ctx.send(|b| {
|
||||||
.send(|b| {
|
|
||||||
b.ephemeral(true).content(format!(
|
b.ephemeral(true).content(format!(
|
||||||
"**Warning!**
|
"**Warning!**
|
||||||
This link can be used by users to anonymously send messages, with or without permissions.
|
This link can be used by users to anonymously send messages, with or without permissions.
|
||||||
@ -167,13 +239,15 @@ Do not share it!
|
|||||||
id, token,
|
id, token,
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.await;
|
.await?;
|
||||||
} else {
|
} else {
|
||||||
let _ = ctx.say("No webhook configured on this channel.").await;
|
ctx.say("No webhook configured on this channel.").await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(e) => {
|
||||||
let _ = ctx.say("No webhook configured on this channel.").await;
|
warn!("Error fetching channel data: {:?}", e);
|
||||||
|
|
||||||
|
ctx.say("No webhook configured on this channel.").await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,21 +1,18 @@
|
|||||||
use std::{
|
use std::{collections::HashSet, string::ToString};
|
||||||
collections::HashSet,
|
|
||||||
string::ToString,
|
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
use chrono::NaiveDateTime;
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
|
use log::warn;
|
||||||
use num_integer::Integer;
|
use num_integer::Integer;
|
||||||
use poise::{
|
use poise::{
|
||||||
serenity_prelude::{
|
serenity_prelude::{
|
||||||
builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType,
|
builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType,
|
||||||
},
|
},
|
||||||
AutocompleteChoice, CreateReply, Modal,
|
CreateReply, Modal,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::autocomplete::timezone_autocomplete;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete},
|
||||||
component_models::{
|
component_models::{
|
||||||
pager::{DelPager, LookPager, Pager},
|
pager::{DelPager, LookPager, Pager},
|
||||||
ComponentDataModel, DelSelector, UndoReminder,
|
ComponentDataModel, DelSelector, UndoReminder,
|
||||||
@ -60,8 +57,8 @@ pub async fn pause(
|
|||||||
let parsed = natural_parser(&until, &timezone.to_string()).await;
|
let parsed = natural_parser(&until, &timezone.to_string()).await;
|
||||||
|
|
||||||
if let Some(timestamp) = parsed {
|
if let Some(timestamp) = parsed {
|
||||||
let dt = NaiveDateTime::from_timestamp(timestamp, 0);
|
match NaiveDateTime::from_timestamp_opt(timestamp, 0) {
|
||||||
|
Some(dt) => {
|
||||||
channel.paused = true;
|
channel.paused = true;
|
||||||
channel.paused_until = Some(dt);
|
channel.paused_until = Some(dt);
|
||||||
|
|
||||||
@ -72,6 +69,15 @@ pub async fn pause(
|
|||||||
timestamp
|
timestamp
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
None => {
|
||||||
|
ctx.say(format!(
|
||||||
|
"Time processed could not be interpreted as `DateTime`. Please write the time as clearly as possible",
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ctx.say(
|
ctx.say(
|
||||||
"Time could not be processed. Please write the time as clearly as possible",
|
"Time could not be processed. Please write the time as clearly as possible",
|
||||||
@ -108,6 +114,8 @@ pub async fn offset(
|
|||||||
#[description = "Number of minutes to offset by"] minutes: Option<isize>,
|
#[description = "Number of minutes to offset by"] minutes: Option<isize>,
|
||||||
#[description = "Number of seconds to offset by"] seconds: Option<isize>,
|
#[description = "Number of seconds to offset by"] seconds: Option<isize>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
|
ctx.defer().await?;
|
||||||
|
|
||||||
let combined_time = hours.map_or(0, |h| h * HOUR as isize)
|
let combined_time = hours.map_or(0, |h| h * HOUR as isize)
|
||||||
+ minutes.map_or(0, |m| m * MINUTE as isize)
|
+ minutes.map_or(0, |m| m * MINUTE as isize)
|
||||||
+ seconds.map_or(0, |s| s);
|
+ seconds.map_or(0, |s| s);
|
||||||
@ -210,7 +218,7 @@ pub async fn look(
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
let channel_opt = ctx.channel_id().to_channel_cached(&ctx.discord());
|
let channel_opt = ctx.channel_id().to_channel_cached(&ctx);
|
||||||
|
|
||||||
let channel_id = if let Some(Channel::Guild(channel)) = channel_opt {
|
let channel_id = if let Some(Channel::Guild(channel)) = channel_opt {
|
||||||
if Some(channel.guild_id) == ctx.guild_id() {
|
if Some(channel.guild_id) == ctx.guild_id() {
|
||||||
@ -222,8 +230,7 @@ pub async fn look(
|
|||||||
ctx.channel_id()
|
ctx.channel_id()
|
||||||
};
|
};
|
||||||
|
|
||||||
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.discord()) {
|
|
||||||
Some(channel.name)
|
Some(channel.name)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@ -245,7 +252,7 @@ pub async fn look(
|
|||||||
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
||||||
})
|
})
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
.join("\n");
|
.join("");
|
||||||
|
|
||||||
let pages = reminders
|
let pages = reminders
|
||||||
.iter()
|
.iter()
|
||||||
@ -289,8 +296,7 @@ pub async fn delete(ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
let timezone = ctx.timezone().await;
|
let timezone = ctx.timezone().await;
|
||||||
|
|
||||||
let reminders =
|
let reminders =
|
||||||
Reminder::from_guild(&ctx.discord(), &ctx.data().database, ctx.guild_id(), ctx.author().id)
|
Reminder::from_guild(&ctx, &ctx.data().database, ctx.guild_id(), ctx.author().id).await;
|
||||||
.await;
|
|
||||||
|
|
||||||
let resp = show_delete_page(&reminders, 0, timezone);
|
let resp = show_delete_page(&reminders, 0, timezone);
|
||||||
|
|
||||||
@ -432,11 +438,8 @@ pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> Cr
|
|||||||
reply
|
reply
|
||||||
}
|
}
|
||||||
|
|
||||||
fn time_difference(start_time: NaiveDateTime) -> String {
|
fn time_difference(start_time: DateTime<Utc>) -> String {
|
||||||
let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
|
let delta = (Utc::now() - start_time).num_seconds();
|
||||||
let now = NaiveDateTime::from_timestamp(unix_time, 0);
|
|
||||||
|
|
||||||
let delta = (now - start_time).num_seconds();
|
|
||||||
|
|
||||||
let (minutes, seconds) = delta.div_rem(&60);
|
let (minutes, seconds) = delta.div_rem(&60);
|
||||||
let (hours, minutes) = minutes.div_rem(&60);
|
let (hours, minutes) = minutes.div_rem(&60);
|
||||||
@ -550,20 +553,6 @@ pub async fn delete_timer(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn multiline_autocomplete(
|
|
||||||
_ctx: Context<'_>,
|
|
||||||
partial: &str,
|
|
||||||
) -> Vec<AutocompleteChoice<String>> {
|
|
||||||
if partial.is_empty() {
|
|
||||||
vec![AutocompleteChoice { name: "Multiline content...".to_string(), value: "".to_string() }]
|
|
||||||
} else {
|
|
||||||
vec![
|
|
||||||
AutocompleteChoice { name: partial.to_string(), value: partial.to_string() },
|
|
||||||
AutocompleteChoice { name: "Multiline content...".to_string(), value: "".to_string() },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(poise::Modal)]
|
#[derive(poise::Modal)]
|
||||||
#[name = "Reminder"]
|
#[name = "Reminder"]
|
||||||
struct ContentModal {
|
struct ContentModal {
|
||||||
@ -574,7 +563,57 @@ struct ContentModal {
|
|||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a reminder. Press "+5 more" for other options. A modal will open if "content" is not provided
|
/// Create a reminder with multi-line content. Press "+4 more" for other options.
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
identifying_name = "multiline",
|
||||||
|
default_member_permissions = "MANAGE_GUILD"
|
||||||
|
)]
|
||||||
|
pub async fn multiline(
|
||||||
|
ctx: ApplicationContext<'_>,
|
||||||
|
#[description = "A description of the time to set the reminder for"]
|
||||||
|
#[autocomplete = "time_hint_autocomplete"]
|
||||||
|
time: String,
|
||||||
|
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
|
||||||
|
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
|
||||||
|
interval: Option<String>,
|
||||||
|
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"]
|
||||||
|
expires: Option<String>,
|
||||||
|
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
|
||||||
|
tts: Option<bool>,
|
||||||
|
#[description = "Set a timezone override for this reminder only"]
|
||||||
|
#[autocomplete = "timezone_autocomplete"]
|
||||||
|
timezone: Option<String>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
|
||||||
|
let data_opt = ContentModal::execute(ctx).await?;
|
||||||
|
|
||||||
|
match data_opt {
|
||||||
|
Some(data) => {
|
||||||
|
create_reminder(
|
||||||
|
Context::Application(ctx),
|
||||||
|
time,
|
||||||
|
data.content,
|
||||||
|
channels,
|
||||||
|
interval,
|
||||||
|
expires,
|
||||||
|
tts,
|
||||||
|
tz,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
None => {
|
||||||
|
warn!("Unexpected None encountered in /multiline");
|
||||||
|
Ok(Context::Application(ctx)
|
||||||
|
.send(|m| m.content("Unexpected error.").ephemeral(true))
|
||||||
|
.await
|
||||||
|
.map(|_| ())?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content.
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
slash_command,
|
slash_command,
|
||||||
identifying_name = "remind",
|
identifying_name = "remind",
|
||||||
@ -582,10 +621,10 @@ struct ContentModal {
|
|||||||
)]
|
)]
|
||||||
pub async fn remind(
|
pub async fn remind(
|
||||||
ctx: ApplicationContext<'_>,
|
ctx: ApplicationContext<'_>,
|
||||||
#[description = "A description of the time to set the reminder for"] time: String,
|
#[description = "The time (and optionally date) to set the reminder for"]
|
||||||
#[description = "The message content to send"]
|
#[autocomplete = "time_hint_autocomplete"]
|
||||||
#[autocomplete = "multiline_autocomplete"]
|
time: String,
|
||||||
content: String,
|
#[description = "The message content to send"] content: String,
|
||||||
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
|
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
|
||||||
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
|
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
|
||||||
interval: Option<String>,
|
interval: Option<String>,
|
||||||
@ -599,33 +638,8 @@ pub async fn remind(
|
|||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
|
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
|
||||||
|
|
||||||
if content.is_empty() {
|
create_reminder(Context::Application(ctx), time, content, channels, interval, expires, tts, tz)
|
||||||
let data = ContentModal::execute(ctx).await?;
|
|
||||||
|
|
||||||
create_reminder(
|
|
||||||
Context::Application(ctx),
|
|
||||||
time,
|
|
||||||
data.content,
|
|
||||||
channels,
|
|
||||||
interval,
|
|
||||||
expires,
|
|
||||||
tts,
|
|
||||||
tz,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
} else {
|
|
||||||
create_reminder(
|
|
||||||
Context::Application(ctx),
|
|
||||||
time,
|
|
||||||
content,
|
|
||||||
channels,
|
|
||||||
interval,
|
|
||||||
expires,
|
|
||||||
tts,
|
|
||||||
tz,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_reminder(
|
async fn create_reminder(
|
||||||
@ -644,7 +658,13 @@ async fn create_reminder(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ephemeral =
|
||||||
|
ctx.guild_data().await.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations));
|
||||||
|
if ephemeral {
|
||||||
|
ctx.defer_ephemeral().await?;
|
||||||
|
} else {
|
||||||
ctx.defer().await?;
|
ctx.defer().await?;
|
||||||
|
}
|
||||||
|
|
||||||
let user_data = ctx.author_data().await.unwrap();
|
let user_data = ctx.author_data().await.unwrap();
|
||||||
let timezone = timezone.unwrap_or(ctx.timezone().await);
|
let timezone = timezone.unwrap_or(ctx.timezone().await);
|
||||||
@ -674,9 +694,9 @@ async fn create_reminder(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (processed_interval, processed_expires) = if let Some(repeat) = &interval {
|
let (processed_interval, processed_expires) = if let Some(repeat) = &interval {
|
||||||
if check_subscription(&ctx.discord(), ctx.author().id).await
|
if check_subscription(&ctx, ctx.author().id).await
|
||||||
|| (ctx.guild_id().is_some()
|
|| (ctx.guild_id().is_some()
|
||||||
&& check_guild_subscription(&ctx.discord(), ctx.guild_id().unwrap()).await)
|
&& check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await)
|
||||||
{
|
{
|
||||||
(
|
(
|
||||||
parse_duration(repeat)
|
parse_duration(repeat)
|
||||||
@ -691,9 +711,10 @@ async fn create_reminder(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ctx.say(
|
ctx.send(|b| {
|
||||||
"`repeat` is only available to Patreon subscribers or self-hosted users",
|
b.content(
|
||||||
)
|
"`repeat` is only available to Patreon subscribers or self-hosted users")
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@ -703,12 +724,17 @@ async fn create_reminder(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if processed_interval.is_none() && interval.is_some() {
|
if processed_interval.is_none() && interval.is_some() {
|
||||||
ctx.say(
|
ctx.send(|b| {
|
||||||
"Repeat interval could not be processed. Try similar to `1 hour` or `4 days`",
|
b.content(
|
||||||
)
|
"Repeat interval could not be processed. Try similar to `1 hour` or `4 days`")
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
} else if processed_expires.is_none() && expires.is_some() {
|
} else if processed_expires.is_none() && expires.is_some() {
|
||||||
ctx.say("Expiry time failed to process. Please make it as clear as possible")
|
ctx.send(|b| {
|
||||||
|
b.ephemeral(true).content(
|
||||||
|
"Expiry time failed to process. Please make it as clear as possible",
|
||||||
|
)
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
} else {
|
} else {
|
||||||
let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id())
|
let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id())
|
||||||
@ -749,7 +775,7 @@ async fn create_reminder(
|
|||||||
b.emoji(ReactionType::Unicode("📝".to_string()))
|
b.emoji(ReactionType::Unicode("📝".to_string()))
|
||||||
.label("Edit")
|
.label("Edit")
|
||||||
.style(ButtonStyle::Link)
|
.style(ButtonStyle::Link)
|
||||||
.url("https://reminder-bot.com/dashboard")
|
.url("https://beta.reminder-bot.com/dashboard")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -340,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()
|
||||||
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ 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 log::warn;
|
use log::warn;
|
||||||
use poise::{
|
use poise::{
|
||||||
@ -51,11 +52,12 @@ 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);
|
||||||
@ -113,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
|
||||||
@ -166,7 +168,10 @@ impl ComponentDataModel {
|
|||||||
ComponentDataModel::DelSelector(selector) => {
|
ComponentDataModel::DelSelector(selector) => {
|
||||||
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!(
|
||||||
|
"UPDATE reminders SET `status` = 'deleted' WHERE FIND_IN_SET(id, ?)",
|
||||||
|
selected_id
|
||||||
|
)
|
||||||
.execute(&data.database)
|
.execute(&data.database)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
@ -2,7 +2,7 @@ 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_";
|
||||||
@ -17,17 +17,13 @@ use regex::Regex;
|
|||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
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() })
|
||||||
@ -35,7 +31,7 @@ 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 =
|
pub static ref MIN_INTERVAL: i64 =
|
||||||
env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
|
env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
|
||||||
pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
|
pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
|
||||||
@ -48,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());
|
||||||
}
|
}
|
||||||
|
27
src/hooks.rs
27
src/hooks.rs
@ -4,7 +4,7 @@ use poise::{
|
|||||||
|
|
||||||
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
|
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
|
||||||
|
|
||||||
async fn recording_macro_check(ctx: Context<'_>) -> bool {
|
async fn macro_check(ctx: Context<'_>) -> bool {
|
||||||
if let Context::Application(app_ctx) = ctx {
|
if let Context::Application(app_ctx) = ctx {
|
||||||
if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) =
|
if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) =
|
||||||
app_ctx.interaction
|
app_ctx.interaction
|
||||||
@ -47,25 +47,26 @@ async fn recording_macro_check(ctx: Context<'_>) -> bool {
|
|||||||
|
|
||||||
async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
||||||
if let Some(guild) = ctx.guild() {
|
if let Some(guild) = ctx.guild() {
|
||||||
let user_id = ctx.discord().cache.current_user_id();
|
let user_id = ctx.serenity_context().cache.current_user_id();
|
||||||
|
|
||||||
|
let manage_webhooks =
|
||||||
|
guild.member_permissions(&ctx, user_id).await.map_or(false, |p| p.manage_webhooks());
|
||||||
|
|
||||||
let manage_webhooks = guild
|
|
||||||
.member_permissions(&ctx.discord(), user_id)
|
|
||||||
.await
|
|
||||||
.map_or(false, |p| p.manage_webhooks());
|
|
||||||
let (view_channel, send_messages, embed_links) = ctx
|
let (view_channel, send_messages, embed_links) = ctx
|
||||||
.channel_id()
|
.channel_id()
|
||||||
.to_channel_cached(&ctx.discord())
|
.to_channel(&ctx)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
.and_then(|c| {
|
.and_then(|c| {
|
||||||
if let Channel::Guild(channel) = c {
|
if let Channel::Guild(channel) = c {
|
||||||
channel.permissions_for_user(&ctx.discord(), 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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map_or((false, false, false), |p| {
|
.unwrap_or((false, false, false));
|
||||||
(p.view_channel(), p.send_messages(), p.embed_links())
|
|
||||||
});
|
|
||||||
|
|
||||||
if manage_webhooks && send_messages && embed_links {
|
if manage_webhooks && send_messages && embed_links {
|
||||||
true
|
true
|
||||||
@ -81,8 +82,8 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
|||||||
{} **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;
|
||||||
@ -95,5 +96,5 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn all_checks(ctx: Context<'_>) -> Result<bool, Error> {
|
pub async fn all_checks(ctx: Context<'_>) -> Result<bool, Error> {
|
||||||
Ok(recording_macro_check(ctx).await && check_self_permissions(ctx).await)
|
Ok(macro_check(ctx).await && check_self_permissions(ctx).await)
|
||||||
}
|
}
|
||||||
|
@ -110,13 +110,14 @@ impl OverflowOp for u64 {
|
|||||||
#[derive(Copy, Clone)]
|
#[derive(Copy, Clone)]
|
||||||
pub struct Interval {
|
pub struct Interval {
|
||||||
pub month: u64,
|
pub month: u64,
|
||||||
|
pub day: u64,
|
||||||
pub sec: u64,
|
pub sec: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Parser<'a> {
|
struct Parser<'a> {
|
||||||
iter: Chars<'a>,
|
iter: Chars<'a>,
|
||||||
src: &'a str,
|
src: &'a str,
|
||||||
current: (u64, u64, u64),
|
current: (u64, u64, u64, u64),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Parser<'a> {
|
impl<'a> Parser<'a> {
|
||||||
@ -140,17 +141,17 @@ impl<'a> Parser<'a> {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> {
|
fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> {
|
||||||
let (mut month, mut sec, nsec) = match &self.src[start..end] {
|
let (mut month, mut day, mut sec, nsec) = match &self.src[start..end] {
|
||||||
"nanos" | "nsec" | "ns" => (0u64, 0u64, n),
|
"nanos" | "nsec" | "ns" => (0, 0u64, 0u64, n),
|
||||||
"usec" | "us" => (0, 0u64, n.mul(1000)?),
|
"usec" | "us" => (0, 0, 0u64, n.mul(1000)?),
|
||||||
"millis" | "msec" | "ms" => (0, 0u64, n.mul(1_000_000)?),
|
"millis" | "msec" | "ms" => (0, 0, 0u64, n.mul(1_000_000)?),
|
||||||
"seconds" | "second" | "secs" | "sec" | "s" => (0, n, 0),
|
"seconds" | "second" | "secs" | "sec" | "s" => (0, 0, n, 0),
|
||||||
"minutes" | "minute" | "min" | "mins" | "m" => (0, n.mul(60)?, 0),
|
"minutes" | "minute" | "min" | "mins" | "m" => (0, 0, n.mul(60)?, 0),
|
||||||
"hours" | "hour" | "hr" | "hrs" | "h" => (0, n.mul(3600)?, 0),
|
"hours" | "hour" | "hr" | "hrs" | "h" => (0, 0, n.mul(3600)?, 0),
|
||||||
"days" | "day" | "d" => (0, n.mul(86400)?, 0),
|
"days" | "day" | "d" => (0, n, 0, 0),
|
||||||
"weeks" | "week" | "w" => (0, n.mul(86400 * 7)?, 0),
|
"weeks" | "week" | "w" => (0, n.mul(7)?, 0, 0),
|
||||||
"months" | "month" | "M" => (n, 0, 0),
|
"months" | "month" => (n, 0, 0, 0),
|
||||||
"years" | "year" | "y" => (12, 0, 0),
|
"years" | "year" | "y" => (n.mul(12)?, 0, 0, 0),
|
||||||
_ => {
|
_ => {
|
||||||
return Err(Error::UnknownUnit {
|
return Err(Error::UnknownUnit {
|
||||||
start,
|
start,
|
||||||
@ -160,15 +161,16 @@ impl<'a> Parser<'a> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut nsec = self.current.2 + nsec;
|
let mut nsec = self.current.3 + nsec;
|
||||||
if nsec > 1_000_000_000 {
|
if nsec > 1_000_000_000 {
|
||||||
sec += nsec / 1_000_000_000;
|
sec += nsec / 1_000_000_000;
|
||||||
nsec %= 1_000_000_000;
|
nsec %= 1_000_000_000;
|
||||||
}
|
}
|
||||||
sec += self.current.1;
|
sec += self.current.2;
|
||||||
|
day += self.current.1;
|
||||||
month += self.current.0;
|
month += self.current.0;
|
||||||
|
|
||||||
self.current = (month, sec, nsec);
|
self.current = (month, day, sec, nsec);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -215,7 +217,13 @@ impl<'a> Parser<'a> {
|
|||||||
self.parse_unit(n, start, off)?;
|
self.parse_unit(n, start, off)?;
|
||||||
n = match self.parse_first_char()? {
|
n = match self.parse_first_char()? {
|
||||||
Some(n) => n,
|
Some(n) => n,
|
||||||
None => return Ok(Interval { month: self.current.0, sec: self.current.1 }),
|
None => {
|
||||||
|
return Ok(Interval {
|
||||||
|
month: self.current.0,
|
||||||
|
day: self.current.1,
|
||||||
|
sec: self.current.2,
|
||||||
|
})
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -247,5 +255,82 @@ impl<'a> Parser<'a> {
|
|||||||
/// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000)));
|
/// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000)));
|
||||||
/// ```
|
/// ```
|
||||||
pub fn parse_duration(s: &str) -> Result<Interval, Error> {
|
pub fn parse_duration(s: &str) -> Result<Interval, Error> {
|
||||||
Parser { iter: s.chars(), src: s, current: (0, 0, 0) }.parse()
|
Parser { iter: s.to_lowercase().chars(), src: &s.to_lowercase(), 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_case() {
|
||||||
|
let interval = parse_duration("200 Seconds").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 200);
|
||||||
|
assert_eq!(interval.day, 0);
|
||||||
|
assert_eq!(interval.month, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
49
src/main.rs
49
src/main.rs
@ -18,10 +18,10 @@ use std::{
|
|||||||
env,
|
env,
|
||||||
error::Error as StdError,
|
error::Error as StdError,
|
||||||
fmt::{Debug, Display, Formatter},
|
fmt::{Debug, Display, Formatter},
|
||||||
|
path::Path,
|
||||||
};
|
};
|
||||||
|
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use dotenv::dotenv;
|
|
||||||
use log::{error, warn};
|
use log::{error, warn};
|
||||||
use poise::serenity_prelude::model::{
|
use poise::serenity_prelude::model::{
|
||||||
gateway::GatewayIntents,
|
gateway::GatewayIntents,
|
||||||
@ -75,7 +75,7 @@ impl Display for Ended {
|
|||||||
|
|
||||||
impl StdError for Ended {}
|
impl StdError for Ended {}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
|
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||||
let (tx, mut rx) = broadcast::channel(16);
|
let (tx, mut rx) = broadcast::channel(16);
|
||||||
|
|
||||||
@ -88,7 +88,11 @@ async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
|
|||||||
async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + 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 discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
|
let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
|
||||||
|
|
||||||
@ -108,6 +112,16 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
|||||||
],
|
],
|
||||||
..moderation_cmds::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(),
|
moderation_cmds::webhook(),
|
||||||
poise::Command {
|
poise::Command {
|
||||||
subcommands: vec![
|
subcommands: vec![
|
||||||
@ -117,7 +131,6 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
|||||||
command_macro::record::record_macro(),
|
command_macro::record::record_macro(),
|
||||||
command_macro::run::run_macro(),
|
command_macro::run::run_macro(),
|
||||||
command_macro::migrate::migrate_macro(),
|
command_macro::migrate::migrate_macro(),
|
||||||
command_macro::install::install_macro(),
|
|
||||||
],
|
],
|
||||||
..command_macro::macro_base()
|
..command_macro::macro_base()
|
||||||
},
|
},
|
||||||
@ -134,6 +147,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
|||||||
],
|
],
|
||||||
..reminder_cmds::timer_base()
|
..reminder_cmds::timer_base()
|
||||||
},
|
},
|
||||||
|
reminder_cmds::multiline(),
|
||||||
reminder_cmds::remind(),
|
reminder_cmds::remind(),
|
||||||
poise::Command {
|
poise::Command {
|
||||||
subcommands: vec![
|
subcommands: vec![
|
||||||
@ -161,15 +175,36 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
|||||||
],
|
],
|
||||||
allowed_mentions: None,
|
allowed_mentions: None,
|
||||||
command_check: Some(|ctx| Box::pin(all_checks(ctx))),
|
command_check: Some(|ctx| Box::pin(all_checks(ctx))),
|
||||||
listener: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)),
|
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()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let database =
|
let database =
|
||||||
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
|
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
|
||||||
|
|
||||||
|
sqlx::migrate!().run(&database).await?;
|
||||||
|
|
||||||
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(&database)
|
.fetch_all(&database)
|
||||||
.await
|
.await
|
||||||
@ -180,7 +215,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
|||||||
|
|
||||||
poise::Framework::builder()
|
poise::Framework::builder()
|
||||||
.token(discord_token)
|
.token(discord_token)
|
||||||
.user_data_setup(move |ctx, _bot, framework| {
|
.setup(move |ctx, _bot, framework| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
register_application_commands(ctx, framework, None).await.unwrap();
|
register_application_commands(ctx, framework, None).await.unwrap();
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -37,7 +37,6 @@ pub struct RawCommandMacro {
|
|||||||
pub commands: Value,
|
pub commands: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a macro by name form a guild.
|
|
||||||
pub async fn guild_command_macro(
|
pub async fn guild_command_macro(
|
||||||
ctx: &Context<'_>,
|
ctx: &Context<'_>,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
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,14 +1,15 @@
|
|||||||
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 poise::serenity_prelude::{async_trait, model::id::UserId};
|
use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelType};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::{channel_data::ChannelData, user_data::UserData},
|
models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData},
|
||||||
CommandMacro, Context, Data, Error, GuildId,
|
CommandMacro, Context, Data, Error, GuildId,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -18,6 +19,8 @@ pub trait CtxData {
|
|||||||
|
|
||||||
async fn author_data(&self) -> Result<UserData, Error>;
|
async fn author_data(&self) -> Result<UserData, Error>;
|
||||||
|
|
||||||
|
async fn guild_data(&self) -> Option<Result<GuildData, Error>>;
|
||||||
|
|
||||||
async fn timezone(&self) -> Tz;
|
async fn timezone(&self) -> Tz;
|
||||||
|
|
||||||
async fn channel_data(&self) -> Result<ChannelData, Error>;
|
async fn channel_data(&self) -> Result<ChannelData, Error>;
|
||||||
@ -27,15 +30,21 @@ pub trait CtxData {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl CtxData for Context<'_> {
|
impl CtxData for Context<'_> {
|
||||||
async fn user_data<U: Into<UserId> + Send>(
|
async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error> {
|
||||||
&self,
|
UserData::from_user(user_id, &self.serenity_context(), &self.data().database).await
|
||||||
user_id: U,
|
|
||||||
) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> {
|
|
||||||
UserData::from_user(user_id, &self.discord(), &self.data().database).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn author_data(&self) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> {
|
async fn author_data(&self) -> Result<UserData, Error> {
|
||||||
UserData::from_user(&self.author().id, &self.discord(), &self.data().database).await
|
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 {
|
async fn timezone(&self) -> Tz {
|
||||||
@ -43,7 +52,20 @@ impl CtxData for Context<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> {
|
async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> {
|
||||||
let channel = self.channel_id().to_channel_cached(&self.discord()).unwrap();
|
// 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
|
ChannelData::from_channel(&channel, &self.data().database).await
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ use poise::serenity_prelude::{
|
|||||||
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;
|
||||||
|
|
||||||
@ -51,9 +51,11 @@ 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_secs: Option<i64>,
|
interval_seconds: Option<i64>,
|
||||||
|
interval_days: Option<i64>,
|
||||||
interval_months: Option<i64>,
|
interval_months: Option<i64>,
|
||||||
expires: Option<NaiveDateTime>,
|
expires: Option<NaiveDateTime>,
|
||||||
content: String,
|
content: String,
|
||||||
@ -87,6 +89,7 @@ INSERT INTO reminders (
|
|||||||
`utc_time`,
|
`utc_time`,
|
||||||
`timezone`,
|
`timezone`,
|
||||||
`interval_seconds`,
|
`interval_seconds`,
|
||||||
|
`interval_days`,
|
||||||
`interval_months`,
|
`interval_months`,
|
||||||
`expires`,
|
`expires`,
|
||||||
`content`,
|
`content`,
|
||||||
@ -106,6 +109,7 @@ INSERT INTO reminders (
|
|||||||
?,
|
?,
|
||||||
?,
|
?,
|
||||||
?,
|
?,
|
||||||
|
?,
|
||||||
?
|
?
|
||||||
)
|
)
|
||||||
",
|
",
|
||||||
@ -113,7 +117,8 @@ INSERT INTO reminders (
|
|||||||
self.channel,
|
self.channel,
|
||||||
utc_time,
|
utc_time,
|
||||||
self.timezone,
|
self.timezone,
|
||||||
self.interval_secs,
|
self.interval_seconds,
|
||||||
|
self.interval_days,
|
||||||
self.interval_months,
|
self.interval_months,
|
||||||
self.expires,
|
self.expires,
|
||||||
self.content,
|
self.content,
|
||||||
@ -175,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
|
||||||
}
|
}
|
||||||
@ -212,26 +215,32 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
|
|
||||||
let mut ok_locs = HashSet::new();
|
let mut ok_locs = HashSet::new();
|
||||||
|
|
||||||
if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) 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.sec + i.month * 30 * DAY) 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.discord()).await {
|
if let Ok(user) = UserId(user_id).to_user(&self.ctx).await {
|
||||||
let user_data = UserData::from_user(
|
let user_data = UserData::from_user(
|
||||||
&user,
|
&user,
|
||||||
&self.ctx.discord(),
|
&self.ctx.serenity_context(),
|
||||||
&self.ctx.data().database,
|
&self.ctx.data().database,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
if let Some(guild_id) = self.guild_id {
|
if let Some(guild_id) = self.guild_id {
|
||||||
if guild_id.member(&self.ctx.discord(), 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)
|
} else if self.set_by.map_or(true, |i| i != user_data.id)
|
||||||
&& !user_data.allowed_dm
|
&& !user_data.allowed_dm
|
||||||
@ -248,27 +257,36 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ReminderScope::Channel(channel_id) => {
|
ReminderScope::Channel(channel_id) => {
|
||||||
let channel =
|
let channel = ChannelId(channel_id).to_channel(&self.ctx).await.unwrap();
|
||||||
ChannelId(channel_id).to_channel(&self.ctx.discord()).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, &self.ctx.data().database)
|
== ChannelType::PublicThread
|
||||||
|
{
|
||||||
|
// fixme jesus christ
|
||||||
|
let parent = guild_channel
|
||||||
|
.parent_id
|
||||||
|
.unwrap()
|
||||||
|
.to_channel(&self.ctx)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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()
|
||||||
{
|
{
|
||||||
match create_webhook(
|
match create_webhook(&self.ctx, guild_channel, "Reminder").await
|
||||||
&self.ctx.discord(),
|
|
||||||
guild_channel,
|
|
||||||
"Reminder",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(webhook) => {
|
Ok(webhook) => {
|
||||||
channel_data.webhook_id =
|
channel_data.webhook_id =
|
||||||
@ -300,9 +318,11 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
pool: self.ctx.data().database.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_secs: self.interval.map(|i| i.sec as i64),
|
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),
|
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(),
|
||||||
|
@ -6,7 +6,7 @@ pub mod look_flags;
|
|||||||
|
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, TimeZone};
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use poise::serenity_prelude::{
|
use poise::serenity_prelude::{
|
||||||
model::id::{ChannelId, GuildId, UserId},
|
model::id::{ChannelId, GuildId, UserId},
|
||||||
@ -24,8 +24,9 @@ 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_seconds: Option<u32>,
|
pub interval_seconds: Option<u32>,
|
||||||
|
pub interval_days: Option<u32>,
|
||||||
pub interval_months: Option<u32>,
|
pub interval_months: Option<u32>,
|
||||||
pub expires: Option<NaiveDateTime>,
|
pub expires: Option<NaiveDateTime>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@ -59,6 +60,7 @@ SELECT
|
|||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
@ -95,6 +97,7 @@ SELECT
|
|||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
@ -138,6 +141,7 @@ SELECT
|
|||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
@ -155,6 +159,7 @@ LEFT JOIN
|
|||||||
ON
|
ON
|
||||||
reminders.set_by = users.id
|
reminders.set_by = users.id
|
||||||
WHERE
|
WHERE
|
||||||
|
`status` = 'pending' AND
|
||||||
channels.channel = ? AND
|
channels.channel = ? AND
|
||||||
FIND_IN_SET(reminders.enabled, ?)
|
FIND_IN_SET(reminders.enabled, ?)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@ -195,6 +200,7 @@ SELECT
|
|||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
@ -212,6 +218,7 @@ 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
|
||||||
@ -228,6 +235,7 @@ SELECT
|
|||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
@ -245,6 +253,7 @@ 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()
|
||||||
@ -262,6 +271,7 @@ SELECT
|
|||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
@ -279,6 +289,7 @@ 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()
|
||||||
@ -293,7 +304,10 @@ WHERE
|
|||||||
&self,
|
&self,
|
||||||
db: impl Executor<'_, Database = Database>,
|
db: impl Executor<'_, Database = Database>,
|
||||||
) -> Result<(), sqlx::Error> {
|
) -> Result<(), sqlx::Error> {
|
||||||
sqlx::query!("DELETE FROM reminders WHERE uid = ?", self.uid).execute(db).await.map(|_| ())
|
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 {
|
||||||
@ -310,30 +324,32 @@ WHERE
|
|||||||
count + 1,
|
count + 1,
|
||||||
self.display_content(),
|
self.display_content(),
|
||||||
self.channel,
|
self.channel,
|
||||||
timezone.timestamp(self.utc_time.timestamp(), 0).format("%Y-%m-%d %H:%M:%S")
|
self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 self.interval_seconds.is_some() || self.interval_months.is_some() {
|
if self.interval_seconds.is_some()
|
||||||
|
|| self.interval_days.is_some()
|
||||||
|
|| self.interval_months.is_some()
|
||||||
|
{
|
||||||
format!(
|
format!(
|
||||||
"'{}' *occurs next at* **{}**, repeating (set by {})",
|
"'{}' *occurs next at* **{}**, repeating (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())
|
||||||
)
|
)
|
||||||
} 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,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
|
||||||
)
|
)
|
||||||
|
@ -83,7 +83,7 @@ pub fn send_as_initial_response(
|
|||||||
components,
|
components,
|
||||||
ephemeral,
|
ephemeral,
|
||||||
allowed_mentions,
|
allowed_mentions,
|
||||||
reference_message: _, // can't reply to a message in interactions
|
reply: _,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
if let Some(content) = content {
|
if let Some(content) = content {
|
||||||
|
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
|
@ -1,21 +1,22 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "reminder_web"
|
name = "reminder_web"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
authors = ["jellywx <judesouthworth@pm.me>"]
|
authors = ["jellywx <judesouthworth@pm.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] }
|
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"] }
|
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"] }
|
serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
||||||
oauth2 = "4"
|
oauth2 = "4"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
reqwest = "0.11"
|
reqwest = "0.11"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
|
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
chrono-tz = "0.5"
|
chrono-tz = "0.8"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
rand = "0.7"
|
rand = "0.8"
|
||||||
base64 = "0.13"
|
base64 = "0.13"
|
||||||
csv = "1.1"
|
csv = "1.2"
|
||||||
|
prometheus = "0.13.3"
|
||||||
|
40
web/src/catchers.rs
Normal file
40
web/src/catchers.rs
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use rocket::serde::json::json;
|
||||||
|
use rocket_dyn_templates::Template;
|
||||||
|
|
||||||
|
use crate::JsonValue;
|
||||||
|
|
||||||
|
#[catch(403)]
|
||||||
|
pub(crate) async fn forbidden() -> Template {
|
||||||
|
let map: HashMap<String, String> = HashMap::new();
|
||||||
|
Template::render("errors/403", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(500)]
|
||||||
|
pub(crate) async fn internal_server_error() -> Template {
|
||||||
|
let map: HashMap<String, String> = HashMap::new();
|
||||||
|
Template::render("errors/500", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(401)]
|
||||||
|
pub(crate) async fn not_authorized() -> Template {
|
||||||
|
let map: HashMap<String, String> = HashMap::new();
|
||||||
|
Template::render("errors/401", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(404)]
|
||||||
|
pub(crate) async fn not_found() -> Template {
|
||||||
|
let map: HashMap<String, String> = HashMap::new();
|
||||||
|
Template::render("errors/404", &map)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(413)]
|
||||||
|
pub(crate) async fn payload_too_large() -> JsonValue {
|
||||||
|
json!({"error": "Data too large.", "errors": ["Data too large."]})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(422)]
|
||||||
|
pub(crate) async fn unprocessable_entity() -> JsonValue {
|
||||||
|
json!({"error": "Invalid request.", "errors": ["Invalid request."]})
|
||||||
|
}
|
@ -2,6 +2,7 @@ pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/to
|
|||||||
pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize";
|
pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize";
|
||||||
pub const DISCORD_API: &'static str = "https://discord.com/api";
|
pub const DISCORD_API: &'static str = "https://discord.com/api";
|
||||||
|
|
||||||
|
pub const MAX_NAME_LENGTH: usize = 100;
|
||||||
pub const MAX_CONTENT_LENGTH: usize = 2000;
|
pub const MAX_CONTENT_LENGTH: usize = 2000;
|
||||||
pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096;
|
pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096;
|
||||||
pub const MAX_EMBED_TITLE_LENGTH: usize = 256;
|
pub const MAX_EMBED_TITLE_LENGTH: usize = 256;
|
||||||
@ -31,7 +32,7 @@ lazy_static! {
|
|||||||
)
|
)
|
||||||
.into();
|
.into();
|
||||||
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() })
|
||||||
@ -39,7 +40,7 @@ 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: u32 = env::var("MIN_INTERVAL")
|
pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|inner| inner.parse::<u32>().ok())
|
.map(|inner| inner.parse::<u32>().ok())
|
||||||
|
1
web/src/guards/mod.rs
Normal file
1
web/src/guards/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub(crate) mod transaction;
|
42
web/src/guards/transaction.rs
Normal file
42
web/src/guards/transaction.rs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
use rocket::{
|
||||||
|
http::Status,
|
||||||
|
request::{FromRequest, Outcome},
|
||||||
|
Request, State,
|
||||||
|
};
|
||||||
|
use sqlx::Pool;
|
||||||
|
|
||||||
|
use crate::Database;
|
||||||
|
|
||||||
|
pub struct Transaction<'a>(sqlx::Transaction<'a, Database>);
|
||||||
|
|
||||||
|
impl Transaction<'_> {
|
||||||
|
pub fn executor(&mut self) -> impl sqlx::Executor<'_, Database = Database> {
|
||||||
|
&mut *(self.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn commit(self) -> Result<(), sqlx::Error> {
|
||||||
|
self.0.commit().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum TransactionError {
|
||||||
|
Error(sqlx::Error),
|
||||||
|
Missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for Transaction<'r> {
|
||||||
|
type Error = TransactionError;
|
||||||
|
|
||||||
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||||
|
match request.guard::<&State<Pool<Database>>>().await {
|
||||||
|
Outcome::Success(pool) => match pool.begin().await {
|
||||||
|
Ok(transaction) => Outcome::Success(Transaction(transaction)),
|
||||||
|
Err(e) => Outcome::Error((Status::InternalServerError, TransactionError::Error(e))),
|
||||||
|
},
|
||||||
|
Outcome::Error(e) => Outcome::Error((e.0, TransactionError::Missing)),
|
||||||
|
Outcome::Forward(f) => Outcome::Forward(f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
179
web/src/lib.rs
179
web/src/lib.rs
@ -4,13 +4,17 @@ extern crate rocket;
|
|||||||
mod consts;
|
mod consts;
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
mod macros;
|
mod macros;
|
||||||
|
mod catchers;
|
||||||
|
mod guards;
|
||||||
|
mod metrics;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
use std::{collections::HashMap, env};
|
use std::{env, path::Path};
|
||||||
|
|
||||||
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
|
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
|
||||||
use rocket::{
|
use rocket::{
|
||||||
fs::FileServer,
|
fs::FileServer,
|
||||||
|
http::CookieJar,
|
||||||
serde::json::{json, Value as JsonValue},
|
serde::json::{json, Value as JsonValue},
|
||||||
tokio::sync::broadcast::Sender,
|
tokio::sync::broadcast::Sender,
|
||||||
};
|
};
|
||||||
@ -22,7 +26,10 @@ use serenity::{
|
|||||||
};
|
};
|
||||||
use sqlx::{MySql, Pool};
|
use sqlx::{MySql, Pool};
|
||||||
|
|
||||||
use crate::consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES};
|
use crate::{
|
||||||
|
consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES},
|
||||||
|
metrics::{init_metrics, MetricProducer},
|
||||||
|
};
|
||||||
|
|
||||||
type Database = MySql;
|
type Database = MySql;
|
||||||
|
|
||||||
@ -32,50 +39,20 @@ enum Error {
|
|||||||
Serenity(serenity::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(
|
pub async fn initialize(
|
||||||
kill_channel: Sender<()>,
|
kill_channel: Sender<()>,
|
||||||
serenity_context: Context,
|
serenity_context: Context,
|
||||||
db_pool: Pool<Database>,
|
db_pool: Pool<Database>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
info!("Checking environment variables...");
|
info!("Checking environment variables...");
|
||||||
|
|
||||||
|
if env::var("OFFLINE").map_or(true, |v| v != "1") {
|
||||||
env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied");
|
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_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied");
|
||||||
env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied");
|
env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied");
|
||||||
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD' not supplied");
|
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied");
|
||||||
|
}
|
||||||
|
|
||||||
info!("Done!");
|
info!("Done!");
|
||||||
|
|
||||||
let oauth2_client = BasicClient::new(
|
let oauth2_client = BasicClient::new(
|
||||||
@ -88,32 +65,40 @@ pub async fn initialize(
|
|||||||
|
|
||||||
let reqwest_client = reqwest::Client::new();
|
let reqwest_client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let static_path =
|
||||||
|
if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" };
|
||||||
|
|
||||||
|
init_metrics();
|
||||||
|
|
||||||
rocket::build()
|
rocket::build()
|
||||||
|
.attach(MetricProducer)
|
||||||
.attach(Template::fairing())
|
.attach(Template::fairing())
|
||||||
.register(
|
.register(
|
||||||
"/",
|
"/",
|
||||||
catchers![
|
catchers![
|
||||||
not_authorized,
|
catchers::not_authorized,
|
||||||
forbidden,
|
catchers::forbidden,
|
||||||
not_found,
|
catchers::not_found,
|
||||||
internal_server_error,
|
catchers::internal_server_error,
|
||||||
unprocessable_entity,
|
catchers::unprocessable_entity,
|
||||||
payload_too_large,
|
catchers::payload_too_large,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.manage(oauth2_client)
|
.manage(oauth2_client)
|
||||||
.manage(reqwest_client)
|
.manage(reqwest_client)
|
||||||
.manage(serenity_context)
|
.manage(serenity_context)
|
||||||
.manage(db_pool)
|
.manage(db_pool)
|
||||||
.mount("/static", FileServer::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static")))
|
.mount("/static", FileServer::from(static_path))
|
||||||
.mount(
|
.mount(
|
||||||
"/",
|
"/",
|
||||||
routes![
|
routes![
|
||||||
routes::index,
|
|
||||||
routes::cookies,
|
routes::cookies,
|
||||||
|
routes::index,
|
||||||
|
routes::metrics::metrics,
|
||||||
routes::privacy,
|
routes::privacy,
|
||||||
|
routes::report::report_error,
|
||||||
|
routes::return_to_same_site,
|
||||||
routes::terms,
|
routes::terms,
|
||||||
routes::return_to_same_site
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.mount(
|
.mount(
|
||||||
@ -131,25 +116,32 @@ pub async fn initialize(
|
|||||||
routes::help_iemanager,
|
routes::help_iemanager,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.mount("/login", routes![routes::login::discord_login, routes::login::discord_callback])
|
.mount(
|
||||||
|
"/login",
|
||||||
|
routes![
|
||||||
|
routes::login::discord_login,
|
||||||
|
routes::login::discord_logout,
|
||||||
|
routes::login::discord_callback
|
||||||
|
],
|
||||||
|
)
|
||||||
.mount(
|
.mount(
|
||||||
"/dashboard",
|
"/dashboard",
|
||||||
routes![
|
routes![
|
||||||
routes::dashboard::dashboard,
|
routes::dashboard::dashboard,
|
||||||
routes::dashboard::dashboard_home,
|
routes::dashboard::dashboard_home,
|
||||||
routes::dashboard::user::get_user_info,
|
routes::dashboard::api::user::get_user_info,
|
||||||
routes::dashboard::user::update_user_info,
|
routes::dashboard::api::user::update_user_info,
|
||||||
routes::dashboard::user::get_user_guilds,
|
routes::dashboard::api::user::get_user_guilds,
|
||||||
routes::dashboard::guild::get_guild_patreon,
|
routes::dashboard::api::guild::get_guild_info,
|
||||||
routes::dashboard::guild::get_guild_channels,
|
routes::dashboard::api::guild::get_guild_channels,
|
||||||
routes::dashboard::guild::get_guild_roles,
|
routes::dashboard::api::guild::get_guild_roles,
|
||||||
routes::dashboard::guild::get_reminder_templates,
|
routes::dashboard::api::guild::get_reminder_templates,
|
||||||
routes::dashboard::guild::create_reminder_template,
|
routes::dashboard::api::guild::create_reminder_template,
|
||||||
routes::dashboard::guild::delete_reminder_template,
|
routes::dashboard::api::guild::delete_reminder_template,
|
||||||
routes::dashboard::guild::create_guild_reminder,
|
routes::dashboard::api::guild::create_guild_reminder,
|
||||||
routes::dashboard::guild::get_reminders,
|
routes::dashboard::api::guild::get_reminders,
|
||||||
routes::dashboard::guild::edit_reminder,
|
routes::dashboard::api::guild::edit_reminder,
|
||||||
routes::dashboard::guild::delete_reminder,
|
routes::dashboard::api::guild::delete_reminder,
|
||||||
routes::dashboard::export::export_reminders,
|
routes::dashboard::export::export_reminders,
|
||||||
routes::dashboard::export::export_reminder_templates,
|
routes::dashboard::export::export_reminder_templates,
|
||||||
routes::dashboard::export::export_todos,
|
routes::dashboard::export::export_todos,
|
||||||
@ -157,6 +149,7 @@ pub async fn initialize(
|
|||||||
routes::dashboard::export::import_todos,
|
routes::dashboard::export::import_todos,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
.mount("/admin", routes![routes::admin::admin_dashboard_home, routes::admin::bot_data])
|
||||||
.launch()
|
.launch()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -173,6 +166,8 @@ pub async fn initialize(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
|
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
|
||||||
|
offline!(true);
|
||||||
|
|
||||||
if let Some(subscription_guild) = *CNC_GUILD {
|
if let Some(subscription_guild) = *CNC_GUILD {
|
||||||
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
|
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
|
||||||
|
|
||||||
@ -194,6 +189,8 @@ pub async fn check_guild_subscription(
|
|||||||
cache_http: impl CacheHttp,
|
cache_http: impl CacheHttp,
|
||||||
guild_id: impl Into<GuildId>,
|
guild_id: impl Into<GuildId>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
offline!(true);
|
||||||
|
|
||||||
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
|
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
|
||||||
let owner = guild.owner_id;
|
let owner = guild.owner_id;
|
||||||
|
|
||||||
@ -202,3 +199,65 @@ pub async fn check_guild_subscription(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn check_authorization(
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &Context,
|
||||||
|
guild: u64,
|
||||||
|
) -> Result<(), JsonValue> {
|
||||||
|
let user_id = cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
|
||||||
|
|
||||||
|
if std::env::var("OFFLINE").map_or(true, |v| v != "1") {
|
||||||
|
match user_id {
|
||||||
|
Some(user_id) => {
|
||||||
|
let admin_id = std::env::var("ADMIN_ID")
|
||||||
|
.map_or(false, |u| u.parse::<u64>().map_or(false, |u| u == user_id));
|
||||||
|
|
||||||
|
if admin_id {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
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"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
@ -1,3 +1,11 @@
|
|||||||
|
macro_rules! offline {
|
||||||
|
($field:expr) => {
|
||||||
|
if std::env::var("OFFLINE").map_or(false, |v| v == "1") {
|
||||||
|
return $field;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
macro_rules! check_length {
|
macro_rules! check_length {
|
||||||
($max:ident, $field:expr) => {
|
($max:ident, $field:expr) => {
|
||||||
if $field.len() > $max {
|
if $field.len() > $max {
|
||||||
@ -46,40 +54,6 @@ macro_rules! check_url_opt {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! check_authorization {
|
|
||||||
($cookies:expr, $ctx:expr, $guild:expr) => {
|
|
||||||
use serenity::model::id::UserId;
|
|
||||||
|
|
||||||
let user_id = $cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
|
|
||||||
|
|
||||||
match user_id {
|
|
||||||
Some(user_id) => {
|
|
||||||
match GuildId($guild).to_guild_cached($ctx) {
|
|
||||||
Some(guild) => {
|
|
||||||
let member = guild.member($ctx, UserId(user_id)).await;
|
|
||||||
|
|
||||||
match member {
|
|
||||||
Err(_) => {
|
|
||||||
return Err(json!({"error": "User not in guild"}));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None => {
|
|
||||||
return Err(json!({"error": "Bot not in guild"}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None => {
|
|
||||||
return Err(json!({"error": "User not authorized"}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
macro_rules! update_field {
|
macro_rules! update_field {
|
||||||
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
|
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
|
||||||
if let Some(value) = &$reminder.$field {
|
if let Some(value) = &$reminder.$field {
|
||||||
|
43
web/src/metrics.rs
Normal file
43
web/src/metrics.rs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
use lazy_static::lazy_static;
|
||||||
|
use prometheus::{IntCounterVec, Opts, Registry};
|
||||||
|
use rocket::{
|
||||||
|
fairing::{Fairing, Info, Kind},
|
||||||
|
Data, Request, Response,
|
||||||
|
};
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref REGISTRY: Registry = Registry::new();
|
||||||
|
static ref REQUEST_COUNTER: IntCounterVec =
|
||||||
|
IntCounterVec::new(Opts::new("requests", "Requests"), &["method", "route"]).unwrap();
|
||||||
|
static ref RESPONSE_COUNTER: IntCounterVec =
|
||||||
|
IntCounterVec::new(Opts::new("responses", "Responses"), &["status", "route"]).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_metrics() {
|
||||||
|
REGISTRY.register(Box::new(REQUEST_COUNTER.clone())).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MetricProducer;
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl Fairing for MetricProducer {
|
||||||
|
fn info(&self) -> Info {
|
||||||
|
Info { name: "Metrics fairing", kind: Kind::Request }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_request(&self, req: &mut Request<'_>, _data: &mut Data<'_>) {
|
||||||
|
if let Some(route) = req.route() {
|
||||||
|
REQUEST_COUNTER
|
||||||
|
.with_label_values(&[req.method().as_str(), &route.uri.to_string()])
|
||||||
|
.inc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_response<'r>(&self, req: &'r Request<'_>, resp: &mut Response<'r>) {
|
||||||
|
if let Some(route) = req.route() {
|
||||||
|
RESPONSE_COUNTER
|
||||||
|
.with_label_values(&[&resp.status().code.to_string(), &route.uri.to_string()])
|
||||||
|
.inc();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
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,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
61
web/src/routes/dashboard/api/guild/channels.rs
Normal file
61
web/src/routes/dashboard/api/guild/channels.rs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
use rocket::{http::CookieJar, serde::json::json, State};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serenity::{
|
||||||
|
client::Context,
|
||||||
|
model::{
|
||||||
|
channel::GuildChannel,
|
||||||
|
id::{ChannelId, GuildId},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{check_authorization, routes::JsonResult};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ChannelInfo {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
webhook_avatar: Option<String>,
|
||||||
|
webhook_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/guild/<id>/channels")]
|
||||||
|
pub async fn get_guild_channels(
|
||||||
|
id: u64,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
) -> JsonResult {
|
||||||
|
offline!(Ok(json!(vec![ChannelInfo {
|
||||||
|
name: "general".to_string(),
|
||||||
|
id: "1".to_string(),
|
||||||
|
webhook_avatar: None,
|
||||||
|
webhook_name: None,
|
||||||
|
}])));
|
||||||
|
check_authorization(cookies, ctx.inner(), id).await?;
|
||||||
|
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
42
web/src/routes/dashboard/api/guild/mod.rs
Normal file
42
web/src/routes/dashboard/api/guild/mod.rs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
mod channels;
|
||||||
|
mod reminders;
|
||||||
|
mod roles;
|
||||||
|
mod templates;
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
pub use channels::*;
|
||||||
|
pub use reminders::*;
|
||||||
|
use rocket::{http::CookieJar, serde::json::json, State};
|
||||||
|
pub use roles::*;
|
||||||
|
use serenity::{
|
||||||
|
client::Context,
|
||||||
|
model::id::{GuildId, RoleId},
|
||||||
|
};
|
||||||
|
pub use templates::*;
|
||||||
|
|
||||||
|
use crate::{check_authorization, routes::JsonResult};
|
||||||
|
|
||||||
|
#[get("/api/guild/<id>")]
|
||||||
|
pub async fn get_guild_info(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
|
||||||
|
offline!(Ok(json!({ "patreon": true, "name": "Guild" })));
|
||||||
|
check_authorization(cookies, ctx.inner(), id).await?;
|
||||||
|
|
||||||
|
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, "name": guild.name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
None => json_err!("Bot not in guild"),
|
||||||
|
}
|
||||||
|
}
|
388
web/src/routes/dashboard/api/guild/reminders.rs
Normal file
388
web/src/routes/dashboard/api/guild/reminders.rs
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
use rocket::{
|
||||||
|
http::CookieJar,
|
||||||
|
serde::json::{json, Json},
|
||||||
|
State,
|
||||||
|
};
|
||||||
|
use serenity::{
|
||||||
|
client::Context,
|
||||||
|
model::id::{ChannelId, GuildId, UserId},
|
||||||
|
};
|
||||||
|
use sqlx::{MySql, Pool};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
check_authorization, check_guild_subscription, check_subscription,
|
||||||
|
consts::MIN_INTERVAL,
|
||||||
|
guards::transaction::Transaction,
|
||||||
|
routes::{
|
||||||
|
dashboard::{
|
||||||
|
create_database_channel, create_reminder, DeleteReminder, PatchReminder, Reminder,
|
||||||
|
},
|
||||||
|
JsonResult,
|
||||||
|
},
|
||||||
|
Database,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
|
||||||
|
pub async fn create_guild_reminder(
|
||||||
|
id: u64,
|
||||||
|
reminder: Json<Reminder>,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
mut transaction: Transaction<'_>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization(cookies, ctx.inner(), id).await?;
|
||||||
|
|
||||||
|
let user_id =
|
||||||
|
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
||||||
|
|
||||||
|
match create_reminder(
|
||||||
|
ctx.inner(),
|
||||||
|
&mut transaction,
|
||||||
|
GuildId(id),
|
||||||
|
UserId(user_id),
|
||||||
|
reminder.into_inner(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => match transaction.commit().await {
|
||||||
|
Ok(_) => Ok(r),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Couldn't commit transaction: {:?}", e);
|
||||||
|
json_err!("Couldn't commit transaction.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/guild/<id>/reminders")]
|
||||||
|
pub async fn get_reminders(
|
||||||
|
id: u64,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization(cookies, ctx.inner(), id).await?;
|
||||||
|
|
||||||
|
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>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
mut transaction: Transaction<'_>,
|
||||||
|
pool: &State<Pool<Database>>,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization(cookies, ctx.inner(), id).await?;
|
||||||
|
|
||||||
|
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!(transaction.executor(), error, reminder.[
|
||||||
|
content,
|
||||||
|
embed_author,
|
||||||
|
embed_description,
|
||||||
|
embed_footer,
|
||||||
|
embed_title,
|
||||||
|
embed_fields,
|
||||||
|
username
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
error.push("Message exceeds limits.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
update_field!(transaction.executor(), 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(&ctx.inner(), id).await
|
||||||
|
|| check_subscription(&ctx.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(transaction.executor())
|
||||||
|
.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(transaction.executor())
|
||||||
|
.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(transaction.executor())
|
||||||
|
.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!(transaction.executor(), error, reminder.[
|
||||||
|
interval_days,
|
||||||
|
interval_months,
|
||||||
|
interval_seconds
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sqlx::query!(
|
||||||
|
"
|
||||||
|
UPDATE reminders
|
||||||
|
SET interval_seconds = NULL, interval_days = NULL, interval_months = NULL
|
||||||
|
WHERE uid = ?
|
||||||
|
",
|
||||||
|
reminder.uid
|
||||||
|
)
|
||||||
|
.execute(transaction.executor())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Error updating reminder interval: {:?}", e);
|
||||||
|
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if reminder.channel > 0 {
|
||||||
|
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx.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(
|
||||||
|
ctx.inner(),
|
||||||
|
ChannelId(reminder.channel),
|
||||||
|
&mut transaction,
|
||||||
|
)
|
||||||
|
.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(transaction.executor())
|
||||||
|
.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"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = transaction.commit().await {
|
||||||
|
warn!("Couldn't commit transaction: {:?}", e);
|
||||||
|
return json_err!("Couldn't commit transaction");
|
||||||
|
}
|
||||||
|
|
||||||
|
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/<id>/reminders", data = "<reminder>")]
|
||||||
|
pub async fn delete_reminder(
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
id: u64,
|
||||||
|
reminder: Json<DeleteReminder>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
check_authorization(cookies, ctx.inner(), id).await?;
|
||||||
|
|
||||||
|
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"}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
35
web/src/routes/dashboard/api/guild/roles.rs
Normal file
35
web/src/routes/dashboard/api/guild/roles.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
use rocket::{http::CookieJar, serde::json::json, State};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serenity::client::Context;
|
||||||
|
|
||||||
|
use crate::{check_authorization, routes::JsonResult};
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
offline!(Ok(json!(vec![RoleInfo { name: "@everyone".to_string(), id: "1".to_string() }])));
|
||||||
|
check_authorization(cookies, ctx.inner(), id).await?;
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
181
web/src/routes/dashboard/api/guild/templates.rs
Normal file
181
web/src/routes/dashboard/api/guild/templates.rs
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
use rocket::{
|
||||||
|
http::CookieJar,
|
||||||
|
serde::json::{json, Json},
|
||||||
|
State,
|
||||||
|
};
|
||||||
|
use serenity::client::Context;
|
||||||
|
use sqlx::{MySql, Pool};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
check_authorization,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
routes::{
|
||||||
|
dashboard::{template_name_default, DeleteReminderTemplate, ReminderTemplate},
|
||||||
|
JsonResult,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[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).await?;
|
||||||
|
|
||||||
|
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).await?;
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
interval_seconds,
|
||||||
|
interval_days,
|
||||||
|
interval_months,
|
||||||
|
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.interval_seconds,
|
||||||
|
reminder_template.interval_days,
|
||||||
|
reminder_template.interval_months,
|
||||||
|
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).await?;
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2
web/src/routes/dashboard/api/mod.rs
Normal file
2
web/src/routes/dashboard/api/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pub mod guild;
|
||||||
|
pub mod user;
|
@ -1,36 +1,14 @@
|
|||||||
use std::env;
|
|
||||||
|
|
||||||
use chrono_tz::Tz;
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
http::CookieJar,
|
http::CookieJar,
|
||||||
serde::json::{json, Json, Value as JsonValue},
|
serde::json::{json, Value as JsonValue},
|
||||||
State,
|
State,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serenity::{
|
use serenity::model::{id::GuildId, permissions::Permissions};
|
||||||
client::Context,
|
|
||||||
model::{
|
|
||||||
id::{GuildId, RoleId},
|
|
||||||
permissions::Permissions,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use sqlx::{MySql, Pool};
|
|
||||||
|
|
||||||
use crate::consts::DISCORD_API;
|
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)]
|
#[derive(Serialize)]
|
||||||
struct GuildInfo {
|
struct GuildInfo {
|
||||||
id: String,
|
id: String,
|
||||||
@ -38,9 +16,8 @@ struct GuildInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct PartialGuild {
|
struct PartialGuild {
|
||||||
pub id: GuildId,
|
pub id: GuildId,
|
||||||
pub icon: Option<String>,
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub owner: bool,
|
pub owner: bool,
|
||||||
@ -48,71 +25,10 @@ pub struct PartialGuild {
|
|||||||
pub permissions: Option<String>,
|
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 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")]
|
#[get("/api/user/guilds")]
|
||||||
pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue {
|
pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue {
|
||||||
|
offline!(json!(vec![GuildInfo { id: "1".to_string(), name: "Guild".to_string() }]));
|
||||||
|
|
||||||
if let Some(access_token) = cookies.get_private("access_token") {
|
if let Some(access_token) = cookies.get_private("access_token") {
|
||||||
let request_res = reqwest_client
|
let request_res = reqwest_client
|
||||||
.get(format!("{}/users/@me/guilds", DISCORD_API))
|
.get(format!("{}/users/@me/guilds", DISCORD_API))
|
97
web/src/routes/dashboard/api/user/mod.rs
Normal file
97
web/src/routes/dashboard/api/user/mod.rs
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
mod guilds;
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use chrono_tz::Tz;
|
||||||
|
pub use guilds::*;
|
||||||
|
use rocket::{
|
||||||
|
http::CookieJar,
|
||||||
|
serde::json::{json, Json, Value as JsonValue},
|
||||||
|
State,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serenity::{
|
||||||
|
client::Context,
|
||||||
|
model::id::{GuildId, RoleId},
|
||||||
|
};
|
||||||
|
use sqlx::{MySql, Pool};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UserInfo {
|
||||||
|
name: String,
|
||||||
|
patreon: bool,
|
||||||
|
timezone: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateUser {
|
||||||
|
timezone: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/user")]
|
||||||
|
pub async fn get_user_info(
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonValue {
|
||||||
|
offline!(json!(UserInfo { name: "Discord".to_string(), patreon: true, timezone: None }));
|
||||||
|
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
}
|
20
web/src/routes/dashboard/api/user/models.rs
Normal file
20
web/src/routes/dashboard/api/user/models.rs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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, routes::JsonResult};
|
29
web/src/routes/dashboard/api/user/reminders.rs
Normal file
29
web/src/routes/dashboard/api/user/reminders.rs
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
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, routes::JsonResult};
|
||||||
|
|
||||||
|
#[get("/api/user/reminders")]
|
||||||
|
pub async fn get_reminders(
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
|
ctx: &State<Context>,
|
||||||
|
pool: &State<Pool<MySql>>,
|
||||||
|
) -> JsonResult {
|
||||||
|
Ok(json! {})
|
||||||
|
}
|
@ -6,13 +6,20 @@ use rocket::{
|
|||||||
};
|
};
|
||||||
use serenity::{
|
use serenity::{
|
||||||
client::Context,
|
client::Context,
|
||||||
model::id::{ChannelId, GuildId},
|
model::id::{ChannelId, GuildId, UserId},
|
||||||
};
|
};
|
||||||
use sqlx::{MySql, Pool};
|
use sqlx::{MySql, Pool};
|
||||||
|
|
||||||
use crate::routes::dashboard::{
|
use crate::{
|
||||||
create_reminder, generate_uid, ImportBody, JsonResult, Reminder, ReminderCsv,
|
check_authorization,
|
||||||
ReminderTemplateCsv, TodoCsv,
|
guards::transaction::Transaction,
|
||||||
|
routes::{
|
||||||
|
dashboard::{
|
||||||
|
create_reminder, generate_uid, ImportBody, Reminder, ReminderCsv, ReminderTemplateCsv,
|
||||||
|
TodoCsv,
|
||||||
|
},
|
||||||
|
JsonResult,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[get("/api/guild/<id>/export/reminders")]
|
#[get("/api/guild/<id>/export/reminders")]
|
||||||
@ -22,7 +29,7 @@ pub async fn export_reminders(
|
|||||||
ctx: &State<Context>,
|
ctx: &State<Context>,
|
||||||
pool: &State<Pool<MySql>>,
|
pool: &State<Pool<MySql>>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
check_authorization!(cookies, ctx.inner(), id);
|
check_authorization(cookies, ctx.inner(), id).await?;
|
||||||
|
|
||||||
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
||||||
|
|
||||||
@ -58,6 +65,7 @@ pub async fn export_reminders(
|
|||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.name,
|
reminders.name,
|
||||||
reminders.restartable,
|
reminders.restartable,
|
||||||
@ -66,7 +74,7 @@ pub async fn export_reminders(
|
|||||||
reminders.utc_time
|
reminders.utc_time
|
||||||
FROM reminders
|
FROM reminders
|
||||||
LEFT JOIN channels ON channels.id = reminders.channel_id
|
LEFT JOIN channels ON channels.id = reminders.channel_id
|
||||||
WHERE FIND_IN_SET(channels.channel, ?)",
|
WHERE FIND_IN_SET(channels.channel, ?) AND status = 'pending'",
|
||||||
channels
|
channels
|
||||||
)
|
)
|
||||||
.fetch_all(pool.inner())
|
.fetch_all(pool.inner())
|
||||||
@ -114,14 +122,14 @@ pub async fn export_reminders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[put("/api/guild/<id>/export/reminders", data = "<body>")]
|
#[put("/api/guild/<id>/export/reminders", data = "<body>")]
|
||||||
pub async fn import_reminders(
|
pub(crate) async fn import_reminders(
|
||||||
id: u64,
|
id: u64,
|
||||||
cookies: &CookieJar<'_>,
|
cookies: &CookieJar<'_>,
|
||||||
body: Json<ImportBody>,
|
body: Json<ImportBody>,
|
||||||
ctx: &State<Context>,
|
ctx: &State<Context>,
|
||||||
pool: &State<Pool<MySql>>,
|
mut transaction: Transaction<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
check_authorization!(cookies, ctx.inner(), id);
|
check_authorization(cookies, ctx.inner(), id).await?;
|
||||||
|
|
||||||
let user_id =
|
let user_id =
|
||||||
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
||||||
@ -129,6 +137,7 @@ pub async fn import_reminders(
|
|||||||
match base64::decode(&body.body) {
|
match base64::decode(&body.body) {
|
||||||
Ok(body) => {
|
Ok(body) => {
|
||||||
let mut reader = csv::Reader::from_reader(body.as_slice());
|
let mut reader = csv::Reader::from_reader(body.as_slice());
|
||||||
|
let mut count = 0;
|
||||||
|
|
||||||
for result in reader.deserialize::<ReminderCsv>() {
|
for result in reader.deserialize::<ReminderCsv>() {
|
||||||
match result {
|
match result {
|
||||||
@ -159,6 +168,7 @@ pub async fn import_reminders(
|
|||||||
enabled: record.enabled,
|
enabled: record.enabled,
|
||||||
expires: record.expires,
|
expires: record.expires,
|
||||||
interval_seconds: record.interval_seconds,
|
interval_seconds: record.interval_seconds,
|
||||||
|
interval_days: record.interval_days,
|
||||||
interval_months: record.interval_months,
|
interval_months: record.interval_months,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
restartable: record.restartable,
|
restartable: record.restartable,
|
||||||
@ -170,12 +180,14 @@ pub async fn import_reminders(
|
|||||||
|
|
||||||
create_reminder(
|
create_reminder(
|
||||||
ctx.inner(),
|
ctx.inner(),
|
||||||
pool.inner(),
|
&mut transaction,
|
||||||
GuildId(id),
|
GuildId(id),
|
||||||
UserId(user_id),
|
UserId(user_id),
|
||||||
reminder,
|
reminder,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@ -195,7 +207,16 @@ pub async fn import_reminders(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(json!({}))
|
match transaction.commit().await {
|
||||||
|
Ok(_) => Ok(json!({
|
||||||
|
"message": format!("Imported {} reminders", count)
|
||||||
|
})),
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to commit transaction: {:?}", e);
|
||||||
|
json_err!("Couldn't commit transaction")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@ -211,7 +232,7 @@ pub async fn export_todos(
|
|||||||
ctx: &State<Context>,
|
ctx: &State<Context>,
|
||||||
pool: &State<Pool<MySql>>,
|
pool: &State<Pool<MySql>>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
check_authorization!(cookies, ctx.inner(), id);
|
check_authorization(cookies, ctx.inner(), id).await?;
|
||||||
|
|
||||||
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
||||||
|
|
||||||
@ -266,7 +287,7 @@ pub async fn import_todos(
|
|||||||
ctx: &State<Context>,
|
ctx: &State<Context>,
|
||||||
pool: &State<Pool<MySql>>,
|
pool: &State<Pool<MySql>>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
check_authorization!(cookies, ctx.inner(), id);
|
check_authorization(cookies, ctx.inner(), id).await?;
|
||||||
|
|
||||||
let channels_res = GuildId(id).channels(&ctx.inner()).await;
|
let channels_res = GuildId(id).channels(&ctx.inner()).await;
|
||||||
|
|
||||||
@ -318,13 +339,6 @@ pub async fn import_todos(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = sqlx::query!(
|
|
||||||
"DELETE FROM todos WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
|
||||||
id
|
|
||||||
)
|
|
||||||
.execute(pool.inner())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let query_str = format!(
|
let query_str = format!(
|
||||||
"INSERT INTO todos (value, channel_id, guild_id) VALUES {}",
|
"INSERT INTO todos (value, channel_id, guild_id) VALUES {}",
|
||||||
vec![query_placeholder].repeat(query_params.len()).join(",")
|
vec![query_placeholder].repeat(query_params.len()).join(",")
|
||||||
@ -368,7 +382,7 @@ pub async fn export_reminder_templates(
|
|||||||
ctx: &State<Context>,
|
ctx: &State<Context>,
|
||||||
pool: &State<Pool<MySql>>,
|
pool: &State<Pool<MySql>>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
check_authorization!(cookies, ctx.inner(), id);
|
check_authorization(cookies, ctx.inner(), id).await?;
|
||||||
|
|
||||||
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
||||||
|
|
||||||
@ -390,6 +404,9 @@ pub async fn export_reminder_templates(
|
|||||||
embed_thumbnail_url,
|
embed_thumbnail_url,
|
||||||
embed_title,
|
embed_title,
|
||||||
embed_fields,
|
embed_fields,
|
||||||
|
interval_seconds,
|
||||||
|
interval_days,
|
||||||
|
interval_months,
|
||||||
tts,
|
tts,
|
||||||
username
|
username
|
||||||
FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||||
|
@ -1,528 +0,0 @@
|
|||||||
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::{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
routes::dashboard::{
|
|
||||||
create_database_channel, create_reminder, template_name_default, DeleteReminder,
|
|
||||||
DeleteReminderTemplate, JsonResult, PatchReminder, Reminder, ReminderTemplate,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[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 fetch templates from {}: {:?}", id, e);
|
|
||||||
|
|
||||||
json_err!("Could not get templates")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")]
|
|
||||||
pub async fn delete_reminder_template(
|
|
||||||
id: u64,
|
|
||||||
delete_reminder_template: Json<DeleteReminderTemplate>,
|
|
||||||
cookies: &CookieJar<'_>,
|
|
||||||
ctx: &State<Context>,
|
|
||||||
pool: &State<Pool<MySql>>,
|
|
||||||
) -> 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, ctx: &State<Context>, pool: &State<Pool<MySql>>) -> JsonResult {
|
|
||||||
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,
|
|
||||||
reminders.embed_fields,
|
|
||||||
reminders.enabled,
|
|
||||||
reminders.expires,
|
|
||||||
reminders.interval_seconds,
|
|
||||||
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 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>>,
|
|
||||||
) -> JsonResult {
|
|
||||||
let mut error = vec![];
|
|
||||||
|
|
||||||
update_field!(pool.inner(), error, reminder.[
|
|
||||||
attachment,
|
|
||||||
attachment_name,
|
|
||||||
avatar,
|
|
||||||
content,
|
|
||||||
embed_author,
|
|
||||||
embed_author_url,
|
|
||||||
embed_color,
|
|
||||||
embed_description,
|
|
||||||
embed_footer,
|
|
||||||
embed_footer_url,
|
|
||||||
embed_image_url,
|
|
||||||
embed_thumbnail_url,
|
|
||||||
embed_title,
|
|
||||||
embed_fields,
|
|
||||||
enabled,
|
|
||||||
expires,
|
|
||||||
interval_seconds,
|
|
||||||
interval_months,
|
|
||||||
name,
|
|
||||||
restartable,
|
|
||||||
tts,
|
|
||||||
username,
|
|
||||||
utc_time
|
|
||||||
]);
|
|
||||||
|
|
||||||
if reminder.channel > 0 {
|
|
||||||
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
|
|
||||||
match channel {
|
|
||||||
Some(channel) => {
|
|
||||||
let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id);
|
|
||||||
|
|
||||||
if !channel_matches_guild {
|
|
||||||
warn!(
|
|
||||||
"Error in `edit_reminder`: channel {:?} not found for guild {}",
|
|
||||||
reminder.channel, id
|
|
||||||
);
|
|
||||||
|
|
||||||
return 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_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!("DELETE FROM reminders 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"}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,20 +1,20 @@
|
|||||||
use std::collections::HashMap;
|
use std::path::Path;
|
||||||
|
|
||||||
use chrono::{naive::NaiveDateTime, Utc};
|
use chrono::{naive::NaiveDateTime, Utc};
|
||||||
use rand::{rngs::OsRng, seq::IteratorRandom};
|
use rand::{rngs::OsRng, seq::IteratorRandom};
|
||||||
use rocket::{
|
use rocket::{
|
||||||
|
fs::{relative, NamedFile},
|
||||||
http::CookieJar,
|
http::CookieJar,
|
||||||
response::Redirect,
|
response::Redirect,
|
||||||
serde::json::{json, Value as JsonValue},
|
serde::json::json,
|
||||||
};
|
};
|
||||||
use rocket_dyn_templates::Template;
|
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serenity::{
|
use serenity::{
|
||||||
client::Context,
|
client::Context,
|
||||||
http::Http,
|
http::Http,
|
||||||
model::id::{ChannelId, GuildId, UserId},
|
model::id::{ChannelId, GuildId, UserId},
|
||||||
};
|
};
|
||||||
use sqlx::{types::Json, Executor, MySql, Pool};
|
use sqlx::types::Json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
check_guild_subscription, check_subscription,
|
check_guild_subscription, check_subscription,
|
||||||
@ -22,16 +22,16 @@ use crate::{
|
|||||||
CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH,
|
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_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH,
|
||||||
MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH,
|
MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH,
|
||||||
MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL,
|
MAX_NAME_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL,
|
||||||
},
|
},
|
||||||
Database, Error,
|
guards::transaction::Transaction,
|
||||||
|
routes::JsonResult,
|
||||||
|
Error,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod guild;
|
|
||||||
pub mod user;
|
|
||||||
|
|
||||||
pub type JsonResult = Result<JsonValue, JsonValue>;
|
|
||||||
type Unset<T> = Option<T>;
|
type Unset<T> = Option<T>;
|
||||||
|
|
||||||
fn name_default() -> String {
|
fn name_default() -> String {
|
||||||
@ -50,6 +50,33 @@ fn id_default() -> u32 {
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn interval_default() -> Unset<Option<u32>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::Type)]
|
||||||
|
#[sqlx(transparent)]
|
||||||
|
struct Attachment(Vec<u8>);
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Attachment {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Attachment, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let string = String::deserialize(deserializer)?;
|
||||||
|
Ok(Attachment(base64::decode(string).map_err(de::Error::custom)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for Attachment {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.collect_str(&base64::encode(&self.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct ReminderTemplate {
|
pub struct ReminderTemplate {
|
||||||
#[serde(default = "id_default")]
|
#[serde(default = "id_default")]
|
||||||
@ -58,7 +85,7 @@ pub struct ReminderTemplate {
|
|||||||
guild_id: u32,
|
guild_id: u32,
|
||||||
#[serde(default = "template_name_default")]
|
#[serde(default = "template_name_default")]
|
||||||
name: String,
|
name: String,
|
||||||
attachment: Option<Vec<u8>>,
|
attachment: Option<Attachment>,
|
||||||
attachment_name: Option<String>,
|
attachment_name: Option<String>,
|
||||||
avatar: Option<String>,
|
avatar: Option<String>,
|
||||||
content: String,
|
content: String,
|
||||||
@ -72,6 +99,9 @@ pub struct ReminderTemplate {
|
|||||||
embed_thumbnail_url: Option<String>,
|
embed_thumbnail_url: Option<String>,
|
||||||
embed_title: String,
|
embed_title: String,
|
||||||
embed_fields: Option<Json<Vec<EmbedField>>>,
|
embed_fields: Option<Json<Vec<EmbedField>>>,
|
||||||
|
interval_seconds: Option<u32>,
|
||||||
|
interval_days: Option<u32>,
|
||||||
|
interval_months: Option<u32>,
|
||||||
tts: bool,
|
tts: bool,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
}
|
}
|
||||||
@ -80,7 +110,7 @@ pub struct ReminderTemplate {
|
|||||||
pub struct ReminderTemplateCsv {
|
pub struct ReminderTemplateCsv {
|
||||||
#[serde(default = "template_name_default")]
|
#[serde(default = "template_name_default")]
|
||||||
name: String,
|
name: String,
|
||||||
attachment: Option<Vec<u8>>,
|
attachment: Option<Attachment>,
|
||||||
attachment_name: Option<String>,
|
attachment_name: Option<String>,
|
||||||
avatar: Option<String>,
|
avatar: Option<String>,
|
||||||
content: String,
|
content: String,
|
||||||
@ -94,6 +124,9 @@ pub struct ReminderTemplateCsv {
|
|||||||
embed_thumbnail_url: Option<String>,
|
embed_thumbnail_url: Option<String>,
|
||||||
embed_title: String,
|
embed_title: String,
|
||||||
embed_fields: Option<String>,
|
embed_fields: Option<String>,
|
||||||
|
interval_seconds: Option<u32>,
|
||||||
|
interval_days: Option<u32>,
|
||||||
|
interval_months: Option<u32>,
|
||||||
tts: bool,
|
tts: bool,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
}
|
}
|
||||||
@ -112,8 +145,7 @@ pub struct EmbedField {
|
|||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct Reminder {
|
pub struct Reminder {
|
||||||
#[serde(with = "base64s")]
|
attachment: Option<Attachment>,
|
||||||
attachment: Option<Vec<u8>>,
|
|
||||||
attachment_name: Option<String>,
|
attachment_name: Option<String>,
|
||||||
avatar: Option<String>,
|
avatar: Option<String>,
|
||||||
#[serde(with = "string")]
|
#[serde(with = "string")]
|
||||||
@ -132,6 +164,7 @@ pub struct Reminder {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
expires: Option<NaiveDateTime>,
|
expires: Option<NaiveDateTime>,
|
||||||
interval_seconds: Option<u32>,
|
interval_seconds: Option<u32>,
|
||||||
|
interval_days: Option<u32>,
|
||||||
interval_months: Option<u32>,
|
interval_months: Option<u32>,
|
||||||
#[serde(default = "name_default")]
|
#[serde(default = "name_default")]
|
||||||
name: String,
|
name: String,
|
||||||
@ -145,8 +178,7 @@ pub struct Reminder {
|
|||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct ReminderCsv {
|
pub struct ReminderCsv {
|
||||||
#[serde(with = "base64s")]
|
attachment: Option<Attachment>,
|
||||||
attachment: Option<Vec<u8>>,
|
|
||||||
attachment_name: Option<String>,
|
attachment_name: Option<String>,
|
||||||
avatar: Option<String>,
|
avatar: Option<String>,
|
||||||
channel: String,
|
channel: String,
|
||||||
@ -164,6 +196,7 @@ pub struct ReminderCsv {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
expires: Option<NaiveDateTime>,
|
expires: Option<NaiveDateTime>,
|
||||||
interval_seconds: Option<u32>,
|
interval_seconds: Option<u32>,
|
||||||
|
interval_days: Option<u32>,
|
||||||
interval_months: Option<u32>,
|
interval_months: Option<u32>,
|
||||||
#[serde(default = "name_default")]
|
#[serde(default = "name_default")]
|
||||||
name: String,
|
name: String,
|
||||||
@ -177,10 +210,13 @@ pub struct ReminderCsv {
|
|||||||
pub struct PatchReminder {
|
pub struct PatchReminder {
|
||||||
uid: String,
|
uid: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
attachment: Unset<Option<String>>,
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
attachment: Unset<Option<Attachment>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
attachment_name: Unset<Option<String>>,
|
attachment_name: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
avatar: Unset<Option<String>>,
|
avatar: Unset<Option<String>>,
|
||||||
#[serde(default = "channel_default")]
|
#[serde(default = "channel_default")]
|
||||||
#[serde(with = "string")]
|
#[serde(with = "string")]
|
||||||
@ -190,6 +226,7 @@ pub struct PatchReminder {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
embed_author: Unset<String>,
|
embed_author: Unset<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
embed_author_url: Unset<Option<String>>,
|
embed_author_url: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
embed_color: Unset<u32>,
|
embed_color: Unset<u32>,
|
||||||
@ -198,10 +235,13 @@ pub struct PatchReminder {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
embed_footer: Unset<String>,
|
embed_footer: Unset<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
embed_footer_url: Unset<Option<String>>,
|
embed_footer_url: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
embed_image_url: Unset<Option<String>>,
|
embed_image_url: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
embed_thumbnail_url: Unset<Option<String>>,
|
embed_thumbnail_url: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
embed_title: Unset<String>,
|
embed_title: Unset<String>,
|
||||||
@ -210,10 +250,16 @@ pub struct PatchReminder {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
enabled: Unset<bool>,
|
enabled: Unset<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
expires: Unset<Option<NaiveDateTime>>,
|
expires: Unset<Option<NaiveDateTime>>,
|
||||||
#[serde(default)]
|
#[serde(default = "interval_default")]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
interval_seconds: Unset<Option<u32>>,
|
interval_seconds: Unset<Option<u32>>,
|
||||||
#[serde(default)]
|
#[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>>,
|
interval_months: Unset<Option<u32>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
name: Unset<String>,
|
name: Unset<String>,
|
||||||
@ -222,11 +268,36 @@ pub struct PatchReminder {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tts: Unset<bool>,
|
tts: Unset<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
username: Unset<Option<String>>,
|
username: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
utc_time: Unset<NaiveDateTime>,
|
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 {
|
pub fn generate_uid() -> String {
|
||||||
let mut generator: OsRng = Default::default();
|
let mut generator: OsRng = Default::default();
|
||||||
|
|
||||||
@ -236,6 +307,14 @@ pub fn generate_uid() -> String {
|
|||||||
.join("")
|
.join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)?))
|
||||||
|
}
|
||||||
|
|
||||||
// https://github.com/serde-rs/json/issues/329#issuecomment-305608405
|
// https://github.com/serde-rs/json/issues/329#issuecomment-305608405
|
||||||
mod string {
|
mod string {
|
||||||
use std::{fmt::Display, str::FromStr};
|
use std::{fmt::Display, str::FromStr};
|
||||||
@ -260,29 +339,6 @@ mod string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod base64s {
|
|
||||||
use serde::{de, Deserialize, Deserializer, Serializer};
|
|
||||||
|
|
||||||
pub fn serialize<S>(value: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
if let Some(opt) = value {
|
|
||||||
serializer.collect_str(&base64::encode(opt))
|
|
||||||
} else {
|
|
||||||
serializer.serialize_none()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let string = Option::<String>::deserialize(deserializer)?;
|
|
||||||
Some(string.map(|b| base64::decode(b).map_err(de::Error::custom))).flatten().transpose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct DeleteReminder {
|
pub struct DeleteReminder {
|
||||||
uid: String,
|
uid: String,
|
||||||
@ -299,13 +355,30 @@ pub struct TodoCsv {
|
|||||||
channel_id: Option<String>,
|
channel_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_reminder(
|
pub(crate) async fn create_reminder(
|
||||||
ctx: &Context,
|
ctx: &Context,
|
||||||
pool: &Pool<MySql>,
|
transaction: &mut Transaction<'_>,
|
||||||
guild_id: GuildId,
|
guild_id: GuildId,
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
reminder: Reminder,
|
reminder: Reminder,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
// check guild in db
|
||||||
|
match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.0)
|
||||||
|
.fetch_one(transaction.executor())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Err(sqlx::Error::RowNotFound) => {
|
||||||
|
if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.0)
|
||||||
|
.execute(transaction.executor())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err(json!({"error": "Guild could not be created"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
// validate channel
|
// validate channel
|
||||||
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx);
|
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx);
|
||||||
let channel_exists = channel.is_some();
|
let channel_exists = channel.is_some();
|
||||||
@ -322,7 +395,7 @@ pub async fn create_reminder(
|
|||||||
return Err(json!({"error": "Channel not found"}));
|
return Err(json!({"error": "Channel not found"}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let channel = create_database_channel(&ctx, ChannelId(reminder.channel), pool).await;
|
let channel = create_database_channel(&ctx, ChannelId(reminder.channel), transaction).await;
|
||||||
|
|
||||||
if let Err(e) = channel {
|
if let Err(e) = channel {
|
||||||
warn!("`create_database_channel` returned an error code: {:?}", e);
|
warn!("`create_database_channel` returned an error code: {:?}", e);
|
||||||
@ -335,6 +408,7 @@ pub async fn create_reminder(
|
|||||||
let channel = channel.unwrap();
|
let channel = channel.unwrap();
|
||||||
|
|
||||||
// validate lengths
|
// validate lengths
|
||||||
|
check_length!(MAX_NAME_LENGTH, reminder.name);
|
||||||
check_length!(MAX_CONTENT_LENGTH, reminder.content);
|
check_length!(MAX_CONTENT_LENGTH, reminder.content);
|
||||||
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
|
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
|
||||||
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
|
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
|
||||||
@ -370,8 +444,12 @@ pub async fn create_reminder(
|
|||||||
if reminder.utc_time < Utc::now().naive_utc() {
|
if reminder.utc_time < Utc::now().naive_utc() {
|
||||||
return Err(json!({"error": "Time must be in the future"}));
|
return Err(json!({"error": "Time must be in the future"}));
|
||||||
}
|
}
|
||||||
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
|
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
|
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)
|
+ reminder.interval_seconds.unwrap_or(0)
|
||||||
< *MIN_INTERVAL
|
< *MIN_INTERVAL
|
||||||
{
|
{
|
||||||
@ -380,7 +458,10 @@ pub async fn create_reminder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check patreon if necessary
|
// check patreon if necessary
|
||||||
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
|
if reminder.interval_seconds.is_some()
|
||||||
|
|| reminder.interval_days.is_some()
|
||||||
|
|| reminder.interval_months.is_some()
|
||||||
|
{
|
||||||
if !check_guild_subscription(&ctx, guild_id).await
|
if !check_guild_subscription(&ctx, guild_id).await
|
||||||
&& !check_subscription(&ctx, user_id).await
|
&& !check_subscription(&ctx, user_id).await
|
||||||
{
|
{
|
||||||
@ -388,9 +469,12 @@ pub async fn create_reminder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 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();
|
let new_uid = generate_uid();
|
||||||
|
|
||||||
@ -416,15 +500,16 @@ pub async fn create_reminder(
|
|||||||
enabled,
|
enabled,
|
||||||
expires,
|
expires,
|
||||||
interval_seconds,
|
interval_seconds,
|
||||||
|
interval_days,
|
||||||
interval_months,
|
interval_months,
|
||||||
name,
|
name,
|
||||||
restartable,
|
restartable,
|
||||||
tts,
|
tts,
|
||||||
username,
|
username,
|
||||||
`utc_time`
|
`utc_time`
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
new_uid,
|
new_uid,
|
||||||
attachment_data,
|
reminder.attachment,
|
||||||
reminder.attachment_name,
|
reminder.attachment_name,
|
||||||
channel,
|
channel,
|
||||||
reminder.avatar,
|
reminder.avatar,
|
||||||
@ -442,14 +527,15 @@ pub async fn create_reminder(
|
|||||||
reminder.enabled,
|
reminder.enabled,
|
||||||
reminder.expires,
|
reminder.expires,
|
||||||
reminder.interval_seconds,
|
reminder.interval_seconds,
|
||||||
|
reminder.interval_days,
|
||||||
reminder.interval_months,
|
reminder.interval_months,
|
||||||
name,
|
name,
|
||||||
reminder.restartable,
|
reminder.restartable,
|
||||||
reminder.tts,
|
reminder.tts,
|
||||||
reminder.username,
|
username,
|
||||||
reminder.utc_time,
|
reminder.utc_time,
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(transaction.executor())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => sqlx::query_as_unchecked!(
|
Ok(_) => sqlx::query_as_unchecked!(
|
||||||
@ -473,6 +559,7 @@ pub async fn create_reminder(
|
|||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.name,
|
reminders.name,
|
||||||
reminders.restartable,
|
reminders.restartable,
|
||||||
@ -485,7 +572,7 @@ pub async fn create_reminder(
|
|||||||
WHERE uid = ?",
|
WHERE uid = ?",
|
||||||
new_uid
|
new_uid
|
||||||
)
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(transaction.executor())
|
||||||
.await
|
.await
|
||||||
.map(|r| Ok(json!(r)))
|
.map(|r| Ok(json!(r)))
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
@ -505,11 +592,11 @@ pub async fn create_reminder(
|
|||||||
async fn create_database_channel(
|
async fn create_database_channel(
|
||||||
ctx: impl AsRef<Http>,
|
ctx: impl AsRef<Http>,
|
||||||
channel: ChannelId,
|
channel: ChannelId,
|
||||||
pool: impl Executor<'_, Database = Database> + Copy,
|
transaction: &mut Transaction<'_>,
|
||||||
) -> Result<u32, crate::Error> {
|
) -> Result<u32, crate::Error> {
|
||||||
let row =
|
let row =
|
||||||
sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0)
|
sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0)
|
||||||
.fetch_one(pool)
|
.fetch_one(transaction.executor())
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match row {
|
match row {
|
||||||
@ -526,7 +613,7 @@ async fn create_database_channel(
|
|||||||
webhook.token,
|
webhook.token,
|
||||||
channel.0
|
channel.0
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(transaction.executor())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error::SQLx(e))?;
|
.map_err(|e| Error::SQLx(e))?;
|
||||||
}
|
}
|
||||||
@ -552,7 +639,7 @@ async fn create_database_channel(
|
|||||||
webhook.token,
|
webhook.token,
|
||||||
channel.0
|
channel.0
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(transaction.executor())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error::SQLx(e))?;
|
.map_err(|e| Error::SQLx(e))?;
|
||||||
|
|
||||||
@ -563,7 +650,7 @@ async fn create_database_channel(
|
|||||||
}?;
|
}?;
|
||||||
|
|
||||||
let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0)
|
let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0)
|
||||||
.fetch_one(pool)
|
.fetch_one(transaction.executor())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error::SQLx(e))?;
|
.map_err(|e| Error::SQLx(e))?;
|
||||||
|
|
||||||
@ -571,20 +658,26 @@ async fn create_database_channel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
|
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<NamedFile, Redirect> {
|
||||||
if cookies.get_private("userid").is_some() {
|
if cookies.get_private("userid").is_some() {
|
||||||
let map: HashMap<&str, String> = HashMap::new();
|
NamedFile::open(Path::new(relative!("static/index.html"))).await.map_err(|e| {
|
||||||
Ok(Template::render("dashboard", &map))
|
warn!("Couldn't render dashboard: {:?}", e);
|
||||||
|
|
||||||
|
Redirect::to("/login/discord")
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
Err(Redirect::to("/login/discord"))
|
Err(Redirect::to("/login/discord"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/<_>")]
|
#[get("/<_..>")]
|
||||||
pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
|
pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<NamedFile, Redirect> {
|
||||||
if cookies.get_private("userid").is_some() {
|
if cookies.get_private("userid").is_some() {
|
||||||
let map: HashMap<&str, String> = HashMap::new();
|
NamedFile::open(Path::new(relative!("static/index.html"))).await.map_err(|e| {
|
||||||
Ok(Template::render("dashboard", &map))
|
warn!("Couldn't render dashboard: {:?}", e);
|
||||||
|
|
||||||
|
Redirect::to("/login/discord")
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
Err(Redirect::to("/login/discord"))
|
Err(Redirect::to("/login/discord"))
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ use rocket::{
|
|||||||
};
|
};
|
||||||
use serenity::model::user::User;
|
use serenity::model::user::User;
|
||||||
|
|
||||||
use crate::consts::DISCORD_API;
|
use crate::{consts::DISCORD_API, routes};
|
||||||
|
|
||||||
#[get("/discord")]
|
#[get("/discord")]
|
||||||
pub async fn discord_login(
|
pub async fn discord_login(
|
||||||
@ -31,27 +31,34 @@ pub async fn discord_login(
|
|||||||
|
|
||||||
// store the pkce secret to verify the authorization later
|
// store the pkce secret to verify the authorization later
|
||||||
cookies.add_private(
|
cookies.add_private(
|
||||||
Cookie::build("verify", pkce_verifier.secret().to_string())
|
Cookie::build(("verify", pkce_verifier.secret().to_string()))
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.path("/login")
|
.path("/login")
|
||||||
.same_site(SameSite::Lax)
|
.same_site(SameSite::Lax)
|
||||||
.expires(Expiration::Session)
|
.expires(Expiration::Session),
|
||||||
.finish(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// store the csrf token to verify no interference
|
// store the csrf token to verify no interference
|
||||||
cookies.add_private(
|
cookies.add_private(
|
||||||
Cookie::build("csrf", csrf_token.secret().to_string())
|
Cookie::build(("csrf", csrf_token.secret().to_string()))
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.path("/login")
|
.path("/login")
|
||||||
.same_site(SameSite::Lax)
|
.same_site(SameSite::Lax)
|
||||||
.expires(Expiration::Session)
|
.expires(Expiration::Session),
|
||||||
.finish(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Redirect::to(auth_url.to_string())
|
Redirect::to(auth_url.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/discord/logout")]
|
||||||
|
pub async fn discord_logout(cookies: &CookieJar<'_>) -> Redirect {
|
||||||
|
cookies.remove_private(Cookie::from("username"));
|
||||||
|
cookies.remove_private(Cookie::from("userid"));
|
||||||
|
cookies.remove_private(Cookie::from("access_token"));
|
||||||
|
|
||||||
|
Redirect::to(uri!(routes::index))
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/discord/authorized?<code>&<state>")]
|
#[get("/discord/authorized?<code>&<state>")]
|
||||||
pub async fn discord_callback(
|
pub async fn discord_callback(
|
||||||
code: &str,
|
code: &str,
|
||||||
@ -71,17 +78,16 @@ pub async fn discord_callback(
|
|||||||
.request_async(async_http_client)
|
.request_async(async_http_client)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
cookies.remove_private(Cookie::named("verify"));
|
cookies.remove_private(Cookie::from("verify"));
|
||||||
cookies.remove_private(Cookie::named("csrf"));
|
cookies.remove_private(Cookie::from("csrf"));
|
||||||
|
|
||||||
match token_result {
|
match token_result {
|
||||||
Ok(token) => {
|
Ok(token) => {
|
||||||
cookies.add_private(
|
cookies.add_private(
|
||||||
Cookie::build("access_token", token.access_token().secret().to_string())
|
Cookie::build(("access_token", token.access_token().secret().to_string()))
|
||||||
.secure(true)
|
.secure(true)
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.path("/dashboard")
|
.path("/dashboard"),
|
||||||
.finish(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let request_res = reqwest_client
|
let request_res = reqwest_client
|
||||||
@ -135,14 +141,14 @@ pub async fn discord_callback(
|
|||||||
Err(Flash::new(
|
Err(Flash::new(
|
||||||
Redirect::to(uri!(super::return_to_same_site(""))),
|
Redirect::to(uri!(super::return_to_same_site(""))),
|
||||||
"warning",
|
"warning",
|
||||||
"Your login request was rejected",
|
"Your login request was rejected. The server may be misconfigured. Please retry or alert us in Discord.",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "danger", "Your request failed to validate, and so has been rejected (error: CSRF Validation Failure)"))
|
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 {
|
} else {
|
||||||
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "warning", "Your request was missing information, and so has been rejected (error: CSRF Validation Tokens Missing)"))
|
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)"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
18
web/src/routes/metrics.rs
Normal file
18
web/src/routes/metrics.rs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
use prometheus;
|
||||||
|
|
||||||
|
use crate::metrics::REGISTRY;
|
||||||
|
|
||||||
|
#[get("/metrics")]
|
||||||
|
pub async fn metrics() -> String {
|
||||||
|
let encoder = prometheus::TextEncoder::new();
|
||||||
|
let res_custom = encoder.encode_to_string(®ISTRY.gather());
|
||||||
|
|
||||||
|
match res_custom {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Error encoding metrics: {:?}", e);
|
||||||
|
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,16 @@
|
|||||||
|
pub mod admin;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
pub mod metrics;
|
||||||
|
pub mod report;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use rocket::request::FlashMessage;
|
use rocket::{request::FlashMessage, serde::json::Value as JsonValue};
|
||||||
use rocket_dyn_templates::Template;
|
use rocket_dyn_templates::Template;
|
||||||
|
|
||||||
|
pub type JsonResult = Result<JsonValue, JsonValue>;
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
pub async fn index(flash: Option<FlashMessage<'_>>) -> Template {
|
pub async fn index(flash: Option<FlashMessage<'_>>) -> Template {
|
||||||
let mut map: HashMap<&str, String> = HashMap::new();
|
let mut map: HashMap<&str, String> = HashMap::new();
|
||||||
|
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!({}))
|
||||||
|
}
|
@ -11,10 +11,26 @@ div.reminderContent.is-collapsed .column.discord-frame {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.reminderContent.is-collapsed .collapses {
|
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 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent.is-collapsed .button-row-edit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent.is-collapsed .reminder-topbar {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
div.reminderContent.is-collapsed .invert-collapses {
|
div.reminderContent.is-collapsed .invert-collapses {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
@ -23,42 +39,42 @@ div.reminderContent .invert-collapses {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.reminderContent.is-collapsed .settings {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.reminderContent.is-collapsed .channel-field {
|
|
||||||
display: inline-flex;
|
|
||||||
order: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.reminderContent.is-collapsed .reminder-topbar {
|
|
||||||
display: inline-flex;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
flex-grow: 1;
|
|
||||||
order: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.reminderContent.is-collapsed input[name="name"] {
|
div.reminderContent.is-collapsed input[name="name"] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
border: none;
|
border: none;
|
||||||
font-weight: 700;
|
|
||||||
background: none;
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.reminderContent.is-collapsed button.hide-box {
|
div.reminderContent.is-collapsed .hide-box {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.reminderContent.is-collapsed button.hide-box i {
|
div.reminderContent.is-collapsed .hide-box i {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
/* END */
|
/* END */
|
||||||
|
|
||||||
/* dashboard styles */
|
/* dashboard styles */
|
||||||
|
.hide-box {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-box:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
button.inline-btn {
|
button.inline-btn {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
@ -85,18 +101,86 @@ div.discord-embed {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.reminderContent {
|
div.split-controls {
|
||||||
padding: 2px;
|
display: flex;
|
||||||
background-color: #f5f5f5;
|
flex-direction: column;
|
||||||
border-radius: 8px;
|
justify-content: space-between;
|
||||||
margin: 8px;
|
flex-grow: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.interval-group > button {
|
.reminder-topbar > div {
|
||||||
margin-left: auto;
|
padding-left: 6px;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-bar {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-button-bar {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patreon-only {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-row {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-topbar {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-settings {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-settings > .column {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-basis: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Interval inputs */
|
/* Interval inputs */
|
||||||
|
div.interval-group {
|
||||||
|
height: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.interval-group .clear:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.interval-group .no-break {
|
||||||
|
text-wrap: avoid;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.interval-group .clear {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 1px;
|
||||||
|
margin-right: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
div.interval-group > .interval-group-left input {
|
div.interval-group > .interval-group-left input {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
border-style: none;
|
border-style: none;
|
||||||
@ -110,12 +194,13 @@ div.interval-group > .interval-group-left input.w2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div.interval-group > .interval-group-left input.w3 {
|
div.interval-group > .interval-group-left input.w3 {
|
||||||
width: 6ch;
|
width: 3ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.interval-group {
|
div.interval-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
/* !Interval inputs */
|
/* !Interval inputs */
|
||||||
|
|
||||||
@ -133,17 +218,16 @@ div.inset-content {
|
|||||||
margin-right: 10%;
|
margin-right: 10%;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.flash-message {
|
div.flash-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.flash-message {
|
||||||
width: calc(100% - 32px);
|
width: calc(100% - 32px);
|
||||||
margin: 16px !important;
|
margin: 16px !important;
|
||||||
z-index: 99;
|
z-index: 99;
|
||||||
bottom: 0;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.flash-message.is-active {
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@ -180,6 +264,23 @@ div#pageNavbar a {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-burger {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-item.pageTitle {
|
||||||
|
flex-shrink: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-burger, .dashboard-burger:active, .dashboard-burger.is-active {
|
||||||
|
background-color: #adc99c !important;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 6px;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
div#pageNavbar a:hover {
|
div#pageNavbar a:hover {
|
||||||
background-color: #4a4a4a;
|
background-color: #4a4a4a;
|
||||||
}
|
}
|
||||||
@ -206,17 +307,24 @@ div.dashboard-sidebar {
|
|||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.dashboard-sidebar:not(.mobile-sidebar) {
|
ul.guildList {
|
||||||
display: flex;
|
flex-grow: 1;
|
||||||
flex-direction: column;
|
flex-shrink: 1;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
|
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 226px;
|
width: 226px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.dashboard-sidebar svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
div.mobile-sidebar {
|
div.mobile-sidebar {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@ -293,10 +401,7 @@ input.default-width {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-input:placeholder-shown {
|
.message-input:placeholder-shown {
|
||||||
border-top: none;
|
font-style: italic;
|
||||||
border-left: none;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom-style: dashed;
|
|
||||||
background-color: #40444b;
|
background-color: #40444b;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
@ -367,8 +472,7 @@ input.default-width {
|
|||||||
.customizable.is-400x300 img {
|
.customizable.is-400x300 img {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100px;
|
height: 100px;
|
||||||
max-height: 400px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.customizable.is-32x32 img {
|
.customizable.is-32x32 img {
|
||||||
@ -462,6 +566,7 @@ input.default-width {
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.embed-body input, .embed-body textarea {
|
.embed-body input, .embed-body textarea {
|
||||||
@ -511,21 +616,88 @@ input.default-width {
|
|||||||
border-bottom: 1px solid #fff;
|
border-bottom: 1px solid #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
.channel-selector {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.highlight {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row-edit > button {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row .button-row-reminder {
|
||||||
|
flex-grow: 0;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row-template {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row .button-row-template > div {
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 1023px) {
|
||||||
|
p.title.pageTitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame {
|
||||||
|
margin-top: 4rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
.customizable.thumbnail img {
|
.customizable.thumbnail img {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.customizable.is-24x24 img {
|
@media only screen and (max-width: 768px) {
|
||||||
width: 16px;
|
.button-row {
|
||||||
height: 16px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row .button-row-reminder {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row .button-row-template > div {
|
||||||
|
flex-basis: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-settings {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-row {
|
||||||
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* loader */
|
/* loader */
|
||||||
#loader {
|
#loader {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
background-color: rgba(255, 255, 255, 0.8);
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
@ -537,6 +709,86 @@ input.default-width {
|
|||||||
|
|
||||||
/* END */
|
/* END */
|
||||||
|
|
||||||
|
div.reminderError {
|
||||||
|
margin: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError .errorHead {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError .errorIcon {
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError .errorIcon .fas {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError[data-case="deleted"] .errorIcon {
|
||||||
|
background-color: #e7e5e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError[data-case="failed"] .errorIcon {
|
||||||
|
background-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError[data-case="sent"] .errorIcon {
|
||||||
|
background-color: #d9f99d;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError[data-case="deleted"] .errorIcon .fas.fa-trash {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError[data-case="failed"] .errorIcon .fas.fa-exclamation-triangle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError[data-case="sent"] .errorIcon .fas.fa-check {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError .errorHead .reminderName {
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgb(54, 54, 54);
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError .errorHead .reminderTime {
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 1;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgb(54, 54, 54);
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-color: #e5e5e5;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError .reminderMessage {
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgb(54, 54, 54);
|
||||||
|
flex-grow: 1;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* other stuff */
|
/* other stuff */
|
||||||
|
|
||||||
.half-rem {
|
.half-rem {
|
||||||
@ -568,11 +820,44 @@ input.default-width {
|
|||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.switch-pane {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-submenu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-submenu li {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.switch-pane.is-active ~ .guild-submenu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback {
|
||||||
|
background-color: #5865F2;
|
||||||
|
}
|
||||||
|
|
||||||
.is-locked {
|
.is-locked {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-locked > :not(.patreon-invert) {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.is-locked .patreon-invert {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patreon-invert {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.is-locked .foreground {
|
.is-locked .foreground {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
@ -580,3 +865,27 @@ input.default-width {
|
|||||||
.is-locked .field:last-of-type {
|
.is-locked .field:last-of-type {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box {
|
||||||
|
flex-grow: 1;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #fcfcfc;
|
||||||
|
border-color: #efefef;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
margin: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figure {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figure-num {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
BIN
web/static/img/logo_nobg.webp
Normal file
BIN
web/static/img/logo_nobg.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 81 KiB |
131
web/static/js/admin.js
Normal file
131
web/static/js/admin.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
fetch("/admin/data")
|
||||||
|
.then((resp) => resp.json())
|
||||||
|
.then((data) => {
|
||||||
|
document.querySelector("#backlog").textContent = data.backlog;
|
||||||
|
document.querySelector("#reminders").textContent = data.count.reminders;
|
||||||
|
document.querySelector("#intervals").textContent = data.count.intervals;
|
||||||
|
|
||||||
|
let historySent = data.historyLong.sent.reduce(
|
||||||
|
(iv, frame) => iv + frame.count,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let historyFailed = data.historyLong.failed.reduce(
|
||||||
|
(iv, frame) => iv + frame.count,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let rate = historyFailed / (historySent + historyFailed);
|
||||||
|
let formatted = Math.round(rate * 10000) / 100;
|
||||||
|
|
||||||
|
document.querySelector("#historySent").textContent = historySent;
|
||||||
|
document.querySelector("#historyFailed").textContent = historyFailed;
|
||||||
|
document.querySelector("#failRate").textContent = `${formatted}%`;
|
||||||
|
|
||||||
|
new Chart(document.getElementById("schedule"), {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: [
|
||||||
|
...data.scheduleShort.once,
|
||||||
|
...data.scheduleShort.interval,
|
||||||
|
].map((row) => luxon.DateTime.fromISO(row.time_key)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Reminders",
|
||||||
|
data: data.scheduleShort.once.map((row) => row.count),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Intervals",
|
||||||
|
data: data.scheduleShort.interval.map((row) => row.count),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
type: "time",
|
||||||
|
time: {
|
||||||
|
unit: "minute",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById("scheduleLong"), {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: [
|
||||||
|
...data.scheduleLong.once,
|
||||||
|
...data.scheduleLong.interval,
|
||||||
|
].map((row) => luxon.DateTime.fromISO(row.time_key)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Reminders",
|
||||||
|
data: data.scheduleLong.once.map((row) => row.count),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Intervals",
|
||||||
|
data: data.scheduleLong.interval.map((row) => row.count),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
type: "time",
|
||||||
|
time: {
|
||||||
|
unit: "day",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById("historyLong"), {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: [...data.historyLong.sent, ...data.historyLong.failed].map(
|
||||||
|
(row) => luxon.DateTime.fromISO(row.time_key)
|
||||||
|
),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Success",
|
||||||
|
data: data.historyLong.sent.map((row) => row.count),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Fail",
|
||||||
|
data: data.historyLong.failed.map((row) => row.count),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
type: "time",
|
||||||
|
time: {
|
||||||
|
unit: "day",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
20
web/static/js/chart.js
Normal file
20
web/static/js/chart.js
Normal file
File diff suppressed because one or more lines are too long
7
web/static/js/chartjs-adapter-luxon.js
Normal file
7
web/static/js/chartjs-adapter-luxon.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/*!
|
||||||
|
* chartjs-adapter-luxon v1.3.1
|
||||||
|
* https://www.chartjs.org
|
||||||
|
* (c) 2023 chartjs-adapter-luxon Contributors
|
||||||
|
* Released under the MIT license
|
||||||
|
*/
|
||||||
|
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("chart.js"),require("luxon")):"function"==typeof define&&define.amd?define(["chart.js","luxon"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Chart,e.luxon)}(this,(function(e,t){"use strict";const n={datetime:t.DateTime.DATETIME_MED_WITH_SECONDS,millisecond:"h:mm:ss.SSS a",second:t.DateTime.TIME_WITH_SECONDS,minute:t.DateTime.TIME_SIMPLE,hour:{hour:"numeric"},day:{day:"numeric",month:"short"},week:"DD",month:{month:"short",year:"numeric"},quarter:"'Q'q - yyyy",year:{year:"numeric"}};e._adapters._date.override({_id:"luxon",_create:function(e){return t.DateTime.fromMillis(e,this.options)},init(e){this.options.locale||(this.options.locale=e.locale)},formats:function(){return n},parse:function(e,n){const i=this.options,r=typeof e;return null===e||"undefined"===r?null:("number"===r?e=this._create(e):"string"===r?e="string"==typeof n?t.DateTime.fromFormat(e,n,i):t.DateTime.fromISO(e,i):e instanceof Date?e=t.DateTime.fromJSDate(e,i):"object"!==r||e instanceof t.DateTime||(e=t.DateTime.fromObject(e,i)),e.isValid?e.valueOf():null)},format:function(e,t){const n=this._create(e);return"string"==typeof t?n.toFormat(t):n.toLocaleString(t)},add:function(e,t,n){const i={};return i[n]=t,this._create(e).plus(i).valueOf()},diff:function(e,t,n){return this._create(e).diff(this._create(t)).as(n).valueOf()},startOf:function(e,t,n){if("isoWeek"===t){n=Math.trunc(Math.min(Math.max(0,n),6));const t=this._create(e);return t.minus({days:(t.weekday-n+7)%7}).startOf("day").valueOf()}return t?this._create(e).startOf(t).valueOf():e},endOf:function(e,t){return this._create(e).endOf(t).valueOf()}})}));
|
@ -7,8 +7,8 @@ function get_interval(element) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
months: parseInt(months) || null,
|
months: parseInt(months) || null,
|
||||||
|
days: parseInt(days) || null,
|
||||||
seconds:
|
seconds:
|
||||||
(parseInt(days) || 0) * 86400 +
|
|
||||||
(parseInt(hours) || 0) * 3600 +
|
(parseInt(hours) || 0) * 3600 +
|
||||||
(parseInt(minutes) || 0) * 60 +
|
(parseInt(minutes) || 0) * 60 +
|
||||||
(parseInt(seconds) || 0) || null,
|
(parseInt(seconds) || 0) || null,
|
||||||
@ -22,6 +22,15 @@ function update_interval(element) {
|
|||||||
let minutes = element.querySelector('input[name="interval_minutes"]');
|
let minutes = element.querySelector('input[name="interval_minutes"]');
|
||||||
let seconds = element.querySelector('input[name="interval_seconds"]');
|
let seconds = element.querySelector('input[name="interval_seconds"]');
|
||||||
|
|
||||||
|
let interval = get_interval(element);
|
||||||
|
|
||||||
|
if (interval.months === null && interval.days === null && interval.seconds === null) {
|
||||||
|
months.value = "";
|
||||||
|
days.value = "";
|
||||||
|
hours.value = "";
|
||||||
|
minutes.value = "";
|
||||||
|
seconds.value = "";
|
||||||
|
} else {
|
||||||
months.value = months.value.padStart(1, "0");
|
months.value = months.value.padStart(1, "0");
|
||||||
days.value = days.value.padStart(1, "0");
|
days.value = days.value.padStart(1, "0");
|
||||||
hours.value = hours.value.padStart(2, "0");
|
hours.value = hours.value.padStart(2, "0");
|
||||||
@ -33,7 +42,10 @@ function update_interval(element) {
|
|||||||
let remainder = seconds.value % 60;
|
let remainder = seconds.value % 60;
|
||||||
|
|
||||||
seconds.value = String(remainder).padStart(2, "0");
|
seconds.value = String(remainder).padStart(2, "0");
|
||||||
minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(2, "0");
|
minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(
|
||||||
|
2,
|
||||||
|
"0"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (minutes.value >= 60) {
|
if (minutes.value >= 60) {
|
||||||
let quotient = Math.floor(minutes.value / 60);
|
let quotient = Math.floor(minutes.value / 60);
|
||||||
@ -42,12 +54,6 @@ function update_interval(element) {
|
|||||||
minutes.value = String(remainder).padStart(2, "0");
|
minutes.value = String(remainder).padStart(2, "0");
|
||||||
hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0");
|
hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0");
|
||||||
}
|
}
|
||||||
if (hours.value >= 24) {
|
|
||||||
let quotient = Math.floor(hours.value / 24);
|
|
||||||
let remainder = hours.value % 24;
|
|
||||||
|
|
||||||
hours.value = String(remainder).padStart(2, "0");
|
|
||||||
days.value = Number(days.value) + Number(quotient);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,7 +33,16 @@ let globalPatreon = false;
|
|||||||
let guildPatreon = false;
|
let guildPatreon = false;
|
||||||
|
|
||||||
function guildId() {
|
function guildId() {
|
||||||
return document.querySelector(".guildList a.is-active").dataset["guild"];
|
return window.location.pathname.match(/dashboard\/(\d+)/)[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pane() {
|
||||||
|
const match = window.location.pathname.match(/dashboard\/\d+\/(.+)/);
|
||||||
|
if (match === null) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorToInt(r, g, b) {
|
function colorToInt(r, g, b) {
|
||||||
@ -56,18 +65,36 @@ function switch_pane(selector) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function update_select(sel) {
|
function update_select(sel) {
|
||||||
if (sel.selectedOptions[0].dataset["webhookAvatar"]) {
|
let channelDisplay = sel.closest("div.reminderContent").querySelector(".channel-bar");
|
||||||
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
|
|
||||||
sel.selectedOptions[0].dataset["webhookAvatar"];
|
if (channelDisplay !== null) {
|
||||||
} else {
|
channelDisplay.textContent = `#${sel.selectedOptions[0].textContent}`;
|
||||||
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = "";
|
|
||||||
}
|
}
|
||||||
if (sel.selectedOptions[0].dataset["webhookName"]) {
|
|
||||||
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
|
if (sel.selectedOptions[0] === undefined) {
|
||||||
sel.selectedOptions[0].dataset["webhookName"];
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarInput = sel.closest("div.reminderContent").querySelector("img.avatar");
|
||||||
|
|
||||||
|
if (!avatarInput.dataset["set"]) {
|
||||||
|
if (sel.selectedOptions[0].dataset["webhookAvatar"]) {
|
||||||
|
avatarInput.src = sel.selectedOptions[0].dataset["webhookAvatar"];
|
||||||
} else {
|
} else {
|
||||||
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
|
avatarInput.src = "/static/img/icon.png";
|
||||||
"";
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernameInput = sel
|
||||||
|
.closest("div.reminderContent")
|
||||||
|
.querySelector("input.discord-username");
|
||||||
|
|
||||||
|
if (usernameInput.value.length === 0) {
|
||||||
|
if (sel.selectedOptions[0].dataset["webhookName"]) {
|
||||||
|
usernameInput.value = sel.selectedOptions[0].dataset["webhookName"];
|
||||||
|
} else {
|
||||||
|
usernameInput.value = "Reminder";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +105,7 @@ function reset_guild_pane() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetch_patreon(guild_id) {
|
async function fetch_patreon(guild_id) {
|
||||||
fetch(`/dashboard/api/guild/${guild_id}/patreon`)
|
fetch(`/dashboard/api/guild/${guild_id}`)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
@ -138,12 +165,18 @@ async function fetch_channels(guild_id) {
|
|||||||
const event = new Event("channelsLoading");
|
const event = new Event("channelsLoading");
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
await fetch(`/dashboard/api/guild/${guild_id}/channels`)
|
await fetch(`/dashboard/api/guild/${guild_id}/channels`)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
if (data.error === "Bot not in guild") {
|
if (data.error === "Bot not in guild") {
|
||||||
switch_pane("guild-error");
|
switch_pane("guild-error");
|
||||||
|
hasError = true;
|
||||||
|
} else if (data.error === "Incorrect permissions") {
|
||||||
|
switch_pane("user-error");
|
||||||
|
hasError = true;
|
||||||
} else {
|
} else {
|
||||||
show_error(data.error);
|
show_error(data.error);
|
||||||
}
|
}
|
||||||
@ -155,6 +188,8 @@ async function fetch_channels(guild_id) {
|
|||||||
const event = new Event("channelsLoaded");
|
const event = new Event("channelsLoaded");
|
||||||
document.dispatchEvent(event);
|
document.dispatchEvent(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return hasError;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetch_reminders(guild_id) {
|
async function fetch_reminders(guild_id) {
|
||||||
@ -197,22 +232,25 @@ 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");
|
||||||
|
|
||||||
if (utc_time.invalid) {
|
if (utc_time.invalid) {
|
||||||
return { error: "Time provided invalid." };
|
return { error: "Time provided invalid." };
|
||||||
} else {
|
} else {
|
||||||
utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
|
utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let expiration = node.querySelector('input[name="expiration"]').value;
|
||||||
|
|
||||||
|
if (expiration) {
|
||||||
expiration_time = luxon.DateTime.fromISO(
|
expiration_time = luxon.DateTime.fromISO(
|
||||||
node.querySelector('input[name="time"]').value
|
node.querySelector('input[name="expiration"]').value
|
||||||
).setZone("UTC");
|
).setZone("UTC");
|
||||||
if (expiration_time.invalid) {
|
if (expiration_time.invalid) {
|
||||||
return { error: "Expiration provided invalid." };
|
return { error: "Expiration provided invalid." };
|
||||||
@ -220,6 +258,12 @@ async function serialize_reminder(node, mode) {
|
|||||||
expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
|
expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = node.querySelector('input[name="name"]').value;
|
||||||
|
if (name.length > 100) {
|
||||||
|
return { error: "Name exceeds maximum length (100)." };
|
||||||
|
}
|
||||||
|
|
||||||
let rgb_color = window.getComputedStyle(
|
let rgb_color = window.getComputedStyle(
|
||||||
node.querySelector("div.discord-embed")
|
node.querySelector("div.discord-embed")
|
||||||
@ -283,15 +327,17 @@ async function serialize_reminder(node, mode) {
|
|||||||
const embed_title = node.querySelector('textarea[name="embed_title"]').value;
|
const embed_title = node.querySelector('textarea[name="embed_title"]').value;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
attachment === null &&
|
content.length === 0 &&
|
||||||
content.length == 0 &&
|
embed_author.length === 0 &&
|
||||||
|
embed_title.length === 0 &&
|
||||||
|
embed_description.length === 0 &&
|
||||||
|
embed_footer.length === 0 &&
|
||||||
embed_author_url === null &&
|
embed_author_url === null &&
|
||||||
embed_author.length == 0 &&
|
|
||||||
embed_description.length == 0 &&
|
|
||||||
embed_footer.length == 0 &&
|
|
||||||
embed_footer_url === null &&
|
embed_footer_url === null &&
|
||||||
embed_image_url === null &&
|
embed_image_url === null &&
|
||||||
embed_thumbnail_url === null
|
embed_thumbnail_url === null &&
|
||||||
|
fields.length === 0 &&
|
||||||
|
attachment === null
|
||||||
) {
|
) {
|
||||||
return { error: "Reminder needs content." };
|
return { error: "Reminder needs content." };
|
||||||
}
|
}
|
||||||
@ -304,7 +350,7 @@ async function serialize_reminder(node, mode) {
|
|||||||
restartable: false,
|
restartable: false,
|
||||||
attachment: attachment,
|
attachment: attachment,
|
||||||
attachment_name: attachment_name,
|
attachment_name: attachment_name,
|
||||||
avatar: has_source(node.querySelector("img.discord-avatar").src),
|
avatar: has_source(node.querySelector("img.avatar").src),
|
||||||
channel: node.querySelector("select.channel-selector").value,
|
channel: node.querySelector("select.channel-selector").value,
|
||||||
content: content,
|
content: content,
|
||||||
embed_author_url: embed_author_url,
|
embed_author_url: embed_author_url,
|
||||||
@ -318,8 +364,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_months: mode !== "template" ? interval.months : null,
|
interval_days: interval.days,
|
||||||
|
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,
|
||||||
@ -331,6 +378,9 @@ function deserialize_reminder(reminder, frame, mode) {
|
|||||||
// populate channels
|
// populate channels
|
||||||
set_channels(frame.querySelector("select.channel-selector"));
|
set_channels(frame.querySelector("select.channel-selector"));
|
||||||
|
|
||||||
|
frame.querySelector(`*[name="interval_hours"]`).value = 0;
|
||||||
|
frame.querySelector(`*[name="interval_minutes"]`).value = 0;
|
||||||
|
|
||||||
// populate majority of items
|
// populate majority of items
|
||||||
for (let prop in reminder) {
|
for (let prop in reminder) {
|
||||||
if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) {
|
if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) {
|
||||||
@ -345,15 +395,27 @@ function deserialize_reminder(reminder, frame, mode) {
|
|||||||
if ($input !== null) {
|
if ($input !== null) {
|
||||||
$input.value = reminder[prop];
|
$input.value = reminder[prop];
|
||||||
} else if ($image !== null) {
|
} else if ($image !== null) {
|
||||||
|
console.log(`loading img ${prop}`);
|
||||||
$image.src = reminder[prop];
|
$image.src = reminder[prop];
|
||||||
|
$image.dataset["set"] = "1";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box");
|
update_interval(frame);
|
||||||
|
update_select(frame.querySelector(".channel-selector"));
|
||||||
|
|
||||||
for (let field of reminder["embed_fields"]) {
|
const lastChild = frame.querySelector(
|
||||||
|
"div.embed-multifield-box .embed-field-box:last-child"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop existing fields
|
||||||
|
frame
|
||||||
|
.querySelectorAll(".embed-field-box:not(:last-child)")
|
||||||
|
.forEach((el) => el.remove());
|
||||||
|
|
||||||
|
for (let field of reminder["embed_fields"] || []) {
|
||||||
let embed_field = $embedFieldTemplate.content.cloneNode(true);
|
let embed_field = $embedFieldTemplate.content.cloneNode(true);
|
||||||
embed_field.querySelector("textarea.discord-field-title").value = field["title"];
|
embed_field.querySelector("textarea.discord-field-title").value = field["title"];
|
||||||
embed_field.querySelector("textarea.discord-field-value").value = field["value"];
|
embed_field.querySelector("textarea.discord-field-value").value = field["value"];
|
||||||
@ -366,9 +428,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";
|
||||||
|
|
||||||
@ -379,7 +441,7 @@ function deserialize_reminder(reminder, frame, mode) {
|
|||||||
timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss");
|
timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss");
|
||||||
|
|
||||||
if (reminder["expires"]) {
|
if (reminder["expires"]) {
|
||||||
let expiresInput = frame.querySelector('input[name="time"]');
|
let expiresInput = frame.querySelector('input[name="expiration"]');
|
||||||
let expiresTime = luxon.DateTime.fromISO(reminder["expires"], {
|
let expiresTime = luxon.DateTime.fromISO(reminder["expires"], {
|
||||||
zone: "UTC",
|
zone: "UTC",
|
||||||
}).setZone(timezone);
|
}).setZone(timezone);
|
||||||
@ -399,9 +461,19 @@ document.addEventListener("guildSwitched", async (e) => {
|
|||||||
`.switch-pane[data-guild="${e.detail.guild_id}"]`
|
`.switch-pane[data-guild="${e.detail.guild_id}"]`
|
||||||
);
|
);
|
||||||
|
|
||||||
switch_pane($anchor.dataset["pane"]);
|
let hasError = false;
|
||||||
reset_guild_pane();
|
|
||||||
|
if (pane() === null) {
|
||||||
|
window.history.replaceState({}, "", `/dashboard/${guildId()}/reminders`);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch_pane(pane());
|
||||||
|
|
||||||
|
if ($anchor !== null) {
|
||||||
$anchor.classList.add("is-active");
|
$anchor.classList.add("is-active");
|
||||||
|
}
|
||||||
|
|
||||||
|
reset_guild_pane();
|
||||||
|
|
||||||
if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) {
|
if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) {
|
||||||
document
|
document
|
||||||
@ -409,9 +481,10 @@ document.addEventListener("guildSwitched", async (e) => {
|
|||||||
.forEach((el) => el.classList.remove("is-locked"));
|
.forEach((el) => el.classList.remove("is-locked"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasError = await fetch_channels(e.detail.guild_id);
|
||||||
|
if (!hasError) {
|
||||||
fetch_roles(e.detail.guild_id);
|
fetch_roles(e.detail.guild_id);
|
||||||
fetch_templates(e.detail.guild_id);
|
fetch_templates(e.detail.guild_id);
|
||||||
await fetch_channels(e.detail.guild_id);
|
|
||||||
fetch_reminders(e.detail.guild_id);
|
fetch_reminders(e.detail.guild_id);
|
||||||
|
|
||||||
document.querySelectorAll("p.pageTitle").forEach((el) => {
|
document.querySelectorAll("p.pageTitle").forEach((el) => {
|
||||||
@ -422,6 +495,7 @@ document.addEventListener("guildSwitched", async (e) => {
|
|||||||
update_select(e.target);
|
update_select(e.target);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$loader.classList.add("is-hidden");
|
$loader.classList.add("is-hidden");
|
||||||
});
|
});
|
||||||
@ -433,6 +507,12 @@ document.addEventListener("channelsLoaded", () => {
|
|||||||
document.addEventListener("remindersLoaded", (event) => {
|
document.addEventListener("remindersLoaded", (event) => {
|
||||||
const guild = guildId();
|
const guild = guildId();
|
||||||
|
|
||||||
|
document.querySelectorAll("select.channel-selector").forEach((el) => {
|
||||||
|
el.addEventListener("change", (e) => {
|
||||||
|
update_select(e.target);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
for (let reminder of event.detail) {
|
for (let reminder of event.detail) {
|
||||||
let node = reminder.node;
|
let node = reminder.node;
|
||||||
|
|
||||||
@ -460,9 +540,9 @@ document.addEventListener("remindersLoaded", (event) => {
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
show_error(data.error);
|
show_error(data.error);
|
||||||
} else {
|
} else {
|
||||||
enableBtn.dataset["action"] = data["enabled"]
|
enableBtn.dataset["action"] = data.reminder["enabled"]
|
||||||
? "enable"
|
? "disable"
|
||||||
: "disable";
|
: "enable";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -497,6 +577,8 @@ document.addEventListener("remindersLoaded", (event) => {
|
|||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
for (let error of data.errors) show_error(error);
|
for (let error of data.errors) show_error(error);
|
||||||
|
|
||||||
|
deserialize_reminder(data.reminder, node, "reload");
|
||||||
});
|
});
|
||||||
|
|
||||||
$saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"];
|
$saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"];
|
||||||
@ -532,6 +614,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", () => {
|
||||||
@ -557,7 +649,7 @@ document.querySelectorAll(".show-modal").forEach((element) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
$loader.classList.remove("is-hidden");
|
$loader.classList.remove("is-hidden");
|
||||||
|
|
||||||
mentions.attach(document.querySelectorAll("textarea"));
|
mentions.attach(document.querySelectorAll("textarea"));
|
||||||
@ -577,7 +669,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
hideBox.closest(".reminderContent").classList.toggle("is-collapsed");
|
hideBox.closest(".reminderContent").classList.toggle("is-collapsed");
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch("/dashboard/api/user")
|
await fetch("/dashboard/api/user")
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
@ -591,7 +683,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch("/dashboard/api/user/guilds")
|
await fetch("/dashboard/api/user/guilds")
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
@ -614,11 +706,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
);
|
);
|
||||||
$anchor.dataset["guild"] = guild.id;
|
$anchor.dataset["guild"] = guild.id;
|
||||||
$anchor.dataset["name"] = guild.name;
|
$anchor.dataset["name"] = guild.name;
|
||||||
$anchor.href = `/dashboard/${guild.id}?name=${guild.name}`;
|
$anchor.href = `/dashboard/${guild.id}/reminders`;
|
||||||
|
|
||||||
$anchor.addEventListener("click", async (e) => {
|
$anchor.addEventListener("click", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.history.pushState({}, "", `/dashboard/${guild.id}`);
|
window.history.pushState(
|
||||||
|
{},
|
||||||
|
"",
|
||||||
|
`/dashboard/${guild.id}/reminders`
|
||||||
|
);
|
||||||
const event = new CustomEvent("guildSwitched", {
|
const event = new CustomEvent("guildSwitched", {
|
||||||
detail: {
|
detail: {
|
||||||
guild_name: guild.name,
|
guild_name: guild.name,
|
||||||
@ -682,11 +778,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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -715,6 +825,7 @@ $createReminderBtn.addEventListener("click", async () => {
|
|||||||
let reminder = await serialize_reminder($createReminder, "create");
|
let reminder = await serialize_reminder($createReminder, "create");
|
||||||
if (reminder.error) {
|
if (reminder.error) {
|
||||||
show_error(reminder.error);
|
show_error(reminder.error);
|
||||||
|
$createReminderBtn.querySelector("span.icon > i").classList = ["fas fa-sparkles"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -772,6 +883,14 @@ $createTemplateBtn.addEventListener("click", async () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let reminder = await serialize_reminder($createReminder, "template");
|
let reminder = await serialize_reminder($createReminder, "template");
|
||||||
|
if (reminder.error) {
|
||||||
|
show_error(reminder.error);
|
||||||
|
$createTemplateBtn.querySelector("span.icon > i").classList = [
|
||||||
|
"fas fa-file-spreadsheet",
|
||||||
|
];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let guild = guildId();
|
let guild = guildId();
|
||||||
|
|
||||||
fetch(`/dashboard/api/guild/${guild}/templates`, {
|
fetch(`/dashboard/api/guild/${guild}/templates`, {
|
||||||
@ -813,6 +932,7 @@ $loadTemplateBtn.addEventListener("click", (ev) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$deleteTemplateBtn.addEventListener("click", (ev) => {
|
$deleteTemplateBtn.addEventListener("click", (ev) => {
|
||||||
|
if (parseInt($templateSelect.value) !== null) {
|
||||||
fetch(`/dashboard/api/guild/${guildId()}/templates`, {
|
fetch(`/dashboard/api/guild/${guildId()}/templates`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
@ -830,13 +950,7 @@ $deleteTemplateBtn.addEventListener("click", (ev) => {
|
|||||||
.remove();
|
.remove();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
document.querySelectorAll("textarea.autoresize").forEach((element) => {
|
|
||||||
element.addEventListener("input", () => {
|
|
||||||
element.style.height = "";
|
|
||||||
element.style.height = element.scrollHeight + 3 + "px";
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let $img;
|
let $img;
|
||||||
@ -894,6 +1008,13 @@ document.addEventListener("remindersLoaded", () => {
|
|||||||
window.getComputedStyle($discordFrame).borderLeftColor;
|
window.getComputedStyle($discordFrame).borderLeftColor;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("textarea.autoresize").forEach((element) => {
|
||||||
|
element.addEventListener("input", () => {
|
||||||
|
element.style.height = "";
|
||||||
|
element.style.height = element.scrollHeight + 3 + "px";
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function check_embed_fields() {
|
function check_embed_fields() {
|
||||||
@ -969,6 +1090,13 @@ document.addEventListener("click", (ev) => {
|
|||||||
if (ev.target.closest("button.inline-btn") !== null) {
|
if (ev.target.closest("button.inline-btn") !== null) {
|
||||||
let inlined = ev.target.closest(".embed-field-box").dataset["inlined"];
|
let inlined = ev.target.closest(".embed-field-box").dataset["inlined"];
|
||||||
ev.target.closest(".embed-field-box").dataset["inlined"] =
|
ev.target.closest(".embed-field-box").dataset["inlined"] =
|
||||||
inlined == "1" ? "0" : "1";
|
inlined === "1" ? "0" : "1";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
let now = luxon.DateTime.now().setZone(timezone);
|
||||||
|
document.querySelectorAll(".prefill-now").forEach((el) => {
|
||||||
|
el.value = now.toFormat("yyyy-LL-dd'T'HH:mm:ss");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
16
web/static/js/reporter.js
Normal file
16
web/static/js/reporter.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
const REPORTER_ID = crypto.randomUUID();
|
||||||
|
|
||||||
|
window.addEventListener("error", async (ev) => {
|
||||||
|
await fetch("/report", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
reporterId: REPORTER_ID,
|
||||||
|
url: window.location.href,
|
||||||
|
relativeTimestamp: ev.timeStamp,
|
||||||
|
errorMessage: ev.message,
|
||||||
|
errorLine: ev.lineno,
|
||||||
|
errorFile: ev.filename,
|
||||||
|
errorType: ev.type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "",
|
"name": "Reminder Bot Dashboard",
|
||||||
"short_name": "",
|
"short_name": "Reminders",
|
||||||
|
"start_url": "/dashboard",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/android-chrome-192x192.png",
|
"src": "/static/favicon/android-chrome-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/android-chrome-512x512.png",
|
"src": "/static/favicon/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
89
web/templates/admin_dashboard.html.tera
Normal file
89
web/templates/admin_dashboard.html.tera
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="EN">
|
||||||
|
<head>
|
||||||
|
<script src="/static/js/reporter.js" type="application/javascript"></script>
|
||||||
|
|
||||||
|
<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/favicon/site.webmanifest">
|
||||||
|
<meta name="msapplication-TileColor" content="#da532c">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
|
<title>Reminder Bot | Admin</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">
|
||||||
|
<link rel="stylesheet" href="/static/css/dtsel.css">
|
||||||
|
|
||||||
|
<script src="/static/js/luxon.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body style="width: 100%;">
|
||||||
|
|
||||||
|
<p class="title pageTitle">Admin dashboard</p>
|
||||||
|
<section id="main">
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat-box" style="height: 400px;">
|
||||||
|
<canvas id="schedule"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat-box figure">
|
||||||
|
<p>Backlog</p>
|
||||||
|
<p class="figure-num" id="backlog">?</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box figure">
|
||||||
|
<p>Reminders</p>
|
||||||
|
<p class="figure-num" id="reminders">?</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box figure">
|
||||||
|
<p>Intervals</p>
|
||||||
|
<p class="figure-num" id="intervals">?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat-box" style="height: 400px;">
|
||||||
|
<canvas id="scheduleLong"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat-box figure">
|
||||||
|
<p>Last 31 days (success)</p>
|
||||||
|
<p class="figure-num" id="historySent">?</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box figure">
|
||||||
|
<p>Last 31 days (failed)</p>
|
||||||
|
<p class="figure-num" id="historyFailed">?</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box figure">
|
||||||
|
<p>Last 31 days (failure rate)</p>
|
||||||
|
<p class="figure-num" id="failRate">?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat-box" style="height: 400px;">
|
||||||
|
<canvas id="historyLong"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="/static/js/chart.js" defer></script>
|
||||||
|
<script src="/static/js/chartjs-adapter-luxon.js" defer></script>
|
||||||
|
<script src="/static/js/admin.js" defer></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -13,7 +13,7 @@
|
|||||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png">
|
<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="32x32" href="/static/favicon/favicon-32x32.png">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon/favicon-16x16.png">
|
||||||
<link rel="manifest" href="/static/favicon/site.webmanifest">
|
<link rel="manifest" href="/static/site.webmanifest">
|
||||||
<meta name="msapplication-TileColor" content="#da532c">
|
<meta name="msapplication-TileColor" content="#da532c">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
@ -51,8 +51,8 @@
|
|||||||
<a class="navbar-item" href="https://invite.reminder-bot.com">
|
<a class="navbar-item" href="https://invite.reminder-bot.com">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
</a>
|
</a>
|
||||||
<a class="navbar-item" href="https://github.com/jellywx">
|
<a class="navbar-item" href="https://gitea.jellypro.xyz/jude">
|
||||||
<i class="fab fa-github"></i>
|
<i class="fab fa-git-square"></i>
|
||||||
</a>
|
</a>
|
||||||
<a class="navbar-item" href="https://discord.jellywx.com">
|
<a class="navbar-item" href="https://discord.jellywx.com">
|
||||||
<i class="fab fa-discord"></i>
|
<i class="fab fa-discord"></i>
|
||||||
@ -128,7 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% elif show_login %}
|
{% elif show_login %}
|
||||||
<div class="hero-foot has-text-centered">
|
<div class="hero-foot has-text-centered">
|
||||||
<a class="button is-size-4 is-rounded is-light" href="/oauth/login">
|
<a class="button is-size-4 is-rounded is-light" href="/login/discord">
|
||||||
<p class="is-size-4">
|
<p class="is-size-4">
|
||||||
<span>Login with Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
<span>Login with Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
||||||
</p>
|
</p>
|
||||||
@ -155,7 +155,7 @@
|
|||||||
<br>
|
<br>
|
||||||
<a href="/cookies">Cookies</a> | <a href="/privacy">Privacy Policy</a> | <a href="/terms">Terms of Service</a>
|
<a href="/cookies">Cookies</a> | <a href="/privacy">Privacy Policy</a> | <a href="/terms">Terms of Service</a>
|
||||||
<br>
|
<br>
|
||||||
<a href="https://patreon.com/jellywx"><strong>Patreon</strong></a> | <a href="https://discord.jellywx.com"><strong>Discord</strong></a> | <a href="https://github.com/JellyWX"><strong>GitHub</strong></a>
|
<a href="https://patreon.com/jellywx"><strong>Patreon</strong></a> | <a href="https://discord.jellywx.com"><strong>Discord</strong></a> | <a href="https://gitea.jellypro.xyz/jude"><strong>Gitea</strong></a>
|
||||||
<br>
|
<br>
|
||||||
or, <a href="mailto:jude@jellywx.com">Email me</a>
|
or, <a href="mailto:jude@jellywx.com">Email me</a>
|
||||||
</p>
|
</p>
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="EN">
|
<html lang="EN">
|
||||||
<head>
|
<head>
|
||||||
|
<script src="/static/js/reporter.js" type="application/javascript"></script>
|
||||||
|
|
||||||
<meta name="description" content="The most powerful Discord Reminders Bot">
|
<meta name="description" content="The most powerful Discord Reminders Bot">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
@ -25,7 +27,7 @@
|
|||||||
<link rel="stylesheet" href="/static/css/bulma.min.css">
|
<link rel="stylesheet" href="/static/css/bulma.min.css">
|
||||||
<link rel="stylesheet" href="/static/css/fa.css">
|
<link rel="stylesheet" href="/static/css/fa.css">
|
||||||
<link rel="stylesheet" href="/static/css/font.css">
|
<link rel="stylesheet" href="/static/css/font.css">
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css?v{{ version }}">
|
||||||
<link rel="stylesheet" href="/static/css/dtsel.css">
|
<link rel="stylesheet" href="/static/css/dtsel.css">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tributejs/5.1.3/tribute.css" integrity="sha512-GnwBnXd+ZGO9CdP343MUr0jCcJXCr++JVtQRnllexRW2IDq4Zvrh/McTQjooAKnSUbXZ7wamp7AQSweTnfMVoA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tributejs/5.1.3/tribute.css" integrity="sha512-GnwBnXd+ZGO9CdP343MUr0jCcJXCr++JVtQRnllexRW2IDq4Zvrh/McTQjooAKnSUbXZ7wamp7AQSweTnfMVoA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
|
||||||
@ -38,14 +40,14 @@
|
|||||||
<div class="navbar-brand">
|
<div class="navbar-brand">
|
||||||
<a class="navbar-item" href="/">
|
<a class="navbar-item" href="/">
|
||||||
<figure class="image">
|
<figure class="image">
|
||||||
<img src="/static/img/logo_flat.webp" alt="Reminder Bot Logo">
|
<img width="28px" height="28px" src="/static/img/logo_nobg.webp" alt="Reminder Bot Logo">
|
||||||
</figure>
|
</figure>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<p class="navbar-item pageTitle">
|
<p class="navbar-item pageTitle">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a role="button" class="navbar-burger is-right" aria-label="menu" aria-expanded="false"
|
<a role="button" class="dashboard-burger navbar-burger is-right" aria-label="menu" aria-expanded="false"
|
||||||
data-target="mobileSidebar">
|
data-target="mobileSidebar">
|
||||||
<span aria-hidden="true"></span>
|
<span aria-hidden="true"></span>
|
||||||
<span aria-hidden="true"></span>
|
<span aria-hidden="true"></span>
|
||||||
@ -74,6 +76,10 @@
|
|||||||
<span class="icon"><i class="far fa-exclamation-circle"></i></span> <span class="error-message"></span>
|
<span class="icon"><i class="far fa-exclamation-circle"></i></span> <span class="error-message"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="notification is-success flash-message" id="success">
|
||||||
|
<span class="icon"><i class="far fa-check"></i></span> <span class="success-message"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal" id="addImageModal">
|
<div class="modal" id="addImageModal">
|
||||||
<div class="modal-background"></div>
|
<div class="modal-background"></div>
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
@ -183,27 +189,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
|
||||||
<div class="field">
|
|
||||||
<label>
|
|
||||||
<input type="radio" class="default-width" name="exportSelect" value="todos">
|
|
||||||
Todo Lists
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<div class="field">
|
|
||||||
<label>
|
|
||||||
<input type="radio" class="default-width" name="exportSelect" value="reminder_templates">
|
|
||||||
Reminder templates
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<br>
|
<br>
|
||||||
<div class="has-text-centered">
|
<div class="has-text-centered">
|
||||||
<div style="color: red; font-weight: bold;">
|
|
||||||
By selecting "Import", you understand that this will overwrite existing data.
|
|
||||||
</div>
|
|
||||||
<div style="color: red">
|
<div style="color: red">
|
||||||
Please first read the <a href="/help/iemanager">support page</a>
|
Please first read the <a href="/help/iemanager">support page</a>
|
||||||
</div>
|
</div>
|
||||||
@ -242,7 +229,8 @@
|
|||||||
<div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch">
|
<div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch">
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<img src="/static/img/logo_flat.webp" alt="Reminder bot logo"
|
<img src="/static/img/logo_nobg.webp" alt="Reminder bot logo"
|
||||||
|
width="52px" height="52px"
|
||||||
class="dashboard-brand">
|
class="dashboard-brand">
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@ -261,7 +249,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="aside-footer">
|
<div class="aside-footer">
|
||||||
<p class="menu-label">
|
<p class="menu-label">
|
||||||
Settings
|
Options
|
||||||
</p>
|
</p>
|
||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
<li>
|
<li>
|
||||||
@ -271,6 +259,12 @@
|
|||||||
<a class="show-modal" data-modal="chooseTimezoneModal">
|
<a class="show-modal" data-modal="chooseTimezoneModal">
|
||||||
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
|
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/login/discord/logout">
|
||||||
|
<span class="icon"><i class="fas fa-sign-out"></i></span> Log out
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.jellywx.com" class="feedback">
|
||||||
|
<span class="icon"><i class="fab fa-discord"></i></span> Give feedback
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -280,7 +274,7 @@
|
|||||||
<div class="dashboard-sidebar mobile-sidebar is-hidden-desktop" id="mobileSidebar">
|
<div class="dashboard-sidebar mobile-sidebar is-hidden-desktop" id="mobileSidebar">
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<img src="/static/img/logo_flat.webp" alt="Reminder bot logo"
|
<img src="/static/img/logo_nobg.webp" alt="Reminder bot logo"
|
||||||
class="dashboard-brand">
|
class="dashboard-brand">
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@ -309,6 +303,12 @@
|
|||||||
<a class="show-modal" data-modal="chooseTimezoneModal">
|
<a class="show-modal" data-modal="chooseTimezoneModal">
|
||||||
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
|
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/login/discord/logout">
|
||||||
|
<span class="icon"><i class="fas fa-sign-out"></i></span> Log out
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.jellywx.com/" class="feedback">
|
||||||
|
<span class="icon"><i class="fab fa-discord"></i></span> Give feedback
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -325,25 +325,17 @@
|
|||||||
<p class="subtitle is-hidden-desktop">Press the <span class="icon"><i class="fal fa-bars"></i></span> to get started</p>
|
<p class="subtitle is-hidden-desktop">Press the <span class="icon"><i class="fal fa-bars"></i></span> to get started</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="guild" class="is-hidden">
|
<section id="reminders" class="is-hidden">
|
||||||
{% include "reminder_dashboard/reminder_dashboard" %}
|
{% include "reminder_dashboard/reminder_dashboard" %}
|
||||||
</section>
|
</section>
|
||||||
<section id="guild-error" class="is-hidden hero is-fullheight">
|
<section id="reminder-errors" class="is-hidden">
|
||||||
<div class="hero-body">
|
{% include "reminder_dashboard/reminder_errors" %}
|
||||||
<div class="container has-text-centered">
|
</section>
|
||||||
<p class="title">
|
<section id="guild-error" class="is-hidden">
|
||||||
We couldn't get this server's data
|
{% include "reminder_dashboard/guild_error" %}
|
||||||
</p>
|
</section>
|
||||||
<p class="subtitle">
|
<section id="user-error" class="is-hidden">
|
||||||
Please check Reminder Bot is in the server, and has correct permissions.
|
{% include "reminder_dashboard/user_error" %}
|
||||||
</p>
|
|
||||||
<a class="button is-size-4 is-rounded is-success" href="https://invite.reminder-bot.com">
|
|
||||||
<p class="is-size-4">
|
|
||||||
<span>Add to Server</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<!-- /main content -->
|
<!-- /main content -->
|
||||||
@ -389,9 +381,9 @@
|
|||||||
<script src="/static/js/iro.js"></script>
|
<script src="/static/js/iro.js"></script>
|
||||||
<script src="/static/js/dtsel.js"></script>
|
<script src="/static/js/dtsel.js"></script>
|
||||||
|
|
||||||
<script src="/static/js/interval.js"></script>
|
<script src="/static/js/interval.js?v{{ version }}"></script>
|
||||||
<script src="/static/js/timezone.js" defer></script>
|
<script src="/static/js/timezone.js?v{{ version }}" defer></script>
|
||||||
<script src="/static/js/main.js" defer></script>
|
<script src="/static/js/main.js?v{{ version }}" defer></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tile is-parent">
|
<div class="tile is-parent">
|
||||||
<article class="tile is-child notification">
|
<article class="tile is-child notification">
|
||||||
<p class="title">Creating reminders</p>
|
<p class="title">Create reminders</p>
|
||||||
<p class="subtitle">Learn to create reminders for your server</p>
|
<p class="subtitle">Learn to create reminders for your server</p>
|
||||||
<div class="content has-text-centered">
|
<div class="content has-text-centered">
|
||||||
<a class="button is-size-4 is-rounded is-light" href="/help/create_reminder">
|
<a class="button is-size-4 is-rounded is-light" href="/help/create_reminder">
|
||||||
@ -52,47 +52,47 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tile is-ancestor">
|
<!-- <div class="tile is-ancestor">-->
|
||||||
<div class="tile is-parent">
|
<!-- <div class="tile is-parent">-->
|
||||||
<article class="tile is-child notification">
|
<!-- <article class="tile is-child notification">-->
|
||||||
<p class="title">Timers</p>
|
<!-- <p class="title">Timers</p>-->
|
||||||
<p class="subtitle">Learn to manage timers</p>
|
<!-- <p class="subtitle">Learn to manage timers</p>-->
|
||||||
<div class="content has-text-centered">
|
<!-- <div class="content has-text-centered">-->
|
||||||
<a class="button is-size-4 is-rounded is-light" href="/help/timers">
|
<!-- <a class="button is-size-4 is-rounded is-light" href="/help/timers">-->
|
||||||
<p class="is-size-4">
|
<!-- <p class="is-size-4">-->
|
||||||
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
<!-- Read <span class="icon"><i class="fas fa-chevron-right"></i></span>-->
|
||||||
</p>
|
<!-- </p>-->
|
||||||
</a>
|
<!-- </a>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
</article>
|
<!-- </article>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
<div class="tile is-parent">
|
<!-- <div class="tile is-parent">-->
|
||||||
<article class="tile is-child notification">
|
<!-- <article class="tile is-child notification">-->
|
||||||
<p class="title">Todo Lists</p>
|
<!-- <p class="title">Todo Lists</p>-->
|
||||||
<p class="subtitle">Learn to manage various todo lists</p>
|
<!-- <p class="subtitle">Learn to manage various todo lists</p>-->
|
||||||
<div class="content has-text-centered">
|
<!-- <div class="content has-text-centered">-->
|
||||||
<a class="button is-size-4 is-rounded is-light" href="/help/todo_lists">
|
<!-- <a class="button is-size-4 is-rounded is-light" href="/help/todo_lists">-->
|
||||||
<p class="is-size-4">
|
<!-- <p class="is-size-4">-->
|
||||||
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
<!-- Read <span class="icon"><i class="fas fa-chevron-right"></i></span>-->
|
||||||
</p>
|
<!-- </p>-->
|
||||||
</a>
|
<!-- </a>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
</article>
|
<!-- </article>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
<div class="tile is-parent is-vertical">
|
<!-- <div class="tile is-parent is-vertical">-->
|
||||||
<article class="tile is-child notification">
|
<!-- <article class="tile is-child notification">-->
|
||||||
<p class="title">Macros</p>
|
<!-- <p class="title">Macros</p>-->
|
||||||
<p class="subtitle">Learn how to create combination commands called macros, to suit advanced use cases</p>
|
<!-- <p class="subtitle">Learn how to create combination commands called macros, to suit advanced use cases</p>-->
|
||||||
<div class="content has-text-centered">
|
<!-- <div class="content has-text-centered">-->
|
||||||
<a class="button is-size-4 is-rounded is-light" href="/help/macros">
|
<!-- <a class="button is-size-4 is-rounded is-light" href="/help/macros">-->
|
||||||
<p class="is-size-4">
|
<!-- <p class="is-size-4">-->
|
||||||
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
<!-- Read <span class="icon"><i class="fas fa-chevron-right"></i></span>-->
|
||||||
</p>
|
<!-- </p>-->
|
||||||
</a>
|
<!-- </a>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
</article>
|
<!-- </article>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
<div class="tile is-ancestor">
|
<div class="tile is-ancestor">
|
||||||
<div class="tile is-parent">
|
<div class="tile is-parent">
|
||||||
<article class="tile is-child notification">
|
<article class="tile is-child notification">
|
||||||
@ -107,7 +107,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tile is-parent is-vertical">
|
||||||
|
{#
|
||||||
|
<article class="tile is-child notification">
|
||||||
|
<p class="title">Import/export</p>
|
||||||
|
<p class="subtitle">Learn how to import and export data from the dashboard</p>
|
||||||
|
<div class="content has-text-centered">
|
||||||
|
<a class="button is-size-4 is-rounded is-light" href="/help/iemanager">
|
||||||
|
<p class="is-size-4">
|
||||||
|
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
#}
|
||||||
|
</div>
|
||||||
<div class="tile is-parent">
|
<div class="tile is-parent">
|
||||||
|
{#
|
||||||
<article class="tile is-child notification">
|
<article class="tile is-child notification">
|
||||||
<p class="title">Dashboard</p>
|
<p class="title">Dashboard</p>
|
||||||
<p class="subtitle">Learn to use the interactive web dashboard</p>
|
<p class="subtitle">Learn to use the interactive web dashboard</p>
|
||||||
@ -119,19 +135,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
#}
|
||||||
<div class="tile is-parent is-vertical">
|
|
||||||
<article class="tile is-child notification">
|
|
||||||
<p class="title">Import/Export</p>
|
|
||||||
<p class="subtitle">Learn how to import and export data from the dashboard</p>
|
|
||||||
<div class="content has-text-centered">
|
|
||||||
<a class="button is-size-4 is-rounded is-light" href="/help/iemanager">
|
|
||||||
<p class="is-size-4">
|
|
||||||
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -141,14 +145,14 @@
|
|||||||
<div class="container has-text-centered">
|
<div class="container has-text-centered">
|
||||||
<p class="title">Need more help?</p>
|
<p class="title">Need more help?</p>
|
||||||
<p class="content">
|
<p class="content">
|
||||||
Feel free to come and ask us!
|
Please come and ask us!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-foot has-text-centered">
|
<div class="hero-foot has-text-centered">
|
||||||
<a class="button is-size-6 is-rounded is-primary" href="https://discord.jellywx.com">
|
<a class="button is-size-6 is-rounded is-primary" href="https://discord.jellywx.com">
|
||||||
<p class="is-size-6">
|
<p class="is-size-6">
|
||||||
Join Discord <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
<span>Join Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
<div class="tile is-parent">
|
<div class="tile is-parent">
|
||||||
<article class="tile is-child notification">
|
<article class="tile is-child notification">
|
||||||
<p class="title">Slash-command Ready <svg aria-hidden="false" width="28" height="28" viewBox="0 0 24 24"><path fill="#777" fill-rule="evenodd" clip-rule="evenodd" d="M5 3C3.89543 3 3 3.89543 3 5V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5ZM16.8995 8.41419L15.4853 6.99998L7 15.4853L8.41421 16.8995L16.8995 8.41419Z"></path></svg></p>
|
<p class="title">Slash-command Ready <svg aria-hidden="false" width="28" height="28" viewBox="0 0 24 24"><path fill="#777" fill-rule="evenodd" clip-rule="evenodd" d="M5 3C3.89543 3 3 3.89543 3 5V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5ZM16.8995 8.41419L15.4853 6.99998L7 15.4853L8.41421 16.8995L16.8995 8.41419Z"></path></svg></p>
|
||||||
<p class="subtitle">Set reminders easily and quickly from anywhere</p>
|
<p class="subtitle">Set reminders easily and quickly from anywhere.</p>
|
||||||
<figure class="image">
|
<figure class="image">
|
||||||
<img class="rounded-corners" src="/static/img/slash-commands.png" alt="Discord slash commands demonstration">
|
<img class="rounded-corners" src="/static/img/slash-commands.png" alt="Discord slash commands demonstration">
|
||||||
</figure>
|
</figure>
|
||||||
@ -25,7 +25,7 @@
|
|||||||
<div class="tile is-parent">
|
<div class="tile is-parent">
|
||||||
<article class="tile is-child notification">
|
<article class="tile is-child notification">
|
||||||
<p class="title">Advanced Options <span class="icon"><i class="fad fa-palette"></i></span></p>
|
<p class="title">Advanced Options <span class="icon"><i class="fad fa-palette"></i></span></p>
|
||||||
<p class="subtitle">Decorate your announcements with our web dashboard</p>
|
<p class="subtitle">Decorate your announcements with our web dashboard.</p>
|
||||||
<figure class="image">
|
<figure class="image">
|
||||||
<img class="rounded-corners" src="/static/img/tournament-demo.png" alt="Advanced options demonstration">
|
<img class="rounded-corners" src="/static/img/tournament-demo.png" alt="Advanced options demonstration">
|
||||||
</figure>
|
</figure>
|
||||||
@ -34,32 +34,62 @@
|
|||||||
<div class="tile is-parent is-vertical">
|
<div class="tile is-parent is-vertical">
|
||||||
<article class="tile is-child notification">
|
<article class="tile is-child notification">
|
||||||
<p class="title">Unlimited Reminders <span class="icon"><i class="far fa-infinity"></i></span></p>
|
<p class="title">Unlimited Reminders <span class="icon"><i class="far fa-infinity"></i></span></p>
|
||||||
<p class="subtitle">Never forget a thing</p>
|
<p class="subtitle">Never forget a thing.</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="tile is-child notification">
|
<article class="tile is-child notification">
|
||||||
<p class="title">Repeating Reminders <span class="icon"><i class="fas fa-repeat"></i></span></p>
|
<p class="title">Repeating Reminders <span class="icon"><i class="fas fa-repeat"></i></span></p>
|
||||||
<p class="subtitle">Available to <a href="https://patreon.com/jellywx"><span class="patreon-color">Patreon <span class="icon"><i class="fab fa-patreon"></i></span></span></a> subscribers at <strong>$2/month</strong></p>
|
<p class="subtitle">Available to <a href="https://patreon.com/jellywx"><span class="patreon-color">Patreon <span class="icon"><i class="fab fa-patreon"></i></span></span></a> subscribers at <strong>$2/month</strong>.</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="hero is-small">
|
<section class="hero is-medium">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="container has-text-centered">
|
||||||
|
<p class="title">Technically-minded?</p>
|
||||||
|
<p class="content">
|
||||||
|
Install the bot on your own computer
|
||||||
|
</p>
|
||||||
|
<a class="button is-size-6 is-rounded is-link" href="https://gitea.jellypro.xyz/jude/reminder-bot">
|
||||||
|
<p class="is-size-6">
|
||||||
|
<span>Install</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
<div class="container has-text-centered">
|
<div class="container has-text-centered">
|
||||||
<p class="title">Ready to go?</p>
|
<p class="title">Ready to go?</p>
|
||||||
<p class="content">
|
<p class="content">
|
||||||
Add the bot to get started!
|
Add the bot to get started
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="hero-foot has-text-centered">
|
|
||||||
<a class="button is-size-6 is-rounded is-success" href="https://invite.reminder-bot.com">
|
<a class="button is-size-6 is-rounded is-success" href="https://invite.reminder-bot.com">
|
||||||
<p class="is-size-6">
|
<p class="is-size-6">
|
||||||
Add Now <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
<span>Add Now</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<div class="container has-text-centered">
|
||||||
|
<p class="title">Need support?</p>
|
||||||
|
<p class="content">
|
||||||
|
Check out our guides, or join our Discord
|
||||||
|
</p>
|
||||||
|
<a class="button is-size-6 is-rounded is-primary" href="/help">
|
||||||
|
<p class="is-size-6">
|
||||||
|
<span>Guides</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="title">Who we are</h2>
|
<h2 class="title">Who we are</h2>
|
||||||
<p class="is-size-5 pl-6">
|
<p>
|
||||||
Reminder Bot is operated solely by Jude Southworth. You can contact me by email at
|
Reminder Bot is operated solely by Jude Southworth. You can contact me by email at
|
||||||
<a href="mailto:jude@jellywx.com">jude@jellywx.com</a>, or via private/public message on Discord at
|
<a href="mailto:jude@jellywx.com">jude@jellywx.com</a>, or via private/public message on Discord at
|
||||||
<a href="https://discord.jellywx.com">https://discord.jellywx.com</a>.
|
<a href="https://discord.jellywx.com">https://discord.jellywx.com</a>.
|
||||||
@ -24,12 +24,16 @@
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="title">What data we collect</h2>
|
<h2 class="title">What data we collect</h2>
|
||||||
<p class="is-size-5 pl-6">
|
<p>
|
||||||
Reminder Bot stores limited data necessary for the function of the bot. This data
|
Reminder Bot stores limited data necessary for the function of the bot. This data
|
||||||
is your <strong>unique user ID</strong>, <strong>timezone</strong>, and <strong>direct message channel</strong>.
|
is your <strong>unique user ID</strong>, <strong>timezone</strong>, and <strong>direct message channel</strong>.
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
Timezones are provided by the user or the user's browser.
|
Timezones are provided by the user or the user's browser.
|
||||||
|
<br><br>
|
||||||
|
Some additional information is collected by the dashboard for the purpose of debugging. This is your
|
||||||
|
<strong>time spent on the website</strong>, <strong>current URL</strong>, <strong>unique user ID</strong>,
|
||||||
|
<strong>unique session token</strong>, <strong>contents of any client errors</strong>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -37,10 +41,12 @@
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="title">Why we collect this data</h2>
|
<h2 class="title">Why we collect this data</h2>
|
||||||
<p class="is-size-5 pl-6">
|
<p>
|
||||||
Unique user IDs are stored to <strong>keep track of who sets reminders</strong>. User timezones are
|
Unique user IDs are stored to <strong>keep track of who sets reminders</strong>. User timezones are
|
||||||
stored to allow users to set reminders in their local timezone. Direct message channels are stored to
|
stored to allow users to set reminders in their local timezone. Direct message channels are stored to
|
||||||
allow the setting of reminders for your direct message channel.
|
allow the setting of reminders for your direct message channel.
|
||||||
|
<br>
|
||||||
|
Information collected by the dashboard is for resolving bugs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -48,7 +54,7 @@
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="title">Who your data is shared with</h2>
|
<h2 class="title">Who your data is shared with</h2>
|
||||||
<p class="is-size-5 pl-6">
|
<p>
|
||||||
Your data is also guarded by the privacy policies of <strong>MEGA</strong>, our backup provider, and
|
Your data is also guarded by the privacy policies of <strong>MEGA</strong>, our backup provider, and
|
||||||
<strong>Hetzner</strong>, our hosting provider.
|
<strong>Hetzner</strong>, our hosting provider.
|
||||||
</p>
|
</p>
|
||||||
@ -58,17 +64,13 @@
|
|||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="title">Accessing or removing your data</h2>
|
<h2 class="title">Accessing or removing your data</h2>
|
||||||
<p class="is-size-5 pl-6">
|
<p>
|
||||||
Your timezone can be removed with the command <strong>/timezone UTC</strong>. Other data can be removed
|
Your timezone can be removed with the command <strong>/timezone UTC</strong>. Other data can be removed
|
||||||
on request. Please contact me.
|
on request. Please contact me.
|
||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
Reminders created in a guild/channel will be removed automatically when the bot is removed from the
|
Reminders created in a guild/channel will be removed automatically when the bot is removed from the
|
||||||
guild, the guild is deleted, or channel is deleted. Data is otherwise not removed automatically.
|
guild, the guild is deleted, or channel is deleted. Data is otherwise not removed automatically.
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
Reminders deleted with <strong>/del</strong> or via the dashboard are removed from the live database
|
|
||||||
instantly, but may persist in backups for up to a year.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
17
web/templates/reminder_dashboard/guild_error.html.tera
Normal file
17
web/templates/reminder_dashboard/guild_error.html.tera
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<div class="hero is-fullheight">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container has-text-centered">
|
||||||
|
<p class="title">
|
||||||
|
We couldn't get this server's data
|
||||||
|
</p>
|
||||||
|
<p class="subtitle">
|
||||||
|
Please check Reminder Bot is in the server, and has correct permissions.
|
||||||
|
</p>
|
||||||
|
<a class="button is-size-4 is-rounded is-success" href="https://invite.reminder-bot.com">
|
||||||
|
<p class="is-size-4">
|
||||||
|
<span>Add to Server</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -1,10 +1,31 @@
|
|||||||
<div class="columns reminderContent {% if creating %}creator{% endif %}">
|
<div class="reminderContent {% if creating %}creator{% endif %}">
|
||||||
|
<div class="columns is-mobile column reminder-topbar">
|
||||||
|
{% if not creating %}
|
||||||
|
<div class="invert-collapses channel-bar">
|
||||||
|
#channel
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="name-bar">
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<label class="label sr-only">Reminder Name</label>
|
||||||
|
<input class="input" type="text" name="name" placeholder="Reminder Name" maxlength="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hide-button-bar">
|
||||||
|
<button class="button hide-box">
|
||||||
|
<span class="is-sr-only">Hide reminder</span><i class="fas fa-chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="columns reminder-settings">
|
||||||
<div class="column discord-frame">
|
<div class="column discord-frame">
|
||||||
<article class="media">
|
<article class="media">
|
||||||
<figure class="media-left">
|
<figure class="media-left">
|
||||||
<p class="image is-32x32 customizable">
|
<p class="image is-32x32 customizable">
|
||||||
<a>
|
<a>
|
||||||
<img class="is-rounded discord-avatar" src="/static/img/bg.webp" alt="Image for discord avatar">
|
<img class="is-rounded avatar" src="/static/img/bg.webp" alt="Image for discord avatar">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</figure>
|
</figure>
|
||||||
@ -112,24 +133,6 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div class="column settings">
|
<div class="column settings">
|
||||||
<div class="columns is-mobile reminder-topbar">
|
|
||||||
<div class="column">
|
|
||||||
<div class="field">
|
|
||||||
<div class="control">
|
|
||||||
<label class="label sr-only">Reminder Name</label>
|
|
||||||
<input class="input" type="text" name="name" placeholder="Reminder Name">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-narrow">
|
|
||||||
<button class="button is-rounded hide-box">
|
|
||||||
<span class="is-sr-only">Hide reminder</span><i class="fas fa-chevron-down"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column">
|
|
||||||
<div class="field channel-field">
|
<div class="field channel-field">
|
||||||
<div class="collapses">
|
<div class="collapses">
|
||||||
<label class="label" for="channelOption">Channel*</label>
|
<label class="label" for="channelOption">Channel*</label>
|
||||||
@ -144,26 +147,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label class="label collapses">
|
<label class="label collapses">
|
||||||
Time*
|
Time*
|
||||||
<input class="input" type="datetime-local" step="1" name="time">
|
<input class="input prefill-now" type="datetime-local" step="1" name="time">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="collapses">
|
<div class="collapses split-controls">
|
||||||
|
<div>
|
||||||
<div class="patreon-only">
|
<div class="patreon-only">
|
||||||
|
<div class="patreon-invert foreground">
|
||||||
|
Intervals available on <a href="https://patreon.com/jellywx">Patreon</a> or <a href="https://gitea.jellypro.xyz/jude/reminder-bot">self-hosting</a>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Interval <a class="foreground" href="/help/intervals"><i class="fas fa-question-circle"></i></a></label>
|
<label class="label">Interval <a class="foreground" href="/help/intervals"><i class="fas fa-question-circle"></i></a></label>
|
||||||
<div class="control intervalSelector" style="min-width: 400px;" >
|
<div class="control intervalSelector">
|
||||||
<div class="input interval-group">
|
<div class="input interval-group">
|
||||||
<div class="interval-group-left">
|
<div class="interval-group-left">
|
||||||
|
<span class="no-break">
|
||||||
<label>
|
<label>
|
||||||
<span class="is-sr-only">Interval months</span>
|
<span class="is-sr-only">Interval months</span>
|
||||||
<input class="w2" type="text" pattern="\d*" name="interval_months" maxlength="2" placeholder=""> <span class="half-rem"></span> months, <span class="half-rem"></span>
|
<input class="w2" type="text" pattern="\d*" name="interval_months" maxlength="2" placeholder=""> <span class="half-rem"></span> months, <span class="half-rem"></span>
|
||||||
@ -172,6 +177,8 @@
|
|||||||
<span class="is-sr-only">Interval days</span>
|
<span class="is-sr-only">Interval days</span>
|
||||||
<input class="w3" type="text" pattern="\d*" name="interval_days" maxlength="4" placeholder=""> <span class="half-rem"></span> days, <span class="half-rem"></span>
|
<input class="w3" type="text" pattern="\d*" name="interval_days" maxlength="4" placeholder=""> <span class="half-rem"></span> days, <span class="half-rem"></span>
|
||||||
</label>
|
</label>
|
||||||
|
</span>
|
||||||
|
<span class="no-break">
|
||||||
<label>
|
<label>
|
||||||
<span class="is-sr-only">Interval hours</span>
|
<span class="is-sr-only">Interval hours</span>
|
||||||
<input class="w2" type="text" pattern="\d*" name="interval_hours" maxlength="2" placeholder="HH">:
|
<input class="w2" type="text" pattern="\d*" name="interval_hours" maxlength="2" placeholder="HH">:
|
||||||
@ -184,6 +191,7 @@
|
|||||||
<span class="is-sr-only">Interval seconds</span>
|
<span class="is-sr-only">Interval seconds</span>
|
||||||
<input class="w2" type="text" pattern="\d*" name="interval_seconds" maxlength="2" placeholder="SS">
|
<input class="w2" type="text" pattern="\d*" name="interval_seconds" maxlength="2" placeholder="SS">
|
||||||
</label>
|
</label>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="clear"><span class="is-sr-only">Clear interval</span><span class="icon"><i class="fas fa-trash"></i></span></button>
|
<button class="clear"><span class="is-sr-only">Clear interval</span><span class="icon"><i class="fas fa-trash"></i></span></button>
|
||||||
</div>
|
</div>
|
||||||
@ -200,7 +208,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns is-mobile tts-row">
|
||||||
<div class="column has-text-centered">
|
<div class="column has-text-centered">
|
||||||
<div class="is-boxed">
|
<div class="is-boxed">
|
||||||
<label class="label">Enable TTS <input type="checkbox" name="tts"></label>
|
<label class="label">Enable TTS <input type="checkbox" name="tts"></label>
|
||||||
@ -222,20 +230,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<span class="pad-left"></span>
|
</div>
|
||||||
|
</div>
|
||||||
{% if creating %}
|
{% if creating %}
|
||||||
|
<div class="button-row">
|
||||||
|
<div class="button-row-reminder">
|
||||||
<button class="button is-success" id="createReminder">
|
<button class="button is-success" id="createReminder">
|
||||||
<span>Create Reminder</span> <span class="icon"><i class="fas fa-sparkles"></i></span>
|
<span>Create Reminder</span> <span class="icon"><i class="fas fa-sparkles"></i></span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="button-row-template">
|
||||||
|
<div>
|
||||||
<button class="button is-success is-outlined" id="createTemplate">
|
<button class="button is-success is-outlined" id="createTemplate">
|
||||||
<span>Create Template</span> <span class="icon"><i class="fas fa-file-spreadsheet"></i></span>
|
<span>Create Template</span> <span class="icon"><i class="fas fa-file-spreadsheet"></i></span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<button class="button is-outlined show-modal is-pulled-right" data-modal="chooseTemplateModal">
|
<button class="button is-outlined show-modal is-pulled-right" data-modal="chooseTemplateModal">
|
||||||
Load Template
|
Load Template
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<div class="button-row-edit">
|
||||||
<button class="button is-success save-btn">
|
<button class="button is-success save-btn">
|
||||||
<span>Save</span> <span class="icon"><i class="fas fa-save"></i></span>
|
<span>Save</span> <span class="icon"><i class="fas fa-save"></i></span>
|
||||||
</button>
|
</button>
|
||||||
@ -244,8 +264,6 @@
|
|||||||
<button class="button is-danger delete-reminder">
|
<button class="button is-danger delete-reminder">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user