Compare commits
43 Commits
jellywx/fi
...
4b42966284
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
bb3386c4e8 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,6 +2,4 @@
|
|||||||
.env
|
.env
|
||||||
/venv
|
/venv
|
||||||
.cargo
|
.cargo
|
||||||
assets
|
|
||||||
out.json
|
|
||||||
/.idea
|
/.idea
|
||||||
|
1442
Cargo.lock
generated
1442
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
41
Cargo.toml
41
Cargo.toml
@ -1,28 +1,31 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "reminder_rs"
|
name = "reminder-rs"
|
||||||
version = "1.6.3"
|
version = "1.6.10"
|
||||||
authors = ["jellywx <judesouthworth@pm.me>"]
|
authors = ["Jude Southworth <judesouthworth@pm.me>"]
|
||||||
edition = "2018"
|
edition = "2021"
|
||||||
|
license = "AGPL-3.0 only"
|
||||||
|
description = "Reminder Bot for Discord, now in Rust"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
poise = "0.2"
|
poise = "0.4"
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
tokio = { version = "1", features = ["process", "full"] }
|
tokio = { version = "1", features = ["process", "full"] }
|
||||||
reqwest = "0.11"
|
reqwest = "0.11"
|
||||||
regex = "1.4"
|
lazy-regex = "2.3.0"
|
||||||
|
regex = "1.6"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.8"
|
env_logger = "0.10"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
chrono-tz = { version = "0.5", features = ["serde"] }
|
chrono-tz = { version = "0.8", features = ["serde"] }
|
||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
num-integer = "0.1"
|
num-integer = "0.1"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde_repr = "0.1"
|
serde_repr = "0.1"
|
||||||
rmp-serde = "0.15"
|
rmp-serde = "1.1"
|
||||||
rand = "0.7"
|
rand = "0.8"
|
||||||
levenshtein = "1.0"
|
levenshtein = "1.0"
|
||||||
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
|
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]}
|
||||||
base64 = "0.13"
|
base64 = "0.13"
|
||||||
|
|
||||||
[dependencies.postman]
|
[dependencies.postman]
|
||||||
@ -30,3 +33,19 @@ path = "postman"
|
|||||||
|
|
||||||
[dependencies.reminder_web]
|
[dependencies.reminder_web]
|
||||||
path = "web"
|
path = "web"
|
||||||
|
|
||||||
|
[package.metadata.deb]
|
||||||
|
depends = "$auto, python3-dateparser"
|
||||||
|
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/default.env", "600"],
|
||||||
|
["web/static/**/*", "var/www/reminder-rs/static", "755"],
|
||||||
|
["web/templates/**/*", "var/www/reminder-rs/templates", "755"],
|
||||||
|
# ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"]
|
||||||
|
]
|
||||||
|
|
||||||
|
[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
|
42
README.md
42
README.md
@ -7,25 +7,30 @@ reminders are paid on the hosted version of the bot. Keep reading if you want to
|
|||||||
|
|
||||||
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust)
|
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust)
|
||||||
|
|
||||||
### Compiling
|
### Compiling for local target
|
||||||
Install build requirements:
|
1. Install requirements:
|
||||||
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential`
|
`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`
|
||||||
|
|
||||||
Install Rust from https://rustup.rs
|
### Compiling for other target
|
||||||
|
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.
|
||||||
|
|
||||||
Reminder Bot can then be built by running `cargo build --release` in the top level directory. It is necessary to create a
|
1. Install container software: `sudo apt install podman`.
|
||||||
folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of
|
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
|
||||||
dimensions 128x128px to be used as the webhook avatar.
|
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`
|
||||||
|
|
||||||
#### 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**
|
|
||||||
|
|
||||||
### Setting up Python
|
### Configuring
|
||||||
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
|
|
||||||
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
|
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
|
||||||
|
|
||||||
__Required Variables__
|
__Required Variables__
|
||||||
@ -37,10 +42,5 @@ __Other Variables__
|
|||||||
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
|
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
|
||||||
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
|
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
|
||||||
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
|
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
|
||||||
* `PYTHON_LOCATION` - default `venv/bin/python3`. Can be changed if your Python executable is located somewhere else
|
* `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is located somewhere else
|
||||||
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds
|
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds
|
||||||
* `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages
|
|
||||||
|
|
||||||
### Todo List
|
|
||||||
|
|
||||||
* Convert aliases to macros
|
|
||||||
|
10
Rocket.toml
10
Rocket.toml
@ -1,6 +1,6 @@
|
|||||||
[default]
|
[default]
|
||||||
address = "0.0.0.0"
|
address = "0.0.0.0"
|
||||||
port = 5000
|
port = 18920
|
||||||
template_dir = "web/templates"
|
template_dir = "web/templates"
|
||||||
limits = { json = "10MiB" }
|
limits = { json = "10MiB" }
|
||||||
|
|
||||||
@ -11,18 +11,18 @@ secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="
|
|||||||
certs = "web/private/rsa_sha256_cert.pem"
|
certs = "web/private/rsa_sha256_cert.pem"
|
||||||
key = "web/private/rsa_sha256_key.pem"
|
key = "web/private/rsa_sha256_key.pem"
|
||||||
|
|
||||||
[rsa_sha256.tls]
|
[debug.rsa_sha256.tls]
|
||||||
certs = "web/private/rsa_sha256_cert.pem"
|
certs = "web/private/rsa_sha256_cert.pem"
|
||||||
key = "web/private/rsa_sha256_key.pem"
|
key = "web/private/rsa_sha256_key.pem"
|
||||||
|
|
||||||
[ecdsa_nistp256_sha256.tls]
|
[debug.ecdsa_nistp256_sha256.tls]
|
||||||
certs = "web/private/ecdsa_nistp256_sha256_cert.pem"
|
certs = "web/private/ecdsa_nistp256_sha256_cert.pem"
|
||||||
key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem"
|
key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem"
|
||||||
|
|
||||||
[ecdsa_nistp384_sha384.tls]
|
[debug.ecdsa_nistp384_sha384.tls]
|
||||||
certs = "web/private/ecdsa_nistp384_sha384_cert.pem"
|
certs = "web/private/ecdsa_nistp384_sha384_cert.pem"
|
||||||
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
|
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
|
||||||
|
|
||||||
[ed25519.tls]
|
[debug.ed25519.tls]
|
||||||
certs = "web/private/ed25519_cert.pem"
|
certs = "web/private/ed25519_cert.pem"
|
||||||
key = "eb/private/ed25519_key.pem"
|
key = "eb/private/ed25519_key.pem"
|
||||||
|
BIN
assets/webhook.jpg
Normal file
BIN
assets/webhook.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
3
build.rs
Normal file
3
build.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=migrations");
|
||||||
|
}
|
16
conf/default.env
Normal file
16
conf/default.env
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
|
DISCORD_TOKEN=
|
||||||
|
PATREON_GUILD_ID=
|
||||||
|
PATREON_ROLE_ID=
|
||||||
|
|
||||||
|
LOCAL_TIMEZONE=
|
||||||
|
MIN_INTERVAL=
|
||||||
|
PYTHON_LOCATION=/usr/bin/python3
|
||||||
|
DONTRUN=web
|
||||||
|
SECRET_KEY=
|
||||||
|
|
||||||
|
REMIND_INTERVAL=
|
||||||
|
OAUTH2_DISCORD_CALLBACK=
|
||||||
|
OAUTH2_CLIENT_ID=
|
||||||
|
OAUTH2_CLIENT_SECRET=
|
13
debian/postinst
vendored
Normal file
13
debian/postinst
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
id -u reminder &>/dev/null || useradd -r -M reminder
|
||||||
|
|
||||||
|
if [ ! -f /etc/reminder-rs/config.env ]; then
|
||||||
|
cp /etc/reminder-rs/default.env /etc/reminder-rs/config.env
|
||||||
|
fi
|
||||||
|
|
||||||
|
chown reminder /etc/reminder-rs/config.env
|
||||||
|
|
||||||
|
#DEBHELPER#
|
11
debian/postrm
vendored
Normal file
11
debian/postrm
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
id -u reminder &>/dev/null || userdel reminder
|
||||||
|
|
||||||
|
if [ -f /etc/reminder-rs/config.env ]; then
|
||||||
|
rm /etc/reminder-rs/config.env
|
||||||
|
fi
|
||||||
|
|
||||||
|
#DEBHELPER#
|
@ -1,4 +0,0 @@
|
|||||||
USE reminders;
|
|
||||||
|
|
||||||
ALTER TABLE reminders.reminders RENAME COLUMN `interval` TO `interval_seconds`;
|
|
||||||
ALTER TABLE reminders.reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;
|
|
@ -1,10 +1,6 @@
|
|||||||
CREATE DATABASE IF NOT EXISTS reminders;
|
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS=0;
|
SET FOREIGN_KEY_CHECKS=0;
|
||||||
|
|
||||||
USE reminders;
|
CREATE TABLE guilds (
|
||||||
|
|
||||||
CREATE TABLE reminders.guilds (
|
|
||||||
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
||||||
guild BIGINT UNSIGNED UNIQUE NOT NULL,
|
guild BIGINT UNSIGNED UNIQUE NOT NULL,
|
||||||
|
|
||||||
@ -18,10 +14,10 @@ CREATE TABLE reminders.guilds (
|
|||||||
default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL,
|
default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (default_channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL
|
FOREIGN KEY (default_channel_id) REFERENCES channels(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.channels (
|
CREATE TABLE channels (
|
||||||
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
||||||
channel BIGINT UNSIGNED UNIQUE NOT NULL,
|
channel BIGINT UNSIGNED UNIQUE NOT NULL,
|
||||||
|
|
||||||
@ -39,10 +35,10 @@ CREATE TABLE reminders.channels (
|
|||||||
guild_id INT UNSIGNED,
|
guild_id INT UNSIGNED,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE
|
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.users (
|
CREATE TABLE users (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
user BIGINT UNSIGNED UNIQUE NOT NULL,
|
user BIGINT UNSIGNED UNIQUE NOT NULL,
|
||||||
|
|
||||||
@ -59,10 +55,10 @@ CREATE TABLE reminders.users (
|
|||||||
patreon BOOLEAN NOT NULL DEFAULT 0,
|
patreon BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (dm_channel) REFERENCES reminders.channels(id) ON DELETE RESTRICT
|
FOREIGN KEY (dm_channel) REFERENCES channels(id) ON DELETE RESTRICT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.roles (
|
CREATE TABLE roles (
|
||||||
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
|
||||||
role BIGINT UNSIGNED UNIQUE NOT NULL,
|
role BIGINT UNSIGNED UNIQUE NOT NULL,
|
||||||
|
|
||||||
@ -71,10 +67,10 @@ CREATE TABLE reminders.roles (
|
|||||||
guild_id INT UNSIGNED NOT NULL,
|
guild_id INT UNSIGNED NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE
|
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.embeds (
|
CREATE TABLE embeds (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
|
|
||||||
title VARCHAR(256) NOT NULL DEFAULT '',
|
title VARCHAR(256) NOT NULL DEFAULT '',
|
||||||
@ -91,7 +87,7 @@ CREATE TABLE reminders.embeds (
|
|||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.embed_fields (
|
CREATE TABLE embed_fields (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
|
|
||||||
title VARCHAR(256) NOT NULL DEFAULT '',
|
title VARCHAR(256) NOT NULL DEFAULT '',
|
||||||
@ -100,10 +96,10 @@ CREATE TABLE reminders.embed_fields (
|
|||||||
embed_id INT UNSIGNED NOT NULL,
|
embed_id INT UNSIGNED NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE CASCADE
|
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.messages (
|
CREATE TABLE messages (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
|
|
||||||
content VARCHAR(2048) NOT NULL DEFAULT '',
|
content VARCHAR(2048) NOT NULL DEFAULT '',
|
||||||
@ -114,10 +110,10 @@ CREATE TABLE reminders.messages (
|
|||||||
attachment_name VARCHAR(260),
|
attachment_name VARCHAR(260),
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE SET NULL
|
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.reminders (
|
CREATE TABLE reminders (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
uid VARCHAR(64) UNIQUE NOT NULL,
|
uid VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
|
||||||
@ -140,20 +136,20 @@ CREATE TABLE reminders.reminders (
|
|||||||
set_by INT UNSIGNED,
|
set_by INT UNSIGNED,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (message_id) REFERENCES reminders.messages(id) ON DELETE RESTRICT,
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE RESTRICT,
|
||||||
FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE CASCADE,
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (set_by) REFERENCES reminders.users(id) ON DELETE SET NULL
|
FOREIGN KEY (set_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TRIGGER message_cleanup AFTER DELETE ON reminders.reminders
|
CREATE TRIGGER message_cleanup AFTER DELETE ON reminders
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
DELETE FROM reminders.messages WHERE id = OLD.message_id;
|
DELETE FROM messages WHERE id = OLD.message_id;
|
||||||
|
|
||||||
CREATE TRIGGER embed_cleanup AFTER DELETE ON reminders.messages
|
CREATE TRIGGER embed_cleanup AFTER DELETE ON messages
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
DELETE FROM reminders.embeds WHERE id = OLD.embed_id;
|
DELETE FROM embeds WHERE id = OLD.embed_id;
|
||||||
|
|
||||||
CREATE TABLE reminders.todos (
|
CREATE TABLE todos (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
user_id INT UNSIGNED,
|
user_id INT UNSIGNED,
|
||||||
guild_id INT UNSIGNED,
|
guild_id INT UNSIGNED,
|
||||||
@ -161,23 +157,23 @@ CREATE TABLE reminders.todos (
|
|||||||
value VARCHAR(2000) NOT NULL,
|
value VARCHAR(2000) NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
|
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.command_restrictions (
|
CREATE TABLE command_restrictions (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
|
|
||||||
role_id INT UNSIGNED NOT NULL,
|
role_id INT UNSIGNED NOT NULL,
|
||||||
command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
|
command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (role_id) REFERENCES reminders.roles(id) ON DELETE CASCADE,
|
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
UNIQUE KEY (`role_id`, `command`)
|
UNIQUE KEY (`role_id`, `command`)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.timers (
|
CREATE TABLE timers (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
start_time TIMESTAMP NOT NULL DEFAULT NOW(),
|
start_time TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
name VARCHAR(32) NOT NULL,
|
name VARCHAR(32) NOT NULL,
|
||||||
@ -186,7 +182,7 @@ CREATE TABLE reminders.timers (
|
|||||||
PRIMARY KEY (id)
|
PRIMARY KEY (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.events (
|
CREATE TABLE events (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
`time` TIMESTAMP NOT NULL DEFAULT NOW(),
|
`time` TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
@ -198,12 +194,12 @@ CREATE TABLE reminders.events (
|
|||||||
reminder_id INT UNSIGNED,
|
reminder_id INT UNSIGNED,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
|
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (reminder_id) REFERENCES reminders.reminders(id) ON DELETE SET NULL
|
FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.command_aliases (
|
CREATE TABLE command_aliases (
|
||||||
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
|
||||||
|
|
||||||
guild_id INT UNSIGNED NOT NULL,
|
guild_id INT UNSIGNED NOT NULL,
|
||||||
@ -212,22 +208,22 @@ CREATE TABLE reminders.command_aliases (
|
|||||||
command VARCHAR(2048) NOT NULL,
|
command VARCHAR(2048) NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
|
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||||
UNIQUE KEY (`guild_id`, `name`)
|
UNIQUE KEY (`guild_id`, `name`)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE reminders.guild_users (
|
CREATE TABLE guild_users (
|
||||||
guild INT UNSIGNED NOT NULL,
|
guild INT UNSIGNED NOT NULL,
|
||||||
user INT UNSIGNED NOT NULL,
|
user INT UNSIGNED NOT NULL,
|
||||||
|
|
||||||
can_access BOOL NOT NULL DEFAULT 0,
|
can_access BOOL NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
FOREIGN KEY (guild) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
|
FOREIGN KEY (guild) REFERENCES guilds(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user) REFERENCES reminders.users(id) ON DELETE CASCADE,
|
FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
UNIQUE KEY (guild, user)
|
UNIQUE KEY (guild, user)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE EVENT reminders.event_cleanup
|
CREATE EVENT event_cleanup
|
||||||
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
|
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
|
||||||
ON COMPLETION PRESERVE
|
ON COMPLETION PRESERVE
|
||||||
DO DELETE FROM reminders.events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);
|
DO DELETE FROM events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);
|
@ -1,5 +1,3 @@
|
|||||||
USE reminders;
|
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 0;
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
DROP TABLE IF EXISTS reminders_new;
|
DROP TABLE IF EXISTS reminders_new;
|
@ -1,5 +1,3 @@
|
|||||||
USE reminders;
|
|
||||||
|
|
||||||
CREATE TABLE macro (
|
CREATE TABLE macro (
|
||||||
id INT UNSIGNED AUTO_INCREMENT,
|
id INT UNSIGNED AUTO_INCREMENT,
|
||||||
guild_id INT UNSIGNED NOT NULL,
|
guild_id INT UNSIGNED NOT NULL,
|
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE reminders RENAME COLUMN `interval` TO `interval_seconds`;
|
||||||
|
ALTER TABLE reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;
|
@ -1,5 +1,3 @@
|
|||||||
USE reminders;
|
|
||||||
|
|
||||||
CREATE TABLE reminder_template (
|
CREATE TABLE reminder_template (
|
||||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
|
1
migrations/20221210000000_reminder_daily_intervals.sql
Normal file
1
migrations/20221210000000_reminder_daily_intervals.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE reminders ADD COLUMN `interval_days` INT UNSIGNED DEFAULT NULL;
|
2
migrations/20230511125236_reminder_threads.sql
Normal file
2
migrations/20230511125236_reminder_threads.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
ALTER TABLE reminders ADD COLUMN `thread_id` BIGINT DEFAULT NULL;
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -12,5 +12,5 @@ chrono-tz = { version = "0.5", features = ["serde"] }
|
|||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
num-integer = "0.1"
|
num-integer = "0.1"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
|
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
|
||||||
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
use chrono::Duration;
|
use chrono::{DateTime, Days, Duration, Months};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
@ -62,18 +62,23 @@ pub fn substitute(string: &str) -> String {
|
|||||||
let format = caps.name("format").map(|m| m.as_str());
|
let format = caps.name("format").map(|m| m.as_str());
|
||||||
|
|
||||||
if let (Some(final_time), Some(format)) = (final_time, format) {
|
if let (Some(final_time), Some(format)) = (final_time, format) {
|
||||||
let dt = NaiveDateTime::from_timestamp(final_time, 0);
|
match NaiveDateTime::from_timestamp_opt(final_time, 0) {
|
||||||
let now = Utc::now().naive_utc();
|
Some(dt) => {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
let difference = {
|
let difference = {
|
||||||
if now < dt {
|
if now < dt {
|
||||||
dt - Utc::now().naive_utc()
|
dt - Utc::now().naive_utc()
|
||||||
} else {
|
} else {
|
||||||
Utc::now().naive_utc() - dt
|
Utc::now().naive_utc() - dt
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fmt_displacement(format, difference.num_seconds() as u64)
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
fmt_displacement(format, difference.num_seconds() as u64)
|
None => String::new(),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
@ -243,11 +248,12 @@ pub struct Reminder {
|
|||||||
attachment: Option<Vec<u8>>,
|
attachment: Option<Vec<u8>>,
|
||||||
attachment_name: Option<String>,
|
attachment_name: Option<String>,
|
||||||
|
|
||||||
utc_time: NaiveDateTime,
|
utc_time: DateTime<Utc>,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
restartable: bool,
|
restartable: bool,
|
||||||
expires: Option<NaiveDateTime>,
|
expires: Option<DateTime<Utc>>,
|
||||||
interval_seconds: Option<u32>,
|
interval_seconds: Option<u32>,
|
||||||
|
interval_days: Option<u32>,
|
||||||
interval_months: Option<u32>,
|
interval_months: Option<u32>,
|
||||||
|
|
||||||
avatar: Option<String>,
|
avatar: Option<String>,
|
||||||
@ -281,6 +287,7 @@ SELECT
|
|||||||
reminders.`restartable` AS restartable,
|
reminders.`restartable` AS restartable,
|
||||||
reminders.`expires` AS 'expires',
|
reminders.`expires` AS 'expires',
|
||||||
reminders.`interval_seconds` AS 'interval_seconds',
|
reminders.`interval_seconds` AS 'interval_seconds',
|
||||||
|
reminders.`interval_days` AS 'interval_days',
|
||||||
reminders.`interval_months` AS 'interval_months',
|
reminders.`interval_months` AS 'interval_months',
|
||||||
|
|
||||||
reminders.`avatar` AS avatar,
|
reminders.`avatar` AS avatar,
|
||||||
@ -330,9 +337,7 @@ WHERE
|
|||||||
|
|
||||||
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||||
let _ = sqlx::query!(
|
let _ = sqlx::query!(
|
||||||
"
|
"UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?",
|
||||||
UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
|
|
||||||
",
|
|
||||||
self.channel_id
|
self.channel_id
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@ -341,55 +346,43 @@ UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
|
|||||||
|
|
||||||
async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||||
if self.interval_seconds.is_some() || self.interval_months.is_some() {
|
if self.interval_seconds.is_some() || self.interval_months.is_some() {
|
||||||
let now = Utc::now().naive_local();
|
let now = Utc::now();
|
||||||
let mut updated_reminder_time = self.utc_time;
|
let mut updated_reminder_time =
|
||||||
|
self.utc_time.with_timezone(&self.timezone.parse().unwrap_or(Tz::UTC));
|
||||||
|
|
||||||
if let Some(interval) = self.interval_months {
|
while updated_reminder_time < now {
|
||||||
match sqlx::query!(
|
if let Some(interval) = self.interval_months {
|
||||||
// use the second date_add to force return value to datetime
|
updated_reminder_time = updated_reminder_time
|
||||||
"SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time",
|
.checked_add_months(Months::new(interval))
|
||||||
updated_reminder_time,
|
.unwrap_or_else(|| {
|
||||||
interval
|
warn!("Could not add months to a reminder");
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(row) => match row.new_time {
|
|
||||||
Some(datetime) => {
|
|
||||||
updated_reminder_time = datetime;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
warn!("Could not update interval by months: got NULL");
|
|
||||||
|
|
||||||
updated_reminder_time += Duration::days(30);
|
updated_reminder_time
|
||||||
}
|
});
|
||||||
},
|
}
|
||||||
|
|
||||||
Err(e) => {
|
if let Some(interval) = self.interval_days {
|
||||||
warn!("Could not update interval by months: {:?}", e);
|
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");
|
||||||
|
|
||||||
// naively fallback to adding 30 days
|
updated_reminder_time
|
||||||
updated_reminder_time += Duration::days(30);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(interval) = self.interval_seconds {
|
||||||
|
updated_reminder_time =
|
||||||
|
updated_reminder_time + Duration::seconds(interval as i64);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(interval) = self.interval_seconds {
|
if self.expires.map_or(false, |expires| updated_reminder_time > expires) {
|
||||||
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;
|
self.force_delete(pool).await;
|
||||||
} else {
|
} else {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"UPDATE reminders SET `utc_time` = ? WHERE `id` = ?",
|
||||||
UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
|
updated_reminder_time.with_timezone(&Utc),
|
||||||
",
|
|
||||||
updated_reminder_time,
|
|
||||||
self.id
|
self.id
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@ -402,15 +395,10 @@ UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||||
sqlx::query!(
|
sqlx::query!("DELETE FROM reminders WHERE `id` = ?", self.id)
|
||||||
"
|
.execute(pool)
|
||||||
DELETE FROM reminders WHERE `id` = ?
|
.await
|
||||||
",
|
.expect(&format!("Could not delete Reminder {}", self.id));
|
||||||
self.id
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await
|
|
||||||
.expect(&format!("Could not delete Reminder {}", self.id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) {
|
async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) {
|
||||||
@ -504,7 +492,9 @@ DELETE FROM reminders WHERE `id` = ?
|
|||||||
w.content(&reminder.content).tts(reminder.tts);
|
w.content(&reminder.content).tts(reminder.tts);
|
||||||
|
|
||||||
if let Some(username) = &reminder.username {
|
if let Some(username) = &reminder.username {
|
||||||
w.username(username);
|
if !username.is_empty() {
|
||||||
|
w.username(username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(avatar) = &reminder.avatar {
|
if let Some(avatar) = &reminder.avatar {
|
||||||
@ -548,9 +538,7 @@ DELETE FROM reminders WHERE `id` = ?
|
|||||||
.map_or(true, |inner| inner >= Utc::now().naive_local()))
|
.map_or(true, |inner| inner >= Utc::now().naive_local()))
|
||||||
{
|
{
|
||||||
let _ = sqlx::query!(
|
let _ = sqlx::query!(
|
||||||
"
|
"UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?",
|
||||||
UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
|
|
||||||
",
|
|
||||||
self.channel_id
|
self.channel_id
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
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: "now".to_string(),
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
if diff > 86400 {
|
||||||
|
vec![
|
||||||
|
AutocompleteChoice {
|
||||||
|
name: partial.to_string(),
|
||||||
|
value: partial.to_string(),
|
||||||
|
},
|
||||||
|
AutocompleteChoice {
|
||||||
|
name: format!(
|
||||||
|
"In approximately {} days, {} hours",
|
||||||
|
diff / 86400,
|
||||||
|
(diff % 86400) / 3600
|
||||||
|
),
|
||||||
|
value: partial.to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} else if diff > 3600 {
|
||||||
|
vec![
|
||||||
|
AutocompleteChoice {
|
||||||
|
name: partial.to_string(),
|
||||||
|
value: partial.to_string(),
|
||||||
|
},
|
||||||
|
AutocompleteChoice {
|
||||||
|
name: format!("In approximately {} hours", diff / 3600),
|
||||||
|
value: partial.to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
AutocompleteChoice {
|
||||||
|
name: partial.to_string(),
|
||||||
|
value: partial.to_string(),
|
||||||
|
},
|
||||||
|
AutocompleteChoice {
|
||||||
|
name: format!("In approximately {} minutes", diff / 60),
|
||||||
|
value: partial.to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
vec![AutocompleteChoice {
|
||||||
|
name: partial.to_string(),
|
||||||
|
value: partial.to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
None => {
|
||||||
|
vec![AutocompleteChoice {
|
||||||
|
name: "Time not recognised".to_string(),
|
||||||
|
value: "now".to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
46
src/commands/command_macro/delete.rs
Normal file
46
src/commands/command_macro/delete.rs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
use super::super::autocomplete::macro_name_autocomplete;
|
||||||
|
use crate::{Context, Error};
|
||||||
|
|
||||||
|
/// Delete a recorded macro
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "delete",
|
||||||
|
guild_only = true,
|
||||||
|
default_member_permissions = "MANAGE_GUILD",
|
||||||
|
identifying_name = "delete_macro"
|
||||||
|
)]
|
||||||
|
pub async fn delete_macro(
|
||||||
|
ctx: Context<'_>,
|
||||||
|
#[description = "Name of macro to delete"]
|
||||||
|
#[autocomplete = "macro_name_autocomplete"]
|
||||||
|
name: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
match sqlx::query!(
|
||||||
|
"
|
||||||
|
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
|
||||||
|
ctx.guild_id().unwrap().0,
|
||||||
|
name
|
||||||
|
)
|
||||||
|
.fetch_one(&ctx.data().database)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(row) => {
|
||||||
|
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
|
||||||
|
.execute(&ctx.data().database)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(sqlx::Error::RowNotFound) => {
|
||||||
|
ctx.say(format!("Macro \"{}\" not found", name)).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e) => {
|
||||||
|
panic!("{}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
89
src/commands/command_macro/list.rs
Normal file
89
src/commands/command_macro/list.rs
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
use poise::CreateReply;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
component_models::pager::{MacroPager, Pager},
|
||||||
|
consts::THEME_COLOR,
|
||||||
|
models::{command_macro::CommandMacro, CtxData},
|
||||||
|
Context, Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// List recorded macros
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "list",
|
||||||
|
guild_only = true,
|
||||||
|
default_member_permissions = "MANAGE_GUILD",
|
||||||
|
identifying_name = "list_macro"
|
||||||
|
)]
|
||||||
|
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let macros = ctx.command_macros().await?;
|
||||||
|
|
||||||
|
let resp = show_macro_page(¯os, 0);
|
||||||
|
|
||||||
|
ctx.send(|m| {
|
||||||
|
*m = resp;
|
||||||
|
m
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
|
||||||
|
((macros.len() as f64) / 25.0).ceil() as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
|
||||||
|
let pager = MacroPager::new(page);
|
||||||
|
|
||||||
|
if macros.is_empty() {
|
||||||
|
let mut reply = CreateReply::default();
|
||||||
|
|
||||||
|
reply.embed(|e| {
|
||||||
|
e.title("Macros")
|
||||||
|
.description("No Macros Set Up. Use `/macro record` to get started.")
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pages = max_macro_page(macros);
|
||||||
|
|
||||||
|
let mut page = page;
|
||||||
|
if page >= pages {
|
||||||
|
page = pages - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = (page * 25).min(macros.len());
|
||||||
|
let upper = ((page + 1) * 25).min(macros.len());
|
||||||
|
|
||||||
|
let fields = macros[lower..upper].iter().map(|m| {
|
||||||
|
if let Some(description) = &m.description {
|
||||||
|
(
|
||||||
|
m.name.clone(),
|
||||||
|
format!("*{}*\n- Has {} commands", description, m.commands.len()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(m.name.clone(), format!("- Has {} commands", m.commands.len()), true)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut reply = CreateReply::default();
|
||||||
|
|
||||||
|
reply
|
||||||
|
.embed(|e| {
|
||||||
|
e.title("Macros")
|
||||||
|
.fields(fields)
|
||||||
|
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
.components(|comp| {
|
||||||
|
pager.create_button_row(pages, comp);
|
||||||
|
|
||||||
|
comp
|
||||||
|
});
|
||||||
|
|
||||||
|
reply
|
||||||
|
}
|
229
src/commands/command_macro/migrate.rs
Normal file
229
src/commands/command_macro/migrate.rs
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
use lazy_regex::regex;
|
||||||
|
use poise::serenity_prelude::command::CommandOptionType;
|
||||||
|
use regex::Captures;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::{models::command_macro::RawCommandMacro, Context, Error, GuildId};
|
||||||
|
|
||||||
|
struct Alias {
|
||||||
|
name: String,
|
||||||
|
command: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrate old $alias reminder commands to macros. Only macro names that are not taken will be used.
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "migrate",
|
||||||
|
guild_only = true,
|
||||||
|
default_member_permissions = "MANAGE_GUILD",
|
||||||
|
identifying_name = "migrate_macro"
|
||||||
|
)]
|
||||||
|
pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let guild_id = ctx.guild_id().unwrap();
|
||||||
|
let mut transaction = ctx.data().database.begin().await?;
|
||||||
|
|
||||||
|
let aliases = sqlx::query_as!(
|
||||||
|
Alias,
|
||||||
|
"SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||||
|
guild_id.0
|
||||||
|
)
|
||||||
|
.fetch_all(&mut transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut added_aliases = 0;
|
||||||
|
|
||||||
|
for alias in aliases {
|
||||||
|
match parse_text_command(guild_id, alias.name, &alias.command) {
|
||||||
|
Some(cmd_macro) => {
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
|
||||||
|
cmd_macro.guild_id.0,
|
||||||
|
cmd_macro.name,
|
||||||
|
cmd_macro.description,
|
||||||
|
cmd_macro.commands
|
||||||
|
)
|
||||||
|
.execute(&mut transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
added_aliases += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.commit().await?;
|
||||||
|
|
||||||
|
ctx.send(|b| b.content(format!("Added {} macros.", added_aliases))).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_text_command(
|
||||||
|
guild_id: GuildId,
|
||||||
|
alias_name: String,
|
||||||
|
command: &str,
|
||||||
|
) -> Option<RawCommandMacro> {
|
||||||
|
match command.split_once(" ") {
|
||||||
|
Some((command_word, args)) => {
|
||||||
|
let command_word = command_word.to_lowercase();
|
||||||
|
|
||||||
|
if command_word == "r"
|
||||||
|
|| command_word == "i"
|
||||||
|
|| command_word == "remind"
|
||||||
|
|| command_word == "interval"
|
||||||
|
{
|
||||||
|
let matcher = regex!(
|
||||||
|
r#"(?P<mentions>(?:<@\d+>\s+|<@!\d+>\s+|<#\d+>\s+)*)(?P<time>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+)(?:\s+(?P<interval>(?:(?:\d+)(?:s|m|h|d|))+))?(?:\s+(?P<expires>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+))?\s+(?P<content>.*)"#s
|
||||||
|
);
|
||||||
|
|
||||||
|
match matcher.captures(&args) {
|
||||||
|
Some(captures) => {
|
||||||
|
let mut args: Vec<Value> = vec![];
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("time") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "time",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("content") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "content",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("interval") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "interval",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("expires") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "expires",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("mentions") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "channels",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(RawCommandMacro {
|
||||||
|
guild_id,
|
||||||
|
name: alias_name,
|
||||||
|
description: None,
|
||||||
|
commands: json!([
|
||||||
|
{
|
||||||
|
"command_name": "remind",
|
||||||
|
"options": args,
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else if command_word == "n" || command_word == "natural" {
|
||||||
|
let matcher_primary = regex!(
|
||||||
|
r#"(?P<time>.*?)(?:\s+)(?:send|say)(?:\s+)(?P<content>.*?)(?:(?:\s+)to(?:\s+)(?P<mentions>((?:<@\d+>)|(?:<@!\d+>)|(?:<#\d+>)|(?:\s+))+))?$"#s
|
||||||
|
);
|
||||||
|
let matcher_secondary = regex!(
|
||||||
|
r#"(?P<msg>.*)(?:\s+)every(?:\s+)(?P<interval>.*?)(?:(?:\s+)(?:until|for)(?:\s+)(?P<expires>.*?))?$"#s
|
||||||
|
);
|
||||||
|
|
||||||
|
match matcher_primary.captures(&args) {
|
||||||
|
Some(captures) => {
|
||||||
|
let captures_secondary = matcher_secondary.captures(&args);
|
||||||
|
|
||||||
|
let mut args: Vec<Value> = vec![];
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("time") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "time",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("content") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "content",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) =
|
||||||
|
captures_secondary.as_ref().and_then(|c: &Captures| c.name("interval"))
|
||||||
|
{
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "interval",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) =
|
||||||
|
captures_secondary.and_then(|c: Captures| c.name("expires"))
|
||||||
|
{
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "expires",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = captures.name("mentions") {
|
||||||
|
let content = group.as_str();
|
||||||
|
args.push(json!({
|
||||||
|
"name": "channels",
|
||||||
|
"value": content,
|
||||||
|
"type": CommandOptionType::String,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(RawCommandMacro {
|
||||||
|
guild_id,
|
||||||
|
name: alias_name,
|
||||||
|
description: None,
|
||||||
|
commands: json!([
|
||||||
|
{
|
||||||
|
"command_name": "remind",
|
||||||
|
"options": args,
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
19
src/commands/command_macro/mod.rs
Normal file
19
src/commands/command_macro/mod.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
use crate::{Context, Error};
|
||||||
|
|
||||||
|
pub mod delete;
|
||||||
|
pub mod list;
|
||||||
|
pub mod migrate;
|
||||||
|
pub mod record;
|
||||||
|
pub mod run;
|
||||||
|
|
||||||
|
/// Record and replay command sequences
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "macro",
|
||||||
|
guild_only = true,
|
||||||
|
default_member_permissions = "MANAGE_GUILD",
|
||||||
|
identifying_name = "macro_base"
|
||||||
|
)]
|
||||||
|
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
151
src/commands/command_macro/record.rs
Normal file
151
src/commands/command_macro/record.rs
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
use std::collections::hash_map::Entry;
|
||||||
|
|
||||||
|
use crate::{consts::THEME_COLOR, models::command_macro::CommandMacro, Context, Error};
|
||||||
|
|
||||||
|
/// Start recording up to 5 commands to replay
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "record",
|
||||||
|
guild_only = true,
|
||||||
|
default_member_permissions = "MANAGE_GUILD",
|
||||||
|
identifying_name = "record_macro"
|
||||||
|
)]
|
||||||
|
pub async fn record_macro(
|
||||||
|
ctx: Context<'_>,
|
||||||
|
#[description = "Name for the new macro"] name: String,
|
||||||
|
#[description = "Description for the new macro"] description: Option<String>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
if name.len() > 100 {
|
||||||
|
ctx.say("Name must be less than 100 characters").await?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if description.as_ref().map_or(0, |d| d.len()) > 100 {
|
||||||
|
ctx.say("Description must be less than 100 characters").await?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let guild_id = ctx.guild_id().unwrap();
|
||||||
|
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"
|
||||||
|
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
|
||||||
|
guild_id.0,
|
||||||
|
name
|
||||||
|
)
|
||||||
|
.fetch_one(&ctx.data().database)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if row.is_ok() {
|
||||||
|
ctx.send(|m| {
|
||||||
|
m.ephemeral(true).embed(|e| {
|
||||||
|
e.title("Unique Name Required")
|
||||||
|
.description(
|
||||||
|
"A macro already exists under this name.
|
||||||
|
Please select a unique name for your macro.",
|
||||||
|
)
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
let okay = {
|
||||||
|
let mut lock = ctx.data().recording_macros.write().await;
|
||||||
|
|
||||||
|
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
|
||||||
|
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if okay {
|
||||||
|
ctx.send(|m| {
|
||||||
|
m.ephemeral(true).embed(|e| {
|
||||||
|
e.title("Macro Recording Started")
|
||||||
|
.description(
|
||||||
|
"Run up to 5 commands, or type `/macro finish` to stop at any point.
|
||||||
|
Any commands ran as part of recording will be inconsequential",
|
||||||
|
)
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
ctx.send(|m| {
|
||||||
|
m.ephemeral(true).embed(|e| {
|
||||||
|
e.title("Macro Already Recording")
|
||||||
|
.description(
|
||||||
|
"You are already recording a macro in this server.
|
||||||
|
Please use `/macro finish` to end this recording before starting another.",
|
||||||
|
)
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finish current macro recording
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "finish",
|
||||||
|
guild_only = true,
|
||||||
|
default_member_permissions = "MANAGE_GUILD",
|
||||||
|
identifying_name = "finish_macro"
|
||||||
|
)]
|
||||||
|
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let key = (ctx.guild_id().unwrap(), ctx.author().id);
|
||||||
|
|
||||||
|
{
|
||||||
|
let lock = ctx.data().recording_macros.read().await;
|
||||||
|
let contained = lock.get(&key);
|
||||||
|
|
||||||
|
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
|
||||||
|
ctx.send(|m| {
|
||||||
|
m.embed(|e| {
|
||||||
|
e.title("No Macro Recorded")
|
||||||
|
.description("Use `/macro record` to start recording a macro")
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
let command_macro = contained.unwrap();
|
||||||
|
let json = serde_json::to_string(&command_macro.commands).unwrap();
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
|
||||||
|
command_macro.guild_id.0,
|
||||||
|
command_macro.name,
|
||||||
|
command_macro.description,
|
||||||
|
json
|
||||||
|
)
|
||||||
|
.execute(&ctx.data().database)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
ctx.send(|m| {
|
||||||
|
m.embed(|e| {
|
||||||
|
e.title("Macro Recorded")
|
||||||
|
.description("Use `/macro run` to execute the macro")
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut lock = ctx.data().recording_macros.write().await;
|
||||||
|
lock.remove(&key);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
56
src/commands/command_macro/run.rs
Normal file
56
src/commands/command_macro/run.rs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
use super::super::autocomplete::macro_name_autocomplete;
|
||||||
|
use crate::{models::command_macro::guild_command_macro, Context, Data, Error, THEME_COLOR};
|
||||||
|
|
||||||
|
/// Run a recorded macro
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
rename = "run",
|
||||||
|
guild_only = true,
|
||||||
|
default_member_permissions = "MANAGE_GUILD",
|
||||||
|
identifying_name = "run_macro"
|
||||||
|
)]
|
||||||
|
pub async fn run_macro(
|
||||||
|
ctx: poise::ApplicationContext<'_, Data, Error>,
|
||||||
|
#[description = "Name of macro to run"]
|
||||||
|
#[autocomplete = "macro_name_autocomplete"]
|
||||||
|
name: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
match guild_command_macro(&Context::Application(ctx), &name).await {
|
||||||
|
Some(command_macro) => {
|
||||||
|
Context::Application(ctx)
|
||||||
|
.send(|b| {
|
||||||
|
b.embed(|e| {
|
||||||
|
e.title("Running Macro").color(*THEME_COLOR).description(format!(
|
||||||
|
"Running macro {} ({} commands)",
|
||||||
|
command_macro.name,
|
||||||
|
command_macro.commands.len()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for command in command_macro.commands {
|
||||||
|
if let Some(action) = command.action {
|
||||||
|
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => {
|
||||||
|
println!("{:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Context::Application(ctx)
|
||||||
|
.say(format!("Command \"{}\" not found", command.command_name))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None => {
|
||||||
|
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
|
mod autocomplete;
|
||||||
|
pub mod command_macro;
|
||||||
pub mod info_cmds;
|
pub mod info_cmds;
|
||||||
pub mod moderation_cmds;
|
pub mod moderation_cmds;
|
||||||
pub mod reminder_cmds;
|
pub mod reminder_cmds;
|
||||||
|
@ -1,32 +1,10 @@
|
|||||||
use std::collections::hash_map::Entry;
|
|
||||||
|
|
||||||
use chrono::offset::Utc;
|
use chrono::offset::Utc;
|
||||||
use chrono_tz::{Tz, TZ_VARIANTS};
|
use chrono_tz::{Tz, TZ_VARIANTS};
|
||||||
use levenshtein::levenshtein;
|
use levenshtein::levenshtein;
|
||||||
use poise::CreateReply;
|
use log::warn;
|
||||||
|
|
||||||
use crate::{
|
use super::autocomplete::timezone_autocomplete;
|
||||||
component_models::pager::{MacroPager, Pager},
|
use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
|
||||||
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
|
|
||||||
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>>()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Select your timezone
|
/// Select your timezone
|
||||||
#[poise::command(slash_command, identifying_name = "timezone")]
|
#[poise::command(slash_command, identifying_name = "timezone")]
|
||||||
@ -180,399 +158,26 @@ pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
|
|||||||
match ctx.channel_data().await {
|
match ctx.channel_data().await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
|
if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
|
||||||
let _ = ctx
|
ctx.send(|b| {
|
||||||
.send(|b| {
|
b.ephemeral(true).content(format!(
|
||||||
b.ephemeral(true).content(format!(
|
"**Warning!**
|
||||||
"**Warning!**
|
|
||||||
This link can be used by users to anonymously send messages, with or without permissions.
|
This link can be used by users to anonymously send messages, with or without permissions.
|
||||||
Do not share it!
|
Do not share it!
|
||||||
|| https://discord.com/api/webhooks/{}/{} ||",
|
|| https://discord.com/api/webhooks/{}/{} ||",
|
||||||
id, token,
|
id, token,
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.await;
|
.await?;
|
||||||
} else {
|
} else {
|
||||||
let _ = ctx.say("No webhook configured on this channel.").await;
|
ctx.say("No webhook configured on this channel.").await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
|
||||||
let _ = ctx.say("No webhook configured on this channel.").await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
|
|
||||||
sqlx::query!(
|
|
||||||
"
|
|
||||||
SELECT name
|
|
||||||
FROM macro
|
|
||||||
WHERE
|
|
||||||
guild_id = (SELECT id FROM guilds WHERE guild = ?)
|
|
||||||
AND name LIKE CONCAT(?, '%')",
|
|
||||||
ctx.guild_id().unwrap().0,
|
|
||||||
partial,
|
|
||||||
)
|
|
||||||
.fetch_all(&ctx.data().database)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default()
|
|
||||||
.iter()
|
|
||||||
.map(|s| s.name.clone())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record and replay command sequences
|
|
||||||
#[poise::command(
|
|
||||||
slash_command,
|
|
||||||
rename = "macro",
|
|
||||||
guild_only = true,
|
|
||||||
default_member_permissions = "MANAGE_GUILD",
|
|
||||||
identifying_name = "macro_base"
|
|
||||||
)]
|
|
||||||
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start recording up to 5 commands to replay
|
|
||||||
#[poise::command(
|
|
||||||
slash_command,
|
|
||||||
rename = "record",
|
|
||||||
guild_only = true,
|
|
||||||
default_member_permissions = "MANAGE_GUILD",
|
|
||||||
identifying_name = "record_macro"
|
|
||||||
)]
|
|
||||||
pub async fn record_macro(
|
|
||||||
ctx: Context<'_>,
|
|
||||||
#[description = "Name for the new macro"] name: String,
|
|
||||||
#[description = "Description for the new macro"] description: Option<String>,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let guild_id = ctx.guild_id().unwrap();
|
|
||||||
|
|
||||||
let row = sqlx::query!(
|
|
||||||
"
|
|
||||||
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
|
|
||||||
guild_id.0,
|
|
||||||
name
|
|
||||||
)
|
|
||||||
.fetch_one(&ctx.data().database)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if row.is_ok() {
|
|
||||||
ctx.send(|m| {
|
|
||||||
m.ephemeral(true).embed(|e| {
|
|
||||||
e.title("Unique Name Required")
|
|
||||||
.description(
|
|
||||||
"A macro already exists under this name.
|
|
||||||
Please select a unique name for your macro.",
|
|
||||||
)
|
|
||||||
.color(*THEME_COLOR)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
let okay = {
|
|
||||||
let mut lock = ctx.data().recording_macros.write().await;
|
|
||||||
|
|
||||||
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
|
|
||||||
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if okay {
|
|
||||||
ctx.send(|m| {
|
|
||||||
m.ephemeral(true).embed(|e| {
|
|
||||||
e.title("Macro Recording Started")
|
|
||||||
.description(
|
|
||||||
"Run up to 5 commands, or type `/macro finish` to stop at any point.
|
|
||||||
Any commands ran as part of recording will be inconsequential",
|
|
||||||
)
|
|
||||||
.color(*THEME_COLOR)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List recorded macros
|
|
||||||
#[poise::command(
|
|
||||||
slash_command,
|
|
||||||
rename = "list",
|
|
||||||
guild_only = true,
|
|
||||||
default_member_permissions = "MANAGE_GUILD",
|
|
||||||
identifying_name = "list_macro"
|
|
||||||
)]
|
|
||||||
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
|
|
||||||
let macros = ctx.command_macros().await?;
|
|
||||||
|
|
||||||
let resp = show_macro_page(¯os, 0);
|
|
||||||
|
|
||||||
ctx.send(|m| {
|
|
||||||
*m = resp;
|
|
||||||
m
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a recorded macro
|
|
||||||
#[poise::command(
|
|
||||||
slash_command,
|
|
||||||
rename = "run",
|
|
||||||
guild_only = true,
|
|
||||||
default_member_permissions = "MANAGE_GUILD",
|
|
||||||
identifying_name = "run_macro"
|
|
||||||
)]
|
|
||||||
pub async fn run_macro(
|
|
||||||
ctx: poise::ApplicationContext<'_, Data, Error>,
|
|
||||||
#[description = "Name of macro to run"]
|
|
||||||
#[autocomplete = "macro_name_autocomplete"]
|
|
||||||
name: String,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
match guild_command_macro(&Context::Application(ctx), &name).await {
|
|
||||||
Some(command_macro) => {
|
|
||||||
ctx.defer_response(false).await?;
|
|
||||||
|
|
||||||
for command in command_macro.commands {
|
|
||||||
if let Some(action) = command.action {
|
|
||||||
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(e) => {
|
|
||||||
println!("{:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Context::Application(ctx)
|
|
||||||
.say(format!("Command \"{}\" not found", command.command_name))
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None => {
|
|
||||||
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a recorded macro
|
|
||||||
#[poise::command(
|
|
||||||
slash_command,
|
|
||||||
rename = "delete",
|
|
||||||
guild_only = true,
|
|
||||||
default_member_permissions = "MANAGE_GUILD",
|
|
||||||
identifying_name = "delete_macro"
|
|
||||||
)]
|
|
||||||
pub async fn delete_macro(
|
|
||||||
ctx: Context<'_>,
|
|
||||||
#[description = "Name of macro to delete"]
|
|
||||||
#[autocomplete = "macro_name_autocomplete"]
|
|
||||||
name: String,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
match sqlx::query!(
|
|
||||||
"
|
|
||||||
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
|
|
||||||
ctx.guild_id().unwrap().0,
|
|
||||||
name
|
|
||||||
)
|
|
||||||
.fetch_one(&ctx.data().database)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(row) => {
|
|
||||||
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
|
|
||||||
.execute(&ctx.data().database)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(sqlx::Error::RowNotFound) => {
|
|
||||||
ctx.say(format!("Macro \"{}\" not found", name)).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
panic!("{}", e);
|
warn!("Error fetching channel data: {:?}", e);
|
||||||
|
|
||||||
|
ctx.say("No webhook configured on this channel.").await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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,17 @@
|
|||||||
use std::{
|
use std::{collections::HashSet, string::ToString};
|
||||||
collections::HashSet,
|
|
||||||
string::ToString,
|
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
use chrono::NaiveDateTime;
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use num_integer::Integer;
|
use num_integer::Integer;
|
||||||
use poise::{
|
use poise::{
|
||||||
serenity::{builder::CreateEmbed, model::channel::Channel},
|
serenity_prelude::{
|
||||||
serenity_prelude::{component::ButtonStyle, ReactionType},
|
builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType,
|
||||||
CreateReply,
|
},
|
||||||
|
CreateReply, Modal,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete},
|
||||||
component_models::{
|
component_models::{
|
||||||
pager::{DelPager, LookPager, Pager},
|
pager::{DelPager, LookPager, Pager},
|
||||||
ComponentDataModel, DelSelector, UndoReminder,
|
ComponentDataModel, DelSelector, UndoReminder,
|
||||||
@ -36,7 +34,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
time_parser::natural_parser,
|
time_parser::natural_parser,
|
||||||
utils::{check_guild_subscription, check_subscription},
|
utils::{check_guild_subscription, check_subscription},
|
||||||
Context, Error,
|
ApplicationContext, Context, Error,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Pause all reminders on the current channel until a certain time or indefinitely
|
/// Pause all reminders on the current channel until a certain time or indefinitely
|
||||||
@ -58,18 +56,27 @@ pub async fn pause(
|
|||||||
let parsed = natural_parser(&until, &timezone.to_string()).await;
|
let parsed = natural_parser(&until, &timezone.to_string()).await;
|
||||||
|
|
||||||
if let Some(timestamp) = parsed {
|
if let Some(timestamp) = parsed {
|
||||||
let dt = NaiveDateTime::from_timestamp(timestamp, 0);
|
match NaiveDateTime::from_timestamp_opt(timestamp, 0) {
|
||||||
|
Some(dt) => {
|
||||||
|
channel.paused = true;
|
||||||
|
channel.paused_until = Some(dt);
|
||||||
|
|
||||||
channel.paused = true;
|
channel.commit_changes(&ctx.data().database).await;
|
||||||
channel.paused_until = Some(dt);
|
|
||||||
|
|
||||||
channel.commit_changes(&ctx.data().database).await;
|
ctx.say(format!(
|
||||||
|
"Reminders in this channel have been silenced until **<t:{}:D>**",
|
||||||
|
timestamp
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.say(format!(
|
None => {
|
||||||
"Reminders in this channel have been silenced until **<t:{}:D>**",
|
ctx.say(format!(
|
||||||
timestamp
|
"Time processed could not be interpreted as `DateTime`. Please write the time as clearly as possible",
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ctx.say(
|
ctx.say(
|
||||||
"Time could not be processed. Please write the time as clearly as possible",
|
"Time could not be processed. Please write the time as clearly as possible",
|
||||||
@ -243,7 +250,7 @@ pub async fn look(
|
|||||||
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
||||||
})
|
})
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
.join("\n");
|
.join("");
|
||||||
|
|
||||||
let pages = reminders
|
let pages = reminders
|
||||||
.iter()
|
.iter()
|
||||||
@ -430,11 +437,8 @@ pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> Cr
|
|||||||
reply
|
reply
|
||||||
}
|
}
|
||||||
|
|
||||||
fn time_difference(start_time: NaiveDateTime) -> String {
|
fn time_difference(start_time: DateTime<Utc>) -> String {
|
||||||
let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
|
let delta = (Utc::now() - start_time).num_seconds();
|
||||||
let now = NaiveDateTime::from_timestamp(unix_time, 0);
|
|
||||||
|
|
||||||
let delta = (now - start_time).num_seconds();
|
|
||||||
|
|
||||||
let (minutes, seconds) = delta.div_rem(&60);
|
let (minutes, seconds) = delta.div_rem(&60);
|
||||||
let (hours, minutes) = minutes.div_rem(&60);
|
let (hours, minutes) = minutes.div_rem(&60);
|
||||||
@ -548,23 +552,92 @@ pub async fn delete_timer(
|
|||||||
Ok(())
|
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 = ContentModal::execute(ctx).await?;
|
||||||
|
|
||||||
|
create_reminder(
|
||||||
|
Context::Application(ctx),
|
||||||
|
time,
|
||||||
|
data.content,
|
||||||
|
channels,
|
||||||
|
interval,
|
||||||
|
expires,
|
||||||
|
tts,
|
||||||
|
tz,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content.
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
slash_command,
|
slash_command,
|
||||||
identifying_name = "remind",
|
identifying_name = "remind",
|
||||||
default_member_permissions = "MANAGE_GUILD"
|
default_member_permissions = "MANAGE_GUILD"
|
||||||
)]
|
)]
|
||||||
pub async fn remind(
|
pub async fn remind(
|
||||||
ctx: Context<'_>,
|
ctx: ApplicationContext<'_>,
|
||||||
#[description = "A description of the time to set the reminder for"] time: String,
|
#[description = "A description of the time to set the reminder for"]
|
||||||
|
#[autocomplete = "time_hint_autocomplete"]
|
||||||
|
time: String,
|
||||||
#[description = "The message content to send"] content: String,
|
#[description = "The message content to send"] content: String,
|
||||||
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
|
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
|
||||||
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
|
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
|
||||||
interval: Option<String>,
|
interval: Option<String>,
|
||||||
#[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>,
|
expires: Option<String>,
|
||||||
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
|
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
|
||||||
tts: Option<bool>,
|
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> {
|
) -> Result<(), Error> {
|
||||||
if interval.is_none() && expires.is_some() {
|
if interval.is_none() && expires.is_some() {
|
||||||
ctx.say("`expires` can only be used with `interval`").await?;
|
ctx.say("`expires` can only be used with `interval`").await?;
|
||||||
@ -575,7 +648,7 @@ pub async fn remind(
|
|||||||
ctx.defer().await?;
|
ctx.defer().await?;
|
||||||
|
|
||||||
let user_data = ctx.author_data().await.unwrap();
|
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;
|
let time = natural_parser(&time, &timezone.to_string()).await;
|
||||||
|
|
||||||
|
@ -340,7 +340,18 @@ pub fn show_todo_page(
|
|||||||
opt.create_option(|o| {
|
opt.create_option(|o| {
|
||||||
o.label(format!("Mark {} complete", count + first_num))
|
o.label(format!("Mark {} complete", count + first_num))
|
||||||
.value(id)
|
.value(id)
|
||||||
.description(disp.split_once(' ').unwrap_or(("", "")).1)
|
.description({
|
||||||
|
let c = disp.split_once(' ').unwrap_or(("", "")).1;
|
||||||
|
|
||||||
|
if c.len() > 100 {
|
||||||
|
format!(
|
||||||
|
"{}...",
|
||||||
|
c.chars().take(97).collect::<String>()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
c.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,9 +5,9 @@ use std::io::Cursor;
|
|||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use poise::{
|
use poise::{
|
||||||
serenity::{
|
serenity_prelude as serenity,
|
||||||
|
serenity_prelude::{
|
||||||
builder::CreateEmbed,
|
builder::CreateEmbed,
|
||||||
client::Context,
|
|
||||||
model::{
|
model::{
|
||||||
application::interaction::{
|
application::interaction::{
|
||||||
message_component::MessageComponentInteraction, InteractionResponseType,
|
message_component::MessageComponentInteraction, InteractionResponseType,
|
||||||
@ -15,15 +15,15 @@ use poise::{
|
|||||||
},
|
},
|
||||||
channel::Channel,
|
channel::Channel,
|
||||||
},
|
},
|
||||||
|
Context,
|
||||||
},
|
},
|
||||||
serenity_prelude as serenity,
|
|
||||||
};
|
};
|
||||||
use rmp_serde::Serializer;
|
use rmp_serde::Serializer;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
commands::{
|
commands::{
|
||||||
moderation_cmds::{max_macro_page, show_macro_page},
|
command_macro::list::{max_macro_page, show_macro_page},
|
||||||
reminder_cmds::{max_delete_page, show_delete_page},
|
reminder_cmds::{max_delete_page, show_delete_page},
|
||||||
todo_cmds::{max_todo_page, show_todo_page},
|
todo_cmds::{max_todo_page, show_todo_page},
|
||||||
},
|
},
|
||||||
@ -113,7 +113,7 @@ impl ComponentDataModel {
|
|||||||
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
||||||
})
|
})
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
.join("\n");
|
.join("");
|
||||||
|
|
||||||
let mut embed = CreateEmbed::default();
|
let mut embed = CreateEmbed::default();
|
||||||
embed
|
embed
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
// todo split pager out into a single struct
|
// todo split pager out into a single struct
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use poise::serenity::{builder::CreateComponents, model::application::component::ButtonStyle};
|
use poise::serenity_prelude::{
|
||||||
|
builder::CreateComponents, model::application::component::ButtonStyle,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_repr::*;
|
use serde_repr::*;
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ pub const DAY: u64 = 86_400;
|
|||||||
pub const HOUR: u64 = 3_600;
|
pub const HOUR: u64 = 3_600;
|
||||||
pub const MINUTE: u64 = 60;
|
pub const MINUTE: u64 = 60;
|
||||||
|
|
||||||
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000;
|
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4096;
|
||||||
pub const SELECT_MAX_ENTRIES: usize = 25;
|
pub const SELECT_MAX_ENTRIES: usize = 25;
|
||||||
|
|
||||||
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
|
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
|
||||||
@ -12,22 +12,18 @@ pub const MACRO_MAX_COMMANDS: usize = 5;
|
|||||||
|
|
||||||
use std::{collections::HashSet, env, iter::FromIterator};
|
use std::{collections::HashSet, env, iter::FromIterator};
|
||||||
|
|
||||||
use poise::serenity::model::prelude::AttachmentType;
|
use poise::serenity_prelude::model::prelude::AttachmentType;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
|
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
|
||||||
include_bytes!(concat!(
|
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/webhook.jpg")) as &[u8],
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
"webhook.jpg",
|
||||||
"/assets/",
|
|
||||||
env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation")
|
|
||||||
)) as &[u8],
|
|
||||||
env!("WEBHOOK_AVATAR"),
|
|
||||||
)
|
)
|
||||||
.into();
|
.into();
|
||||||
pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap();
|
pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap();
|
||||||
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
||||||
env::var("SUBSCRIPTION_ROLES")
|
env::var("PATREON_ROLE_ID")
|
||||||
.map(|var| var
|
.map(|var| var
|
||||||
.split(',')
|
.split(',')
|
||||||
.filter_map(|item| { item.parse::<u64>().ok() })
|
.filter_map(|item| { item.parse::<u64>().ok() })
|
||||||
@ -35,7 +31,7 @@ lazy_static! {
|
|||||||
.unwrap_or_else(|_| Vec::new())
|
.unwrap_or_else(|_| Vec::new())
|
||||||
);
|
);
|
||||||
pub static ref CNC_GUILD: Option<u64> =
|
pub static ref CNC_GUILD: Option<u64> =
|
||||||
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
|
env::var("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten();
|
||||||
pub static ref MIN_INTERVAL: i64 =
|
pub static ref MIN_INTERVAL: i64 =
|
||||||
env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
|
env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
|
||||||
pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
|
pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
|
||||||
@ -48,5 +44,5 @@ lazy_static! {
|
|||||||
.map_or(THEME_COLOR_FALLBACK, |inner| u32::from_str_radix(&inner, 16)
|
.map_or(THEME_COLOR_FALLBACK, |inner| u32::from_str_radix(&inner, 16)
|
||||||
.unwrap_or(THEME_COLOR_FALLBACK));
|
.unwrap_or(THEME_COLOR_FALLBACK));
|
||||||
pub static ref PYTHON_LOCATION: String =
|
pub static ref PYTHON_LOCATION: String =
|
||||||
env::var("PYTHON_LOCATION").unwrap_or_else(|_| "venv/bin/python3".to_string());
|
env::var("PYTHON_LOCATION").unwrap_or_else(|_| "/usr/bin/python3".to_string());
|
||||||
}
|
}
|
||||||
|
@ -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::{
|
use poise::{
|
||||||
serenity::{model::application::interaction::Interaction, utils::shard_id},
|
|
||||||
serenity_prelude as serenity,
|
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(
|
pub async fn listener(
|
||||||
ctx: &serenity::Context,
|
ctx: &serenity::Context,
|
||||||
@ -17,45 +17,6 @@ pub async fn listener(
|
|||||||
poise::Event::Ready { .. } => {
|
poise::Event::Ready { .. } => {
|
||||||
ctx.set_activity(serenity::Activity::watching("for /remind")).await;
|
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 } => {
|
poise::Event::ChannelDelete { channel } => {
|
||||||
sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64())
|
sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64())
|
||||||
.execute(&data.database)
|
.execute(&data.database)
|
||||||
@ -66,46 +27,36 @@ pub async fn listener(
|
|||||||
if *is_new {
|
if *is_new {
|
||||||
let guild_id = guild.id.as_u64().to_owned();
|
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)
|
.execute(&data.database)
|
||||||
.await
|
.await?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
|
if let Err(e) = post_guild_count(ctx, &data.http, guild_id).await {
|
||||||
let shard_count = ctx.cache.shard_count();
|
error!("DiscordBotList: {:?}", e);
|
||||||
let current_shard_id = shard_id(guild_id, shard_count);
|
}
|
||||||
|
|
||||||
let guild_count = ctx
|
let default_channel = guild.default_channel_guaranteed();
|
||||||
.cache
|
|
||||||
.guilds()
|
if let Some(default_channel) = default_channel {
|
||||||
.iter()
|
default_channel
|
||||||
.filter(|g| {
|
.send_message(&ctx, |m| {
|
||||||
shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.count() as u64;
|
.await?;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -126,3 +77,38 @@ pub async fn listener(
|
|||||||
|
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
59
src/hooks.rs
59
src/hooks.rs
@ -1,36 +1,42 @@
|
|||||||
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};
|
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
|
||||||
|
|
||||||
async fn macro_check(ctx: Context<'_>) -> bool {
|
async fn macro_check(ctx: Context<'_>) -> bool {
|
||||||
if let Context::Application(app_ctx) = ctx {
|
if let Context::Application(app_ctx) = ctx {
|
||||||
if let Some(guild_id) = ctx.guild_id() {
|
if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) =
|
||||||
if ctx.command().identifying_name != "finish_macro" {
|
app_ctx.interaction
|
||||||
let mut lock = ctx.data().recording_macros.write().await;
|
{
|
||||||
|
if let Some(guild_id) = ctx.guild_id() {
|
||||||
|
if ctx.command().identifying_name != "finish_macro" {
|
||||||
|
let mut lock = ctx.data().recording_macros.write().await;
|
||||||
|
|
||||||
if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) {
|
if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) {
|
||||||
if command_macro.commands.len() >= MACRO_MAX_COMMANDS {
|
if command_macro.commands.len() >= MACRO_MAX_COMMANDS {
|
||||||
let _ = ctx.send(|m| {
|
let _ = ctx.send(|m| {
|
||||||
m.ephemeral(true).content(
|
m.ephemeral(true).content(
|
||||||
format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS),
|
format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
} else {
|
} else {
|
||||||
let recorded = RecordedCommand {
|
let recorded = RecordedCommand {
|
||||||
action: None,
|
action: None,
|
||||||
command_name: ctx.command().identifying_name.clone(),
|
command_name: ctx.command().identifying_name.clone(),
|
||||||
options: Vec::from(app_ctx.args),
|
options: Vec::from(app_ctx.args),
|
||||||
};
|
};
|
||||||
|
|
||||||
command_macro.commands.push(recorded);
|
command_macro.commands.push(recorded);
|
||||||
|
|
||||||
let _ = ctx
|
let _ = ctx
|
||||||
.send(|m| m.ephemeral(true).content("Command recorded to macro"))
|
.send(|m| m.ephemeral(true).content("Command recorded to macro"))
|
||||||
.await;
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -47,19 +53,22 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
|||||||
.member_permissions(&ctx.discord(), user_id)
|
.member_permissions(&ctx.discord(), user_id)
|
||||||
.await
|
.await
|
||||||
.map_or(false, |p| p.manage_webhooks());
|
.map_or(false, |p| p.manage_webhooks());
|
||||||
|
|
||||||
let (view_channel, send_messages, embed_links) = ctx
|
let (view_channel, send_messages, embed_links) = ctx
|
||||||
.channel_id()
|
.channel_id()
|
||||||
.to_channel_cached(&ctx.discord())
|
.to_channel(&ctx.discord())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
.and_then(|c| {
|
.and_then(|c| {
|
||||||
if let Channel::Guild(channel) = c {
|
if let Channel::Guild(channel) = c {
|
||||||
channel.permissions_for_user(&ctx.discord(), user_id).ok()
|
let perms = channel.permissions_for_user(&ctx.discord(), user_id).ok()?;
|
||||||
|
|
||||||
|
Some((perms.view_channel(), perms.send_messages(), perms.embed_links()))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map_or((false, false, false), |p| {
|
.unwrap_or((false, false, false));
|
||||||
(p.view_channel(), p.send_messages(), p.embed_links())
|
|
||||||
});
|
|
||||||
|
|
||||||
if manage_webhooks && send_messages && embed_links {
|
if manage_webhooks && send_messages && embed_links {
|
||||||
true
|
true
|
||||||
@ -75,8 +84,8 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
|||||||
{} **Manage Webhooks**",
|
{} **Manage Webhooks**",
|
||||||
if view_channel { "✅" } else { "❌" },
|
if view_channel { "✅" } else { "❌" },
|
||||||
if send_messages { "✅" } else { "❌" },
|
if send_messages { "✅" } else { "❌" },
|
||||||
if manage_webhooks { "✅" } else { "❌" },
|
|
||||||
if embed_links { "✅" } else { "❌" },
|
if embed_links { "✅" } else { "❌" },
|
||||||
|
if manage_webhooks { "✅" } else { "❌" },
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
@ -110,13 +110,14 @@ impl OverflowOp for u64 {
|
|||||||
#[derive(Copy, Clone)]
|
#[derive(Copy, Clone)]
|
||||||
pub struct Interval {
|
pub struct Interval {
|
||||||
pub month: u64,
|
pub month: u64,
|
||||||
|
pub day: u64,
|
||||||
pub sec: u64,
|
pub sec: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Parser<'a> {
|
struct Parser<'a> {
|
||||||
iter: Chars<'a>,
|
iter: Chars<'a>,
|
||||||
src: &'a str,
|
src: &'a str,
|
||||||
current: (u64, u64, u64),
|
current: (u64, u64, u64, u64),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Parser<'a> {
|
impl<'a> Parser<'a> {
|
||||||
@ -140,17 +141,17 @@ impl<'a> Parser<'a> {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> {
|
fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> {
|
||||||
let (mut month, mut sec, nsec) = match &self.src[start..end] {
|
let (mut month, mut day, mut sec, nsec) = match &self.src[start..end] {
|
||||||
"nanos" | "nsec" | "ns" => (0u64, 0u64, n),
|
"nanos" | "nsec" | "ns" => (0, 0u64, 0u64, n),
|
||||||
"usec" | "us" => (0, 0u64, n.mul(1000)?),
|
"usec" | "us" => (0, 0, 0u64, n.mul(1000)?),
|
||||||
"millis" | "msec" | "ms" => (0, 0u64, n.mul(1_000_000)?),
|
"millis" | "msec" | "ms" => (0, 0, 0u64, n.mul(1_000_000)?),
|
||||||
"seconds" | "second" | "secs" | "sec" | "s" => (0, n, 0),
|
"seconds" | "second" | "secs" | "sec" | "s" => (0, 0, n, 0),
|
||||||
"minutes" | "minute" | "min" | "mins" | "m" => (0, n.mul(60)?, 0),
|
"minutes" | "minute" | "min" | "mins" | "m" => (0, 0, n.mul(60)?, 0),
|
||||||
"hours" | "hour" | "hr" | "hrs" | "h" => (0, n.mul(3600)?, 0),
|
"hours" | "hour" | "hr" | "hrs" | "h" => (0, 0, n.mul(3600)?, 0),
|
||||||
"days" | "day" | "d" => (0, n.mul(86400)?, 0),
|
"days" | "day" | "d" => (0, n, 0, 0),
|
||||||
"weeks" | "week" | "w" => (0, n.mul(86400 * 7)?, 0),
|
"weeks" | "week" | "w" => (0, n.mul(7)?, 0, 0),
|
||||||
"months" | "month" | "M" => (n, 0, 0),
|
"months" | "month" | "M" => (n, 0, 0, 0),
|
||||||
"years" | "year" | "y" => (12, 0, 0),
|
"years" | "year" | "y" => (n.mul(12)?, 0, 0, 0),
|
||||||
_ => {
|
_ => {
|
||||||
return Err(Error::UnknownUnit {
|
return Err(Error::UnknownUnit {
|
||||||
start,
|
start,
|
||||||
@ -160,15 +161,16 @@ impl<'a> Parser<'a> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut nsec = self.current.2 + nsec;
|
let mut nsec = self.current.3 + nsec;
|
||||||
if nsec > 1_000_000_000 {
|
if nsec > 1_000_000_000 {
|
||||||
sec += nsec / 1_000_000_000;
|
sec += nsec / 1_000_000_000;
|
||||||
nsec %= 1_000_000_000;
|
nsec %= 1_000_000_000;
|
||||||
}
|
}
|
||||||
sec += self.current.1;
|
sec += self.current.2;
|
||||||
|
day += self.current.1;
|
||||||
month += self.current.0;
|
month += self.current.0;
|
||||||
|
|
||||||
self.current = (month, sec, nsec);
|
self.current = (month, day, sec, nsec);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -215,7 +217,13 @@ impl<'a> Parser<'a> {
|
|||||||
self.parse_unit(n, start, off)?;
|
self.parse_unit(n, start, off)?;
|
||||||
n = match self.parse_first_char()? {
|
n = match self.parse_first_char()? {
|
||||||
Some(n) => n,
|
Some(n) => n,
|
||||||
None => return Ok(Interval { month: self.current.0, sec: self.current.1 }),
|
None => {
|
||||||
|
return Ok(Interval {
|
||||||
|
month: self.current.0,
|
||||||
|
day: self.current.1,
|
||||||
|
sec: self.current.2,
|
||||||
|
})
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -247,5 +255,73 @@ impl<'a> Parser<'a> {
|
|||||||
/// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000)));
|
/// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000)));
|
||||||
/// ```
|
/// ```
|
||||||
pub fn parse_duration(s: &str) -> Result<Interval, Error> {
|
pub fn parse_duration(s: &str) -> Result<Interval, Error> {
|
||||||
Parser { iter: s.chars(), src: s, current: (0, 0, 0) }.parse()
|
Parser { iter: s.chars(), src: s, current: (0, 0, 0, 0) }.parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_seconds() {
|
||||||
|
let interval = parse_duration("10 seconds").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 10);
|
||||||
|
assert_eq!(interval.day, 0);
|
||||||
|
assert_eq!(interval.month, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_minutes() {
|
||||||
|
let interval = parse_duration("10 minutes").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 600);
|
||||||
|
assert_eq!(interval.day, 0);
|
||||||
|
assert_eq!(interval.month, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_hours() {
|
||||||
|
let interval = parse_duration("10 hours").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 36_000);
|
||||||
|
assert_eq!(interval.day, 0);
|
||||||
|
assert_eq!(interval.month, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_days() {
|
||||||
|
let interval = parse_duration("10 days").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 0);
|
||||||
|
assert_eq!(interval.day, 10);
|
||||||
|
assert_eq!(interval.month, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_weeks() {
|
||||||
|
let interval = parse_duration("10 weeks").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 0);
|
||||||
|
assert_eq!(interval.day, 70);
|
||||||
|
assert_eq!(interval.month, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_months() {
|
||||||
|
let interval = parse_duration("10 months").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 0);
|
||||||
|
assert_eq!(interval.day, 0);
|
||||||
|
assert_eq!(interval.month, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_years() {
|
||||||
|
let interval = parse_duration("10 years").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(interval.sec, 0);
|
||||||
|
assert_eq!(interval.day, 0);
|
||||||
|
assert_eq!(interval.month, 120);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
90
src/main.rs
90
src/main.rs
@ -18,12 +18,12 @@ use std::{
|
|||||||
env,
|
env,
|
||||||
error::Error as StdError,
|
error::Error as StdError,
|
||||||
fmt::{Debug, Display, Formatter},
|
fmt::{Debug, Display, Formatter},
|
||||||
sync::atomic::AtomicBool,
|
path::Path,
|
||||||
};
|
};
|
||||||
|
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use dotenv::dotenv;
|
use log::{error, warn};
|
||||||
use poise::serenity::model::{
|
use poise::serenity_prelude::model::{
|
||||||
gateway::GatewayIntents,
|
gateway::GatewayIntents,
|
||||||
id::{GuildId, UserId},
|
id::{GuildId, UserId},
|
||||||
};
|
};
|
||||||
@ -31,7 +31,7 @@ use sqlx::{MySql, Pool};
|
|||||||
use tokio::sync::{broadcast, broadcast::Sender, RwLock};
|
use tokio::sync::{broadcast, broadcast::Sender, RwLock};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
|
commands::{command_macro, info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
|
||||||
consts::THEME_COLOR,
|
consts::THEME_COLOR,
|
||||||
event_handlers::listener,
|
event_handlers::listener,
|
||||||
hooks::all_checks,
|
hooks::all_checks,
|
||||||
@ -43,14 +43,14 @@ type Database = MySql;
|
|||||||
|
|
||||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||||
type Context<'a> = poise::Context<'a, Data, Error>;
|
type Context<'a> = poise::Context<'a, Data, Error>;
|
||||||
|
type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>;
|
||||||
|
|
||||||
pub struct Data {
|
pub struct Data {
|
||||||
database: Pool<Database>,
|
database: Pool<Database>,
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>,
|
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>,
|
||||||
popular_timezones: Vec<Tz>,
|
popular_timezones: Vec<Tz>,
|
||||||
is_loop_running: AtomicBool,
|
_broadcast: Sender<()>,
|
||||||
broadcast: Sender<()>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Debug for Data {
|
impl Debug for Data {
|
||||||
@ -75,7 +75,7 @@ impl Display for Ended {
|
|||||||
|
|
||||||
impl StdError for Ended {}
|
impl StdError for Ended {}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
|
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||||
let (tx, mut rx) = broadcast::channel(16);
|
let (tx, mut rx) = broadcast::channel(16);
|
||||||
|
|
||||||
@ -88,7 +88,11 @@ async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
|
|||||||
async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
dotenv()?;
|
if Path::new("/etc/reminder-rs/config.env").exists() {
|
||||||
|
dotenv::from_path("/etc/reminder-rs/config.env")?;
|
||||||
|
} else {
|
||||||
|
dotenv::from_path(".env")?;
|
||||||
|
}
|
||||||
|
|
||||||
let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
|
let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
|
||||||
|
|
||||||
@ -111,13 +115,14 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
|||||||
moderation_cmds::webhook(),
|
moderation_cmds::webhook(),
|
||||||
poise::Command {
|
poise::Command {
|
||||||
subcommands: vec![
|
subcommands: vec![
|
||||||
moderation_cmds::delete_macro(),
|
command_macro::delete::delete_macro(),
|
||||||
moderation_cmds::finish_macro(),
|
command_macro::record::finish_macro(),
|
||||||
moderation_cmds::list_macro(),
|
command_macro::list::list_macro(),
|
||||||
moderation_cmds::record_macro(),
|
command_macro::record::record_macro(),
|
||||||
moderation_cmds::run_macro(),
|
command_macro::run::run_macro(),
|
||||||
|
command_macro::migrate::migrate_macro(),
|
||||||
],
|
],
|
||||||
..moderation_cmds::macro_base()
|
..command_macro::macro_base()
|
||||||
},
|
},
|
||||||
reminder_cmds::pause(),
|
reminder_cmds::pause(),
|
||||||
reminder_cmds::offset(),
|
reminder_cmds::offset(),
|
||||||
@ -132,6 +137,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
|||||||
],
|
],
|
||||||
..reminder_cmds::timer_base()
|
..reminder_cmds::timer_base()
|
||||||
},
|
},
|
||||||
|
reminder_cmds::multiline(),
|
||||||
reminder_cmds::remind(),
|
reminder_cmds::remind(),
|
||||||
poise::Command {
|
poise::Command {
|
||||||
subcommands: vec![
|
subcommands: vec![
|
||||||
@ -166,8 +172,15 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
|||||||
let database =
|
let database =
|
||||||
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
|
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
|
||||||
|
|
||||||
|
sqlx::migrate!().run(&database).await?;
|
||||||
|
|
||||||
let popular_timezones = sqlx::query!(
|
let popular_timezones = sqlx::query!(
|
||||||
"SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21"
|
"SELECT IFNULL(timezone, 'UTC') AS timezone
|
||||||
|
FROM users
|
||||||
|
WHERE timezone IS NOT NULL
|
||||||
|
GROUP BY timezone
|
||||||
|
ORDER BY COUNT(timezone) DESC
|
||||||
|
LIMIT 21"
|
||||||
)
|
)
|
||||||
.fetch_all(&database)
|
.fetch_all(&database)
|
||||||
.await
|
.await
|
||||||
@ -176,27 +189,50 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
|||||||
.map(|t| t.timezone.parse::<Tz>().unwrap())
|
.map(|t| t.timezone.parse::<Tz>().unwrap())
|
||||||
.collect::<Vec<Tz>>();
|
.collect::<Vec<Tz>>();
|
||||||
|
|
||||||
poise::Framework::build()
|
poise::Framework::builder()
|
||||||
.token(discord_token)
|
.token(discord_token)
|
||||||
.user_data_setup(move |ctx, _bot, framework| {
|
.user_data_setup(move |ctx, _bot, framework| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
register_application_commands(
|
register_application_commands(ctx, framework, None).await.unwrap();
|
||||||
ctx,
|
|
||||||
framework,
|
let kill_tx = tx.clone();
|
||||||
env::var("DEBUG_GUILD")
|
let kill_recv = tx.subscribe();
|
||||||
.map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid")))
|
|
||||||
.ok(),
|
let ctx1 = ctx.clone();
|
||||||
)
|
let ctx2 = ctx.clone();
|
||||||
.await
|
|
||||||
.unwrap();
|
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 {
|
Ok(Data {
|
||||||
http: reqwest::Client::new(),
|
http: reqwest::Client::new(),
|
||||||
database,
|
database,
|
||||||
popular_timezones,
|
popular_timezones,
|
||||||
recording_macros: Default::default(),
|
recording_macros: Default::default(),
|
||||||
is_loop_running: AtomicBool::new(false),
|
_broadcast: tx,
|
||||||
broadcast: tx,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use poise::serenity::model::channel::Channel;
|
use poise::serenity_prelude::model::channel::Channel;
|
||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
pub struct ChannelData {
|
pub struct ChannelData {
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
use poise::serenity::model::{
|
use poise::serenity_prelude::model::{
|
||||||
application::interaction::application_command::CommandDataOption, id::GuildId,
|
application::interaction::application_command::CommandDataOption, id::GuildId,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{Context, Data, Error};
|
use crate::{Context, Data, Error};
|
||||||
|
|
||||||
@ -29,6 +30,13 @@ pub struct CommandMacro<U, E> {
|
|||||||
pub commands: Vec<RecordedCommand<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(
|
pub async fn guild_command_macro(
|
||||||
ctx: &Context<'_>,
|
ctx: &Context<'_>,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
@ -5,7 +5,7 @@ pub mod timer;
|
|||||||
pub mod user_data;
|
pub mod user_data;
|
||||||
|
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use poise::serenity::{async_trait, model::id::UserId};
|
use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelType};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
models::{channel_data::ChannelData, user_data::UserData},
|
models::{channel_data::ChannelData, user_data::UserData},
|
||||||
@ -43,7 +43,20 @@ impl CtxData for Context<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> {
|
async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>> {
|
||||||
let channel = self.channel_id().to_channel_cached(&self.discord()).unwrap();
|
// If we're in a thread, get the parent channel.
|
||||||
|
let recv_channel = self.channel_id().to_channel(&self.discord()).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.discord()).unwrap()
|
||||||
|
} else {
|
||||||
|
self.channel_id().to_channel_cached(&self.discord()).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None => self.channel_id().to_channel_cached(&self.discord()).unwrap(),
|
||||||
|
};
|
||||||
|
|
||||||
ChannelData::from_channel(&channel, &self.data().database).await
|
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::{Duration, NaiveDateTime, Utc};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use poise::serenity::{
|
use poise::serenity_prelude::{
|
||||||
http::CacheHttp,
|
http::CacheHttp,
|
||||||
model::{
|
model::{
|
||||||
channel::GuildChannel,
|
channel::GuildChannel,
|
||||||
id::{ChannelId, GuildId, UserId},
|
id::{ChannelId, GuildId, UserId},
|
||||||
webhook::Webhook,
|
webhook::Webhook,
|
||||||
},
|
},
|
||||||
Result as SerenityResult,
|
ChannelType, Result as SerenityResult,
|
||||||
};
|
};
|
||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
@ -51,9 +51,11 @@ pub struct ReminderBuilder {
|
|||||||
pool: MySqlPool,
|
pool: MySqlPool,
|
||||||
uid: String,
|
uid: String,
|
||||||
channel: u32,
|
channel: u32,
|
||||||
|
thread_id: Option<u64>,
|
||||||
utc_time: NaiveDateTime,
|
utc_time: NaiveDateTime,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
interval_secs: Option<i64>,
|
interval_seconds: Option<i64>,
|
||||||
|
interval_days: Option<i64>,
|
||||||
interval_months: Option<i64>,
|
interval_months: Option<i64>,
|
||||||
expires: Option<NaiveDateTime>,
|
expires: Option<NaiveDateTime>,
|
||||||
content: String,
|
content: String,
|
||||||
@ -87,6 +89,7 @@ INSERT INTO reminders (
|
|||||||
`utc_time`,
|
`utc_time`,
|
||||||
`timezone`,
|
`timezone`,
|
||||||
`interval_seconds`,
|
`interval_seconds`,
|
||||||
|
`interval_days`,
|
||||||
`interval_months`,
|
`interval_months`,
|
||||||
`expires`,
|
`expires`,
|
||||||
`content`,
|
`content`,
|
||||||
@ -106,6 +109,7 @@ INSERT INTO reminders (
|
|||||||
?,
|
?,
|
||||||
?,
|
?,
|
||||||
?,
|
?,
|
||||||
|
?,
|
||||||
?
|
?
|
||||||
)
|
)
|
||||||
",
|
",
|
||||||
@ -113,7 +117,8 @@ INSERT INTO reminders (
|
|||||||
self.channel,
|
self.channel,
|
||||||
utc_time,
|
utc_time,
|
||||||
self.timezone,
|
self.timezone,
|
||||||
self.interval_secs,
|
self.interval_seconds,
|
||||||
|
self.interval_days,
|
||||||
self.interval_months,
|
self.interval_months,
|
||||||
self.expires,
|
self.expires,
|
||||||
self.content,
|
self.content,
|
||||||
@ -175,17 +180,15 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn time<T: Into<i64>>(mut self, time: T) -> Self {
|
pub fn time<T: Into<i64>>(mut self, time: T) -> Self {
|
||||||
self.utc_time = NaiveDateTime::from_timestamp(time.into(), 0);
|
if let Some(utc_time) = NaiveDateTime::from_timestamp_opt(time.into(), 0) {
|
||||||
|
self.utc_time = utc_time;
|
||||||
|
}
|
||||||
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self {
|
pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self {
|
||||||
if let Some(t) = time {
|
self.expires = time.map(|t| NaiveDateTime::from_timestamp_opt(t.into(), 0)).flatten();
|
||||||
self.expires = Some(NaiveDateTime::from_timestamp(t.into(), 0));
|
|
||||||
} else {
|
|
||||||
self.expires = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@ -212,13 +215,19 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
|
|
||||||
let mut ok_locs = HashSet::new();
|
let mut ok_locs = HashSet::new();
|
||||||
|
|
||||||
if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) as i64) < *MIN_INTERVAL) {
|
if self
|
||||||
|
.interval
|
||||||
|
.map_or(false, |i| ((i.sec + i.day * DAY + i.month * 30 * DAY) as i64) < *MIN_INTERVAL)
|
||||||
|
{
|
||||||
errors.insert(ReminderError::ShortInterval);
|
errors.insert(ReminderError::ShortInterval);
|
||||||
} else if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) as i64) > *MAX_TIME)
|
} else if self
|
||||||
|
.interval
|
||||||
|
.map_or(false, |i| ((i.sec + i.day * DAY + i.month * 30 * DAY) as i64) > *MAX_TIME)
|
||||||
{
|
{
|
||||||
errors.insert(ReminderError::LongInterval);
|
errors.insert(ReminderError::LongInterval);
|
||||||
} else {
|
} else {
|
||||||
for scope in self.scopes {
|
for scope in self.scopes {
|
||||||
|
let thread_id = None;
|
||||||
let db_channel_id = match scope {
|
let db_channel_id = match scope {
|
||||||
ReminderScope::User(user_id) => {
|
ReminderScope::User(user_id) => {
|
||||||
if let Ok(user) = UserId(user_id).to_user(&self.ctx.discord()).await {
|
if let Ok(user) = UserId(user_id).to_user(&self.ctx.discord()).await {
|
||||||
@ -251,14 +260,29 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
let channel =
|
let channel =
|
||||||
ChannelId(channel_id).to_channel(&self.ctx.discord()).await.unwrap();
|
ChannelId(channel_id).to_channel(&self.ctx.discord()).await.unwrap();
|
||||||
|
|
||||||
if let Some(guild_channel) = channel.clone().guild() {
|
if let Some(mut guild_channel) = channel.clone().guild() {
|
||||||
if Some(guild_channel.guild_id) != self.guild_id {
|
if Some(guild_channel.guild_id) != self.guild_id {
|
||||||
Err(ReminderError::InvalidTag)
|
Err(ReminderError::InvalidTag)
|
||||||
} else {
|
} else {
|
||||||
let mut channel_data =
|
let mut channel_data = if guild_channel.kind
|
||||||
ChannelData::from_channel(&channel, &self.ctx.data().database)
|
== ChannelType::PublicThread
|
||||||
|
{
|
||||||
|
// fixme jesus christ
|
||||||
|
let parent = guild_channel
|
||||||
|
.parent_id
|
||||||
|
.unwrap()
|
||||||
|
.to_channel(&self.ctx.discord())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
guild_channel = parent.clone().guild().unwrap();
|
||||||
|
ChannelData::from_channel(&parent, &self.ctx.data().database)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
} else {
|
||||||
|
ChannelData::from_channel(&channel, &self.ctx.data().database)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
if channel_data.webhook_id.is_none()
|
if channel_data.webhook_id.is_none()
|
||||||
|| channel_data.webhook_token.is_none()
|
|| channel_data.webhook_token.is_none()
|
||||||
@ -300,9 +324,11 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
pool: self.ctx.data().database.clone(),
|
pool: self.ctx.data().database.clone(),
|
||||||
uid: generate_uid(),
|
uid: generate_uid(),
|
||||||
channel: c,
|
channel: c,
|
||||||
|
thread_id,
|
||||||
utc_time: self.utc_time,
|
utc_time: self.utc_time,
|
||||||
timezone: self.timezone.to_string(),
|
timezone: self.timezone.to_string(),
|
||||||
interval_secs: self.interval.map(|i| i.sec as i64),
|
interval_seconds: self.interval.map(|i| i.sec as i64),
|
||||||
|
interval_days: self.interval.map(|i| i.day as i64),
|
||||||
interval_months: self.interval.map(|i| i.month as i64),
|
interval_months: self.interval.map(|i| i.month as i64),
|
||||||
expires: self.expires,
|
expires: self.expires,
|
||||||
content: self.content.content.clone(),
|
content: self.content.content.clone(),
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
use poise::serenity::model::id::ChannelId;
|
use poise::serenity_prelude::model::id::ChannelId;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_repr::*;
|
use serde_repr::*;
|
||||||
|
|
||||||
|
@ -6,11 +6,11 @@ pub mod look_flags;
|
|||||||
|
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
use chrono::{NaiveDateTime, TimeZone};
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use poise::{
|
use poise::serenity_prelude::{
|
||||||
serenity::model::id::{ChannelId, GuildId, UserId},
|
model::id::{ChannelId, GuildId, UserId},
|
||||||
serenity_prelude::Cache,
|
Cache,
|
||||||
};
|
};
|
||||||
use sqlx::Executor;
|
use sqlx::Executor;
|
||||||
|
|
||||||
@ -24,8 +24,9 @@ pub struct Reminder {
|
|||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
pub channel: u64,
|
pub channel: u64,
|
||||||
pub utc_time: NaiveDateTime,
|
pub utc_time: DateTime<Utc>,
|
||||||
pub interval_seconds: Option<u32>,
|
pub interval_seconds: Option<u32>,
|
||||||
|
pub interval_days: Option<u32>,
|
||||||
pub interval_months: Option<u32>,
|
pub interval_months: Option<u32>,
|
||||||
pub expires: Option<NaiveDateTime>,
|
pub expires: Option<NaiveDateTime>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
@ -59,6 +60,7 @@ SELECT
|
|||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
@ -95,6 +97,7 @@ SELECT
|
|||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
@ -138,6 +141,7 @@ SELECT
|
|||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
@ -195,6 +199,7 @@ SELECT
|
|||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
@ -228,6 +233,7 @@ SELECT
|
|||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
@ -262,6 +268,7 @@ SELECT
|
|||||||
channels.channel,
|
channels.channel,
|
||||||
reminders.utc_time,
|
reminders.utc_time,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
@ -310,30 +317,32 @@ WHERE
|
|||||||
count + 1,
|
count + 1,
|
||||||
self.display_content(),
|
self.display_content(),
|
||||||
self.channel,
|
self.channel,
|
||||||
timezone.timestamp(self.utc_time.timestamp(), 0).format("%Y-%m-%d %H:%M:%S")
|
self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn display(&self, flags: &LookFlags, timezone: &Tz) -> String {
|
pub fn display(&self, flags: &LookFlags, timezone: &Tz) -> String {
|
||||||
let time_display = match flags.time_display {
|
let time_display = match flags.time_display {
|
||||||
TimeDisplayType::Absolute => timezone
|
TimeDisplayType::Absolute => {
|
||||||
.timestamp(self.utc_time.timestamp(), 0)
|
self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S").to_string()
|
||||||
.format("%Y-%m-%d %H:%M:%S")
|
}
|
||||||
.to_string(),
|
|
||||||
|
|
||||||
TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()),
|
TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if self.interval_seconds.is_some() || self.interval_months.is_some() {
|
if self.interval_seconds.is_some()
|
||||||
|
|| self.interval_days.is_some()
|
||||||
|
|| self.interval_months.is_some()
|
||||||
|
{
|
||||||
format!(
|
format!(
|
||||||
"'{}' *occurs next at* **{}**, repeating (set by {})",
|
"'{}' *occurs next at* **{}**, repeating (set by {})\n",
|
||||||
self.display_content(),
|
self.display_content(),
|
||||||
time_display,
|
time_display,
|
||||||
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
|
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"'{}' *occurs next at* **{}** (set by {})",
|
"'{}' *occurs next at* **{}** (set by {})\n",
|
||||||
self.display_content(),
|
self.display_content(),
|
||||||
time_display,
|
time_display,
|
||||||
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
|
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::{DateTime, Utc};
|
||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
pub struct Timer {
|
pub struct Timer {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub start_time: NaiveDateTime,
|
pub start_time: DateTime<Utc>,
|
||||||
pub owner: u64,
|
pub owner: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use log::error;
|
use log::error;
|
||||||
use poise::serenity::{http::CacheHttp, model::id::UserId};
|
use poise::serenity_prelude::{http::CacheHttp, model::id::UserId};
|
||||||
use sqlx::MySqlPool;
|
use sqlx::MySqlPool;
|
||||||
|
|
||||||
use crate::consts::LOCAL_TIMEZONE;
|
use crate::consts::LOCAL_TIMEZONE;
|
||||||
@ -22,7 +22,7 @@ impl UserData {
|
|||||||
|
|
||||||
match sqlx::query!(
|
match sqlx::query!(
|
||||||
"
|
"
|
||||||
SELECT timezone FROM users WHERE user = ?
|
SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?
|
||||||
",
|
",
|
||||||
user_id
|
user_id
|
||||||
)
|
)
|
||||||
|
12
src/utils.rs
12
src/utils.rs
@ -1,11 +1,11 @@
|
|||||||
use poise::{
|
use poise::{
|
||||||
serenity::{
|
serenity_prelude as serenity,
|
||||||
|
serenity_prelude::{
|
||||||
builder::CreateApplicationCommands,
|
builder::CreateApplicationCommands,
|
||||||
http::CacheHttp,
|
http::CacheHttp,
|
||||||
|
interaction::MessageFlags,
|
||||||
model::id::{GuildId, UserId},
|
model::id::{GuildId, UserId},
|
||||||
},
|
},
|
||||||
serenity_prelude as serenity,
|
|
||||||
serenity_prelude::interaction::MessageFlags,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@ -14,10 +14,10 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub async fn register_application_commands(
|
pub async fn register_application_commands(
|
||||||
ctx: &poise::serenity::client::Context,
|
ctx: &serenity::Context,
|
||||||
framework: &poise::Framework<Data, Error>,
|
framework: &poise::Framework<Data, Error>,
|
||||||
guild_id: Option<GuildId>,
|
guild_id: Option<GuildId>,
|
||||||
) -> Result<(), poise::serenity::Error> {
|
) -> Result<(), serenity::Error> {
|
||||||
let mut commands_builder = CreateApplicationCommands::default();
|
let mut commands_builder = CreateApplicationCommands::default();
|
||||||
let commands = &framework.options().commands;
|
let commands = &framework.options().commands;
|
||||||
for command in commands {
|
for command in commands {
|
||||||
@ -28,7 +28,7 @@ pub async fn register_application_commands(
|
|||||||
commands_builder.add_application_command(context_menu_command);
|
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 {
|
if let Some(guild_id) = guild_id {
|
||||||
ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?;
|
ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?;
|
||||||
|
13
systemd/reminder-rs.service
Normal file
13
systemd/reminder-rs.service
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Reminder Bot
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=reminder
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/reminder-rs
|
||||||
|
Restart=always
|
||||||
|
RestartSec=4
|
||||||
|
# Environment="RUST_LOG=warn,reminder_rs=info,postman=info"
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
@ -12,7 +12,7 @@ oauth2 = "4"
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
reqwest = "0.11"
|
reqwest = "0.11"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
|
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
chrono-tz = "0.5"
|
chrono-tz = "0.5"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
|
@ -31,7 +31,7 @@ lazy_static! {
|
|||||||
)
|
)
|
||||||
.into();
|
.into();
|
||||||
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
||||||
env::var("SUBSCRIPTION_ROLES")
|
env::var("PATREON_ROLE_ID")
|
||||||
.map(|var| var
|
.map(|var| var
|
||||||
.split(',')
|
.split(',')
|
||||||
.filter_map(|item| { item.parse::<u64>().ok() })
|
.filter_map(|item| { item.parse::<u64>().ok() })
|
||||||
@ -39,7 +39,7 @@ lazy_static! {
|
|||||||
.unwrap_or_else(|_| Vec::new())
|
.unwrap_or_else(|_| Vec::new())
|
||||||
);
|
);
|
||||||
pub static ref CNC_GUILD: Option<u64> =
|
pub static ref CNC_GUILD: Option<u64> =
|
||||||
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
|
env::var("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten();
|
||||||
pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL")
|
pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|inner| inner.parse::<u32>().ok())
|
.map(|inner| inner.parse::<u32>().ok())
|
||||||
|
@ -75,7 +75,7 @@ pub async fn initialize(
|
|||||||
env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied");
|
env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied");
|
||||||
env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied");
|
env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied");
|
||||||
env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied");
|
env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied");
|
||||||
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD' not supplied");
|
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied");
|
||||||
info!("Done!");
|
info!("Done!");
|
||||||
|
|
||||||
let oauth2_client = BasicClient::new(
|
let oauth2_client = BasicClient::new(
|
||||||
|
@ -58,6 +58,7 @@ pub async fn export_reminders(
|
|||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.name,
|
reminders.name,
|
||||||
reminders.restartable,
|
reminders.restartable,
|
||||||
@ -159,6 +160,7 @@ pub async fn import_reminders(
|
|||||||
enabled: record.enabled,
|
enabled: record.enabled,
|
||||||
expires: record.expires,
|
expires: record.expires,
|
||||||
interval_seconds: record.interval_seconds,
|
interval_seconds: record.interval_seconds,
|
||||||
|
interval_days: record.interval_days,
|
||||||
interval_months: record.interval_months,
|
interval_months: record.interval_months,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
restartable: record.restartable,
|
restartable: record.restartable,
|
||||||
@ -318,13 +320,6 @@ pub async fn import_todos(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = sqlx::query!(
|
|
||||||
"DELETE FROM todos WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
|
||||||
id
|
|
||||||
)
|
|
||||||
.execute(pool.inner())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let query_str = format!(
|
let query_str = format!(
|
||||||
"INSERT INTO todos (value, channel_id, guild_id) VALUES {}",
|
"INSERT INTO todos (value, channel_id, guild_id) VALUES {}",
|
||||||
vec![query_placeholder].repeat(query_params.len()).join(",")
|
vec![query_placeholder].repeat(query_params.len()).join(",")
|
||||||
|
@ -16,10 +16,12 @@ use serenity::{
|
|||||||
use sqlx::{MySql, Pool};
|
use sqlx::{MySql, Pool};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
check_guild_subscription, check_subscription,
|
||||||
consts::{
|
consts::{
|
||||||
MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
|
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_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,
|
MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
|
||||||
|
MIN_INTERVAL,
|
||||||
},
|
},
|
||||||
routes::dashboard::{
|
routes::dashboard::{
|
||||||
create_database_channel, create_reminder, template_name_default, DeleteReminder,
|
create_database_channel, create_reminder, template_name_default, DeleteReminder,
|
||||||
@ -247,9 +249,9 @@ pub async fn create_reminder_template(
|
|||||||
Ok(json!({}))
|
Ok(json!({}))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Could not fetch templates from {}: {:?}", id, e);
|
warn!("Could not create template for {}: {:?}", id, e);
|
||||||
|
|
||||||
json_err!("Could not get templates")
|
json_err!("Could not create template")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -339,6 +341,7 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq
|
|||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.name,
|
reminders.name,
|
||||||
reminders.restartable,
|
reminders.restartable,
|
||||||
@ -374,35 +377,109 @@ pub async fn edit_reminder(
|
|||||||
reminder: Json<PatchReminder>,
|
reminder: Json<PatchReminder>,
|
||||||
serenity_context: &State<Context>,
|
serenity_context: &State<Context>,
|
||||||
pool: &State<Pool<MySql>>,
|
pool: &State<Pool<MySql>>,
|
||||||
|
cookies: &CookieJar<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
check_authorization!(cookies, serenity_context.inner(), id);
|
||||||
|
|
||||||
let mut error = vec![];
|
let mut error = vec![];
|
||||||
|
|
||||||
|
let user_id =
|
||||||
|
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
||||||
|
|
||||||
|
if reminder.message_ok() {
|
||||||
|
update_field!(pool.inner(), error, reminder.[
|
||||||
|
content,
|
||||||
|
embed_author,
|
||||||
|
embed_description,
|
||||||
|
embed_footer,
|
||||||
|
embed_title,
|
||||||
|
embed_fields,
|
||||||
|
username
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
error.push("Message exceeds limits.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
update_field!(pool.inner(), error, reminder.[
|
update_field!(pool.inner(), error, reminder.[
|
||||||
attachment,
|
attachment,
|
||||||
attachment_name,
|
attachment_name,
|
||||||
avatar,
|
avatar,
|
||||||
content,
|
|
||||||
embed_author,
|
|
||||||
embed_author_url,
|
embed_author_url,
|
||||||
embed_color,
|
embed_color,
|
||||||
embed_description,
|
|
||||||
embed_footer,
|
|
||||||
embed_footer_url,
|
embed_footer_url,
|
||||||
embed_image_url,
|
embed_image_url,
|
||||||
embed_thumbnail_url,
|
embed_thumbnail_url,
|
||||||
embed_title,
|
|
||||||
embed_fields,
|
|
||||||
enabled,
|
enabled,
|
||||||
expires,
|
expires,
|
||||||
interval_seconds,
|
|
||||||
interval_months,
|
|
||||||
name,
|
name,
|
||||||
restartable,
|
restartable,
|
||||||
tts,
|
tts,
|
||||||
username,
|
|
||||||
utc_time
|
utc_time
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if reminder.interval_days.flatten().is_some()
|
||||||
|
|| reminder.interval_months.flatten().is_some()
|
||||||
|
|| reminder.interval_seconds.flatten().is_some()
|
||||||
|
{
|
||||||
|
if check_guild_subscription(&serenity_context.inner(), id).await
|
||||||
|
|| check_subscription(&serenity_context.inner(), user_id).await
|
||||||
|
{
|
||||||
|
let new_interval_length = match reminder.interval_days {
|
||||||
|
Some(interval) => interval.unwrap_or(0),
|
||||||
|
None => sqlx::query!(
|
||||||
|
"SELECT interval_days AS days FROM reminders WHERE uid = ?",
|
||||||
|
reminder.uid
|
||||||
|
)
|
||||||
|
.fetch_one(pool.inner())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Error updating reminder interval: {:?}", e);
|
||||||
|
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
|
||||||
|
})?
|
||||||
|
.days
|
||||||
|
.unwrap_or(0),
|
||||||
|
} + match reminder.interval_months {
|
||||||
|
Some(interval) => interval.unwrap_or(0),
|
||||||
|
None => sqlx::query!(
|
||||||
|
"SELECT interval_months AS months FROM reminders WHERE uid = ?",
|
||||||
|
reminder.uid
|
||||||
|
)
|
||||||
|
.fetch_one(pool.inner())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Error updating reminder interval: {:?}", e);
|
||||||
|
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
|
||||||
|
})?
|
||||||
|
.months
|
||||||
|
.unwrap_or(0),
|
||||||
|
} + match reminder.interval_seconds {
|
||||||
|
Some(interval) => interval.unwrap_or(0),
|
||||||
|
None => sqlx::query!(
|
||||||
|
"SELECT interval_seconds AS seconds FROM reminders WHERE uid = ?",
|
||||||
|
reminder.uid
|
||||||
|
)
|
||||||
|
.fetch_one(pool.inner())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!("Error updating reminder interval: {:?}", e);
|
||||||
|
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
|
||||||
|
})?
|
||||||
|
.seconds
|
||||||
|
.unwrap_or(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
if new_interval_length < *MIN_INTERVAL {
|
||||||
|
error.push(String::from("New interval is too short."));
|
||||||
|
} else {
|
||||||
|
update_field!(pool.inner(), error, reminder.[
|
||||||
|
interval_days,
|
||||||
|
interval_months,
|
||||||
|
interval_seconds
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if reminder.channel > 0 {
|
if reminder.channel > 0 {
|
||||||
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
|
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
|
||||||
match channel {
|
match channel {
|
||||||
@ -483,6 +560,7 @@ pub async fn edit_reminder(
|
|||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.name,
|
reminders.name,
|
||||||
reminders.restartable,
|
reminders.restartable,
|
||||||
|
@ -8,13 +8,13 @@ use rocket::{
|
|||||||
serde::json::{json, Value as JsonValue},
|
serde::json::{json, Value as JsonValue},
|
||||||
};
|
};
|
||||||
use rocket_dyn_templates::Template;
|
use rocket_dyn_templates::Template;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
use serenity::{
|
use serenity::{
|
||||||
client::Context,
|
client::Context,
|
||||||
http::Http,
|
http::Http,
|
||||||
model::id::{ChannelId, GuildId, UserId},
|
model::id::{ChannelId, GuildId, UserId},
|
||||||
};
|
};
|
||||||
use sqlx::{types::Json, Executor, MySql, Pool};
|
use sqlx::{types::Json, Executor};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
check_guild_subscription, check_subscription,
|
check_guild_subscription, check_subscription,
|
||||||
@ -50,6 +50,18 @@ fn id_default() -> u32 {
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn interval_default() -> Unset<Option<u32>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
T: Deserialize<'de>,
|
||||||
|
{
|
||||||
|
Ok(Some(Option::deserialize(deserializer)?))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct ReminderTemplate {
|
pub struct ReminderTemplate {
|
||||||
#[serde(default = "id_default")]
|
#[serde(default = "id_default")]
|
||||||
@ -132,6 +144,7 @@ pub struct Reminder {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
expires: Option<NaiveDateTime>,
|
expires: Option<NaiveDateTime>,
|
||||||
interval_seconds: Option<u32>,
|
interval_seconds: Option<u32>,
|
||||||
|
interval_days: Option<u32>,
|
||||||
interval_months: Option<u32>,
|
interval_months: Option<u32>,
|
||||||
#[serde(default = "name_default")]
|
#[serde(default = "name_default")]
|
||||||
name: String,
|
name: String,
|
||||||
@ -164,6 +177,7 @@ pub struct ReminderCsv {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
expires: Option<NaiveDateTime>,
|
expires: Option<NaiveDateTime>,
|
||||||
interval_seconds: Option<u32>,
|
interval_seconds: Option<u32>,
|
||||||
|
interval_days: Option<u32>,
|
||||||
interval_months: Option<u32>,
|
interval_months: Option<u32>,
|
||||||
#[serde(default = "name_default")]
|
#[serde(default = "name_default")]
|
||||||
name: String,
|
name: String,
|
||||||
@ -177,10 +191,13 @@ pub struct ReminderCsv {
|
|||||||
pub struct PatchReminder {
|
pub struct PatchReminder {
|
||||||
uid: String,
|
uid: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
attachment: Unset<Option<String>>,
|
attachment: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
attachment_name: Unset<Option<String>>,
|
attachment_name: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
avatar: Unset<Option<String>>,
|
avatar: Unset<Option<String>>,
|
||||||
#[serde(default = "channel_default")]
|
#[serde(default = "channel_default")]
|
||||||
#[serde(with = "string")]
|
#[serde(with = "string")]
|
||||||
@ -190,6 +207,7 @@ pub struct PatchReminder {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
embed_author: Unset<String>,
|
embed_author: Unset<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
embed_author_url: Unset<Option<String>>,
|
embed_author_url: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
embed_color: Unset<u32>,
|
embed_color: Unset<u32>,
|
||||||
@ -198,10 +216,13 @@ pub struct PatchReminder {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
embed_footer: Unset<String>,
|
embed_footer: Unset<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
embed_footer_url: Unset<Option<String>>,
|
embed_footer_url: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
embed_image_url: Unset<Option<String>>,
|
embed_image_url: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
embed_thumbnail_url: Unset<Option<String>>,
|
embed_thumbnail_url: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
embed_title: Unset<String>,
|
embed_title: Unset<String>,
|
||||||
@ -210,10 +231,16 @@ pub struct PatchReminder {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
enabled: Unset<bool>,
|
enabled: Unset<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
expires: Unset<Option<NaiveDateTime>>,
|
expires: Unset<Option<NaiveDateTime>>,
|
||||||
#[serde(default)]
|
#[serde(default = "interval_default")]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
interval_seconds: Unset<Option<u32>>,
|
interval_seconds: Unset<Option<u32>>,
|
||||||
#[serde(default)]
|
#[serde(default = "interval_default")]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
|
interval_days: Unset<Option<u32>>,
|
||||||
|
#[serde(default = "interval_default")]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
interval_months: Unset<Option<u32>>,
|
interval_months: Unset<Option<u32>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
name: Unset<String>,
|
name: Unset<String>,
|
||||||
@ -222,11 +249,36 @@ pub struct PatchReminder {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tts: Unset<bool>,
|
tts: Unset<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(deserialize_with = "deserialize_optional_field")]
|
||||||
username: Unset<Option<String>>,
|
username: Unset<Option<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
utc_time: Unset<NaiveDateTime>,
|
utc_time: Unset<NaiveDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PatchReminder {
|
||||||
|
fn message_ok(&self) -> bool {
|
||||||
|
self.content.as_ref().map_or(true, |c| c.len() <= MAX_CONTENT_LENGTH)
|
||||||
|
&& self.embed_author.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_AUTHOR_LENGTH)
|
||||||
|
&& self
|
||||||
|
.embed_description
|
||||||
|
.as_ref()
|
||||||
|
.map_or(true, |c| c.len() <= MAX_EMBED_DESCRIPTION_LENGTH)
|
||||||
|
&& self.embed_footer.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_FOOTER_LENGTH)
|
||||||
|
&& self.embed_title.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_TITLE_LENGTH)
|
||||||
|
&& self.embed_fields.as_ref().map_or(true, |c| {
|
||||||
|
c.0.len() <= MAX_EMBED_FIELDS
|
||||||
|
&& c.0.iter().all(|f| {
|
||||||
|
f.title.len() <= MAX_EMBED_FIELD_TITLE_LENGTH
|
||||||
|
&& f.value.len() <= MAX_EMBED_FIELD_VALUE_LENGTH
|
||||||
|
})
|
||||||
|
})
|
||||||
|
&& self
|
||||||
|
.username
|
||||||
|
.as_ref()
|
||||||
|
.map_or(true, |c| c.as_ref().map_or(true, |v| v.len() <= MAX_USERNAME_LENGTH))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn generate_uid() -> String {
|
pub fn generate_uid() -> String {
|
||||||
let mut generator: OsRng = Default::default();
|
let mut generator: OsRng = Default::default();
|
||||||
|
|
||||||
@ -301,11 +353,28 @@ pub struct TodoCsv {
|
|||||||
|
|
||||||
pub async fn create_reminder(
|
pub async fn create_reminder(
|
||||||
ctx: &Context,
|
ctx: &Context,
|
||||||
pool: &Pool<MySql>,
|
pool: impl sqlx::Executor<'_, Database = Database> + Copy,
|
||||||
guild_id: GuildId,
|
guild_id: GuildId,
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
reminder: Reminder,
|
reminder: Reminder,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
|
// check guild in db
|
||||||
|
match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.0)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Err(sqlx::Error::RowNotFound) => {
|
||||||
|
if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.0)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err(json!({"error": "Guild could not be created"}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
// validate channel
|
// validate channel
|
||||||
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx);
|
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx);
|
||||||
let channel_exists = channel.is_some();
|
let channel_exists = channel.is_some();
|
||||||
@ -370,8 +439,12 @@ pub async fn create_reminder(
|
|||||||
if reminder.utc_time < Utc::now().naive_utc() {
|
if reminder.utc_time < Utc::now().naive_utc() {
|
||||||
return Err(json!({"error": "Time must be in the future"}));
|
return Err(json!({"error": "Time must be in the future"}));
|
||||||
}
|
}
|
||||||
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
|
if reminder.interval_seconds.is_some()
|
||||||
|
|| reminder.interval_days.is_some()
|
||||||
|
|| reminder.interval_months.is_some()
|
||||||
|
{
|
||||||
if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32
|
if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32
|
||||||
|
+ reminder.interval_days.unwrap_or(0) * DAY as u32
|
||||||
+ reminder.interval_seconds.unwrap_or(0)
|
+ reminder.interval_seconds.unwrap_or(0)
|
||||||
< *MIN_INTERVAL
|
< *MIN_INTERVAL
|
||||||
{
|
{
|
||||||
@ -380,7 +453,10 @@ pub async fn create_reminder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check patreon if necessary
|
// check patreon if necessary
|
||||||
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
|
if reminder.interval_seconds.is_some()
|
||||||
|
|| reminder.interval_days.is_some()
|
||||||
|
|| reminder.interval_months.is_some()
|
||||||
|
{
|
||||||
if !check_guild_subscription(&ctx, guild_id).await
|
if !check_guild_subscription(&ctx, guild_id).await
|
||||||
&& !check_subscription(&ctx, user_id).await
|
&& !check_subscription(&ctx, user_id).await
|
||||||
{
|
{
|
||||||
@ -391,6 +467,11 @@ pub async fn create_reminder(
|
|||||||
// base64 decode error dropped here
|
// base64 decode error dropped here
|
||||||
let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten();
|
let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten();
|
||||||
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
|
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
|
||||||
|
let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
reminder.username
|
||||||
|
};
|
||||||
|
|
||||||
let new_uid = generate_uid();
|
let new_uid = generate_uid();
|
||||||
|
|
||||||
@ -416,13 +497,14 @@ pub async fn create_reminder(
|
|||||||
enabled,
|
enabled,
|
||||||
expires,
|
expires,
|
||||||
interval_seconds,
|
interval_seconds,
|
||||||
|
interval_days,
|
||||||
interval_months,
|
interval_months,
|
||||||
name,
|
name,
|
||||||
restartable,
|
restartable,
|
||||||
tts,
|
tts,
|
||||||
username,
|
username,
|
||||||
`utc_time`
|
`utc_time`
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
new_uid,
|
new_uid,
|
||||||
attachment_data,
|
attachment_data,
|
||||||
reminder.attachment_name,
|
reminder.attachment_name,
|
||||||
@ -442,11 +524,12 @@ pub async fn create_reminder(
|
|||||||
reminder.enabled,
|
reminder.enabled,
|
||||||
reminder.expires,
|
reminder.expires,
|
||||||
reminder.interval_seconds,
|
reminder.interval_seconds,
|
||||||
|
reminder.interval_days,
|
||||||
reminder.interval_months,
|
reminder.interval_months,
|
||||||
name,
|
name,
|
||||||
reminder.restartable,
|
reminder.restartable,
|
||||||
reminder.tts,
|
reminder.tts,
|
||||||
reminder.username,
|
username,
|
||||||
reminder.utc_time,
|
reminder.utc_time,
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@ -473,6 +556,7 @@ pub async fn create_reminder(
|
|||||||
reminders.enabled,
|
reminders.enabled,
|
||||||
reminders.expires,
|
reminders.expires,
|
||||||
reminders.interval_seconds,
|
reminders.interval_seconds,
|
||||||
|
reminders.interval_days,
|
||||||
reminders.interval_months,
|
reminders.interval_months,
|
||||||
reminders.name,
|
reminders.name,
|
||||||
reminders.restartable,
|
reminders.restartable,
|
||||||
|
@ -61,10 +61,13 @@ pub async fn get_user_info(
|
|||||||
.member(&ctx.inner(), user_id)
|
.member(&ctx.inner(), user_id)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let timezone = sqlx::query!("SELECT timezone FROM users WHERE user = ?", user_id)
|
let timezone = sqlx::query!(
|
||||||
.fetch_one(pool.inner())
|
"SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?",
|
||||||
.await
|
user_id
|
||||||
.map_or(None, |q| Some(q.timezone));
|
)
|
||||||
|
.fetch_one(pool.inner())
|
||||||
|
.await
|
||||||
|
.map_or(None, |q| Some(q.timezone));
|
||||||
|
|
||||||
let user_info = UserInfo {
|
let user_info = UserInfo {
|
||||||
name: cookies
|
name: cookies
|
||||||
|
@ -135,14 +135,14 @@ pub async fn discord_callback(
|
|||||||
Err(Flash::new(
|
Err(Flash::new(
|
||||||
Redirect::to(uri!(super::return_to_same_site(""))),
|
Redirect::to(uri!(super::return_to_same_site(""))),
|
||||||
"warning",
|
"warning",
|
||||||
"Your login request was rejected",
|
"Your login request was rejected. The server may be misconfigured. Please retry or alert us in Discord.",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "danger", "Your request failed to validate, and so has been rejected (error: CSRF Validation Failure)"))
|
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "danger", "Your request failed to validate, and so has been rejected (CSRF Validation Failure)"))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "warning", "Your request was missing information, and so has been rejected (error: CSRF Validation Tokens Missing)"))
|
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "warning", "Your request was missing information, and so has been rejected (CSRF Validation Tokens Missing)"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,9 +7,9 @@ function get_interval(element) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
months: parseInt(months) || null,
|
months: parseInt(months) || null,
|
||||||
|
days: parseInt(days) || null,
|
||||||
seconds:
|
seconds:
|
||||||
(parseInt(days) || 0) * 86400 +
|
(parseInt(hours) || 0) * 3600 +
|
||||||
(parseInt(hours) || 0) * 3600 +
|
|
||||||
(parseInt(minutes) || 0) * 60 +
|
(parseInt(minutes) || 0) * 60 +
|
||||||
(parseInt(seconds) || 0) || null,
|
(parseInt(seconds) || 0) || null,
|
||||||
};
|
};
|
||||||
@ -22,32 +22,38 @@ function update_interval(element) {
|
|||||||
let minutes = element.querySelector('input[name="interval_minutes"]');
|
let minutes = element.querySelector('input[name="interval_minutes"]');
|
||||||
let seconds = element.querySelector('input[name="interval_seconds"]');
|
let seconds = element.querySelector('input[name="interval_seconds"]');
|
||||||
|
|
||||||
months.value = months.value.padStart(1, "0");
|
let interval = get_interval(element);
|
||||||
days.value = days.value.padStart(1, "0");
|
|
||||||
hours.value = hours.value.padStart(2, "0");
|
|
||||||
minutes.value = minutes.value.padStart(2, "0");
|
|
||||||
seconds.value = seconds.value.padStart(2, "0");
|
|
||||||
|
|
||||||
if (seconds.value >= 60) {
|
if (interval.months === null && interval.days === null && interval.seconds === null) {
|
||||||
let quotient = Math.floor(seconds.value / 60);
|
months.value = "";
|
||||||
let remainder = seconds.value % 60;
|
days.value = "";
|
||||||
|
hours.value = "";
|
||||||
|
minutes.value = "";
|
||||||
|
seconds.value = "";
|
||||||
|
} else {
|
||||||
|
months.value = months.value.padStart(1, "0");
|
||||||
|
days.value = days.value.padStart(1, "0");
|
||||||
|
hours.value = hours.value.padStart(2, "0");
|
||||||
|
minutes.value = minutes.value.padStart(2, "0");
|
||||||
|
seconds.value = seconds.value.padStart(2, "0");
|
||||||
|
|
||||||
seconds.value = String(remainder).padStart(2, "0");
|
if (seconds.value >= 60) {
|
||||||
minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(2, "0");
|
let quotient = Math.floor(seconds.value / 60);
|
||||||
}
|
let remainder = seconds.value % 60;
|
||||||
if (minutes.value >= 60) {
|
|
||||||
let quotient = Math.floor(minutes.value / 60);
|
|
||||||
let remainder = minutes.value % 60;
|
|
||||||
|
|
||||||
minutes.value = String(remainder).padStart(2, "0");
|
seconds.value = String(remainder).padStart(2, "0");
|
||||||
hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0");
|
minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(
|
||||||
}
|
2,
|
||||||
if (hours.value >= 24) {
|
"0"
|
||||||
let quotient = Math.floor(hours.value / 24);
|
);
|
||||||
let remainder = hours.value % 24;
|
}
|
||||||
|
if (minutes.value >= 60) {
|
||||||
|
let quotient = Math.floor(minutes.value / 60);
|
||||||
|
let remainder = minutes.value % 60;
|
||||||
|
|
||||||
hours.value = String(remainder).padStart(2, "0");
|
minutes.value = String(remainder).padStart(2, "0");
|
||||||
days.value = Number(days.value) + Number(quotient);
|
hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,14 +60,15 @@ function update_select(sel) {
|
|||||||
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
|
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
|
||||||
sel.selectedOptions[0].dataset["webhookAvatar"];
|
sel.selectedOptions[0].dataset["webhookAvatar"];
|
||||||
} else {
|
} else {
|
||||||
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = "";
|
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
|
||||||
|
"/static/img/icon.png";
|
||||||
}
|
}
|
||||||
if (sel.selectedOptions[0].dataset["webhookName"]) {
|
if (sel.selectedOptions[0].dataset["webhookName"]) {
|
||||||
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
|
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
|
||||||
sel.selectedOptions[0].dataset["webhookName"];
|
sel.selectedOptions[0].dataset["webhookName"];
|
||||||
} else {
|
} else {
|
||||||
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
|
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
|
||||||
"";
|
"Reminder";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,6 +320,7 @@ async function serialize_reminder(node, mode) {
|
|||||||
embed_fields: fields,
|
embed_fields: fields,
|
||||||
expires: expiration_time,
|
expires: expiration_time,
|
||||||
interval_seconds: mode !== "template" ? interval.seconds : null,
|
interval_seconds: mode !== "template" ? interval.seconds : null,
|
||||||
|
interval_days: mode !== "template" ? interval.days : null,
|
||||||
interval_months: mode !== "template" ? interval.months : null,
|
interval_months: mode !== "template" ? interval.months : null,
|
||||||
name: node.querySelector('input[name="name"]').value,
|
name: node.querySelector('input[name="name"]').value,
|
||||||
tts: node.querySelector('input[name="tts"]').checked,
|
tts: node.querySelector('input[name="tts"]').checked,
|
||||||
@ -331,6 +333,9 @@ function deserialize_reminder(reminder, frame, mode) {
|
|||||||
// populate channels
|
// populate channels
|
||||||
set_channels(frame.querySelector("select.channel-selector"));
|
set_channels(frame.querySelector("select.channel-selector"));
|
||||||
|
|
||||||
|
frame.querySelector(`*[name="interval_hours"]`).value = 0;
|
||||||
|
frame.querySelector(`*[name="interval_minutes"]`).value = 0;
|
||||||
|
|
||||||
// populate majority of items
|
// populate majority of items
|
||||||
for (let prop in reminder) {
|
for (let prop in reminder) {
|
||||||
if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) {
|
if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) {
|
||||||
@ -351,6 +356,8 @@ function deserialize_reminder(reminder, frame, mode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
update_interval(frame);
|
||||||
|
|
||||||
const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box");
|
const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box");
|
||||||
|
|
||||||
for (let field of reminder["embed_fields"]) {
|
for (let field of reminder["embed_fields"]) {
|
||||||
@ -497,6 +504,8 @@ document.addEventListener("remindersLoaded", (event) => {
|
|||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
for (let error of data.errors) show_error(error);
|
for (let error of data.errors) show_error(error);
|
||||||
|
|
||||||
|
deserialize_reminder(data.reminder, node, "reload");
|
||||||
});
|
});
|
||||||
|
|
||||||
$saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"];
|
$saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"];
|
||||||
@ -715,6 +724,7 @@ $createReminderBtn.addEventListener("click", async () => {
|
|||||||
let reminder = await serialize_reminder($createReminder, "create");
|
let reminder = await serialize_reminder($createReminder, "create");
|
||||||
if (reminder.error) {
|
if (reminder.error) {
|
||||||
show_error(reminder.error);
|
show_error(reminder.error);
|
||||||
|
$createReminderBtn.querySelector("span.icon > i").classList = ["fas fa-sparkles"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -832,13 +842,6 @@ $deleteTemplateBtn.addEventListener("click", (ev) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll("textarea.autoresize").forEach((element) => {
|
|
||||||
element.addEventListener("input", () => {
|
|
||||||
element.style.height = "";
|
|
||||||
element.style.height = element.scrollHeight + 3 + "px";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let $img;
|
let $img;
|
||||||
const $urlModal = document.querySelector("div#addImageModal");
|
const $urlModal = document.querySelector("div#addImageModal");
|
||||||
const $urlInput = $urlModal.querySelector("input");
|
const $urlInput = $urlModal.querySelector("input");
|
||||||
@ -894,6 +897,13 @@ document.addEventListener("remindersLoaded", () => {
|
|||||||
window.getComputedStyle($discordFrame).borderLeftColor;
|
window.getComputedStyle($discordFrame).borderLeftColor;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("textarea.autoresize").forEach((element) => {
|
||||||
|
element.addEventListener("input", () => {
|
||||||
|
element.style.height = "";
|
||||||
|
element.style.height = element.scrollHeight + 3 + "px";
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function check_embed_fields() {
|
function check_embed_fields() {
|
||||||
|
@ -191,19 +191,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
|
||||||
<div class="field">
|
|
||||||
<label>
|
|
||||||
<input type="radio" class="default-width" name="exportSelect" value="reminder_templates">
|
|
||||||
Reminder templates
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<br>
|
<br>
|
||||||
<div class="has-text-centered">
|
<div class="has-text-centered">
|
||||||
<div style="color: red; font-weight: bold;">
|
|
||||||
By selecting "Import", you understand that this will overwrite existing data.
|
|
||||||
</div>
|
|
||||||
<div style="color: red">
|
<div style="color: red">
|
||||||
Please first read the <a href="/help/iemanager">support page</a>
|
Please first read the <a href="/help/iemanager">support page</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tile is-parent">
|
<div class="tile is-parent">
|
||||||
<article class="tile is-child notification">
|
<article class="tile is-child notification">
|
||||||
<p class="title">Creating reminders</p>
|
<p class="title">Create reminders</p>
|
||||||
<p class="subtitle">Learn to create reminders for your server</p>
|
<p class="subtitle">Learn to create reminders for your server</p>
|
||||||
<div class="content has-text-centered">
|
<div class="content has-text-centered">
|
||||||
<a class="button is-size-4 is-rounded is-light" href="/help/create_reminder">
|
<a class="button is-size-4 is-rounded is-light" href="/help/create_reminder">
|
||||||
@ -52,47 +52,47 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tile is-ancestor">
|
<!-- <div class="tile is-ancestor">-->
|
||||||
<div class="tile is-parent">
|
<!-- <div class="tile is-parent">-->
|
||||||
<article class="tile is-child notification">
|
<!-- <article class="tile is-child notification">-->
|
||||||
<p class="title">Timers</p>
|
<!-- <p class="title">Timers</p>-->
|
||||||
<p class="subtitle">Learn to manage timers</p>
|
<!-- <p class="subtitle">Learn to manage timers</p>-->
|
||||||
<div class="content has-text-centered">
|
<!-- <div class="content has-text-centered">-->
|
||||||
<a class="button is-size-4 is-rounded is-light" href="/help/timers">
|
<!-- <a class="button is-size-4 is-rounded is-light" href="/help/timers">-->
|
||||||
<p class="is-size-4">
|
<!-- <p class="is-size-4">-->
|
||||||
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
<!-- Read <span class="icon"><i class="fas fa-chevron-right"></i></span>-->
|
||||||
</p>
|
<!-- </p>-->
|
||||||
</a>
|
<!-- </a>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
</article>
|
<!-- </article>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
<div class="tile is-parent">
|
<!-- <div class="tile is-parent">-->
|
||||||
<article class="tile is-child notification">
|
<!-- <article class="tile is-child notification">-->
|
||||||
<p class="title">Todo Lists</p>
|
<!-- <p class="title">Todo Lists</p>-->
|
||||||
<p class="subtitle">Learn to manage various todo lists</p>
|
<!-- <p class="subtitle">Learn to manage various todo lists</p>-->
|
||||||
<div class="content has-text-centered">
|
<!-- <div class="content has-text-centered">-->
|
||||||
<a class="button is-size-4 is-rounded is-light" href="/help/todo_lists">
|
<!-- <a class="button is-size-4 is-rounded is-light" href="/help/todo_lists">-->
|
||||||
<p class="is-size-4">
|
<!-- <p class="is-size-4">-->
|
||||||
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
<!-- Read <span class="icon"><i class="fas fa-chevron-right"></i></span>-->
|
||||||
</p>
|
<!-- </p>-->
|
||||||
</a>
|
<!-- </a>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
</article>
|
<!-- </article>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
<div class="tile is-parent is-vertical">
|
<!-- <div class="tile is-parent is-vertical">-->
|
||||||
<article class="tile is-child notification">
|
<!-- <article class="tile is-child notification">-->
|
||||||
<p class="title">Macros</p>
|
<!-- <p class="title">Macros</p>-->
|
||||||
<p class="subtitle">Learn how to create combination commands called macros, to suit advanced use cases</p>
|
<!-- <p class="subtitle">Learn how to create combination commands called macros, to suit advanced use cases</p>-->
|
||||||
<div class="content has-text-centered">
|
<!-- <div class="content has-text-centered">-->
|
||||||
<a class="button is-size-4 is-rounded is-light" href="/help/macros">
|
<!-- <a class="button is-size-4 is-rounded is-light" href="/help/macros">-->
|
||||||
<p class="is-size-4">
|
<!-- <p class="is-size-4">-->
|
||||||
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
<!-- Read <span class="icon"><i class="fas fa-chevron-right"></i></span>-->
|
||||||
</p>
|
<!-- </p>-->
|
||||||
</a>
|
<!-- </a>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
</article>
|
<!-- </article>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
<div class="tile is-ancestor">
|
<div class="tile is-ancestor">
|
||||||
<div class="tile is-parent">
|
<div class="tile is-parent">
|
||||||
<article class="tile is-child notification">
|
<article class="tile is-child notification">
|
||||||
@ -107,19 +107,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div class="tile is-parent">
|
|
||||||
<article class="tile is-child notification">
|
|
||||||
<p class="title">Dashboard</p>
|
|
||||||
<p class="subtitle">Learn to use the interactive web dashboard</p>
|
|
||||||
<div class="content has-text-centered">
|
|
||||||
<a class="button is-size-4 is-rounded is-light" href="/help/dashboard">
|
|
||||||
<p class="is-size-4">
|
|
||||||
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<div class="tile is-parent is-vertical">
|
<div class="tile is-parent is-vertical">
|
||||||
<article class="tile is-child notification">
|
<article class="tile is-child notification">
|
||||||
<p class="title">Import/Export</p>
|
<p class="title">Import/Export</p>
|
||||||
@ -133,6 +120,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tile is-parent">
|
||||||
|
<!-- <article class="tile is-child notification">-->
|
||||||
|
<!-- <p class="title">Dashboard</p>-->
|
||||||
|
<!-- <p class="subtitle">Learn to use the interactive web dashboard</p>-->
|
||||||
|
<!-- <div class="content has-text-centered">-->
|
||||||
|
<!-- <a class="button is-size-4 is-rounded is-light" href="/help/dashboard">-->
|
||||||
|
<!-- <p class="is-size-4">-->
|
||||||
|
<!-- Read <span class="icon"><i class="fas fa-chevron-right"></i></span>-->
|
||||||
|
<!-- </p>-->
|
||||||
|
<!-- </a>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- </article>-->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -28,7 +28,10 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="title">Create reminders via the dashboard</p>
|
<p class="title">Create reminders via the dashboard</p>
|
||||||
<p class="content">
|
<p class="content">
|
||||||
Reminders can also be created on the dashboard.
|
Reminders can also be created on the dashboard. The dashboard offers more options for configuring
|
||||||
|
reminders, and offers templates for quick recreation of reminders.
|
||||||
|
|
||||||
|
<a href="/dashboard">Access the dashboard.</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
<section class="hero is-small">
|
<section class="hero is-small">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="title">Export your data</p>
|
<p class="title">Export data</p>
|
||||||
<p class="content">
|
<p class="content">
|
||||||
You can export data associated with your server from the dashboard. The data will export as a CSV
|
You can export data associated with your server from the dashboard. The data will export as a CSV
|
||||||
file. The CSV file can then be edited and imported to bulk edit server data.
|
file. The CSV file can then be edited and imported to bulk edit server data.
|
||||||
@ -26,8 +26,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="title">Import data</p>
|
<p class="title">Import data</p>
|
||||||
<p class="content">
|
<p class="content">
|
||||||
You can import previous exports or modified exports. When importing a file, <strong>existing data
|
You can import previous exports or modified exports. When importing a file, the new data will be added alongside existing data.
|
||||||
will be overwritten</strong>.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -55,7 +54,7 @@
|
|||||||
</figure>
|
</figure>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Make edits to the spreadsheet. You can add, edit, and remove rows for reminders. Don't remove the title row.
|
Make edits to the spreadsheet. You can add, edit, and remove rows for reminders. Don't remove the top-most (title) row.
|
||||||
<figure>
|
<figure>
|
||||||
<img src="/static/img/support/iemanager/edit_spreadsheet.png" alt="Editing spreadsheet">
|
<img src="/static/img/support/iemanager/edit_spreadsheet.png" alt="Editing spreadsheet">
|
||||||
</figure>
|
</figure>
|
||||||
@ -70,7 +69,7 @@
|
|||||||
Other spreadsheet tools can also be used to edit exports, as long as they are properly configured:
|
Other spreadsheet tools can also be used to edit exports, as long as they are properly configured:
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<strong>Google Sheets</strong>: Create a new blank spreadsheet. Select <strong>File >> Import >> Upload >> export.csv</strong>.
|
<strong>Google Sheets</strong>: Create a new blank spreadsheet. Select <strong>File > Import > Upload > export.csv</strong>.
|
||||||
Use the following import settings:
|
Use the following import settings:
|
||||||
<figure>
|
<figure>
|
||||||
<img src="/static/img/support/iemanager/sheets_settings.png" alt="Google sheets import settings">
|
<img src="/static/img/support/iemanager/sheets_settings.png" alt="Google sheets import settings">
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
<p class="content">
|
<p class="content">
|
||||||
Monthly or yearly intervals are configured the same as fixed intervals. Instead of a fixed time
|
Monthly or yearly intervals are configured the same as fixed intervals. Instead of a fixed time
|
||||||
interval, these reminders repeat on a certain day each month or each year. This makes them ideal
|
interval, these reminders repeat on a certain day each month or each year. This makes them ideal
|
||||||
for marking certain dates.
|
for marking calendar events.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -61,7 +61,8 @@
|
|||||||
<p class="title">Interval expiration</p>
|
<p class="title">Interval expiration</p>
|
||||||
<p class="content">
|
<p class="content">
|
||||||
An expiration time can also be specified, both via commands and dashboard, for repeating reminders.
|
An expiration time can also be specified, both via commands and dashboard, for repeating reminders.
|
||||||
This is optional, and if omitted, the reminder will repeat indefinitely.
|
This is optional, and if omitted, the reminder will repeat indefinitely. Otherwise, the reminder
|
||||||
|
will be deleted once the expiration date is reached.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Reference in New Issue
Block a user