Compare commits
169 Commits
postgres
...
jude/fix-o
Author | SHA1 | Date | |
---|---|---|---|
adb9c728f4 | |||
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 | |||
8f8235a86e | |||
c8f646a8fa | |||
ecaa382a1e | |||
8991198fd3 | |||
f20b95a482 | |||
8dd7dc6409 | |||
c799d10727 | |||
ceb6fb7b12 | |||
6708abdb0f | |||
a38f6024c1 | |||
7d8748e3ef | |||
bb3386c4e8 | |||
25b84880a5 | |||
7b6e967a5d | |||
2781f2923e | |||
03f08f0a18 | |||
79c86d43f2 | |||
e19af54caf | |||
f4213c6a83 | |||
f56db14720 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,6 +2,4 @@
|
||||
.env
|
||||
/venv
|
||||
.cargo
|
||||
assets
|
||||
out.json
|
||||
/.idea
|
||||
|
2581
Cargo.lock
generated
2581
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
50
Cargo.toml
50
Cargo.toml
@ -1,32 +1,58 @@
|
||||
[package]
|
||||
name = "reminder_rs"
|
||||
version = "1.6.0"
|
||||
authors = ["jellywx <judesouthworth@pm.me>"]
|
||||
edition = "2018"
|
||||
workspaces = [".", "postman", "web", "entity", "migration"]
|
||||
name = "reminder-rs"
|
||||
version = "1.6.48"
|
||||
authors = ["Jude Southworth <judesouthworth@pm.me>"]
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0 only"
|
||||
description = "Reminder Bot for Discord, now in Rust"
|
||||
|
||||
[dependencies]
|
||||
poise = "0.2"
|
||||
poise = "0.5"
|
||||
dotenv = "0.15"
|
||||
tokio = { version = "1", features = ["process", "full"] }
|
||||
reqwest = "0.11"
|
||||
regex = "1.4"
|
||||
lazy-regex = "3.0.2"
|
||||
regex = "1.9"
|
||||
log = "0.4"
|
||||
env_logger = "0.8"
|
||||
env_logger = "0.10"
|
||||
chrono = "0.4"
|
||||
chrono-tz = { version = "0.5", features = ["serde"] }
|
||||
chrono-tz = { version = "0.8", features = ["serde"] }
|
||||
lazy_static = "1.4"
|
||||
num-integer = "0.1"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
serde_repr = "0.1"
|
||||
rmp-serde = "0.15"
|
||||
rand = "0.7"
|
||||
rmp-serde = "1.1"
|
||||
rand = "0.8"
|
||||
levenshtein = "1.0"
|
||||
base64 = "0.13.0"
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]}
|
||||
base64 = "0.21.0"
|
||||
|
||||
[dependencies.postman]
|
||||
path = "postman"
|
||||
|
||||
[dependencies.reminder_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"],
|
||||
["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)
|
||||
|
||||
### Compiling
|
||||
Install build requirements:
|
||||
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential`
|
||||
### Build APT package
|
||||
|
||||
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
|
||||
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.
|
||||
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.
|
||||
|
||||
#### Compilation environment variables
|
||||
These environment variables must be provided when compiling the bot
|
||||
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
|
||||
* `WEBHOOK_AVATAR` - accepts the name of an image file located in `$CARGO_MANIFEST_DIR/assets/` to be used as the avatar when creating webhooks. **IMPORTANT: image file must be 128x128 or smaller in size**
|
||||
1. Install container software: `sudo apt install podman`.
|
||||
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
|
||||
3. Install SQLx CLI: `cargo install sqlx-cli`
|
||||
4. From the source code directory, execute `sqlx migrate run`
|
||||
5. Build container image: `podman build -t reminder-rs .`
|
||||
6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
|
||||
|
||||
### 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.
|
||||
|
||||
__Required Variables__
|
||||
@ -37,10 +48,5 @@ __Other Variables__
|
||||
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
|
||||
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
|
||||
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
|
||||
* `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
|
||||
* `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]
|
||||
address = "0.0.0.0"
|
||||
port = 5000
|
||||
port = 18920
|
||||
template_dir = "web/templates"
|
||||
limits = { json = "10MiB" }
|
||||
|
||||
@ -11,18 +11,18 @@ secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="
|
||||
certs = "web/private/rsa_sha256_cert.pem"
|
||||
key = "web/private/rsa_sha256_key.pem"
|
||||
|
||||
[rsa_sha256.tls]
|
||||
[debug.rsa_sha256.tls]
|
||||
certs = "web/private/rsa_sha256_cert.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"
|
||||
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"
|
||||
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
|
||||
|
||||
[ed25519.tls]
|
||||
[debug.ed25519.tls]
|
||||
certs = "web/private/ed25519_cert.pem"
|
||||
key = "eb/private/ed25519_key.pem"
|
||||
|
BIN
assets/webhook.jpg
Normal file
BIN
assets/webhook.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
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
|
229
migrations/20210603000000_initial.sql
Normal file
229
migrations/20210603000000_initial.sql
Normal file
@ -0,0 +1,229 @@
|
||||
SET FOREIGN_KEY_CHECKS=0;
|
||||
|
||||
CREATE TABLE guilds (
|
||||
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
||||
guild BIGINT UNSIGNED UNIQUE NOT NULL,
|
||||
|
||||
name VARCHAR(100),
|
||||
|
||||
prefix VARCHAR(5) DEFAULT '$' NOT NULL,
|
||||
timezone VARCHAR(32) DEFAULT 'UTC' NOT NULL,
|
||||
|
||||
default_channel_id INT UNSIGNED,
|
||||
default_username VARCHAR(32) DEFAULT 'Reminder' NOT NULL,
|
||||
default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (default_channel_id) REFERENCES channels(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE channels (
|
||||
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
||||
channel BIGINT UNSIGNED UNIQUE NOT NULL,
|
||||
|
||||
name VARCHAR(100),
|
||||
|
||||
nudge SMALLINT NOT NULL DEFAULT 0,
|
||||
blacklisted BOOL NOT NULL DEFAULT FALSE,
|
||||
|
||||
webhook_id BIGINT UNSIGNED UNIQUE,
|
||||
webhook_token TEXT,
|
||||
|
||||
paused BOOL NOT NULL DEFAULT 0,
|
||||
paused_until TIMESTAMP,
|
||||
|
||||
guild_id INT UNSIGNED,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||
user BIGINT UNSIGNED UNIQUE NOT NULL,
|
||||
|
||||
name VARCHAR(37) NOT NULL,
|
||||
|
||||
dm_channel INT UNSIGNED UNIQUE NOT NULL,
|
||||
|
||||
language VARCHAR(2) DEFAULT 'EN' NOT NULL,
|
||||
timezone VARCHAR(32) DEFAULT 'UTC' NOT NULL,
|
||||
meridian_time BOOLEAN DEFAULT 0 NOT NULL,
|
||||
|
||||
allowed_dm BOOLEAN DEFAULT 1 NOT NULL,
|
||||
|
||||
patreon BOOLEAN NOT NULL DEFAULT 0,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (dm_channel) REFERENCES channels(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE roles (
|
||||
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
||||
role BIGINT UNSIGNED UNIQUE NOT NULL,
|
||||
|
||||
name VARCHAR(100),
|
||||
|
||||
guild_id INT UNSIGNED NOT NULL,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE embeds (
|
||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||
|
||||
title VARCHAR(256) NOT NULL DEFAULT '',
|
||||
description VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
|
||||
image_url VARCHAR(512),
|
||||
thumbnail_url VARCHAR(512),
|
||||
|
||||
footer VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
footer_icon VARCHAR(512),
|
||||
|
||||
color MEDIUMINT UNSIGNED NOT NULL DEFAULT 0x0,
|
||||
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE embed_fields (
|
||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||
|
||||
title VARCHAR(256) NOT NULL DEFAULT '',
|
||||
value VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
inline BOOL NOT NULL DEFAULT 0,
|
||||
embed_id INT UNSIGNED NOT NULL,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||
|
||||
content VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
tts BOOL NOT NULL DEFAULT 0,
|
||||
embed_id INT UNSIGNED,
|
||||
|
||||
attachment MEDIUMBLOB,
|
||||
attachment_name VARCHAR(260),
|
||||
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE reminders (
|
||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||
uid VARCHAR(64) UNIQUE NOT NULL,
|
||||
|
||||
name VARCHAR(24) NOT NULL DEFAULT 'Reminder',
|
||||
|
||||
message_id INT UNSIGNED NOT NULL,
|
||||
channel_id INT UNSIGNED NOT NULL,
|
||||
|
||||
`time` INT UNSIGNED DEFAULT 0 NOT NULL,
|
||||
`interval` INT UNSIGNED DEFAULT NULL,
|
||||
expires TIMESTAMP DEFAULT NULL,
|
||||
|
||||
enabled BOOLEAN DEFAULT 1 NOT NULL,
|
||||
|
||||
avatar VARCHAR(512),
|
||||
username VARCHAR(32),
|
||||
|
||||
method ENUM('remind', 'natural', 'dashboard', 'todo', 'countdown'),
|
||||
set_at TIMESTAMP DEFAULT NOW(),
|
||||
set_by INT UNSIGNED,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (set_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TRIGGER message_cleanup AFTER DELETE ON reminders
|
||||
FOR EACH ROW
|
||||
DELETE FROM messages WHERE id = OLD.message_id;
|
||||
|
||||
CREATE TRIGGER embed_cleanup AFTER DELETE ON messages
|
||||
FOR EACH ROW
|
||||
DELETE FROM embeds WHERE id = OLD.embed_id;
|
||||
|
||||
CREATE TABLE todos (
|
||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||
user_id INT UNSIGNED,
|
||||
guild_id INT UNSIGNED,
|
||||
channel_id INT UNSIGNED,
|
||||
value VARCHAR(2000) NOT NULL,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE command_restrictions (
|
||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||
|
||||
role_id INT UNSIGNED NOT NULL,
|
||||
command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY (`role_id`, `command`)
|
||||
);
|
||||
|
||||
CREATE TABLE timers (
|
||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||
start_time TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
name VARCHAR(32) NOT NULL,
|
||||
owner BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE events (
|
||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||
`time` TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
|
||||
event_name ENUM('edit', 'enable', 'disable', 'delete') NOT NULL,
|
||||
bulk_count INT UNSIGNED,
|
||||
|
||||
guild_id INT UNSIGNED NOT NULL,
|
||||
user_id INT UNSIGNED,
|
||||
reminder_id INT UNSIGNED,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE command_aliases (
|
||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||
|
||||
guild_id INT UNSIGNED NOT NULL,
|
||||
name VARCHAR(12) NOT NULL,
|
||||
|
||||
command VARCHAR(2048) NOT NULL,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY (`guild_id`, `name`)
|
||||
);
|
||||
|
||||
CREATE TABLE guild_users (
|
||||
guild INT UNSIGNED NOT NULL,
|
||||
user INT UNSIGNED NOT NULL,
|
||||
|
||||
can_access BOOL NOT NULL DEFAULT 0,
|
||||
|
||||
FOREIGN KEY (guild) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY (guild, user)
|
||||
);
|
||||
|
||||
CREATE EVENT event_cleanup
|
||||
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
|
||||
ON COMPLETION PRESERVE
|
||||
DO DELETE FROM events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);
|
163
migrations/20210623000000_reminder_message_embed.sql
Normal file
163
migrations/20210623000000_reminder_message_embed.sql
Normal file
File diff suppressed because one or more lines are too long
11
migrations/20210922000000_macro.sql
Normal file
11
migrations/20210922000000_macro.sql
Normal file
@ -0,0 +1,11 @@
|
||||
CREATE TABLE macro (
|
||||
id INT UNSIGNED AUTO_INCREMENT,
|
||||
guild_id INT UNSIGNED NOT NULL,
|
||||
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(100),
|
||||
commands TEXT NOT NULL,
|
||||
|
||||
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (id)
|
||||
);
|
@ -0,0 +1,2 @@
|
||||
ALTER TABLE reminders RENAME COLUMN `interval` TO `interval_seconds`;
|
||||
ALTER TABLE reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;
|
49
migrations/20220211000000_reminder_templates.sql
Normal file
49
migrations/20220211000000_reminder_templates.sql
Normal file
@ -0,0 +1,49 @@
|
||||
CREATE TABLE reminder_template (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
`name` VARCHAR(24) NOT NULL DEFAULT 'Reminder',
|
||||
|
||||
`guild_id` INT UNSIGNED NOT NULL,
|
||||
|
||||
`username` VARCHAR(32) DEFAULT NULL,
|
||||
`avatar` VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
`content` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
`tts` BOOL NOT NULL DEFAULT 0,
|
||||
`attachment` MEDIUMBLOB,
|
||||
`attachment_name` VARCHAR(260),
|
||||
|
||||
`embed_title` VARCHAR(256) NOT NULL DEFAULT '',
|
||||
`embed_description` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
`embed_image_url` VARCHAR(512),
|
||||
`embed_thumbnail_url` VARCHAR(512),
|
||||
`embed_footer` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
`embed_footer_url` VARCHAR(512),
|
||||
`embed_author` VARCHAR(256) NOT NULL DEFAULT '',
|
||||
`embed_author_url` VARCHAR(512),
|
||||
`embed_color` INT UNSIGNED NOT NULL DEFAULT 0x0,
|
||||
`embed_fields` JSON,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
|
||||
FOREIGN KEY (`guild_id`) REFERENCES guilds (`id`) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
ALTER TABLE reminders ADD COLUMN embed_fields JSON;
|
||||
|
||||
update reminders
|
||||
inner join embed_fields as E
|
||||
on E.reminder_id = reminders.id
|
||||
set embed_fields = (
|
||||
select JSON_ARRAYAGG(
|
||||
JSON_OBJECT(
|
||||
'title', E.title,
|
||||
'value', E.value,
|
||||
'inline',
|
||||
if(inline = 1, cast(TRUE as json), cast(FALSE as json))
|
||||
)
|
||||
)
|
||||
from embed_fields
|
||||
group by reminder_id
|
||||
having reminder_id = reminders.id
|
||||
);
|
1
migrations/20221210000000_reminder_daily_intervals.sql
Normal file
1
migrations/20221210000000_reminder_daily_intervals.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE reminders ADD COLUMN `interval_days` INT UNSIGNED DEFAULT NULL;
|
1
migrations/20230511125236_reminder_threads.sql
Normal file
1
migrations/20230511125236_reminder_threads.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE reminders ADD COLUMN `thread_id` BIGINT DEFAULT NULL;
|
1
migrations/20230511180231_ephemeral_confirmations.sql
Normal file
1
migrations/20230511180231_ephemeral_confirmations.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE guilds ADD COLUMN ephemeral_confirmations BOOL NOT NULL DEFAULT 0;
|
2
migrations/20230722130906_increase_reminder_name.sql
Normal file
2
migrations/20230722130906_increase_reminder_name.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE reminders MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder';
|
||||
ALTER TABLE reminder_template MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder';
|
9
migrations/20230730134827_stats.sql
Normal file
9
migrations/20230730134827_stats.sql
Normal file
@ -0,0 +1,9 @@
|
||||
CREATE TABLE stat (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`utc_time` DATETIME NOT NULL DEFAULT NOW(),
|
||||
`type` ENUM('reminder_sent', 'reminder_failed'),
|
||||
`reminder_id` INT UNSIGNED,
|
||||
`message` TEXT,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
);
|
2
migrations/20230731170452_reminder_archive.sql
Normal file
2
migrations/20230731170452_reminder_archive.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE reminders ADD COLUMN `status` ENUM ('pending', 'sent', 'failed', 'deleted') NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE reminders ADD COLUMN `status_message` TEXT;
|
@ -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;
|
7
models/Cargo.lock
generated
7
models/Cargo.lock
generated
@ -1,7 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "models"
|
||||
version = "0.1.0"
|
@ -1,8 +0,0 @@
|
||||
[package]
|
||||
name = "models"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
@ -1,8 +0,0 @@
|
||||
[package]
|
||||
name = "entity"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chrono-tz = "^0.6"
|
||||
sea-orm = { version = "^0.8", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] }
|
@ -1,60 +0,0 @@
|
||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "channel")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: i64,
|
||||
pub guild_id: Option<i64>,
|
||||
pub nudge: i32,
|
||||
pub webhook_id: Option<i64>,
|
||||
pub webhook_token: Option<String>,
|
||||
pub paused: bool,
|
||||
pub paused_until: Option<DateTimeUtc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::guild::Entity",
|
||||
from = "Column::GuildId",
|
||||
to = "super::guild::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Guild,
|
||||
#[sea_orm(has_many = "super::user::Entity")]
|
||||
User,
|
||||
#[sea_orm(has_many = "super::reminder::Entity")]
|
||||
Reminder,
|
||||
#[sea_orm(has_many = "super::todo::Entity")]
|
||||
Todo,
|
||||
}
|
||||
|
||||
impl Related<super::guild::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Guild.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::reminder::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Reminder.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::todo::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Todo.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
@ -1,34 +0,0 @@
|
||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "command_macro")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub guild_id: i64,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub commands: Option<Json>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::guild::Entity",
|
||||
from = "Column::GuildId",
|
||||
to = "super::guild::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Guild,
|
||||
}
|
||||
|
||||
impl Related<super::guild::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Guild.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
@ -1,48 +0,0 @@
|
||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "guild")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: i64,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::reminder_template::Entity")]
|
||||
ReminderTemplate,
|
||||
#[sea_orm(has_many = "super::channel::Entity")]
|
||||
Channel,
|
||||
#[sea_orm(has_many = "super::todo::Entity")]
|
||||
Todo,
|
||||
#[sea_orm(has_many = "super::command_macro::Entity")]
|
||||
CommandMacro,
|
||||
}
|
||||
|
||||
impl Related<super::reminder_template::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ReminderTemplate.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::channel::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Channel.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::todo::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Todo.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::command_macro::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::CommandMacro.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
@ -1 +0,0 @@
|
||||
|
@ -1,14 +0,0 @@
|
||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod channel;
|
||||
pub mod command_macro;
|
||||
pub mod guild;
|
||||
pub mod reminder;
|
||||
pub mod reminder_template;
|
||||
pub mod sea_orm_active_enums;
|
||||
pub mod seaql_migrations;
|
||||
pub mod timer;
|
||||
pub mod todo;
|
||||
pub mod user;
|
@ -1,8 +0,0 @@
|
||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
|
||||
|
||||
pub use super::{
|
||||
channel::Entity as Channel, command_macro::Entity as CommandMacro, guild::Entity as Guild,
|
||||
reminder::Entity as Reminder, reminder_template::Entity as ReminderTemplate,
|
||||
seaql_migrations::Entity as SeaqlMigrations, timer::Entity as Timer, todo::Entity as Todo,
|
||||
user::Entity as User,
|
||||
};
|
@ -1,73 +0,0 @@
|
||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use super::sea_orm_active_enums::Timezone;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "reminder")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub uid: String,
|
||||
pub name: String,
|
||||
pub channel_id: i64,
|
||||
pub utc_time: DateTimeUtc,
|
||||
pub timezone: Timezone,
|
||||
pub interval_seconds: Option<i32>,
|
||||
pub interval_months: Option<i32>,
|
||||
pub enabled: bool,
|
||||
pub expires: Option<DateTimeUtc>,
|
||||
pub username: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub tts: bool,
|
||||
pub attachment: Option<Vec<u8>>,
|
||||
pub attachment_name: Option<String>,
|
||||
pub embed_title: Option<String>,
|
||||
pub embed_description: Option<String>,
|
||||
pub embed_image_url: Option<String>,
|
||||
pub embed_thumbnail_url: Option<String>,
|
||||
pub embed_footer: Option<String>,
|
||||
pub embed_footer_url: Option<String>,
|
||||
pub embed_author: Option<String>,
|
||||
pub embed_author_url: Option<String>,
|
||||
pub embed_color: Option<i32>,
|
||||
pub embed_fields: Option<Json>,
|
||||
pub set_at: DateTimeUtc,
|
||||
pub set_by: i64,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::channel::Entity",
|
||||
from = "Column::ChannelId",
|
||||
to = "super::channel::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Channel,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::SetBy",
|
||||
to = "super::user::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::channel::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Channel.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
@ -1,48 +0,0 @@
|
||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "reminder_template")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub guild_id: i64,
|
||||
pub name: String,
|
||||
pub username: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub tts: bool,
|
||||
pub attachment: Option<Vec<u8>>,
|
||||
pub attachment_name: Option<String>,
|
||||
pub embed_title: Option<String>,
|
||||
pub embed_description: Option<String>,
|
||||
pub embed_image_url: Option<String>,
|
||||
pub embed_thumbnail_url: Option<String>,
|
||||
pub embed_footer: Option<String>,
|
||||
pub embed_footer_url: Option<String>,
|
||||
pub embed_author: Option<String>,
|
||||
pub embed_author_url: Option<String>,
|
||||
pub embed_color: Option<i32>,
|
||||
pub embed_fields: Option<Json>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::guild::Entity",
|
||||
from = "Column::GuildId",
|
||||
to = "super::guild::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Guild,
|
||||
}
|
||||
|
||||
impl Related<super::guild::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Guild.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
File diff suppressed because it is too large
Load Diff
@ -1,22 +0,0 @@
|
||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "seaql_migrations")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub version: String,
|
||||
pub applied_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl RelationTrait for Relation {
|
||||
fn def(&self) -> RelationDef {
|
||||
panic!("No RelationDef")
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
@ -1,36 +0,0 @@
|
||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "timer")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub start_time: DateTimeUtc,
|
||||
pub name: String,
|
||||
pub user_id: Option<i64>,
|
||||
pub guild_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::guild::Entity",
|
||||
from = "Column::GuildId",
|
||||
to = "super::guild::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Guild2,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::guild::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::guild::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Guild1,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
@ -1,62 +0,0 @@
|
||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "todo")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub user_id: Option<i64>,
|
||||
pub guild_id: Option<i64>,
|
||||
pub channel_id: Option<i64>,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::channel::Entity",
|
||||
from = "Column::ChannelId",
|
||||
to = "super::channel::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Channel,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::guild::Entity",
|
||||
from = "Column::GuildId",
|
||||
to = "super::guild::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Guild,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::channel::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Channel.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::guild::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Guild.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
@ -1,50 +0,0 @@
|
||||
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use super::sea_orm_active_enums::Timezone;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "user")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: i64,
|
||||
pub dm_channel: i64,
|
||||
pub timezone: Timezone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::channel::Entity",
|
||||
from = "Column::DmChannel",
|
||||
to = "super::channel::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Channel,
|
||||
#[sea_orm(has_many = "super::reminder::Entity")]
|
||||
Reminder,
|
||||
#[sea_orm(has_many = "super::todo::Entity")]
|
||||
Todo,
|
||||
}
|
||||
|
||||
impl Related<super::channel::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Channel.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::reminder::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Reminder.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::todo::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Todo.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
2400
models/migration/Cargo.lock
generated
2400
models/migration/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,16 +0,0 @@
|
||||
[package]
|
||||
name = "migration"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "migration"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
entity = { path = "../entity" }
|
||||
chrono-tz = "^0.6"
|
||||
|
||||
[dependencies.sea-orm-migration]
|
||||
version = "^0.8.0"
|
@ -1,37 +0,0 @@
|
||||
# Running Migrator CLI
|
||||
|
||||
- Apply all pending migrations
|
||||
```sh
|
||||
cargo run
|
||||
```
|
||||
```sh
|
||||
cargo run -- up
|
||||
```
|
||||
- Apply first 10 pending migrations
|
||||
```sh
|
||||
cargo run -- up -n 10
|
||||
```
|
||||
- Rollback last applied migrations
|
||||
```sh
|
||||
cargo run -- down
|
||||
```
|
||||
- Rollback last 10 applied migrations
|
||||
```sh
|
||||
cargo run -- down -n 10
|
||||
```
|
||||
- Drop all tables from the database, then reapply all migrations
|
||||
```sh
|
||||
cargo run -- fresh
|
||||
```
|
||||
- Rollback all applied migrations, then reapply all migrations
|
||||
```sh
|
||||
cargo run -- refresh
|
||||
```
|
||||
- Rollback all applied migrations
|
||||
```sh
|
||||
cargo run -- reset
|
||||
```
|
||||
- Check the status of all migrations
|
||||
```sh
|
||||
cargo run -- status
|
||||
```
|
@ -1,12 +0,0 @@
|
||||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20220101_000001_create_table;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![Box::new(m20220101_000001_create_table::Migration)]
|
||||
}
|
||||
}
|
@ -1,553 +0,0 @@
|
||||
use chrono_tz::{Tz, TZ_VARIANTS};
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
use crate::extension::postgres::Type;
|
||||
|
||||
pub struct Migration;
|
||||
|
||||
impl MigrationName for Migration {
|
||||
fn name(&self) -> &str {
|
||||
"m20220101_000001_create_table"
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
pub enum Guild {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
pub enum Channel {
|
||||
Table,
|
||||
Id,
|
||||
GuildId,
|
||||
Nudge,
|
||||
WebhookId,
|
||||
WebhookToken,
|
||||
Paused,
|
||||
PausedUntil,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
pub enum User {
|
||||
Table,
|
||||
Id,
|
||||
DmChannel,
|
||||
Timezone,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
pub enum Reminder {
|
||||
Table,
|
||||
Id,
|
||||
Uid,
|
||||
Name,
|
||||
ChannelId,
|
||||
UtcTime,
|
||||
Timezone,
|
||||
IntervalSeconds,
|
||||
IntervalMonths,
|
||||
Enabled,
|
||||
Expires,
|
||||
Username,
|
||||
Avatar,
|
||||
Content,
|
||||
Tts,
|
||||
Attachment,
|
||||
AttachmentName,
|
||||
EmbedTitle,
|
||||
EmbedDescription,
|
||||
EmbedImageUrl,
|
||||
EmbedThumbnailUrl,
|
||||
EmbedFooter,
|
||||
EmbedFooterUrl,
|
||||
EmbedAuthor,
|
||||
EmbedAuthorUrl,
|
||||
EmbedColor,
|
||||
EmbedFields,
|
||||
SetAt,
|
||||
SetBy,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
pub enum ReminderTemplate {
|
||||
Table,
|
||||
Id,
|
||||
GuildId,
|
||||
Name,
|
||||
Username,
|
||||
Avatar,
|
||||
Content,
|
||||
Tts,
|
||||
Attachment,
|
||||
AttachmentName,
|
||||
EmbedTitle,
|
||||
EmbedDescription,
|
||||
EmbedImageUrl,
|
||||
EmbedThumbnailUrl,
|
||||
EmbedFooter,
|
||||
EmbedFooterUrl,
|
||||
EmbedAuthor,
|
||||
EmbedAuthorUrl,
|
||||
EmbedColor,
|
||||
EmbedFields,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
pub enum Timer {
|
||||
Table,
|
||||
Id,
|
||||
StartTime,
|
||||
Name,
|
||||
UserId,
|
||||
GuildId,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
pub enum Todo {
|
||||
Table,
|
||||
Id,
|
||||
UserId,
|
||||
GuildId,
|
||||
ChannelId,
|
||||
Value,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
pub enum CommandMacro {
|
||||
Table,
|
||||
Id,
|
||||
GuildId,
|
||||
Name,
|
||||
Description,
|
||||
Commands,
|
||||
}
|
||||
|
||||
pub enum Timezone {
|
||||
Type,
|
||||
Tz(Tz),
|
||||
}
|
||||
|
||||
impl Iden for Timezone {
|
||||
fn unquoted(&self, s: &mut dyn Write) {
|
||||
write!(
|
||||
s,
|
||||
"{}",
|
||||
match self {
|
||||
Self::Type => "timezone".to_string(),
|
||||
Self::Tz(tz) => tz.to_string(),
|
||||
}
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_type(
|
||||
Type::create()
|
||||
.as_enum(Timezone::Type)
|
||||
.values(TZ_VARIANTS.iter().map(|tz| Timezone::Tz(tz.to_owned())))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Guild::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Guild::Id).big_integer().not_null().primary_key())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Channel::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Channel::Id).big_integer().not_null().primary_key())
|
||||
.col(ColumnDef::new(Channel::GuildId).big_integer())
|
||||
.col(ColumnDef::new(Channel::Nudge).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(Channel::WebhookId).big_integer())
|
||||
.col(ColumnDef::new(Channel::WebhookToken).string())
|
||||
.col(ColumnDef::new(Channel::Paused).boolean().not_null().default(false))
|
||||
.col(ColumnDef::new(Channel::PausedUntil).date_time())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_channel_guild")
|
||||
.from(Channel::Table, Channel::GuildId)
|
||||
.to(Guild::Table, Guild::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(User::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(User::Id).big_integer().not_null().primary_key())
|
||||
.col(ColumnDef::new(User::DmChannel).big_integer().not_null())
|
||||
.col(
|
||||
ColumnDef::new(User::Timezone)
|
||||
.custom(Timezone::Type)
|
||||
.not_null()
|
||||
.default("UTC"),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_user_channel")
|
||||
.from(User::Table, User::DmChannel)
|
||||
.to(Channel::Table, Channel::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Reminder::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Reminder::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Reminder::Uid).string().char_len(64).not_null())
|
||||
.col(
|
||||
ColumnDef::new(Reminder::Name)
|
||||
.string()
|
||||
.char_len(24)
|
||||
.default("Reminder")
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Reminder::ChannelId).big_integer().not_null())
|
||||
.col(ColumnDef::new(Reminder::UtcTime).date_time().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Reminder::Timezone)
|
||||
.custom(Timezone::Type)
|
||||
.not_null()
|
||||
.default("UTC"),
|
||||
)
|
||||
.col(ColumnDef::new(Reminder::IntervalSeconds).integer())
|
||||
.col(ColumnDef::new(Reminder::IntervalMonths).integer())
|
||||
.col(ColumnDef::new(Reminder::Enabled).boolean().not_null().default(false))
|
||||
.col(ColumnDef::new(Reminder::Expires).date_time())
|
||||
.col(ColumnDef::new(Reminder::Username).string_len(32))
|
||||
.col(ColumnDef::new(Reminder::Avatar).string_len(512))
|
||||
.col(ColumnDef::new(Reminder::Content).string_len(2000))
|
||||
.col(ColumnDef::new(Reminder::Tts).boolean().not_null().default(false))
|
||||
.col(ColumnDef::new(Reminder::Attachment).binary_len(8 * 1024 * 1024))
|
||||
.col(ColumnDef::new(Reminder::AttachmentName).string_len(260))
|
||||
.col(ColumnDef::new(Reminder::EmbedTitle).string_len(256))
|
||||
.col(ColumnDef::new(Reminder::EmbedDescription).string_len(4096))
|
||||
.col(ColumnDef::new(Reminder::EmbedImageUrl).string_len(500))
|
||||
.col(ColumnDef::new(Reminder::EmbedThumbnailUrl).string_len(500))
|
||||
.col(ColumnDef::new(Reminder::EmbedFooter).string_len(2048))
|
||||
.col(ColumnDef::new(Reminder::EmbedFooterUrl).string_len(500))
|
||||
.col(ColumnDef::new(Reminder::EmbedAuthor).string_len(256))
|
||||
.col(ColumnDef::new(Reminder::EmbedAuthorUrl).string_len(500))
|
||||
.col(ColumnDef::new(Reminder::EmbedColor).integer())
|
||||
.col(ColumnDef::new(Reminder::EmbedFields).json())
|
||||
.col(ColumnDef::new(Reminder::SetAt).date_time().not_null().default("NOW()"))
|
||||
.col(ColumnDef::new(Reminder::SetBy).big_integer().not_null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_reminder_channel")
|
||||
.from(Reminder::Table, Reminder::ChannelId)
|
||||
.to(Channel::Table, Channel::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_reminder_user")
|
||||
.from(Reminder::Table, Reminder::SetBy)
|
||||
.to(User::Table, User::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(ReminderTemplate::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(ReminderTemplate::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(ReminderTemplate::GuildId).big_integer().not_null())
|
||||
.col(
|
||||
ColumnDef::new(ReminderTemplate::Name)
|
||||
.string()
|
||||
.char_len(24)
|
||||
.default("Reminder")
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(ReminderTemplate::Username).string_len(32))
|
||||
.col(ColumnDef::new(ReminderTemplate::Avatar).string_len(512))
|
||||
.col(ColumnDef::new(ReminderTemplate::Content).string_len(2000))
|
||||
.col(ColumnDef::new(ReminderTemplate::Tts).boolean().not_null().default(false))
|
||||
.col(ColumnDef::new(ReminderTemplate::Attachment).binary_len(8 * 1024 * 1024))
|
||||
.col(ColumnDef::new(ReminderTemplate::AttachmentName).string_len(260))
|
||||
.col(ColumnDef::new(ReminderTemplate::EmbedTitle).string_len(256))
|
||||
.col(ColumnDef::new(ReminderTemplate::EmbedDescription).string_len(4096))
|
||||
.col(ColumnDef::new(ReminderTemplate::EmbedImageUrl).string_len(500))
|
||||
.col(ColumnDef::new(ReminderTemplate::EmbedThumbnailUrl).string_len(500))
|
||||
.col(ColumnDef::new(ReminderTemplate::EmbedFooter).string_len(2048))
|
||||
.col(ColumnDef::new(ReminderTemplate::EmbedFooterUrl).string_len(500))
|
||||
.col(ColumnDef::new(ReminderTemplate::EmbedAuthor).string_len(256))
|
||||
.col(ColumnDef::new(ReminderTemplate::EmbedAuthorUrl).string_len(500))
|
||||
.col(ColumnDef::new(ReminderTemplate::EmbedColor).integer())
|
||||
.col(ColumnDef::new(ReminderTemplate::EmbedFields).json())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_reminder_template_guild")
|
||||
.from(ReminderTemplate::Table, ReminderTemplate::GuildId)
|
||||
.to(Guild::Table, Guild::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Timer::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Timer::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Timer::StartTime).date_time().not_null().default("NOW()"))
|
||||
.col(ColumnDef::new(Timer::Name).string_len(32).not_null().default("Timer"))
|
||||
.col(ColumnDef::new(Timer::UserId).big_integer())
|
||||
.col(ColumnDef::new(Timer::GuildId).big_integer())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_timer_user")
|
||||
.from(Timer::Table, Timer::UserId)
|
||||
.to(Guild::Table, Guild::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_timer_guild")
|
||||
.from(Timer::Table, Timer::GuildId)
|
||||
.to(Guild::Table, Guild::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Todo::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Todo::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Todo::UserId).big_integer())
|
||||
.col(ColumnDef::new(Todo::GuildId).big_integer())
|
||||
.col(ColumnDef::new(Todo::ChannelId).big_integer())
|
||||
.col(ColumnDef::new(Todo::Value).string_len(2000).not_null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_todo_user")
|
||||
.from(Todo::Table, Todo::UserId)
|
||||
.to(User::Table, User::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_todo_guild")
|
||||
.from(Todo::Table, Todo::GuildId)
|
||||
.to(Guild::Table, Guild::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_todo_channel")
|
||||
.from(Todo::Table, Todo::ChannelId)
|
||||
.to(Channel::Table, Channel::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(CommandMacro::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(CommandMacro::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(CommandMacro::GuildId).big_integer().not_null())
|
||||
.col(ColumnDef::new(CommandMacro::Name).string_len(100).not_null())
|
||||
.col(ColumnDef::new(CommandMacro::Description).string_len(100))
|
||||
.col(ColumnDef::new(CommandMacro::Commands).json())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_command_macro_guild")
|
||||
.from(CommandMacro::Table, CommandMacro::GuildId)
|
||||
.to(Guild::Table, Guild::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop().table(Channel::Table).name("fk_channel_guild").to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop().table(User::Table).name("fk_user_channel").to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop().table(Reminder::Table).name("fk_reminder_channel").to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop().table(Reminder::Table).name("fk_reminder_user").to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.table(ReminderTemplate::Table)
|
||||
.name("fk_reminder_template_guild")
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop().table(Timer::Table).name("fk_timer_user").to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop().table(Timer::Table).name("fk_timer_guild").to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_foreign_key(ForeignKey::drop().table(Todo::Table).name("fk_todo_user").to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop().table(Todo::Table).name("fk_todo_guild").to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop().table(Todo::Table).name("fk_todo_channel").to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.table(CommandMacro::Table)
|
||||
.name("fk_command_macro_guild")
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager.drop_table(Table::drop().table(Guild::Table).to_owned()).await?;
|
||||
manager.drop_table(Table::drop().table(Channel::Table).to_owned()).await?;
|
||||
manager.drop_table(Table::drop().table(User::Table).to_owned()).await?;
|
||||
manager.drop_table(Table::drop().table(Reminder::Table).to_owned()).await?;
|
||||
manager.drop_table(Table::drop().table(ReminderTemplate::Table).to_owned()).await?;
|
||||
manager.drop_table(Table::drop().table(Timer::Table).to_owned()).await?;
|
||||
manager.drop_table(Table::drop().table(Todo::Table).to_owned()).await?;
|
||||
manager.drop_table(Table::drop().table(CommandMacro::Table).to_owned()).await?;
|
||||
|
||||
manager.drop_type(Type::drop().name(Timezone::Type).to_owned()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[async_std::main]
|
||||
async fn main() {
|
||||
cli::run_cli(migration::Migrator).await;
|
||||
}
|
@ -1 +0,0 @@
|
||||
|
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,14 +5,12 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["process", "full"] }
|
||||
regex = "1.4"
|
||||
regex = "1.9"
|
||||
log = "0.4"
|
||||
env_logger = "0.8"
|
||||
chrono = "0.4"
|
||||
chrono-tz = { version = "0.5", features = ["serde"] }
|
||||
chrono-tz = { version = "0.8", features = ["serde"] }
|
||||
lazy_static = "1.4"
|
||||
num-integer = "0.1"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
|
||||
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
|
||||
serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
||||
|
@ -1,4 +1,6 @@
|
||||
use chrono::Duration;
|
||||
use std::env;
|
||||
|
||||
use chrono::{DateTime, Days, Duration, Months};
|
||||
use chrono_tz::Tz;
|
||||
use lazy_static::lazy_static;
|
||||
use log::{error, info, warn};
|
||||
@ -7,7 +9,7 @@ use regex::{Captures, Regex};
|
||||
use serde::Deserialize;
|
||||
use serenity::{
|
||||
builder::CreateEmbed,
|
||||
http::{CacheHttp, Http, HttpError, StatusCode},
|
||||
http::{CacheHttp, Http, HttpError},
|
||||
model::{
|
||||
channel::{Channel, Embed as SerenityEmbed},
|
||||
id::ChannelId,
|
||||
@ -30,6 +32,7 @@ lazy_static! {
|
||||
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
|
||||
pub static ref TIMENOW_REGEX: Regex =
|
||||
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
|
||||
pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1");
|
||||
}
|
||||
|
||||
fn fmt_displacement(format: &str, seconds: u64) -> String {
|
||||
@ -62,7 +65,8 @@ pub fn substitute(string: &str) -> String {
|
||||
let format = caps.name("format").map(|m| m.as_str());
|
||||
|
||||
if let (Some(final_time), Some(format)) = (final_time, format) {
|
||||
let dt = NaiveDateTime::from_timestamp(final_time, 0);
|
||||
match NaiveDateTime::from_timestamp_opt(final_time, 0) {
|
||||
Some(dt) => {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let difference = {
|
||||
@ -74,6 +78,10 @@ pub fn substitute(string: &str) -> String {
|
||||
};
|
||||
|
||||
fmt_displacement(format, difference.num_seconds() as u64)
|
||||
}
|
||||
|
||||
None => String::new(),
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
@ -146,7 +154,7 @@ impl Embed {
|
||||
embed.description = substitute(&embed.description);
|
||||
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.value = substitute(&field.value);
|
||||
});
|
||||
@ -226,7 +234,6 @@ impl Into<CreateEmbed> for Embed {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Reminder {
|
||||
id: u32,
|
||||
|
||||
@ -244,11 +251,12 @@ pub struct Reminder {
|
||||
attachment: Option<Vec<u8>>,
|
||||
attachment_name: Option<String>,
|
||||
|
||||
utc_time: NaiveDateTime,
|
||||
utc_time: DateTime<Utc>,
|
||||
timezone: String,
|
||||
restartable: bool,
|
||||
expires: Option<NaiveDateTime>,
|
||||
expires: Option<DateTime<Utc>>,
|
||||
interval_seconds: Option<u32>,
|
||||
interval_days: Option<u32>,
|
||||
interval_months: Option<u32>,
|
||||
|
||||
avatar: Option<String>,
|
||||
@ -282,6 +290,7 @@ SELECT
|
||||
reminders.`restartable` AS restartable,
|
||||
reminders.`expires` AS 'expires',
|
||||
reminders.`interval_seconds` AS 'interval_seconds',
|
||||
reminders.`interval_days` AS 'interval_days',
|
||||
reminders.`interval_months` AS 'interval_months',
|
||||
|
||||
reminders.`avatar` AS avatar,
|
||||
@ -293,8 +302,23 @@ INNER JOIN
|
||||
ON
|
||||
reminders.channel_id = channels.id
|
||||
WHERE
|
||||
reminders.`utc_time` < NOW()
|
||||
LIMIT 25
|
||||
reminders.`status` = 'pending' AND
|
||||
reminders.`id` IN (
|
||||
SELECT
|
||||
MIN(id)
|
||||
FROM
|
||||
reminders
|
||||
WHERE
|
||||
reminders.`utc_time` <= NOW() AND
|
||||
`status` = 'pending' AND
|
||||
(
|
||||
reminders.`interval_seconds` IS NOT NULL
|
||||
OR reminders.`interval_months` IS NOT NULL
|
||||
OR reminders.`interval_days` IS NOT NULL
|
||||
OR reminders.enabled
|
||||
)
|
||||
GROUP BY channel_id
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
@ -319,9 +343,7 @@ LIMIT 25
|
||||
|
||||
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||
let _ = sqlx::query!(
|
||||
"
|
||||
UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
|
||||
",
|
||||
"UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?",
|
||||
self.channel_id
|
||||
)
|
||||
.execute(pool)
|
||||
@ -329,56 +351,72 @@ UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
|
||||
}
|
||||
|
||||
async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||
if self.interval_seconds.is_some() || self.interval_months.is_some() {
|
||||
let now = Utc::now().naive_local();
|
||||
let mut updated_reminder_time = self.utc_time;
|
||||
|
||||
if let Some(interval) = self.interval_months {
|
||||
match sqlx::query!(
|
||||
// use the second date_add to force return value to datetime
|
||||
"SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time",
|
||||
updated_reminder_time,
|
||||
interval
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
if self.interval_seconds.is_some()
|
||||
|| self.interval_months.is_some()
|
||||
|| self.interval_days.is_some()
|
||||
{
|
||||
Ok(row) => match row.new_time {
|
||||
Some(datetime) => {
|
||||
updated_reminder_time = datetime;
|
||||
// If all intervals are zero then dont care
|
||||
if self.interval_seconds == Some(0)
|
||||
&& self.interval_days == Some(0)
|
||||
&& self.interval_months == Some(0)
|
||||
{
|
||||
self.set_sent(pool).await;
|
||||
}
|
||||
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 {
|
||||
while updated_reminder_time < now {
|
||||
updated_reminder_time += Duration::seconds(interval as i64);
|
||||
}
|
||||
}
|
||||
|
||||
if self.expires.map_or(false, |expires| {
|
||||
NaiveDateTime::from_timestamp(updated_reminder_time.timestamp(), 0) > expires
|
||||
}) {
|
||||
self.force_delete(pool).await;
|
||||
if fail_count >= 4 {
|
||||
self.log_error(
|
||||
pool,
|
||||
"Failed to update 4 times and so is being deleted",
|
||||
None::<&'static str>,
|
||||
)
|
||||
.await;
|
||||
self.set_failed(pool, "Failed to update 4 times and so is being deleted").await;
|
||||
} else if self.expires.map_or(false, |expires| updated_reminder_time > expires) {
|
||||
self.set_sent(pool).await;
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
|
||||
",
|
||||
updated_reminder_time,
|
||||
"UPDATE reminders SET `utc_time` = ? WHERE `id` = ?",
|
||||
updated_reminder_time.with_timezone(&Utc),
|
||||
self.id
|
||||
)
|
||||
.execute(pool)
|
||||
@ -386,15 +424,67 @@ UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
|
||||
.expect(&format!("Could not update time on Reminder {}", self.id));
|
||||
}
|
||||
} 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!(
|
||||
"
|
||||
DELETE FROM reminders WHERE `id` = ?
|
||||
",
|
||||
"INSERT INTO stat (type, reminder_id, message) VALUES ('reminder_failed', ?, ?)",
|
||||
self.id,
|
||||
message,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Could not log error to database");
|
||||
}
|
||||
}
|
||||
|
||||
async fn log_success(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||
if *LOG_TO_DATABASE {
|
||||
sqlx::query!(
|
||||
"INSERT INTO stat (type, reminder_id) VALUES ('reminder_sent', ?)",
|
||||
self.id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("Could not log success to database");
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||
sqlx::query!("UPDATE reminders SET `status` = 'sent' WHERE `id` = ?", self.id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect(&format!("Could not delete Reminder {}", self.id));
|
||||
}
|
||||
|
||||
async fn set_failed(
|
||||
&self,
|
||||
pool: impl Executor<'_, Database = Database> + Copy,
|
||||
message: &'static str,
|
||||
) {
|
||||
sqlx::query!(
|
||||
"UPDATE reminders SET `status` = 'failed', `status_message` = ? WHERE `id` = ?",
|
||||
message,
|
||||
self.id
|
||||
)
|
||||
.execute(pool)
|
||||
@ -493,8 +583,10 @@ DELETE FROM reminders WHERE `id` = ?
|
||||
w.content(&reminder.content).tts(reminder.tts);
|
||||
|
||||
if let Some(username) = &reminder.username {
|
||||
if !username.is_empty() {
|
||||
w.username(username);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(avatar) = &reminder.avatar {
|
||||
w.avatar_url(avatar);
|
||||
@ -537,9 +629,7 @@ DELETE FROM reminders WHERE `id` = ?
|
||||
.map_or(true, |inner| inner >= Utc::now().naive_local()))
|
||||
{
|
||||
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
|
||||
)
|
||||
.execute(pool)
|
||||
@ -556,7 +646,7 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
|
||||
if let Ok(webhook) = webhook_res {
|
||||
send_to_webhook(cache_http, &self, webhook, embed).await
|
||||
} else {
|
||||
warn!("Webhook vanished: {:?}", webhook_res);
|
||||
warn!("Webhook vanished for reminder {}: {:?}", self.id, webhook_res);
|
||||
|
||||
self.reset_webhook(pool).await;
|
||||
send_to_channel(cache_http, &self, embed).await
|
||||
@ -566,24 +656,84 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
error!("Error sending {:?}: {:?}", self, e);
|
||||
|
||||
if let Error::Http(error) = e {
|
||||
if error.status_code() == Some(StatusCode::NOT_FOUND) {
|
||||
warn!("Seeing channel is deleted. Removing reminder");
|
||||
self.force_delete(pool).await;
|
||||
} else if let HttpError::UnsuccessfulRequest(error) = *error {
|
||||
if error.error.code == 50007 {
|
||||
warn!("User cannot receive DMs");
|
||||
self.force_delete(pool).await;
|
||||
} else {
|
||||
if let HttpError::UnsuccessfulRequest(http_error) = *error {
|
||||
match http_error.error.code {
|
||||
10003 => {
|
||||
self.log_error(
|
||||
pool,
|
||||
"Could not be sent as channel does not exist",
|
||||
None::<&'static str>,
|
||||
)
|
||||
.await;
|
||||
self.set_failed(
|
||||
pool,
|
||||
"Could not be sent as channel does not exist",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
10004 => {
|
||||
self.log_error(
|
||||
pool,
|
||||
"Could not be sent as guild does not exist",
|
||||
None::<&'static str>,
|
||||
)
|
||||
.await;
|
||||
self.set_failed(pool, "Could not be sent as guild does not exist")
|
||||
.await;
|
||||
}
|
||||
50001 => {
|
||||
self.log_error(
|
||||
pool,
|
||||
"Could not be sent as missing access",
|
||||
None::<&'static str>,
|
||||
)
|
||||
.await;
|
||||
self.set_failed(pool, "Could not be sent as missing access").await;
|
||||
}
|
||||
50007 => {
|
||||
self.log_error(
|
||||
pool,
|
||||
"Could not be sent as user has DMs disabled",
|
||||
None::<&'static str>,
|
||||
)
|
||||
.await;
|
||||
self.set_failed(pool, "Could not be sent as user has DMs disabled")
|
||||
.await;
|
||||
}
|
||||
50013 => {
|
||||
self.log_error(
|
||||
pool,
|
||||
"Could not be sent as permissions are invalid",
|
||||
None::<&'static str>,
|
||||
)
|
||||
.await;
|
||||
self.set_failed(
|
||||
pool,
|
||||
"Could not be sent as permissions are invalid",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ => {
|
||||
self.log_error(
|
||||
pool,
|
||||
"HTTP error sending reminder",
|
||||
Some(http_error),
|
||||
)
|
||||
.await;
|
||||
self.refresh(pool).await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.log_error(pool, "(Likely) a parsing error", Some(error)).await;
|
||||
self.refresh(pool).await;
|
||||
}
|
||||
} else {
|
||||
self.log_error(pool, "Non-HTTP error", Some(e)).await;
|
||||
self.refresh(pool).await;
|
||||
}
|
||||
} else {
|
||||
self.log_success(pool).await;
|
||||
self.refresh(pool).await;
|
||||
}
|
||||
} else {
|
||||
|
117
src/commands/autocomplete.rs
Normal file
117
src/commands/autocomplete.rs
Normal file
@ -0,0 +1,117 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use chrono_tz::TZ_VARIANTS;
|
||||
use poise::AutocompleteChoice;
|
||||
|
||||
use crate::{models::CtxData, time_parser::natural_parser, Context};
|
||||
|
||||
pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
|
||||
if partial.is_empty() {
|
||||
ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
|
||||
} else {
|
||||
TZ_VARIANTS
|
||||
.iter()
|
||||
.filter(|tz| tz.to_string().contains(&partial))
|
||||
.take(25)
|
||||
.map(|t| t.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT name
|
||||
FROM macro
|
||||
WHERE
|
||||
guild_id = (SELECT id FROM guilds WHERE guild = ?)
|
||||
AND name LIKE CONCAT(?, '%')",
|
||||
ctx.guild_id().unwrap().0,
|
||||
partial,
|
||||
)
|
||||
.fetch_all(&ctx.data().database)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn time_hint_autocomplete(
|
||||
ctx: Context<'_>,
|
||||
partial: &str,
|
||||
) -> Vec<AutocompleteChoice<String>> {
|
||||
if partial.is_empty() {
|
||||
vec![AutocompleteChoice {
|
||||
name: "Start typing a time...".to_string(),
|
||||
value: "now".to_string(),
|
||||
}]
|
||||
} else {
|
||||
match natural_parser(partial, &ctx.timezone().await.to_string()).await {
|
||||
Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
Ok(now) => {
|
||||
let diff = timestamp - now.as_secs() as i64;
|
||||
|
||||
if diff < 0 {
|
||||
vec![AutocompleteChoice {
|
||||
name: "Time is in the past".to_string(),
|
||||
value: "1 year ago".to_string(),
|
||||
}]
|
||||
} else {
|
||||
if diff > 86400 {
|
||||
vec![
|
||||
AutocompleteChoice {
|
||||
name: partial.to_string(),
|
||||
value: partial.to_string(),
|
||||
},
|
||||
AutocompleteChoice {
|
||||
name: format!(
|
||||
"In approximately {} days, {} hours",
|
||||
diff / 86400,
|
||||
(diff % 86400) / 3600
|
||||
),
|
||||
value: partial.to_string(),
|
||||
},
|
||||
]
|
||||
} else if diff > 3600 {
|
||||
vec![
|
||||
AutocompleteChoice {
|
||||
name: partial.to_string(),
|
||||
value: partial.to_string(),
|
||||
},
|
||||
AutocompleteChoice {
|
||||
name: format!("In approximately {} hours", diff / 3600),
|
||||
value: partial.to_string(),
|
||||
},
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
AutocompleteChoice {
|
||||
name: partial.to_string(),
|
||||
value: partial.to_string(),
|
||||
},
|
||||
AutocompleteChoice {
|
||||
name: format!("In approximately {} minutes", diff / 60),
|
||||
value: partial.to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
vec![AutocompleteChoice {
|
||||
name: partial.to_string(),
|
||||
value: partial.to_string(),
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
None => {
|
||||
vec![AutocompleteChoice {
|
||||
name: "Time not recognised".to_string(),
|
||||
value: "now".to_string(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
46
src/commands/command_macro/delete.rs
Normal file
46
src/commands/command_macro/delete.rs
Normal file
@ -0,0 +1,46 @@
|
||||
use super::super::autocomplete::macro_name_autocomplete;
|
||||
use crate::{Context, Error};
|
||||
|
||||
/// Delete a recorded macro
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "delete",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "delete_macro"
|
||||
)]
|
||||
pub async fn delete_macro(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Name of macro to delete"]
|
||||
#[autocomplete = "macro_name_autocomplete"]
|
||||
name: String,
|
||||
) -> Result<(), Error> {
|
||||
match sqlx::query!(
|
||||
"
|
||||
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
|
||||
ctx.guild_id().unwrap().0,
|
||||
name
|
||||
)
|
||||
.fetch_one(&ctx.data().database)
|
||||
.await
|
||||
{
|
||||
Ok(row) => {
|
||||
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
|
||||
.execute(&ctx.data().database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
|
||||
}
|
||||
|
||||
Err(sqlx::Error::RowNotFound) => {
|
||||
ctx.say(format!("Macro \"{}\" not found", name)).await?;
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
panic!("{}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
89
src/commands/command_macro/list.rs
Normal file
89
src/commands/command_macro/list.rs
Normal file
@ -0,0 +1,89 @@
|
||||
use poise::CreateReply;
|
||||
|
||||
use crate::{
|
||||
component_models::pager::{MacroPager, Pager},
|
||||
consts::THEME_COLOR,
|
||||
models::{command_macro::CommandMacro, CtxData},
|
||||
Context, Error,
|
||||
};
|
||||
|
||||
/// List recorded macros
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "list",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "list_macro"
|
||||
)]
|
||||
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let macros = ctx.command_macros().await?;
|
||||
|
||||
let resp = show_macro_page(¯os, 0);
|
||||
|
||||
ctx.send(|m| {
|
||||
*m = resp;
|
||||
m
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
|
||||
((macros.len() as f64) / 25.0).ceil() as usize
|
||||
}
|
||||
|
||||
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
|
||||
let pager = MacroPager::new(page);
|
||||
|
||||
if macros.is_empty() {
|
||||
let mut reply = CreateReply::default();
|
||||
|
||||
reply.embed(|e| {
|
||||
e.title("Macros")
|
||||
.description("No Macros Set Up. Use `/macro record` to get started.")
|
||||
.color(*THEME_COLOR)
|
||||
});
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
let pages = max_macro_page(macros);
|
||||
|
||||
let mut page = page;
|
||||
if page >= pages {
|
||||
page = pages - 1;
|
||||
}
|
||||
|
||||
let lower = (page * 25).min(macros.len());
|
||||
let upper = ((page + 1) * 25).min(macros.len());
|
||||
|
||||
let fields = macros[lower..upper].iter().map(|m| {
|
||||
if let Some(description) = &m.description {
|
||||
(
|
||||
m.name.clone(),
|
||||
format!("*{}*\n- Has {} commands", description, m.commands.len()),
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
(m.name.clone(), format!("- Has {} commands", m.commands.len()), true)
|
||||
}
|
||||
});
|
||||
|
||||
let mut reply = CreateReply::default();
|
||||
|
||||
reply
|
||||
.embed(|e| {
|
||||
e.title("Macros")
|
||||
.fields(fields)
|
||||
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
.components(|comp| {
|
||||
pager.create_button_row(pages, comp);
|
||||
|
||||
comp
|
||||
});
|
||||
|
||||
reply
|
||||
}
|
229
src/commands/command_macro/migrate.rs
Normal file
229
src/commands/command_macro/migrate.rs
Normal file
@ -0,0 +1,229 @@
|
||||
use lazy_regex::regex;
|
||||
use poise::serenity_prelude::command::CommandOptionType;
|
||||
use regex::Captures;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{models::command_macro::RawCommandMacro, Context, Error, GuildId};
|
||||
|
||||
struct Alias {
|
||||
name: String,
|
||||
command: String,
|
||||
}
|
||||
|
||||
/// Migrate old $alias reminder commands to macros. Only macro names that are not taken will be used.
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "migrate",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "migrate_macro"
|
||||
)]
|
||||
pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let guild_id = ctx.guild_id().unwrap();
|
||||
let mut transaction = ctx.data().database.begin().await?;
|
||||
|
||||
let aliases = sqlx::query_as!(
|
||||
Alias,
|
||||
"SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||
guild_id.0
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let mut added_aliases = 0;
|
||||
|
||||
for alias in aliases {
|
||||
match parse_text_command(guild_id, alias.name, &alias.command) {
|
||||
Some(cmd_macro) => {
|
||||
sqlx::query!(
|
||||
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
|
||||
cmd_macro.guild_id.0,
|
||||
cmd_macro.name,
|
||||
cmd_macro.description,
|
||||
cmd_macro.commands
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
added_aliases += 1;
|
||||
}
|
||||
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
ctx.send(|b| b.content(format!("Added {} macros.", added_aliases))).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_text_command(
|
||||
guild_id: GuildId,
|
||||
alias_name: String,
|
||||
command: &str,
|
||||
) -> Option<RawCommandMacro> {
|
||||
match command.split_once(" ") {
|
||||
Some((command_word, args)) => {
|
||||
let command_word = command_word.to_lowercase();
|
||||
|
||||
if command_word == "r"
|
||||
|| command_word == "i"
|
||||
|| command_word == "remind"
|
||||
|| command_word == "interval"
|
||||
{
|
||||
let matcher = regex!(
|
||||
r#"(?P<mentions>(?:<@\d+>\s+|<@!\d+>\s+|<#\d+>\s+)*)(?P<time>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+)(?:\s+(?P<interval>(?:(?:\d+)(?:s|m|h|d|))+))?(?:\s+(?P<expires>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+))?\s+(?P<content>.*)"#s
|
||||
);
|
||||
|
||||
match matcher.captures(&args) {
|
||||
Some(captures) => {
|
||||
let mut args: Vec<Value> = vec![];
|
||||
|
||||
if let Some(group) = captures.name("time") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "time",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) = captures.name("content") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "content",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) = captures.name("interval") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "interval",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) = captures.name("expires") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "expires",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) = captures.name("mentions") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "channels",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
Some(RawCommandMacro {
|
||||
guild_id,
|
||||
name: alias_name,
|
||||
description: None,
|
||||
commands: json!([
|
||||
{
|
||||
"command_name": "remind",
|
||||
"options": args,
|
||||
}
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
None => None,
|
||||
}
|
||||
} else if command_word == "n" || command_word == "natural" {
|
||||
let matcher_primary = regex!(
|
||||
r#"(?P<time>.*?)(?:\s+)(?:send|say)(?:\s+)(?P<content>.*?)(?:(?:\s+)to(?:\s+)(?P<mentions>((?:<@\d+>)|(?:<@!\d+>)|(?:<#\d+>)|(?:\s+))+))?$"#s
|
||||
);
|
||||
let matcher_secondary = regex!(
|
||||
r#"(?P<msg>.*)(?:\s+)every(?:\s+)(?P<interval>.*?)(?:(?:\s+)(?:until|for)(?:\s+)(?P<expires>.*?))?$"#s
|
||||
);
|
||||
|
||||
match matcher_primary.captures(&args) {
|
||||
Some(captures) => {
|
||||
let captures_secondary = matcher_secondary.captures(&args);
|
||||
|
||||
let mut args: Vec<Value> = vec![];
|
||||
|
||||
if let Some(group) = captures.name("time") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "time",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) = captures.name("content") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "content",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) =
|
||||
captures_secondary.as_ref().and_then(|c: &Captures| c.name("interval"))
|
||||
{
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "interval",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) =
|
||||
captures_secondary.and_then(|c: Captures| c.name("expires"))
|
||||
{
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "expires",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) = captures.name("mentions") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "channels",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
Some(RawCommandMacro {
|
||||
guild_id,
|
||||
name: alias_name,
|
||||
description: None,
|
||||
commands: json!([
|
||||
{
|
||||
"command_name": "remind",
|
||||
"options": args,
|
||||
}
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
None => None,
|
||||
}
|
||||
}
|
19
src/commands/command_macro/mod.rs
Normal file
19
src/commands/command_macro/mod.rs
Normal file
@ -0,0 +1,19 @@
|
||||
use crate::{Context, Error};
|
||||
|
||||
pub mod delete;
|
||||
pub mod list;
|
||||
pub mod migrate;
|
||||
pub mod record;
|
||||
pub mod run;
|
||||
|
||||
/// Record and replay command sequences
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "macro",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "macro_base"
|
||||
)]
|
||||
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
151
src/commands/command_macro/record.rs
Normal file
151
src/commands/command_macro/record.rs
Normal file
@ -0,0 +1,151 @@
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
use crate::{consts::THEME_COLOR, models::command_macro::CommandMacro, Context, Error};
|
||||
|
||||
/// Start recording up to 5 commands to replay
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "record",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "record_macro"
|
||||
)]
|
||||
pub async fn record_macro(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Name for the new macro"] name: String,
|
||||
#[description = "Description for the new macro"] description: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
if name.len() > 100 {
|
||||
ctx.say("Name must be less than 100 characters").await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if description.as_ref().map_or(0, |d| d.len()) > 100 {
|
||||
ctx.say("Description must be less than 100 characters").await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let guild_id = ctx.guild_id().unwrap();
|
||||
|
||||
let row = sqlx::query!(
|
||||
"
|
||||
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
|
||||
guild_id.0,
|
||||
name
|
||||
)
|
||||
.fetch_one(&ctx.data().database)
|
||||
.await;
|
||||
|
||||
if row.is_ok() {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Unique Name Required")
|
||||
.description(
|
||||
"A macro already exists under this name.
|
||||
Please select a unique name for your macro.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
let okay = {
|
||||
let mut lock = ctx.data().recording_macros.write().await;
|
||||
|
||||
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
|
||||
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if okay {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Macro Recording Started")
|
||||
.description(
|
||||
"Run up to 5 commands, or type `/macro finish` to stop at any point.
|
||||
Any commands ran as part of recording will be inconsequential",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Macro Already Recording")
|
||||
.description(
|
||||
"You are already recording a macro in this server.
|
||||
Please use `/macro finish` to end this recording before starting another.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish current macro recording
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "finish",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "finish_macro"
|
||||
)]
|
||||
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let key = (ctx.guild_id().unwrap(), ctx.author().id);
|
||||
|
||||
{
|
||||
let lock = ctx.data().recording_macros.read().await;
|
||||
let contained = lock.get(&key);
|
||||
|
||||
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
|
||||
ctx.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title("No Macro Recorded")
|
||||
.description("Use `/macro record` to start recording a macro")
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
let command_macro = contained.unwrap();
|
||||
let json = serde_json::to_string(&command_macro.commands).unwrap();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
|
||||
command_macro.guild_id.0,
|
||||
command_macro.name,
|
||||
command_macro.description,
|
||||
json
|
||||
)
|
||||
.execute(&ctx.data().database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ctx.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title("Macro Recorded")
|
||||
.description("Use `/macro run` to execute the macro")
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut lock = ctx.data().recording_macros.write().await;
|
||||
lock.remove(&key);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
56
src/commands/command_macro/run.rs
Normal file
56
src/commands/command_macro/run.rs
Normal file
@ -0,0 +1,56 @@
|
||||
use super::super::autocomplete::macro_name_autocomplete;
|
||||
use crate::{models::command_macro::guild_command_macro, Context, Data, Error, THEME_COLOR};
|
||||
|
||||
/// Run a recorded macro
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "run",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "run_macro"
|
||||
)]
|
||||
pub async fn run_macro(
|
||||
ctx: poise::ApplicationContext<'_, Data, Error>,
|
||||
#[description = "Name of macro to run"]
|
||||
#[autocomplete = "macro_name_autocomplete"]
|
||||
name: String,
|
||||
) -> Result<(), Error> {
|
||||
match guild_command_macro(&Context::Application(ctx), &name).await {
|
||||
Some(command_macro) => {
|
||||
Context::Application(ctx)
|
||||
.send(|b| {
|
||||
b.embed(|e| {
|
||||
e.title("Running Macro").color(*THEME_COLOR).description(format!(
|
||||
"Running macro {} ({} commands)",
|
||||
command_macro.name,
|
||||
command_macro.commands.len()
|
||||
))
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
for command in command_macro.commands {
|
||||
if let Some(action) = command.action {
|
||||
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Context::Application(ctx)
|
||||
.say(format!("Command \"{}\" not found", command.command_name))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
@ -6,8 +6,8 @@ use crate::{models::CtxData, Context, Error, THEME_COLOR};
|
||||
fn footer(
|
||||
ctx: Context<'_>,
|
||||
) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
|
||||
let shard_count = ctx.discord().cache.shard_count();
|
||||
let shard = ctx.discord().shard_id;
|
||||
let shard_count = ctx.serenity_context().cache.shard_count();
|
||||
let shard = ctx.serenity_context().shard_id;
|
||||
|
||||
move |f| {
|
||||
f.text(format!(
|
||||
@ -49,6 +49,7 @@ __Todo Commands__
|
||||
|
||||
__Setup Commands__
|
||||
`/timezone` - Set your timezone (necessary for `/remind` to work properly)
|
||||
`/dm allow/block` - Change your DM settings for reminders.
|
||||
|
||||
__Advanced Commands__
|
||||
`/macro` - Record and replay command sequences
|
||||
|
@ -1,3 +1,5 @@
|
||||
mod autocomplete;
|
||||
pub mod command_macro;
|
||||
pub mod info_cmds;
|
||||
pub mod moderation_cmds;
|
||||
pub mod reminder_cmds;
|
||||
|
@ -1,32 +1,10 @@
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
use chrono::offset::Utc;
|
||||
use chrono_tz::{Tz, TZ_VARIANTS};
|
||||
use levenshtein::levenshtein;
|
||||
use poise::CreateReply;
|
||||
use log::warn;
|
||||
|
||||
use crate::{
|
||||
component_models::pager::{MacroPager, Pager},
|
||||
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
|
||||
models::{
|
||||
command_macro::{guild_command_macro, CommandMacro},
|
||||
CtxData,
|
||||
},
|
||||
Context, Data, Error,
|
||||
};
|
||||
|
||||
async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
|
||||
if partial.is_empty() {
|
||||
ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
|
||||
} else {
|
||||
TZ_VARIANTS
|
||||
.iter()
|
||||
.filter(|tz| tz.to_string().contains(&partial))
|
||||
.take(25)
|
||||
.map(|t| t.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
}
|
||||
}
|
||||
use super::autocomplete::timezone_autocomplete;
|
||||
use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
|
||||
|
||||
/// Select your timezone
|
||||
#[poise::command(slash_command, identifying_name = "timezone")]
|
||||
@ -124,376 +102,154 @@ You may want to use one of the popular timezones below, otherwise click [here](h
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT name
|
||||
FROM macro
|
||||
WHERE
|
||||
guild_id = (SELECT id FROM guilds WHERE guild = ?)
|
||||
AND name LIKE CONCAT(?, '%')",
|
||||
ctx.guild_id().unwrap().0,
|
||||
partial,
|
||||
)
|
||||
.fetch_all(&ctx.data().database)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Record and replay command sequences
|
||||
/// Configure server settings
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "macro",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "macro_base"
|
||||
rename = "settings",
|
||||
identifying_name = "settings",
|
||||
guild_only = true
|
||||
)]
|
||||
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
pub async fn settings(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start recording up to 5 commands to replay
|
||||
/// Configure ephemeral setup
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "record",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "record_macro"
|
||||
rename = "ephemeral",
|
||||
identifying_name = "ephemeral_confirmations",
|
||||
guild_only = true
|
||||
)]
|
||||
pub async fn record_macro(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Name for the new macro"] name: String,
|
||||
#[description = "Description for the new macro"] description: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
let guild_id = ctx.guild_id().unwrap();
|
||||
pub async fn ephemeral_confirmations(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let row = sqlx::query!(
|
||||
"
|
||||
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
|
||||
guild_id.0,
|
||||
name
|
||||
)
|
||||
.fetch_one(&ctx.data().database)
|
||||
.await;
|
||||
/// 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;
|
||||
|
||||
if row.is_ok() {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Unique Name Required")
|
||||
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(
|
||||
"A macro already exists under this name.
|
||||
Please select a unique name for your macro.",
|
||||
"Reminder confirmations will be sent as regular messages, and won't be removed automatically.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
let okay = {
|
||||
let mut lock = ctx.data().recording_macros.write().await;
|
||||
|
||||
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
|
||||
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if okay {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Macro Recording Started")
|
||||
/// Configure whether other users can set reminders to your direct messages
|
||||
#[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")]
|
||||
pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Allow other users to set reminders in your direct messages
|
||||
#[poise::command(slash_command, rename = "allow", identifying_name = "set_allowed_dm")]
|
||||
pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let mut user_data = ctx.author_data().await?;
|
||||
user_data.allowed_dm = true;
|
||||
user_data.commit_changes(&ctx.data().database).await;
|
||||
|
||||
ctx.send(|r| {
|
||||
r.ephemeral(true).embed(|e| {
|
||||
e.title("DMs permitted")
|
||||
.description("You will receive a message if a user sets a DM reminder for you.")
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Block other users from setting reminders in your direct messages
|
||||
#[poise::command(slash_command, rename = "block", identifying_name = "unset_allowed_dm")]
|
||||
pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let mut user_data = ctx.author_data().await?;
|
||||
user_data.allowed_dm = false;
|
||||
user_data.commit_changes(&ctx.data().database).await;
|
||||
|
||||
ctx.send(|r| {
|
||||
r.ephemeral(true).embed(|e| {
|
||||
e.title("DMs blocked")
|
||||
.description(
|
||||
"Run up to 5 commands, or type `/macro finish` to stop at any point.
|
||||
Any commands ran as part of recording will be inconsequential",
|
||||
"You can still set DM reminders for yourself or for users with DMs enabled.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// View the webhook being used to send reminders to this channel
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
identifying_name = "webhook_url",
|
||||
required_permissions = "ADMINISTRATOR"
|
||||
)]
|
||||
pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
|
||||
match ctx.channel_data().await {
|
||||
Ok(data) => {
|
||||
if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
|
||||
ctx.send(|b| {
|
||||
b.ephemeral(true).content(format!(
|
||||
"**Warning!**
|
||||
This link can be used by users to anonymously send messages, with or without permissions.
|
||||
Do not share it!
|
||||
|| https://discord.com/api/webhooks/{}/{} ||",
|
||||
id, token,
|
||||
))
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Macro Already Recording")
|
||||
.description(
|
||||
"You are already recording a macro in this server.
|
||||
Please use `/macro finish` to end this recording before starting another.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
ctx.say("No webhook configured on this channel.").await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish current macro recording
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "finish",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "finish_macro"
|
||||
)]
|
||||
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let key = (ctx.guild_id().unwrap(), ctx.author().id);
|
||||
|
||||
{
|
||||
let lock = ctx.data().recording_macros.read().await;
|
||||
let contained = lock.get(&key);
|
||||
|
||||
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
|
||||
ctx.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title("No Macro Recorded")
|
||||
.description("Use `/macro record` to start recording a macro")
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
let command_macro = contained.unwrap();
|
||||
let json = serde_json::to_string(&command_macro.commands).unwrap();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
|
||||
command_macro.guild_id.0,
|
||||
command_macro.name,
|
||||
command_macro.description,
|
||||
json
|
||||
)
|
||||
.execute(&ctx.data().database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ctx.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title("Macro Recorded")
|
||||
.description("Use `/macro run` to execute the macro")
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut lock = ctx.data().recording_macros.write().await;
|
||||
lock.remove(&key);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List recorded macros
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "list",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "list_macro"
|
||||
)]
|
||||
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let macros = ctx.command_macros().await?;
|
||||
|
||||
let resp = show_macro_page(¯os, 0);
|
||||
|
||||
ctx.send(|m| {
|
||||
*m = resp;
|
||||
m
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a recorded macro
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "run",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "run_macro"
|
||||
)]
|
||||
pub async fn run_macro(
|
||||
ctx: poise::ApplicationContext<'_, Data, Error>,
|
||||
#[description = "Name of macro to run"]
|
||||
#[autocomplete = "macro_name_autocomplete"]
|
||||
name: String,
|
||||
) -> Result<(), Error> {
|
||||
match guild_command_macro(&Context::Application(ctx), &name).await {
|
||||
Some(command_macro) => {
|
||||
ctx.defer_response(false).await?;
|
||||
|
||||
for command in command_macro.commands {
|
||||
if let Some(action) = command.action {
|
||||
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Context::Application(ctx)
|
||||
.say(format!("Command \"{}\" not found", command.command_name))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
warn!("Error fetching channel data: {:?}", e);
|
||||
|
||||
None => {
|
||||
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
|
||||
ctx.say("No webhook configured on this channel.").await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a recorded macro
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "delete",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "delete_macro"
|
||||
)]
|
||||
pub async fn delete_macro(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Name of macro to delete"]
|
||||
#[autocomplete = "macro_name_autocomplete"]
|
||||
name: String,
|
||||
) -> Result<(), Error> {
|
||||
match sqlx::query!(
|
||||
"
|
||||
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
|
||||
ctx.guild_id().unwrap().0,
|
||||
name
|
||||
)
|
||||
.fetch_one(&ctx.data().database)
|
||||
.await
|
||||
{
|
||||
Ok(row) => {
|
||||
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
|
||||
.execute(&ctx.data().database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
|
||||
}
|
||||
|
||||
Err(sqlx::Error::RowNotFound) => {
|
||||
ctx.say(format!("Macro \"{}\" not found", name)).await?;
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
panic!("{}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
|
||||
let mut skipped_char_count = 0;
|
||||
|
||||
macros
|
||||
.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 {
|
||||
let pager = MacroPager::new(page);
|
||||
|
||||
if macros.is_empty() {
|
||||
let mut reply = CreateReply::default();
|
||||
|
||||
reply.embed(|e| {
|
||||
e.title("Macros")
|
||||
.description("No Macros Set Up. Use `/macro record` to get started.")
|
||||
.color(*THEME_COLOR)
|
||||
});
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
let pages = max_macro_page(macros);
|
||||
|
||||
let mut page = page;
|
||||
if page >= pages {
|
||||
page = pages - 1;
|
||||
}
|
||||
|
||||
let mut char_count = 0;
|
||||
let mut skipped_char_count = 0;
|
||||
|
||||
let mut skipped_pages = 0;
|
||||
|
||||
let display_vec: Vec<String> = macros
|
||||
.iter()
|
||||
.map(|m| {
|
||||
if let Some(description) = &m.description {
|
||||
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
|
||||
} else {
|
||||
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
|
||||
}
|
||||
})
|
||||
.skip_while(|p| {
|
||||
skipped_char_count += p.len();
|
||||
|
||||
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
|
||||
skipped_char_count = p.len();
|
||||
skipped_pages += 1;
|
||||
}
|
||||
|
||||
skipped_pages < page
|
||||
})
|
||||
.take_while(|p| {
|
||||
char_count += p.len();
|
||||
|
||||
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
||||
})
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let display = display_vec.join("\n");
|
||||
|
||||
let mut reply = CreateReply::default();
|
||||
|
||||
reply
|
||||
.embed(|e| {
|
||||
e.title("Macros")
|
||||
.description(display)
|
||||
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
.components(|comp| {
|
||||
pager.create_button_row(pages, comp);
|
||||
|
||||
comp
|
||||
});
|
||||
|
||||
reply
|
||||
}
|
||||
|
@ -1,19 +1,18 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
string::ToString,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use std::{collections::HashSet, string::ToString};
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use log::warn;
|
||||
use num_integer::Integer;
|
||||
use poise::{
|
||||
serenity::{builder::CreateEmbed, model::channel::Channel},
|
||||
serenity_prelude::{ButtonStyle, ReactionType},
|
||||
CreateReply,
|
||||
serenity_prelude::{
|
||||
builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType,
|
||||
},
|
||||
CreateReply, Modal,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete},
|
||||
component_models::{
|
||||
pager::{DelPager, LookPager, Pager},
|
||||
ComponentDataModel, DelSelector, UndoReminder,
|
||||
@ -36,7 +35,7 @@ use crate::{
|
||||
},
|
||||
time_parser::natural_parser,
|
||||
utils::{check_guild_subscription, check_subscription},
|
||||
Context, Error,
|
||||
ApplicationContext, Context, Error,
|
||||
};
|
||||
|
||||
/// Pause all reminders on the current channel until a certain time or indefinitely
|
||||
@ -58,8 +57,8 @@ pub async fn pause(
|
||||
let parsed = natural_parser(&until, &timezone.to_string()).await;
|
||||
|
||||
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_until = Some(dt);
|
||||
|
||||
@ -70,6 +69,15 @@ pub async fn pause(
|
||||
timestamp
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
None => {
|
||||
ctx.say(format!(
|
||||
"Time processed could not be interpreted as `DateTime`. Please write the time as clearly as possible",
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ctx.say(
|
||||
"Time could not be processed. Please write the time as clearly as possible",
|
||||
@ -106,6 +114,8 @@ pub async fn offset(
|
||||
#[description = "Number of minutes to offset by"] minutes: Option<isize>,
|
||||
#[description = "Number of seconds to offset by"] seconds: Option<isize>,
|
||||
) -> Result<(), Error> {
|
||||
ctx.defer().await?;
|
||||
|
||||
let combined_time = hours.map_or(0, |h| h * HOUR as isize)
|
||||
+ minutes.map_or(0, |m| m * MINUTE as isize)
|
||||
+ seconds.map_or(0, |s| s);
|
||||
@ -208,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 {
|
||||
if Some(channel.guild_id) == ctx.guild_id() {
|
||||
@ -220,8 +230,7 @@ pub async fn look(
|
||||
ctx.channel_id()
|
||||
};
|
||||
|
||||
let channel_name =
|
||||
if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx.discord()) {
|
||||
let channel_name = if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) {
|
||||
Some(channel.name)
|
||||
} else {
|
||||
None
|
||||
@ -243,7 +252,7 @@ pub async fn look(
|
||||
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n");
|
||||
.join("");
|
||||
|
||||
let pages = reminders
|
||||
.iter()
|
||||
@ -287,8 +296,7 @@ pub async fn delete(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let timezone = ctx.timezone().await;
|
||||
|
||||
let reminders =
|
||||
Reminder::from_guild(&ctx.discord(), &ctx.data().database, ctx.guild_id(), ctx.author().id)
|
||||
.await;
|
||||
Reminder::from_guild(&ctx, &ctx.data().database, ctx.guild_id(), ctx.author().id).await;
|
||||
|
||||
let resp = show_delete_page(&reminders, 0, timezone);
|
||||
|
||||
@ -430,11 +438,8 @@ pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> Cr
|
||||
reply
|
||||
}
|
||||
|
||||
fn time_difference(start_time: NaiveDateTime) -> String {
|
||||
let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
|
||||
let now = NaiveDateTime::from_timestamp(unix_time, 0);
|
||||
|
||||
let delta = (now - start_time).num_seconds();
|
||||
fn time_difference(start_time: DateTime<Utc>) -> String {
|
||||
let delta = (Utc::now() - start_time).num_seconds();
|
||||
|
||||
let (minutes, seconds) = delta.div_rem(&60);
|
||||
let (hours, minutes) = minutes.div_rem(&60);
|
||||
@ -548,23 +553,104 @@ pub async fn delete_timer(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new reminder
|
||||
#[derive(poise::Modal)]
|
||||
#[name = "Reminder"]
|
||||
struct ContentModal {
|
||||
#[name = "Content"]
|
||||
#[placeholder = "Message..."]
|
||||
#[paragraph]
|
||||
#[max_length = 2000]
|
||||
content: String,
|
||||
}
|
||||
|
||||
/// 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(
|
||||
slash_command,
|
||||
identifying_name = "remind",
|
||||
default_member_permissions = "MANAGE_GUILD"
|
||||
)]
|
||||
pub async fn remind(
|
||||
ctx: Context<'_>,
|
||||
#[description = "A description of the time to set the reminder for"] time: String,
|
||||
ctx: ApplicationContext<'_>,
|
||||
#[description = "The time (and optionally date) to set the reminder for"]
|
||||
#[autocomplete = "time_hint_autocomplete"]
|
||||
time: String,
|
||||
#[description = "The message content to send"] content: 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 sending"]
|
||||
#[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();
|
||||
|
||||
create_reminder(Context::Application(ctx), time, content, channels, interval, expires, tts, tz)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_reminder(
|
||||
ctx: Context<'_>,
|
||||
time: String,
|
||||
content: String,
|
||||
channels: Option<String>,
|
||||
interval: Option<String>,
|
||||
expires: Option<String>,
|
||||
tts: Option<bool>,
|
||||
timezone: Option<Tz>,
|
||||
) -> Result<(), Error> {
|
||||
if interval.is_none() && expires.is_some() {
|
||||
ctx.say("`expires` can only be used with `interval`").await?;
|
||||
@ -572,10 +658,16 @@ pub async fn remind(
|
||||
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?;
|
||||
}
|
||||
|
||||
let user_data = ctx.author_data().await.unwrap();
|
||||
let timezone = ctx.timezone().await;
|
||||
let timezone = timezone.unwrap_or(ctx.timezone().await);
|
||||
|
||||
let time = natural_parser(&time, &timezone.to_string()).await;
|
||||
|
||||
@ -602,9 +694,9 @@ pub async fn remind(
|
||||
};
|
||||
|
||||
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()
|
||||
&& check_guild_subscription(&ctx.discord(), ctx.guild_id().unwrap()).await)
|
||||
&& check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await)
|
||||
{
|
||||
(
|
||||
parse_duration(repeat)
|
||||
@ -619,9 +711,10 @@ pub async fn remind(
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ctx.say(
|
||||
"`repeat` is only available to Patreon subscribers or self-hosted users",
|
||||
)
|
||||
ctx.send(|b| {
|
||||
b.content(
|
||||
"`repeat` is only available to Patreon subscribers or self-hosted users")
|
||||
})
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
@ -631,12 +724,17 @@ pub async fn remind(
|
||||
};
|
||||
|
||||
if processed_interval.is_none() && interval.is_some() {
|
||||
ctx.say(
|
||||
"Repeat interval could not be processed. Try similar to `1 hour` or `4 days`",
|
||||
)
|
||||
ctx.send(|b| {
|
||||
b.content(
|
||||
"Repeat interval could not be processed. Try similar to `1 hour` or `4 days`")
|
||||
})
|
||||
.await?;
|
||||
} 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?;
|
||||
} else {
|
||||
let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id())
|
||||
@ -677,7 +775,7 @@ pub async fn remind(
|
||||
b.emoji(ReactionType::Unicode("📝".to_string()))
|
||||
.label("Edit")
|
||||
.style(ButtonStyle::Link)
|
||||
.url("https://reminder-bot.com/dashboard")
|
||||
.url("https://beta.reminder-bot.com/dashboard")
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -694,6 +792,7 @@ pub async fn remind(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
ctx.say("Time could not be processed").await?;
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ use crate::{
|
||||
ComponentDataModel, TodoSelector,
|
||||
},
|
||||
consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR},
|
||||
models::CtxData,
|
||||
Context, Error,
|
||||
};
|
||||
|
||||
@ -116,6 +117,9 @@ pub async fn todo_channel_add(
|
||||
ctx: Context<'_>,
|
||||
#[description = "The task to add to the todo list"] task: String,
|
||||
) -> Result<(), Error> {
|
||||
// ensure channel is cached
|
||||
let _ = ctx.channel_data().await;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO todos (guild_id, channel_id, value)
|
||||
VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)",
|
||||
@ -336,7 +340,18 @@ pub fn show_todo_page(
|
||||
opt.create_option(|o| {
|
||||
o.label(format!("Mark {} complete", count + first_num))
|
||||
.value(id)
|
||||
.description(disp.split_once(' ').unwrap_or(("", "")).1)
|
||||
.description({
|
||||
let c = disp.split_once(' ').unwrap_or(("", "")).1;
|
||||
|
||||
if c.len() > 100 {
|
||||
format!(
|
||||
"{}...",
|
||||
c.chars().take(97).collect::<String>()
|
||||
)
|
||||
} else {
|
||||
c.to_string()
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -2,28 +2,29 @@ pub(crate) mod pager;
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use chrono_tz::Tz;
|
||||
use log::warn;
|
||||
use poise::{
|
||||
serenity::{
|
||||
builder::CreateEmbed,
|
||||
client::Context,
|
||||
model::{
|
||||
channel::Channel,
|
||||
interactions::{
|
||||
message_component::MessageComponentInteraction, InteractionResponseType,
|
||||
},
|
||||
prelude::InteractionApplicationCommandCallbackDataFlags,
|
||||
},
|
||||
},
|
||||
serenity_prelude as serenity,
|
||||
serenity_prelude::{
|
||||
builder::CreateEmbed,
|
||||
model::{
|
||||
application::interaction::{
|
||||
message_component::MessageComponentInteraction, InteractionResponseType,
|
||||
MessageFlags,
|
||||
},
|
||||
channel::Channel,
|
||||
},
|
||||
Context,
|
||||
},
|
||||
};
|
||||
use rmp_serde::Serializer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
commands::{
|
||||
moderation_cmds::{max_macro_page, show_macro_page},
|
||||
command_macro::list::{max_macro_page, show_macro_page},
|
||||
reminder_cmds::{max_delete_page, show_delete_page},
|
||||
todo_cmds::{max_todo_page, show_todo_page},
|
||||
},
|
||||
@ -51,11 +52,12 @@ impl ComponentDataModel {
|
||||
pub fn to_custom_id(&self) -> String {
|
||||
let mut buf = Vec::new();
|
||||
self.serialize(&mut Serializer::new(&mut buf)).unwrap();
|
||||
base64::encode(buf)
|
||||
general_purpose::STANDARD.encode(buf)
|
||||
}
|
||||
|
||||
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))
|
||||
.unwrap();
|
||||
let cur = Cursor::new(buf);
|
||||
@ -113,7 +115,7 @@ impl ComponentDataModel {
|
||||
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n");
|
||||
.join("");
|
||||
|
||||
let mut embed = CreateEmbed::default();
|
||||
embed
|
||||
@ -166,7 +168,10 @@ impl ComponentDataModel {
|
||||
ComponentDataModel::DelSelector(selector) => {
|
||||
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)
|
||||
.await
|
||||
.unwrap();
|
||||
@ -260,7 +265,7 @@ WHERE guilds.guild = ?",
|
||||
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|d| {
|
||||
d.flags(
|
||||
InteractionApplicationCommandCallbackDataFlags::EPHEMERAL,
|
||||
MessageFlags::EPHEMERAL,
|
||||
)
|
||||
.content("Only the user who performed the command can use these components")
|
||||
})
|
||||
@ -314,7 +319,7 @@ WHERE guilds.guild = ?",
|
||||
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|d| {
|
||||
d.flags(
|
||||
InteractionApplicationCommandCallbackDataFlags::EPHEMERAL,
|
||||
MessageFlags::EPHEMERAL,
|
||||
)
|
||||
.content("Only the user who performed the command can use these components")
|
||||
})
|
||||
|
@ -1,7 +1,7 @@
|
||||
// todo split pager out into a single struct
|
||||
use chrono_tz::Tz;
|
||||
use poise::serenity::{
|
||||
builder::CreateComponents, model::interactions::message_component::ButtonStyle,
|
||||
use poise::serenity_prelude::{
|
||||
builder::CreateComponents, model::application::component::ButtonStyle,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_repr::*;
|
||||
|
@ -2,7 +2,7 @@ pub const DAY: u64 = 86_400;
|
||||
pub const HOUR: u64 = 3_600;
|
||||
pub const MINUTE: u64 = 60;
|
||||
|
||||
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000;
|
||||
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4096;
|
||||
pub const SELECT_MAX_ENTRIES: usize = 25;
|
||||
|
||||
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
|
||||
@ -12,22 +12,18 @@ pub const MACRO_MAX_COMMANDS: usize = 5;
|
||||
|
||||
use std::{collections::HashSet, env, iter::FromIterator};
|
||||
|
||||
use poise::serenity::model::prelude::AttachmentType;
|
||||
use poise::serenity_prelude::model::prelude::AttachmentType;
|
||||
use regex::Regex;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
|
||||
include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/assets/",
|
||||
env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation")
|
||||
)) as &[u8],
|
||||
env!("WEBHOOK_AVATAR"),
|
||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/webhook.jpg")) as &[u8],
|
||||
"webhook.jpg",
|
||||
)
|
||||
.into();
|
||||
pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap();
|
||||
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
||||
env::var("SUBSCRIPTION_ROLES")
|
||||
env::var("PATREON_ROLE_ID")
|
||||
.map(|var| var
|
||||
.split(',')
|
||||
.filter_map(|item| { item.parse::<u64>().ok() })
|
||||
@ -35,7 +31,7 @@ lazy_static! {
|
||||
.unwrap_or_else(|_| Vec::new())
|
||||
);
|
||||
pub static ref CNC_GUILD: Option<u64> =
|
||||
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
|
||||
env::var("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten();
|
||||
pub static ref MIN_INTERVAL: i64 =
|
||||
env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
|
||||
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)
|
||||
.unwrap_or(THEME_COLOR_FALLBACK));
|
||||
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());
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
use std::{collections::HashMap, env, sync::atomic::Ordering};
|
||||
use std::{collections::HashMap, env};
|
||||
|
||||
use log::{error, info, warn};
|
||||
use log::error;
|
||||
use poise::{
|
||||
serenity::{model::interactions::Interaction, utils::shard_id},
|
||||
serenity_prelude as serenity,
|
||||
serenity_prelude::{model::application::interaction::Interaction, utils::shard_id},
|
||||
};
|
||||
|
||||
use crate::{component_models::ComponentDataModel, Data, Error};
|
||||
use crate::{component_models::ComponentDataModel, Data, Error, THEME_COLOR};
|
||||
|
||||
pub async fn listener(
|
||||
ctx: &serenity::Context,
|
||||
@ -17,45 +17,6 @@ pub async fn listener(
|
||||
poise::Event::Ready { .. } => {
|
||||
ctx.set_activity(serenity::Activity::watching("for /remind")).await;
|
||||
}
|
||||
poise::Event::CacheReady { .. } => {
|
||||
info!("Cache Ready! Preparing extra processes");
|
||||
|
||||
if !data.is_loop_running.load(Ordering::Relaxed) {
|
||||
let kill_tx = data.broadcast.clone();
|
||||
let kill_recv = data.broadcast.subscribe();
|
||||
|
||||
let ctx1 = ctx.clone();
|
||||
let ctx2 = ctx.clone();
|
||||
|
||||
let pool1 = data.database.clone();
|
||||
let pool2 = data.database.clone();
|
||||
|
||||
let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string());
|
||||
|
||||
if !run_settings.contains("postman") {
|
||||
tokio::spawn(async move {
|
||||
match postman::initialize(kill_recv, ctx1, &pool1).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("postman exiting: {}", e);
|
||||
}
|
||||
};
|
||||
});
|
||||
} else {
|
||||
warn!("Not running postman");
|
||||
}
|
||||
|
||||
if !run_settings.contains("web") {
|
||||
tokio::spawn(async move {
|
||||
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
|
||||
});
|
||||
} else {
|
||||
warn!("Not running web");
|
||||
}
|
||||
|
||||
data.is_loop_running.swap(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
poise::Event::ChannelDelete { channel } => {
|
||||
sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64())
|
||||
.execute(&data.database)
|
||||
@ -66,46 +27,36 @@ pub async fn listener(
|
||||
if *is_new {
|
||||
let guild_id = guild.id.as_u64().to_owned();
|
||||
|
||||
sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id)
|
||||
sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id)
|
||||
.execute(&data.database)
|
||||
.await
|
||||
.unwrap();
|
||||
.await?;
|
||||
|
||||
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
|
||||
let shard_count = ctx.cache.shard_count();
|
||||
let current_shard_id = shard_id(guild_id, shard_count);
|
||||
|
||||
let guild_count = ctx
|
||||
.cache
|
||||
.guilds()
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id
|
||||
})
|
||||
.count() as u64;
|
||||
|
||||
let mut hm = HashMap::new();
|
||||
hm.insert("server_count", guild_count);
|
||||
hm.insert("shard_id", current_shard_id);
|
||||
hm.insert("shard_count", shard_count);
|
||||
|
||||
let response = data
|
||||
.http
|
||||
.post(
|
||||
format!(
|
||||
"https://top.gg/api/bots/{}/stats",
|
||||
ctx.cache.current_user_id().as_u64()
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
.header("Authorization", token)
|
||||
.json(&hm)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Err(res) = response {
|
||||
println!("DiscordBots Response: {:?}", res);
|
||||
if let Err(e) = post_guild_count(ctx, &data.http, guild_id).await {
|
||||
error!("DiscordBotList: {:?}", e);
|
||||
}
|
||||
|
||||
let default_channel = guild.default_channel_guaranteed();
|
||||
|
||||
if let Some(default_channel) = default_channel {
|
||||
default_channel
|
||||
.send_message(&ctx, |m| {
|
||||
m.embed(|e| {
|
||||
e.title("Thank you for adding Reminder Bot!").description(
|
||||
"To get started:
|
||||
• Set your timezone with `/timezone`
|
||||
• Set up permissions in Server Settings 🠚 Integrations 🠚 Reminder Bot (desktop only)
|
||||
• Create your first reminder with `/remind`
|
||||
|
||||
__Support__
|
||||
If you need any support, please come and ask us! Join our [Discord](https://discord.jellywx.com).
|
||||
|
||||
__Updates__
|
||||
To stay up to date on the latest features and fixes, join our [Discord](https://discord.jellywx.com).
|
||||
",
|
||||
).color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -126,3 +77,38 @@ pub async fn listener(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn post_guild_count(
|
||||
ctx: &serenity::Context,
|
||||
http: &reqwest::Client,
|
||||
guild_id: u64,
|
||||
) -> Result<(), reqwest::Error> {
|
||||
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
|
||||
let shard_count = ctx.cache.shard_count();
|
||||
let current_shard_id = shard_id(guild_id, shard_count);
|
||||
|
||||
let guild_count = ctx
|
||||
.cache
|
||||
.guilds()
|
||||
.iter()
|
||||
.filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id)
|
||||
.count() as u64;
|
||||
|
||||
let mut hm = HashMap::new();
|
||||
hm.insert("server_count", guild_count);
|
||||
hm.insert("shard_id", current_shard_id);
|
||||
hm.insert("shard_count", shard_count);
|
||||
|
||||
http.post(
|
||||
format!("https://top.gg/api/bots/{}/stats", ctx.cache.current_user_id().as_u64())
|
||||
.as_str(),
|
||||
)
|
||||
.header("Authorization", token)
|
||||
.json(&hm)
|
||||
.send()
|
||||
.await
|
||||
.map(|_| ())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
31
src/hooks.rs
31
src/hooks.rs
@ -1,9 +1,14 @@
|
||||
use poise::serenity::model::channel::Channel;
|
||||
use poise::{
|
||||
serenity_prelude::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction,
|
||||
};
|
||||
|
||||
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
|
||||
|
||||
async fn macro_check(ctx: Context<'_>) -> bool {
|
||||
if let Context::Application(app_ctx) = ctx {
|
||||
if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) =
|
||||
app_ctx.interaction
|
||||
{
|
||||
if let Some(guild_id) = ctx.guild_id() {
|
||||
if ctx.command().identifying_name != "finish_macro" {
|
||||
let mut lock = ctx.data().recording_macros.write().await;
|
||||
@ -35,31 +40,33 @@ async fn macro_check(ctx: Context<'_>) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
||||
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
|
||||
.channel_id()
|
||||
.to_channel_cached(&ctx.discord())
|
||||
.to_channel(&ctx)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|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 {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map_or((false, false, false), |p| {
|
||||
(p.view_channel(), p.send_messages(), p.embed_links())
|
||||
});
|
||||
.unwrap_or((false, false, false));
|
||||
|
||||
if manage_webhooks && send_messages && embed_links {
|
||||
true
|
||||
@ -75,8 +82,8 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
||||
{} **Manage Webhooks**",
|
||||
if view_channel { "✅" } else { "❌" },
|
||||
if send_messages { "✅" } else { "❌" },
|
||||
if manage_webhooks { "✅" } else { "❌" },
|
||||
if embed_links { "✅" } else { "❌" },
|
||||
if manage_webhooks { "✅" } else { "❌" },
|
||||
))
|
||||
})
|
||||
.await;
|
||||
|
@ -110,13 +110,14 @@ impl OverflowOp for u64 {
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Interval {
|
||||
pub month: u64,
|
||||
pub day: u64,
|
||||
pub sec: u64,
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
iter: Chars<'a>,
|
||||
src: &'a str,
|
||||
current: (u64, u64, u64),
|
||||
current: (u64, u64, u64, u64),
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
@ -140,17 +141,17 @@ impl<'a> Parser<'a> {
|
||||
Ok(None)
|
||||
}
|
||||
fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> {
|
||||
let (mut month, mut sec, nsec) = match &self.src[start..end] {
|
||||
"nanos" | "nsec" | "ns" => (0u64, 0u64, n),
|
||||
"usec" | "us" => (0, 0u64, n.mul(1000)?),
|
||||
"millis" | "msec" | "ms" => (0, 0u64, n.mul(1_000_000)?),
|
||||
"seconds" | "second" | "secs" | "sec" | "s" => (0, n, 0),
|
||||
"minutes" | "minute" | "min" | "mins" | "m" => (0, n.mul(60)?, 0),
|
||||
"hours" | "hour" | "hr" | "hrs" | "h" => (0, n.mul(3600)?, 0),
|
||||
"days" | "day" | "d" => (0, n.mul(86400)?, 0),
|
||||
"weeks" | "week" | "w" => (0, n.mul(86400 * 7)?, 0),
|
||||
"months" | "month" | "M" => (n, 0, 0),
|
||||
"years" | "year" | "y" => (12, 0, 0),
|
||||
let (mut month, mut day, mut sec, nsec) = match &self.src[start..end] {
|
||||
"nanos" | "nsec" | "ns" => (0, 0u64, 0u64, n),
|
||||
"usec" | "us" => (0, 0, 0u64, n.mul(1000)?),
|
||||
"millis" | "msec" | "ms" => (0, 0, 0u64, n.mul(1_000_000)?),
|
||||
"seconds" | "second" | "secs" | "sec" | "s" => (0, 0, n, 0),
|
||||
"minutes" | "minute" | "min" | "mins" | "m" => (0, 0, n.mul(60)?, 0),
|
||||
"hours" | "hour" | "hr" | "hrs" | "h" => (0, 0, n.mul(3600)?, 0),
|
||||
"days" | "day" | "d" => (0, n, 0, 0),
|
||||
"weeks" | "week" | "w" => (0, n.mul(7)?, 0, 0),
|
||||
"months" | "month" => (n, 0, 0, 0),
|
||||
"years" | "year" | "y" => (n.mul(12)?, 0, 0, 0),
|
||||
_ => {
|
||||
return Err(Error::UnknownUnit {
|
||||
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 {
|
||||
sec += 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;
|
||||
|
||||
self.current = (month, sec, nsec);
|
||||
self.current = (month, day, sec, nsec);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -215,7 +217,13 @@ impl<'a> Parser<'a> {
|
||||
self.parse_unit(n, start, off)?;
|
||||
n = match self.parse_first_char()? {
|
||||
Some(n) => n,
|
||||
None => return Ok(Interval { month: self.current.0, sec: self.current.1 }),
|
||||
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)));
|
||||
/// ```
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
126
src/main.rs
126
src/main.rs
@ -18,12 +18,12 @@ use std::{
|
||||
env,
|
||||
error::Error as StdError,
|
||||
fmt::{Debug, Display, Formatter},
|
||||
sync::atomic::AtomicBool,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use chrono_tz::Tz;
|
||||
use dotenv::dotenv;
|
||||
use poise::serenity::model::{
|
||||
use log::{error, warn};
|
||||
use poise::serenity_prelude::model::{
|
||||
gateway::GatewayIntents,
|
||||
id::{GuildId, UserId},
|
||||
};
|
||||
@ -31,7 +31,7 @@ use sqlx::{MySql, Pool};
|
||||
use tokio::sync::{broadcast, broadcast::Sender, RwLock};
|
||||
|
||||
use crate::{
|
||||
commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
|
||||
commands::{command_macro, info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
|
||||
consts::THEME_COLOR,
|
||||
event_handlers::listener,
|
||||
hooks::all_checks,
|
||||
@ -43,14 +43,14 @@ type Database = MySql;
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
type Context<'a> = poise::Context<'a, Data, Error>;
|
||||
type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>;
|
||||
|
||||
pub struct Data {
|
||||
database: Pool<Database>,
|
||||
http: reqwest::Client,
|
||||
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>,
|
||||
popular_timezones: Vec<Tz>,
|
||||
is_loop_running: AtomicBool,
|
||||
broadcast: Sender<()>,
|
||||
_broadcast: Sender<()>,
|
||||
}
|
||||
|
||||
impl Debug for Data {
|
||||
@ -75,7 +75,7 @@ impl Display for Ended {
|
||||
|
||||
impl StdError for Ended {}
|
||||
|
||||
#[tokio::main]
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||
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>> {
|
||||
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");
|
||||
|
||||
@ -103,13 +107,32 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||
moderation_cmds::timezone(),
|
||||
poise::Command {
|
||||
subcommands: vec![
|
||||
moderation_cmds::delete_macro(),
|
||||
moderation_cmds::finish_macro(),
|
||||
moderation_cmds::list_macro(),
|
||||
moderation_cmds::record_macro(),
|
||||
moderation_cmds::run_macro(),
|
||||
moderation_cmds::set_allowed_dm(),
|
||||
moderation_cmds::unset_allowed_dm(),
|
||||
],
|
||||
..moderation_cmds::macro_base()
|
||||
..moderation_cmds::allowed_dm()
|
||||
},
|
||||
poise::Command {
|
||||
subcommands: vec![poise::Command {
|
||||
subcommands: vec![
|
||||
moderation_cmds::set_ephemeral_confirmations(),
|
||||
moderation_cmds::unset_ephemeral_confirmations(),
|
||||
],
|
||||
..moderation_cmds::ephemeral_confirmations()
|
||||
}],
|
||||
..moderation_cmds::settings()
|
||||
},
|
||||
moderation_cmds::webhook(),
|
||||
poise::Command {
|
||||
subcommands: vec![
|
||||
command_macro::delete::delete_macro(),
|
||||
command_macro::record::finish_macro(),
|
||||
command_macro::list::list_macro(),
|
||||
command_macro::record::record_macro(),
|
||||
command_macro::run::run_macro(),
|
||||
command_macro::migrate::migrate_macro(),
|
||||
],
|
||||
..command_macro::macro_base()
|
||||
},
|
||||
reminder_cmds::pause(),
|
||||
reminder_cmds::offset(),
|
||||
@ -124,6 +147,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||
],
|
||||
..reminder_cmds::timer_base()
|
||||
},
|
||||
reminder_cmds::multiline(),
|
||||
reminder_cmds::remind(),
|
||||
poise::Command {
|
||||
subcommands: vec![
|
||||
@ -151,15 +175,36 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||
],
|
||||
allowed_mentions: None,
|
||||
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()
|
||||
};
|
||||
|
||||
let database =
|
||||
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
|
||||
|
||||
sqlx::migrate!().run(&database).await?;
|
||||
|
||||
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)
|
||||
.await
|
||||
@ -168,27 +213,50 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||
.map(|t| t.timezone.parse::<Tz>().unwrap())
|
||||
.collect::<Vec<Tz>>();
|
||||
|
||||
poise::Framework::build()
|
||||
poise::Framework::builder()
|
||||
.token(discord_token)
|
||||
.user_data_setup(move |ctx, _bot, framework| {
|
||||
.setup(move |ctx, _bot, framework| {
|
||||
Box::pin(async move {
|
||||
register_application_commands(
|
||||
ctx,
|
||||
framework,
|
||||
env::var("DEBUG_GUILD")
|
||||
.map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid")))
|
||||
.ok(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
register_application_commands(ctx, framework, None).await.unwrap();
|
||||
|
||||
let kill_tx = tx.clone();
|
||||
let kill_recv = tx.subscribe();
|
||||
|
||||
let ctx1 = ctx.clone();
|
||||
let ctx2 = ctx.clone();
|
||||
|
||||
let pool1 = database.clone();
|
||||
let pool2 = database.clone();
|
||||
|
||||
let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string());
|
||||
|
||||
if !run_settings.contains("postman") {
|
||||
tokio::spawn(async move {
|
||||
match postman::initialize(kill_recv, ctx1, &pool1).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("postman exiting: {}", e);
|
||||
}
|
||||
};
|
||||
});
|
||||
} else {
|
||||
warn!("Not running postman");
|
||||
}
|
||||
|
||||
if !run_settings.contains("web") {
|
||||
tokio::spawn(async move {
|
||||
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
|
||||
});
|
||||
} else {
|
||||
warn!("Not running web");
|
||||
}
|
||||
|
||||
Ok(Data {
|
||||
http: reqwest::Client::new(),
|
||||
database,
|
||||
popular_timezones,
|
||||
recording_macros: Default::default(),
|
||||
is_loop_running: AtomicBool::new(false),
|
||||
broadcast: tx,
|
||||
_broadcast: tx,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -1,5 +1,5 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use poise::serenity::model::channel::Channel;
|
||||
use poise::serenity_prelude::model::channel::Channel;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
pub struct ChannelData {
|
||||
@ -22,9 +22,7 @@ impl ChannelData {
|
||||
|
||||
if let Ok(c) = sqlx::query_as_unchecked!(
|
||||
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
|
||||
)
|
||||
.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) };
|
||||
|
||||
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_name,
|
||||
guild_id
|
||||
|
@ -1,7 +1,8 @@
|
||||
use poise::serenity::model::{
|
||||
id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption,
|
||||
use poise::serenity_prelude::model::{
|
||||
application::interaction::application_command::CommandDataOption, id::GuildId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Context, Data, Error};
|
||||
|
||||
@ -19,7 +20,7 @@ pub struct RecordedCommand<U, E> {
|
||||
#[serde(default = "default_none::<U, E>")]
|
||||
pub action: Option<Func<U, E>>,
|
||||
pub command_name: String,
|
||||
pub options: Vec<ApplicationCommandInteractionDataOption>,
|
||||
pub options: Vec<CommandDataOption>,
|
||||
}
|
||||
|
||||
pub struct CommandMacro<U, E> {
|
||||
@ -29,6 +30,13 @@ pub struct CommandMacro<U, E> {
|
||||
pub commands: Vec<RecordedCommand<U, E>>,
|
||||
}
|
||||
|
||||
pub struct RawCommandMacro {
|
||||
pub guild_id: GuildId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub commands: Value,
|
||||
}
|
||||
|
||||
pub async fn guild_command_macro(
|
||||
ctx: &Context<'_>,
|
||||
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 command_macro;
|
||||
pub mod guild_data;
|
||||
pub mod reminder;
|
||||
pub mod timer;
|
||||
pub mod user_data;
|
||||
|
||||
use chrono_tz::Tz;
|
||||
use poise::serenity::{async_trait, model::id::UserId};
|
||||
use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelType};
|
||||
|
||||
use crate::{
|
||||
models::{channel_data::ChannelData, user_data::UserData},
|
||||
models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData},
|
||||
CommandMacro, Context, Data, Error, GuildId,
|
||||
};
|
||||
|
||||
@ -18,6 +19,8 @@ pub trait CtxData {
|
||||
|
||||
async fn author_data(&self) -> Result<UserData, Error>;
|
||||
|
||||
async fn guild_data(&self) -> Option<Result<GuildData, Error>>;
|
||||
|
||||
async fn timezone(&self) -> Tz;
|
||||
|
||||
async fn channel_data(&self) -> Result<ChannelData, Error>;
|
||||
@ -27,15 +30,21 @@ pub trait CtxData {
|
||||
|
||||
#[async_trait]
|
||||
impl CtxData for Context<'_> {
|
||||
async fn user_data<U: Into<UserId> + Send>(
|
||||
&self,
|
||||
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 user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error> {
|
||||
UserData::from_user(user_id, &self.serenity_context(), &self.data().database).await
|
||||
}
|
||||
|
||||
async fn author_data(&self) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> {
|
||||
UserData::from_user(&self.author().id, &self.discord(), &self.data().database).await
|
||||
async fn author_data(&self) -> Result<UserData, Error> {
|
||||
UserData::from_user(&self.author().id, &self.serenity_context(), &self.data().database)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn guild_data(&self) -> Option<Result<GuildData, Error>> {
|
||||
if let Some(guild_id) = self.guild_id() {
|
||||
Some(GuildData::from_guild(guild_id, &self.data().database).await)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn timezone(&self) -> Tz {
|
||||
@ -43,7 +52,20 @@ impl CtxData for Context<'_> {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -2,14 +2,14 @@ use std::{collections::HashSet, fmt::Display};
|
||||
|
||||
use chrono::{Duration, NaiveDateTime, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use poise::serenity::{
|
||||
use poise::serenity_prelude::{
|
||||
http::CacheHttp,
|
||||
model::{
|
||||
channel::GuildChannel,
|
||||
id::{ChannelId, GuildId, UserId},
|
||||
webhook::Webhook,
|
||||
},
|
||||
Result as SerenityResult,
|
||||
ChannelType, Result as SerenityResult,
|
||||
};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
@ -51,9 +51,11 @@ pub struct ReminderBuilder {
|
||||
pool: MySqlPool,
|
||||
uid: String,
|
||||
channel: u32,
|
||||
thread_id: Option<u64>,
|
||||
utc_time: NaiveDateTime,
|
||||
timezone: String,
|
||||
interval_secs: Option<i64>,
|
||||
interval_seconds: Option<i64>,
|
||||
interval_days: Option<i64>,
|
||||
interval_months: Option<i64>,
|
||||
expires: Option<NaiveDateTime>,
|
||||
content: String,
|
||||
@ -87,6 +89,7 @@ INSERT INTO reminders (
|
||||
`utc_time`,
|
||||
`timezone`,
|
||||
`interval_seconds`,
|
||||
`interval_days`,
|
||||
`interval_months`,
|
||||
`expires`,
|
||||
`content`,
|
||||
@ -106,6 +109,7 @@ INSERT INTO reminders (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?
|
||||
)
|
||||
",
|
||||
@ -113,7 +117,8 @@ INSERT INTO reminders (
|
||||
self.channel,
|
||||
utc_time,
|
||||
self.timezone,
|
||||
self.interval_secs,
|
||||
self.interval_seconds,
|
||||
self.interval_days,
|
||||
self.interval_months,
|
||||
self.expires,
|
||||
self.content,
|
||||
@ -175,17 +180,15 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self {
|
||||
if let Some(t) = time {
|
||||
self.expires = Some(NaiveDateTime::from_timestamp(t.into(), 0));
|
||||
} else {
|
||||
self.expires = None;
|
||||
}
|
||||
self.expires = time.map(|t| NaiveDateTime::from_timestamp_opt(t.into(), 0)).flatten();
|
||||
|
||||
self
|
||||
}
|
||||
@ -212,27 +215,37 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
|
||||
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);
|
||||
} 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);
|
||||
} else {
|
||||
for scope in self.scopes {
|
||||
let thread_id = None;
|
||||
let db_channel_id = match scope {
|
||||
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(
|
||||
&user,
|
||||
&self.ctx.discord(),
|
||||
&self.ctx.serenity_context(),
|
||||
&self.ctx.data().database,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
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)
|
||||
} else if self.set_by.map_or(true, |i| i != user_data.id)
|
||||
&& !user_data.allowed_dm
|
||||
{
|
||||
Err(ReminderError::UserBlockedDm)
|
||||
} else {
|
||||
Ok(user_data.dm_channel)
|
||||
}
|
||||
@ -244,27 +257,36 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
}
|
||||
}
|
||||
ReminderScope::Channel(channel_id) => {
|
||||
let channel =
|
||||
ChannelId(channel_id).to_channel(&self.ctx.discord()).await.unwrap();
|
||||
let channel = ChannelId(channel_id).to_channel(&self.ctx).await.unwrap();
|
||||
|
||||
if let Some(guild_channel) = channel.clone().guild() {
|
||||
if let Some(mut guild_channel) = channel.clone().guild() {
|
||||
if Some(guild_channel.guild_id) != self.guild_id {
|
||||
Err(ReminderError::InvalidTag)
|
||||
} else {
|
||||
let mut channel_data =
|
||||
ChannelData::from_channel(&channel, &self.ctx.data().database)
|
||||
let mut channel_data = if guild_channel.kind
|
||||
== ChannelType::PublicThread
|
||||
{
|
||||
// fixme jesus christ
|
||||
let parent = guild_channel
|
||||
.parent_id
|
||||
.unwrap()
|
||||
.to_channel(&self.ctx)
|
||||
.await
|
||||
.unwrap();
|
||||
guild_channel = parent.clone().guild().unwrap();
|
||||
ChannelData::from_channel(&parent, &self.ctx.data().database)
|
||||
.await
|
||||
.unwrap()
|
||||
} else {
|
||||
ChannelData::from_channel(&channel, &self.ctx.data().database)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
if channel_data.webhook_id.is_none()
|
||||
|| channel_data.webhook_token.is_none()
|
||||
{
|
||||
match create_webhook(
|
||||
&self.ctx.discord(),
|
||||
guild_channel,
|
||||
"Reminder",
|
||||
)
|
||||
.await
|
||||
match create_webhook(&self.ctx, guild_channel, "Reminder").await
|
||||
{
|
||||
Ok(webhook) => {
|
||||
channel_data.webhook_id =
|
||||
@ -296,9 +318,11 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
pool: self.ctx.data().database.clone(),
|
||||
uid: generate_uid(),
|
||||
channel: c,
|
||||
thread_id,
|
||||
utc_time: self.utc_time,
|
||||
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),
|
||||
expires: self.expires,
|
||||
content: self.content.content.clone(),
|
||||
|
@ -7,6 +7,7 @@ pub enum ReminderError {
|
||||
PastTime,
|
||||
ShortInterval,
|
||||
InvalidTag,
|
||||
UserBlockedDm,
|
||||
DiscordError(String),
|
||||
}
|
||||
|
||||
@ -30,6 +31,9 @@ impl ToString for ReminderError {
|
||||
ReminderError::InvalidTag => {
|
||||
"Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string()
|
||||
}
|
||||
ReminderError::UserBlockedDm => {
|
||||
"User has DM reminders disabled".to_string()
|
||||
}
|
||||
ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s),
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
use poise::serenity::model::id::ChannelId;
|
||||
use poise::serenity_prelude::model::id::ChannelId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_repr::*;
|
||||
|
||||
|
@ -6,11 +6,11 @@ pub mod look_flags;
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use chrono::{NaiveDateTime, TimeZone};
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use poise::{
|
||||
serenity::model::id::{ChannelId, GuildId, UserId},
|
||||
serenity_prelude::Cache,
|
||||
use poise::serenity_prelude::{
|
||||
model::id::{ChannelId, GuildId, UserId},
|
||||
Cache,
|
||||
};
|
||||
use sqlx::Executor;
|
||||
|
||||
@ -24,8 +24,9 @@ pub struct Reminder {
|
||||
pub id: u32,
|
||||
pub uid: String,
|
||||
pub channel: u64,
|
||||
pub utc_time: NaiveDateTime,
|
||||
pub utc_time: DateTime<Utc>,
|
||||
pub interval_seconds: Option<u32>,
|
||||
pub interval_days: Option<u32>,
|
||||
pub interval_months: Option<u32>,
|
||||
pub expires: Option<NaiveDateTime>,
|
||||
pub enabled: bool,
|
||||
@ -59,6 +60,7 @@ SELECT
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_days,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
@ -95,6 +97,7 @@ SELECT
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_days,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
@ -138,6 +141,7 @@ SELECT
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_days,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
@ -155,6 +159,7 @@ LEFT JOIN
|
||||
ON
|
||||
reminders.set_by = users.id
|
||||
WHERE
|
||||
`status` = 'pending' AND
|
||||
channels.channel = ? AND
|
||||
FIND_IN_SET(reminders.enabled, ?)
|
||||
ORDER BY
|
||||
@ -195,6 +200,7 @@ SELECT
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_days,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
@ -212,6 +218,7 @@ LEFT JOIN
|
||||
ON
|
||||
reminders.set_by = users.id
|
||||
WHERE
|
||||
`status` = 'pending' AND
|
||||
FIND_IN_SET(channels.channel, ?)
|
||||
",
|
||||
channels
|
||||
@ -228,6 +235,7 @@ SELECT
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_days,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
@ -245,6 +253,7 @@ LEFT JOIN
|
||||
ON
|
||||
reminders.set_by = users.id
|
||||
WHERE
|
||||
`status` = 'pending' AND
|
||||
channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)
|
||||
",
|
||||
guild_id.as_u64()
|
||||
@ -262,6 +271,7 @@ SELECT
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_days,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
@ -279,6 +289,7 @@ LEFT JOIN
|
||||
ON
|
||||
reminders.set_by = users.id
|
||||
WHERE
|
||||
`status` = 'pending' AND
|
||||
channels.id = (SELECT dm_channel FROM users WHERE user = ?)
|
||||
",
|
||||
user.as_u64()
|
||||
@ -293,7 +304,10 @@ WHERE
|
||||
&self,
|
||||
db: impl Executor<'_, Database = Database>,
|
||||
) -> 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 {
|
||||
@ -310,30 +324,32 @@ WHERE
|
||||
count + 1,
|
||||
self.display_content(),
|
||||
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 {
|
||||
let time_display = match flags.time_display {
|
||||
TimeDisplayType::Absolute => timezone
|
||||
.timestamp(self.utc_time.timestamp(), 0)
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string(),
|
||||
TimeDisplayType::Absolute => {
|
||||
self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
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!(
|
||||
"'{}' *occurs next at* **{}**, repeating (set by {})",
|
||||
"'{}' *occurs next at* **{}**, repeating (set by {})\n",
|
||||
self.display_content(),
|
||||
time_display,
|
||||
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"'{}' *occurs next at* **{}** (set by {})",
|
||||
"'{}' *occurs next at* **{}** (set by {})\n",
|
||||
self.display_content(),
|
||||
time_display,
|
||||
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;
|
||||
|
||||
pub struct Timer {
|
||||
pub name: String,
|
||||
pub start_time: NaiveDateTime,
|
||||
pub start_time: DateTime<Utc>,
|
||||
pub owner: u64,
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
use chrono_tz::Tz;
|
||||
use log::error;
|
||||
use poise::serenity::{http::CacheHttp, model::id::UserId};
|
||||
use poise::serenity_prelude::{http::CacheHttp, model::id::UserId};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::consts::LOCAL_TIMEZONE;
|
||||
@ -10,6 +10,7 @@ pub struct UserData {
|
||||
pub user: u64,
|
||||
pub dm_channel: u32,
|
||||
pub timezone: String,
|
||||
pub allowed_dm: bool,
|
||||
}
|
||||
|
||||
impl UserData {
|
||||
@ -21,7 +22,7 @@ impl UserData {
|
||||
|
||||
match sqlx::query!(
|
||||
"
|
||||
SELECT timezone FROM users WHERE user = ?
|
||||
SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?
|
||||
",
|
||||
user_id
|
||||
)
|
||||
@ -46,7 +47,7 @@ SELECT timezone FROM users WHERE user = ?
|
||||
match sqlx::query_as_unchecked!(
|
||||
Self,
|
||||
"
|
||||
SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ?
|
||||
SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone, allowed_dm FROM users WHERE user = ?
|
||||
",
|
||||
*LOCAL_TIMEZONE,
|
||||
user_id.0
|
||||
@ -83,7 +84,7 @@ INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id F
|
||||
Ok(sqlx::query_as_unchecked!(
|
||||
Self,
|
||||
"
|
||||
SELECT id, user, dm_channel, timezone FROM users WHERE user = ?
|
||||
SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ?
|
||||
",
|
||||
user_id.0
|
||||
)
|
||||
@ -102,9 +103,10 @@ SELECT id, user, dm_channel, timezone FROM users WHERE user = ?
|
||||
pub async fn commit_changes(&self, pool: &MySqlPool) {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users SET timezone = ? WHERE id = ?
|
||||
UPDATE users SET timezone = ?, allowed_dm = ? WHERE id = ?
|
||||
",
|
||||
self.timezone,
|
||||
self.allowed_dm,
|
||||
self.id
|
||||
)
|
||||
.execute(pool)
|
||||
|
15
src/utils.rs
15
src/utils.rs
@ -1,10 +1,11 @@
|
||||
use poise::{
|
||||
serenity::{
|
||||
serenity_prelude as serenity,
|
||||
serenity_prelude::{
|
||||
builder::CreateApplicationCommands,
|
||||
http::CacheHttp,
|
||||
interaction::MessageFlags,
|
||||
model::id::{GuildId, UserId},
|
||||
},
|
||||
serenity_prelude as serenity,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@ -13,10 +14,10 @@ use crate::{
|
||||
};
|
||||
|
||||
pub async fn register_application_commands(
|
||||
ctx: &poise::serenity::client::Context,
|
||||
ctx: &serenity::Context,
|
||||
framework: &poise::Framework<Data, Error>,
|
||||
guild_id: Option<GuildId>,
|
||||
) -> Result<(), poise::serenity::Error> {
|
||||
) -> Result<(), serenity::Error> {
|
||||
let mut commands_builder = CreateApplicationCommands::default();
|
||||
let commands = &framework.options().commands;
|
||||
for command in commands {
|
||||
@ -27,7 +28,7 @@ pub async fn register_application_commands(
|
||||
commands_builder.add_application_command(context_menu_command);
|
||||
}
|
||||
}
|
||||
let commands_builder = poise::serenity::json::Value::Array(commands_builder.0);
|
||||
let commands_builder = poise::serenity_prelude::json::Value::Array(commands_builder.0);
|
||||
|
||||
if let Some(guild_id) = guild_id {
|
||||
ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?;
|
||||
@ -82,7 +83,7 @@ pub fn send_as_initial_response(
|
||||
components,
|
||||
ephemeral,
|
||||
allowed_mentions,
|
||||
reference_message: _, // can't reply to a message in interactions
|
||||
reply: _,
|
||||
} = data;
|
||||
|
||||
if let Some(content) = content {
|
||||
@ -102,6 +103,6 @@ pub fn send_as_initial_response(
|
||||
});
|
||||
}
|
||||
if ephemeral {
|
||||
f.flags(serenity::InteractionApplicationCommandCallbackDataFlags::EPHEMERAL);
|
||||
f.flags(MessageFlags::EPHEMERAL);
|
||||
}
|
||||
}
|
||||
|
14
systemd/reminder-rs.service
Normal file
14
systemd/reminder-rs.service
Normal file
@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=Reminder Bot
|
||||
|
||||
[Service]
|
||||
User=reminder
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/reminder-rs
|
||||
WorkingDirectory=/etc/reminder-rs
|
||||
Restart=always
|
||||
RestartSec=4
|
||||
Environment="reminder_rs=warn,postman=warn"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
@ -1,21 +1,21 @@
|
||||
[package]
|
||||
name = "reminder_web"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
authors = ["jellywx <judesouthworth@pm.me>"]
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] }
|
||||
rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] }
|
||||
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
||||
serenity = { version = "0.11", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
||||
oauth2 = "4"
|
||||
log = "0.4"
|
||||
reqwest = "0.11"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
|
||||
chrono = "0.4"
|
||||
chrono-tz = "0.5"
|
||||
chrono-tz = "0.8"
|
||||
lazy_static = "1.4.0"
|
||||
rand = "0.7"
|
||||
rand = "0.8"
|
||||
base64 = "0.13"
|
||||
csv = "1.2"
|
||||
|
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_API: &'static str = "https://discord.com/api";
|
||||
|
||||
pub const MAX_NAME_LENGTH: usize = 100;
|
||||
pub const MAX_CONTENT_LENGTH: usize = 2000;
|
||||
pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096;
|
||||
pub const MAX_EMBED_TITLE_LENGTH: usize = 256;
|
||||
@ -26,16 +27,12 @@ use serenity::model::prelude::AttachmentType;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
|
||||
include_bytes!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../assets/",
|
||||
env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation")
|
||||
)) as &[u8],
|
||||
env!("WEBHOOK_AVATAR"),
|
||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/webhook.jpg")) as &[u8],
|
||||
"webhook.jpg",
|
||||
)
|
||||
.into();
|
||||
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
||||
env::var("SUBSCRIPTION_ROLES")
|
||||
env::var("PATREON_ROLE_ID")
|
||||
.map(|var| var
|
||||
.split(',')
|
||||
.filter_map(|item| { item.parse::<u64>().ok() })
|
||||
@ -43,7 +40,7 @@ lazy_static! {
|
||||
.unwrap_or_else(|_| Vec::new())
|
||||
);
|
||||
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")
|
||||
.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;
|
44
web/src/guards/transaction.rs
Normal file
44
web/src/guards/transaction.rs
Normal file
@ -0,0 +1,44 @@
|
||||
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::Failure((Status::InternalServerError, TransactionError::Error(e)))
|
||||
}
|
||||
},
|
||||
Outcome::Failure(e) => Outcome::Failure((e.0, TransactionError::Missing)),
|
||||
Outcome::Forward(f) => Outcome::Forward(f),
|
||||
}
|
||||
}
|
||||
}
|
172
web/src/lib.rs
172
web/src/lib.rs
@ -4,13 +4,16 @@ extern crate rocket;
|
||||
mod consts;
|
||||
#[macro_use]
|
||||
mod macros;
|
||||
mod catchers;
|
||||
mod guards;
|
||||
mod routes;
|
||||
|
||||
use std::{collections::HashMap, env};
|
||||
use std::{env, path::Path};
|
||||
|
||||
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
|
||||
use rocket::{
|
||||
fs::FileServer,
|
||||
http::CookieJar,
|
||||
serde::json::{json, Value as JsonValue},
|
||||
tokio::sync::broadcast::Sender,
|
||||
};
|
||||
@ -32,50 +35,20 @@ enum Error {
|
||||
Serenity(serenity::Error),
|
||||
}
|
||||
|
||||
#[catch(401)]
|
||||
async fn not_authorized() -> Template {
|
||||
let map: HashMap<String, String> = HashMap::new();
|
||||
Template::render("errors/401", &map)
|
||||
}
|
||||
|
||||
#[catch(403)]
|
||||
async fn forbidden() -> Template {
|
||||
let map: HashMap<String, String> = HashMap::new();
|
||||
Template::render("errors/403", &map)
|
||||
}
|
||||
|
||||
#[catch(404)]
|
||||
async fn not_found() -> Template {
|
||||
let map: HashMap<String, String> = HashMap::new();
|
||||
Template::render("errors/404", &map)
|
||||
}
|
||||
|
||||
#[catch(413)]
|
||||
async fn payload_too_large() -> JsonValue {
|
||||
json!({"error": "Data too large.", "errors": ["Data too large."]})
|
||||
}
|
||||
|
||||
#[catch(422)]
|
||||
async fn unprocessable_entity() -> JsonValue {
|
||||
json!({"error": "Invalid request.", "errors": ["Invalid request."]})
|
||||
}
|
||||
|
||||
#[catch(500)]
|
||||
async fn internal_server_error() -> Template {
|
||||
let map: HashMap<String, String> = HashMap::new();
|
||||
Template::render("errors/500", &map)
|
||||
}
|
||||
|
||||
pub async fn initialize(
|
||||
kill_channel: Sender<()>,
|
||||
serenity_context: Context,
|
||||
db_pool: Pool<Database>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("Checking environment variables...");
|
||||
|
||||
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_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied");
|
||||
env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied");
|
||||
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD' not supplied");
|
||||
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied");
|
||||
}
|
||||
|
||||
info!("Done!");
|
||||
|
||||
let oauth2_client = BasicClient::new(
|
||||
@ -88,24 +61,27 @@ pub async fn initialize(
|
||||
|
||||
let reqwest_client = reqwest::Client::new();
|
||||
|
||||
let static_path =
|
||||
if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" };
|
||||
|
||||
rocket::build()
|
||||
.attach(Template::fairing())
|
||||
.register(
|
||||
"/",
|
||||
catchers![
|
||||
not_authorized,
|
||||
forbidden,
|
||||
not_found,
|
||||
internal_server_error,
|
||||
unprocessable_entity,
|
||||
payload_too_large,
|
||||
catchers::not_authorized,
|
||||
catchers::forbidden,
|
||||
catchers::not_found,
|
||||
catchers::internal_server_error,
|
||||
catchers::unprocessable_entity,
|
||||
catchers::payload_too_large,
|
||||
],
|
||||
)
|
||||
.manage(oauth2_client)
|
||||
.manage(reqwest_client)
|
||||
.manage(serenity_context)
|
||||
.manage(db_pool)
|
||||
.mount("/static", FileServer::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static")))
|
||||
.mount("/static", FileServer::from(static_path))
|
||||
.mount(
|
||||
"/",
|
||||
routes![
|
||||
@ -113,7 +89,8 @@ pub async fn initialize(
|
||||
routes::cookies,
|
||||
routes::privacy,
|
||||
routes::terms,
|
||||
routes::return_to_same_site
|
||||
routes::return_to_same_site,
|
||||
routes::report::report_error,
|
||||
],
|
||||
)
|
||||
.mount(
|
||||
@ -131,27 +108,40 @@ pub async fn initialize(
|
||||
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(
|
||||
"/dashboard",
|
||||
routes![
|
||||
routes::dashboard::dashboard,
|
||||
routes::dashboard::dashboard_home,
|
||||
routes::dashboard::user::get_user_info,
|
||||
routes::dashboard::user::update_user_info,
|
||||
routes::dashboard::user::get_user_guilds,
|
||||
routes::dashboard::guild::get_guild_patreon,
|
||||
routes::dashboard::guild::get_guild_channels,
|
||||
routes::dashboard::guild::get_guild_roles,
|
||||
routes::dashboard::guild::get_reminder_templates,
|
||||
routes::dashboard::guild::create_reminder_template,
|
||||
routes::dashboard::guild::delete_reminder_template,
|
||||
routes::dashboard::guild::create_reminder,
|
||||
routes::dashboard::guild::get_reminders,
|
||||
routes::dashboard::guild::edit_reminder,
|
||||
routes::dashboard::guild::delete_reminder,
|
||||
routes::dashboard::api::user::get_user_info,
|
||||
routes::dashboard::api::user::update_user_info,
|
||||
routes::dashboard::api::user::get_user_guilds,
|
||||
routes::dashboard::api::guild::get_guild_info,
|
||||
routes::dashboard::api::guild::get_guild_channels,
|
||||
routes::dashboard::api::guild::get_guild_roles,
|
||||
routes::dashboard::api::guild::get_reminder_templates,
|
||||
routes::dashboard::api::guild::create_reminder_template,
|
||||
routes::dashboard::api::guild::delete_reminder_template,
|
||||
routes::dashboard::api::guild::create_guild_reminder,
|
||||
routes::dashboard::api::guild::get_reminders,
|
||||
routes::dashboard::api::guild::edit_reminder,
|
||||
routes::dashboard::api::guild::delete_reminder,
|
||||
routes::dashboard::export::export_reminders,
|
||||
routes::dashboard::export::export_reminder_templates,
|
||||
routes::dashboard::export::export_todos,
|
||||
routes::dashboard::export::import_reminders,
|
||||
routes::dashboard::export::import_todos,
|
||||
],
|
||||
)
|
||||
.mount("/admin", routes![routes::admin::admin_dashboard_home, routes::admin::bot_data])
|
||||
.launch()
|
||||
.await?;
|
||||
|
||||
@ -168,6 +158,8 @@ pub async fn initialize(
|
||||
}
|
||||
|
||||
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
|
||||
offline!(true);
|
||||
|
||||
if let Some(subscription_guild) = *CNC_GUILD {
|
||||
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
|
||||
|
||||
@ -189,6 +181,8 @@ pub async fn check_guild_subscription(
|
||||
cache_http: impl CacheHttp,
|
||||
guild_id: impl Into<GuildId>,
|
||||
) -> bool {
|
||||
offline!(true);
|
||||
|
||||
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
|
||||
let owner = guild.owner_id;
|
||||
|
||||
@ -197,3 +191,65 @@ pub async fn check_guild_subscription(
|
||||
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,7 +1,15 @@
|
||||
macro_rules! offline {
|
||||
($field:expr) => {
|
||||
if std::env::var("OFFLINE").map_or(false, |v| v == "1") {
|
||||
return $field;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! check_length {
|
||||
($max:ident, $field:expr) => {
|
||||
if $field.len() > $max {
|
||||
return json!({ "error": format!("{} exceeded", stringify!($max)) });
|
||||
return Err(json!({ "error": format!("{} exceeded", stringify!($max)) }));
|
||||
}
|
||||
};
|
||||
($max:ident, $field:expr, $($fields:expr),+) => {
|
||||
@ -25,7 +33,7 @@ macro_rules! check_length_opt {
|
||||
macro_rules! check_url {
|
||||
($field:expr) => {
|
||||
if !($field.starts_with("http://") || $field.starts_with("https://")) {
|
||||
return json!({ "error": "URL invalid" });
|
||||
return Err(json!({ "error": "URL invalid" }));
|
||||
}
|
||||
};
|
||||
($field:expr, $($fields:expr),+) => {
|
||||
@ -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 json!({"error": "User not in guild"})
|
||||
}
|
||||
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
return json!({"error": "Bot not in guild"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
return json!({"error": "User not authorized"});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! update_field {
|
||||
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
|
||||
if let Some(value) = &$reminder.$field {
|
||||
@ -117,3 +91,9 @@ macro_rules! update_field {
|
||||
update_field!($pool, $error, $reminder.[$($fields),+]);
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! json_err {
|
||||
($message:expr) => {
|
||||
Err(json!({ "error": $message }))
|
||||
};
|
||||
}
|
||||
|
218
web/src/routes/admin.rs
Normal file
218
web/src/routes/admin.rs
Normal file
@ -0,0 +1,218 @@
|
||||
use std::{collections::HashMap, env};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use rocket::{
|
||||
http::{CookieJar, Status},
|
||||
serde::json::json,
|
||||
State,
|
||||
};
|
||||
use rocket_dyn_templates::Template;
|
||||
use serde::Serialize;
|
||||
use sqlx::{MySql, Pool};
|
||||
|
||||
use crate::routes::JsonResult;
|
||||
|
||||
fn is_admin(cookies: &CookieJar<'_>) -> bool {
|
||||
cookies
|
||||
.get_private("userid")
|
||||
.map_or(false, |cookie| Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok())
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
pub async fn admin_dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Status> {
|
||||
if let Some(cookie) = cookies.get_private("userid") {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
if Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok() {
|
||||
Ok(Template::render("admin_dashboard", &map))
|
||||
} else {
|
||||
Err(Status::Forbidden)
|
||||
}
|
||||
} else {
|
||||
Err(Status::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TimeFrame {
|
||||
time_key: DateTime<Utc>,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[get("/data")]
|
||||
pub async fn bot_data(cookies: &CookieJar<'_>, pool: &State<Pool<MySql>>) -> JsonResult {
|
||||
if !is_admin(cookies) {
|
||||
return json_err!("Not authorized");
|
||||
}
|
||||
|
||||
let backlog = sqlx::query!(
|
||||
"SELECT COUNT(1) AS backlog FROM reminders WHERE `utc_time` < NOW() AND enabled = 1 AND `status` = 'pending'"
|
||||
)
|
||||
.fetch_one(pool.inner())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let schedule_once = sqlx::query_as_unchecked!(
|
||||
TimeFrame,
|
||||
"SELECT
|
||||
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`,
|
||||
COUNT(1) AS `count`
|
||||
FROM reminders
|
||||
WHERE
|
||||
`utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND
|
||||
`utc_time` >= NOW() AND
|
||||
`enabled` = 1 AND
|
||||
`status` = 'pending' AND
|
||||
`interval_seconds` IS NULL AND
|
||||
`interval_months` IS NULL AND
|
||||
`interval_days` IS NULL
|
||||
GROUP BY `time_key`
|
||||
ORDER BY `time_key`"
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let schedule_interval = sqlx::query_as_unchecked!(
|
||||
TimeFrame,
|
||||
"SELECT
|
||||
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`,
|
||||
COUNT(1) AS `count`
|
||||
FROM reminders
|
||||
WHERE
|
||||
`utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND
|
||||
`utc_time` >= NOW() AND
|
||||
`status` = 'pending' AND
|
||||
`enabled` = 1 AND (
|
||||
`interval_seconds` IS NOT NULL OR
|
||||
`interval_months` IS NOT NULL OR
|
||||
`interval_days` IS NOT NULL
|
||||
)
|
||||
GROUP BY `time_key`
|
||||
ORDER BY `time_key`"
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let schedule_once_long = sqlx::query_as_unchecked!(
|
||||
TimeFrame,
|
||||
"SELECT
|
||||
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
|
||||
COUNT(1) AS `count`
|
||||
FROM reminders
|
||||
WHERE
|
||||
`utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND
|
||||
`utc_time` >= NOW() AND
|
||||
`enabled` = 1 AND
|
||||
`status` = 'pending' AND
|
||||
`interval_seconds` IS NULL AND
|
||||
`interval_months` IS NULL AND
|
||||
`interval_days` IS NULL
|
||||
GROUP BY `time_key`
|
||||
ORDER BY `time_key`"
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let schedule_interval_long = sqlx::query_as_unchecked!(
|
||||
TimeFrame,
|
||||
"SELECT
|
||||
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
|
||||
COUNT(1) AS `count`
|
||||
FROM reminders
|
||||
WHERE
|
||||
`utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND
|
||||
`utc_time` >= NOW() AND
|
||||
`status` = 'pending' AND
|
||||
`enabled` = 1 AND (
|
||||
`interval_seconds` IS NOT NULL OR
|
||||
`interval_months` IS NOT NULL OR
|
||||
`interval_days` IS NOT NULL
|
||||
)
|
||||
GROUP BY `time_key`
|
||||
ORDER BY `time_key`"
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let history = sqlx::query_as_unchecked!(
|
||||
TimeFrame,
|
||||
"SELECT
|
||||
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
|
||||
COUNT(1) AS `count`
|
||||
FROM stat
|
||||
WHERE
|
||||
`utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND
|
||||
`type` = 'reminder_sent'
|
||||
GROUP BY `time_key`
|
||||
ORDER BY `time_key`"
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let history_failed = sqlx::query_as_unchecked!(
|
||||
TimeFrame,
|
||||
"SELECT
|
||||
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
|
||||
COUNT(1) AS `count`
|
||||
FROM stat
|
||||
WHERE
|
||||
`utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND
|
||||
`type` = 'reminder_failed'
|
||||
GROUP BY `time_key`
|
||||
ORDER BY `time_key`"
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let interval_count = sqlx::query!(
|
||||
"SELECT COUNT(1) AS count
|
||||
FROM reminders
|
||||
WHERE
|
||||
`status` = 'pending' AND (
|
||||
`interval_seconds` IS NOT NULL OR
|
||||
`interval_months` IS NOT NULL OR
|
||||
`interval_days` IS NOT NULL
|
||||
)"
|
||||
)
|
||||
.fetch_one(pool.inner())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let reminder_count = sqlx::query!(
|
||||
"SELECT COUNT(1) AS count
|
||||
FROM reminders
|
||||
WHERE
|
||||
`status` = 'pending' AND
|
||||
`interval_seconds` IS NULL AND
|
||||
`interval_months` IS NULL AND
|
||||
`interval_days` IS NULL"
|
||||
)
|
||||
.fetch_one(pool.inner())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Ok(json!({
|
||||
"backlog": backlog.backlog,
|
||||
"scheduleShort": {
|
||||
"once": schedule_once,
|
||||
"interval": schedule_interval
|
||||
},
|
||||
"scheduleLong": {
|
||||
"once": schedule_once_long,
|
||||
"interval": schedule_interval_long,
|
||||
},
|
||||
"historyLong": {
|
||||
"sent": history,
|
||||
"failed": history_failed,
|
||||
},
|
||||
"count": {
|
||||
"reminders": reminder_count.count,
|
||||
"intervals": interval_count.count,
|
||||
}
|
||||
}))
|
||||
}
|
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"),
|
||||
}
|
||||
}
|
373
web/src/routes/dashboard/api/guild/reminders.rs
Normal file
373
web/src/routes/dashboard/api/guild/reminders.rs
Normal file
@ -0,0 +1,373 @@
|
||||
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
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 rocket::{
|
||||
http::CookieJar,
|
||||
serde::json::{json, Json, Value as JsonValue},
|
||||
serde::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 serenity::model::{id::GuildId, permissions::Permissions};
|
||||
|
||||
use crate::consts::DISCORD_API;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UserInfo {
|
||||
name: String,
|
||||
patreon: bool,
|
||||
timezone: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUser {
|
||||
timezone: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GuildInfo {
|
||||
id: String,
|
||||
@ -38,9 +16,8 @@ struct GuildInfo {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PartialGuild {
|
||||
struct PartialGuild {
|
||||
pub id: GuildId,
|
||||
pub icon: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub owner: bool,
|
||||
@ -48,71 +25,10 @@ pub struct PartialGuild {
|
||||
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")]
|
||||
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") {
|
||||
let request_res = reqwest_client
|
||||
.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};
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user