3 Commits

Author SHA1 Message Date
b0a04bb289 Default channel command 2022-09-17 18:09:40 +01:00
eef1f6f3e8 Correct migration. Add guilds on interaction. Correct queries 2022-09-17 18:08:45 +01:00
3d08027325 Add migration for guild IDs to use discord ID 2022-09-17 18:08:45 +01:00
95 changed files with 2372 additions and 4716 deletions

2
.gitignore vendored
View File

@ -2,4 +2,6 @@
.env .env
/venv /venv
.cargo .cargo
assets
out.json
/.idea /.idea

2303
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,20 @@
[package] [package]
name = "reminder-rs" name = "reminder_rs"
version = "1.6.40" version = "1.6.6"
authors = ["Jude Southworth <judesouthworth@pm.me>"] authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2021" edition = "2018"
license = "AGPL-3.0 only"
description = "Reminder Bot for Discord, now in Rust"
[dependencies] [dependencies]
poise = "0.5" poise = "0.3"
dotenv = "0.15" dotenv = "0.15"
tokio = { version = "1", features = ["process", "full"] } tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11" reqwest = "0.11"
lazy-regex = "3.0" lazy-regex = "2.3.0"
regex = "1.9" regex = "1.6"
log = "0.4" log = "0.4"
env_logger = "0.10" env_logger = "0.9"
chrono = "0.4" chrono = "0.4"
chrono-tz = { version = "0.8", features = ["serde"] } chrono-tz = { version = "0.6", features = ["serde"] }
lazy_static = "1.4" lazy_static = "1.4"
num-integer = "0.1" num-integer = "0.1"
serde = "1.0" serde = "1.0"
@ -25,34 +23,11 @@ serde_repr = "0.1"
rmp-serde = "1.1" rmp-serde = "1.1"
rand = "0.8" rand = "0.8"
levenshtein = "1.0" levenshtein = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"]} sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
base64 = "0.21.0" base64 = "0.13"
[dependencies.postman] [dependencies.postman]
path = "postman" path = "postman"
[dependencies.reminder_web] [dependencies.reminder_web]
path = "web" path = "web"
[package.metadata.deb]
depends = "$auto, python3-dateparser (>= 1.0.0)"
suggests = "mysql-server-8.0, nginx"
maintainer-scripts = "debian"
assets = [
["target/release/reminder-rs", "usr/bin/reminder-rs", "755"],
["conf/default.env", "etc/reminder-rs/config.env", "600"],
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
["web/static/**/*", "lib/reminder-rs/static", "644"],
["web/templates/**/*", "lib/reminder-rs/templates", "644"],
["healthcheck", "lib/reminder-rs/healthcheck", "755"],
["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"],
# ["nginx/reminder-rs", "etc/nginx/sites-available/reminder-rs", "755"]
]
conf-files = [
"/etc/reminder-rs/config.env",
"/etc/reminder-rs/Rocket.toml",
]
[package.metadata.deb.systemd-units]
unit-scripts = "systemd"
start = false

View File

@ -1,9 +0,0 @@
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

View File

@ -7,36 +7,25 @@ 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)
### Build APT package ### Compiling
Install build requirements:
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential`
Recommended method. Install Rust from https://rustup.rs
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
folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of
dimensions 128x128px to be used as the webhook avatar.
1. Install container software: `sudo apt install podman`. #### Compilation environment variables
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders` These environment variables must be provided when compiling the bot
3. Install SQLx CLI: `cargo install sqlx-cli` * `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
4. From the source code directory, execute `sqlx migrate run` * `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**
5. Build container image: `podman build -t reminder-rs .`
6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
### Setting up Python
Reminder Bot by default looks for a venv within it's working directory to run Python out of. To set up a venv, install `python3-venv` and run `python3 -m venv venv`. Then, run `source venv/bin/activate` to activate the venv, and do `pip install dateparser` to install the required library
### Compiling for other target ### Environment Variables
1. Install requirements:
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser`
2. Install rustup from https://rustup.rs
3. Install the nightly toolchain: `rustup toolchain default nightly`
4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`.
5. Install `sqlx-cli`: `cargo install sqlx-cli`.
6. Run migrations: `sqlx migrate run`.
7. Set environment variables:
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
8. Build: `cargo build --release`
### Configuring
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory. Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
__Required Variables__ __Required Variables__
@ -48,5 +37,10 @@ __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 `/usr/bin/python3`. Can be changed if your Python executable is located somewhere else * `PYTHON_LOCATION` - default `venv/bin/python3`. Can be changed if your Python executable is located somewhere else
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds * `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds
* `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages
### Todo List
* Convert aliases to macros

View File

@ -1,6 +1,6 @@
[default] [default]
address = "0.0.0.0" address = "0.0.0.0"
port = 18920 port = 5000
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"
[debug.rsa_sha256.tls] [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"
[debug.ecdsa_nistp256_sha256.tls] [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"
[debug.ecdsa_nistp384_sha384.tls] [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"
[debug.ed25519.tls] [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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1,3 +0,0 @@
fn main() {
println!("cargo:rerun-if-changed=migrations");
}

View File

@ -1,8 +0,0 @@
[default]
address = "127.0.0.1"
port = 18920
template_dir = "/lib/reminder-rs/templates"
limits = { json = "10MiB" }
[release]
# secret_key = ""

View File

@ -1,19 +0,0 @@
DATABASE_URL=
DISCORD_TOKEN=
PATREON_GUILD_ID=
PATREON_ROLE_ID=
LOCAL_TIMEZONE=
MIN_INTERVAL=
PYTHON_LOCATION=/usr/bin/python3
DONTRUN=
SECRET_KEY=
REMIND_INTERVAL=
OAUTH2_DISCORD_CALLBACK=
OAUTH2_CLIENT_ID=
OAUTH2_CLIENT_SECRET=
REPORT_EMAIL=
LOG_TO_DATABASE=1

View File

@ -1 +0,0 @@
*/10 * * * * reminder /lib/reminder-rs/healthcheck

9
debian/postinst vendored
View File

@ -1,9 +0,0 @@
#!/bin/bash
set -e
id -u reminder &>/dev/null || useradd -r -M reminder
chown -R reminder /etc/reminder-rs
#DEBHELPER#

7
debian/postrm vendored
View File

@ -1,7 +0,0 @@
#!/bin/bash
set -e
id -u reminder &>/dev/null || userdel reminder
#DEBHELPER#

View File

@ -1,13 +0,0 @@
#!/bin/bash
export $(grep -v '^#' /etc/reminder-rs/config.env | xargs -d '\n')
REGEX='mysql://([A-Za-z]+)@(.+)/(.+)'
[[ $DATABASE_URL =~ $REGEX ]]
VAR=$(mysql -u "${BASH_REMATCH[1]}" -h "${BASH_REMATCH[2]}" -N -D "${BASH_REMATCH[3]}" -e "SELECT COUNT(1) FROM reminders WHERE utc_time < NOW() - INTERVAL 10 MINUTE AND enabled = 1 AND status = 'pending'")
if [ "$VAR" -gt 0 ]
then
echo "This is to inform that there is a reminder backlog which must be resolved." | mail -s "Backlog: $VAR" "$REPORT_EMAIL"
fi

View File

@ -1,6 +1,10 @@
CREATE DATABASE IF NOT EXISTS reminders;
SET FOREIGN_KEY_CHECKS=0; SET FOREIGN_KEY_CHECKS=0;
CREATE TABLE guilds ( USE reminders;
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,
@ -14,10 +18,10 @@ CREATE TABLE 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 channels(id) ON DELETE SET NULL FOREIGN KEY (default_channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL
); );
CREATE TABLE channels ( CREATE TABLE reminders.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,
@ -35,10 +39,10 @@ CREATE TABLE channels (
guild_id INT UNSIGNED, guild_id INT UNSIGNED,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE
); );
CREATE TABLE users ( CREATE TABLE reminders.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,
@ -55,10 +59,10 @@ CREATE TABLE users (
patreon BOOLEAN NOT NULL DEFAULT 0, patreon BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (dm_channel) REFERENCES channels(id) ON DELETE RESTRICT FOREIGN KEY (dm_channel) REFERENCES reminders.channels(id) ON DELETE RESTRICT
); );
CREATE TABLE roles ( CREATE TABLE reminders.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,
@ -67,10 +71,10 @@ CREATE TABLE roles (
guild_id INT UNSIGNED NOT NULL, guild_id INT UNSIGNED NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE
); );
CREATE TABLE embeds ( CREATE TABLE reminders.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 '',
@ -87,7 +91,7 @@ CREATE TABLE embeds (
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE TABLE embed_fields ( CREATE TABLE reminders.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 '',
@ -96,10 +100,10 @@ CREATE TABLE 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 embeds(id) ON DELETE CASCADE FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE CASCADE
); );
CREATE TABLE messages ( CREATE TABLE reminders.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 '',
@ -110,10 +114,10 @@ CREATE TABLE messages (
attachment_name VARCHAR(260), attachment_name VARCHAR(260),
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE SET NULL FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE SET NULL
); );
CREATE TABLE reminders ( CREATE TABLE reminders.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,
@ -136,20 +140,20 @@ CREATE TABLE reminders (
set_by INT UNSIGNED, set_by INT UNSIGNED,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE RESTRICT, FOREIGN KEY (message_id) REFERENCES reminders.messages(id) ON DELETE RESTRICT,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE CASCADE,
FOREIGN KEY (set_by) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (set_by) REFERENCES reminders.users(id) ON DELETE SET NULL
); );
CREATE TRIGGER message_cleanup AFTER DELETE ON reminders CREATE TRIGGER message_cleanup AFTER DELETE ON reminders.reminders
FOR EACH ROW FOR EACH ROW
DELETE FROM messages WHERE id = OLD.message_id; DELETE FROM reminders.messages WHERE id = OLD.message_id;
CREATE TRIGGER embed_cleanup AFTER DELETE ON messages CREATE TRIGGER embed_cleanup AFTER DELETE ON reminders.messages
FOR EACH ROW FOR EACH ROW
DELETE FROM embeds WHERE id = OLD.embed_id; DELETE FROM reminders.embeds WHERE id = OLD.embed_id;
CREATE TABLE todos ( CREATE TABLE reminders.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,
@ -157,23 +161,23 @@ CREATE TABLE todos (
value VARCHAR(2000) NOT NULL, value VARCHAR(2000) NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL
); );
CREATE TABLE command_restrictions ( CREATE TABLE reminders.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 roles(id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES reminders.roles(id) ON DELETE CASCADE,
UNIQUE KEY (`role_id`, `command`) UNIQUE KEY (`role_id`, `command`)
); );
CREATE TABLE timers ( CREATE TABLE reminders.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,
@ -182,7 +186,7 @@ CREATE TABLE timers (
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE TABLE events ( CREATE TABLE reminders.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(),
@ -194,12 +198,12 @@ CREATE TABLE events (
reminder_id INT UNSIGNED, reminder_id INT UNSIGNED,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE SET NULL FOREIGN KEY (reminder_id) REFERENCES reminders.reminders(id) ON DELETE SET NULL
); );
CREATE TABLE command_aliases ( CREATE TABLE reminders.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,
@ -208,22 +212,22 @@ CREATE TABLE command_aliases (
command VARCHAR(2048) NOT NULL, command VARCHAR(2048) NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
UNIQUE KEY (`guild_id`, `name`) UNIQUE KEY (`guild_id`, `name`)
); );
CREATE TABLE guild_users ( CREATE TABLE reminders.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 guilds(id) ON DELETE CASCADE, FOREIGN KEY (guild) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (user) REFERENCES reminders.users(id) ON DELETE CASCADE,
UNIQUE KEY (guild, user) UNIQUE KEY (guild, user)
); );
CREATE EVENT event_cleanup CREATE EVENT reminders.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 events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY); DO DELETE FROM reminders.events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);

View File

@ -1,3 +1,5 @@
USE reminders;
SET FOREIGN_KEY_CHECKS = 0; SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS reminders_new; DROP TABLE IF EXISTS reminders_new;

View File

@ -1,3 +1,5 @@
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,

View File

@ -0,0 +1,4 @@
USE reminders;
ALTER TABLE reminders.reminders RENAME COLUMN `interval` TO `interval_seconds`;
ALTER TABLE reminders.reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;

View File

@ -1,3 +1,5 @@
USE reminders;
CREATE TABLE reminder_template ( CREATE TABLE reminder_template (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,

View File

@ -0,0 +1,92 @@
SET foreign_key_checks = 0;
START TRANSACTION;
-- drop existing constraints
ALTER TABLE channels DROP FOREIGN KEY `channels_ibfk_1`;
ALTER TABLE command_aliases DROP FOREIGN KEY `command_aliases_ibfk_1`;
ALTER TABLE events DROP FOREIGN KEY `events_ibfk_1`;
ALTER TABLE guild_users DROP FOREIGN KEY `guild_users_ibfk_1`;
ALTER TABLE macro DROP FOREIGN KEY `macro_ibfk_1`;
ALTER TABLE roles DROP FOREIGN KEY `roles_ibfk_1`;
ALTER TABLE todos DROP FOREIGN KEY `todos_ibfk_2`;
ALTER TABLE reminder_template DROP FOREIGN KEY `reminder_template_ibfk_1`;
-- update foreign key types
ALTER TABLE channels MODIFY `guild_id` BIGINT UNSIGNED;
ALTER TABLE command_aliases MODIFY `guild_id` BIGINT UNSIGNED;
ALTER TABLE events MODIFY `guild_id` BIGINT UNSIGNED;
ALTER TABLE guild_users MODIFY `guild` BIGINT UNSIGNED;
ALTER TABLE macro MODIFY `guild_id` BIGINT UNSIGNED;
ALTER TABLE roles MODIFY `guild_id` BIGINT UNSIGNED;
ALTER TABLE todos MODIFY `guild_id` BIGINT UNSIGNED;
ALTER TABLE reminder_template MODIFY `guild_id` BIGINT UNSIGNED;
-- update foreign key values
UPDATE channels SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
UPDATE command_aliases SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
UPDATE events SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
UPDATE guild_users SET `guild` = (SELECT `guild` FROM guilds WHERE guilds.`id` = guild_users.`guild`);
UPDATE macro SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
UPDATE roles SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
UPDATE todos SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
UPDATE reminder_template SET `guild_id` = (SELECT `guild` FROM guilds WHERE guilds.`id` = `guild_id`);
-- update guilds table
ALTER TABLE guilds MODIFY `id` BIGINT UNSIGNED NOT NULL;
UPDATE guilds SET `id` = `guild`;
ALTER TABLE guilds DROP COLUMN `guild`;
ALTER TABLE guilds ADD COLUMN `default_channel` BIGINT UNSIGNED;
ALTER TABLE guilds ADD CONSTRAINT `default_channel_fk`
FOREIGN KEY (`default_channel`)
REFERENCES channels(`channel`)
ON DELETE SET NULL
ON UPDATE CASCADE;
-- re-add constraints
ALTER TABLE channels ADD CONSTRAINT
FOREIGN KEY (`guild_id`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE command_aliases ADD CONSTRAINT
FOREIGN KEY (`guild_id`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE events ADD CONSTRAINT
FOREIGN KEY (`guild_id`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE guild_users ADD CONSTRAINT
FOREIGN KEY (`guild`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE macro ADD CONSTRAINT
FOREIGN KEY (`guild_id`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE roles ADD CONSTRAINT
FOREIGN KEY (`guild_id`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
ALTER TABLE todos ADD CONSTRAINT
FOREIGN KEY (`guild_id`)
REFERENCES guilds(`id`)
ON DELETE CASCADE
ON UPDATE CASCADE;
COMMIT;
SET foreign_key_checks = 1;

View File

@ -1,2 +0,0 @@
ALTER TABLE reminders RENAME COLUMN `interval` TO `interval_seconds`;
ALTER TABLE reminders ADD COLUMN `interval_months` INT UNSIGNED DEFAULT NULL;

View File

@ -1 +0,0 @@
ALTER TABLE reminders ADD COLUMN `interval_days` INT UNSIGNED DEFAULT NULL;

View File

@ -1 +0,0 @@
ALTER TABLE reminders ADD COLUMN `thread_id` BIGINT DEFAULT NULL;

View File

@ -1 +0,0 @@
ALTER TABLE guilds ADD COLUMN ephemeral_confirmations BOOL NOT NULL DEFAULT 0;

View File

@ -1,2 +0,0 @@
ALTER TABLE reminders MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder';
ALTER TABLE reminder_template MODIFY `name` VARCHAR(100) NOT NULL DEFAULT 'Reminder';

View File

@ -1,9 +0,0 @@
CREATE TABLE stat (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`utc_time` DATETIME NOT NULL DEFAULT NOW(),
`type` ENUM('reminder_sent', 'reminder_failed'),
`reminder_id` INT UNSIGNED,
`message` TEXT,
PRIMARY KEY (`id`)
);

View File

@ -1,2 +0,0 @@
ALTER TABLE reminders ADD COLUMN `status` ENUM ('pending', 'sent', 'failed', 'deleted') NOT NULL DEFAULT 'pending';
ALTER TABLE reminders ADD COLUMN `status_message` TEXT;

View File

@ -1,19 +0,0 @@
-- Drop existing constraint
ALTER TABLE `reminders` DROP CONSTRAINT `reminders_ibfk_1`;
ALTER TABLE `reminders` MODIFY COLUMN `channel_id` INT UNSIGNED;
ALTER TABLE `reminders` ADD COLUMN `guild_id` INT UNSIGNED;
ALTER TABLE `reminders`
ADD CONSTRAINT `guild_id_fk`
FOREIGN KEY (`guild_id`)
REFERENCES `guilds`(`id`)
ON DELETE CASCADE;
ALTER TABLE `reminders`
ADD CONSTRAINT `channel_id_fk`
FOREIGN KEY (`channel_id`)
REFERENCES `channels`(`id`)
ON DELETE SET NULL;
UPDATE `reminders` SET `guild_id` = (SELECT guilds.`id` FROM `channels` INNER JOIN `guilds` ON channels.guild_id = guilds.id WHERE reminders.channel_id = channels.id);

View File

@ -1,4 +0,0 @@
ALTER TABLE reminders ADD COLUMN `status_change_time` DATETIME;
-- This is a best-guess as to the status change time.
UPDATE reminders SET `status_change_time` = `utc_time` WHERE `status` != 'pending';

View File

@ -1,41 +0,0 @@
server {
server_name www.reminder-bot.com;
return 301 $scheme://reminder-bot.com$request_uri;
}
server {
listen 80;
server_name reminder-bot.com;
return 301 https://reminder-bot.com$request_uri;
}
server {
listen 443 ssl;
server_name reminder-bot.com;
ssl_certificate /etc/letsencrypt/live/reminder-bot.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/reminder-bot.com/privkey.pem;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
location / {
proxy_pass http://localhost:18920;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static {
alias /var/www/reminder-rs/static;
expires 30d;
}
}

View File

@ -5,12 +5,12 @@ edition = "2021"
[dependencies] [dependencies]
tokio = { version = "1", features = ["process", "full"] } tokio = { version = "1", features = ["process", "full"] }
regex = "1.9" regex = "1.4"
log = "0.4" log = "0.4"
chrono = "0.4" chrono = "0.4"
chrono-tz = { version = "0.8", features = ["serde"] } 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.7", 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", 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"] }

View File

@ -1,6 +1,4 @@
use std::env; 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};
@ -9,7 +7,7 @@ use regex::{Captures, Regex};
use serde::Deserialize; use serde::Deserialize;
use serenity::{ use serenity::{
builder::CreateEmbed, builder::CreateEmbed,
http::{CacheHttp, Http, HttpError}, http::{CacheHttp, Http, HttpError, StatusCode},
model::{ model::{
channel::{Channel, Embed as SerenityEmbed}, channel::{Channel, Embed as SerenityEmbed},
id::ChannelId, id::ChannelId,
@ -32,7 +30,6 @@ lazy_static! {
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap(); Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
pub static ref TIMENOW_REGEX: Regex = pub static ref TIMENOW_REGEX: Regex =
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap(); Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
pub static ref LOG_TO_DATABASE: bool = env::var("LOG_TO_DATABASE").map_or(true, |v| v == "1");
} }
fn fmt_displacement(format: &str, seconds: u64) -> String { fn fmt_displacement(format: &str, seconds: u64) -> String {
@ -65,8 +62,7 @@ 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) {
match NaiveDateTime::from_timestamp_opt(final_time, 0) { let dt = NaiveDateTime::from_timestamp(final_time, 0);
Some(dt) => {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let difference = { let difference = {
@ -78,10 +74,6 @@ pub fn substitute(string: &str) -> String {
}; };
fmt_displacement(format, difference.num_seconds() as u64) fmt_displacement(format, difference.num_seconds() as u64)
}
None => String::new(),
}
} else { } else {
String::new() String::new()
} }
@ -154,7 +146,7 @@ impl Embed {
embed.description = substitute(&embed.description); embed.description = substitute(&embed.description);
embed.footer = substitute(&embed.footer); embed.footer = substitute(&embed.footer);
embed.fields.iter_mut().for_each(|field| { embed.fields.iter_mut().for_each(|mut field| {
field.title = substitute(&field.title); field.title = substitute(&field.title);
field.value = substitute(&field.value); field.value = substitute(&field.value);
}); });
@ -237,11 +229,11 @@ impl Into<CreateEmbed> for Embed {
pub struct Reminder { pub struct Reminder {
id: u32, id: u32,
channel_id: Option<u64>, channel_id: u64,
webhook_id: Option<u64>, webhook_id: Option<u64>,
webhook_token: Option<String>, webhook_token: Option<String>,
channel_paused: Option<bool>, channel_paused: bool,
channel_paused_until: Option<NaiveDateTime>, channel_paused_until: Option<NaiveDateTime>,
enabled: bool, enabled: bool,
@ -251,12 +243,11 @@ pub struct Reminder {
attachment: Option<Vec<u8>>, attachment: Option<Vec<u8>>,
attachment_name: Option<String>, attachment_name: Option<String>,
utc_time: DateTime<Utc>, utc_time: NaiveDateTime,
timezone: String, timezone: String,
restartable: bool, restartable: bool,
expires: Option<DateTime<Utc>>, expires: Option<NaiveDateTime>,
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>,
@ -290,31 +281,27 @@ 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,
reminders.`username` AS username reminders.`username` AS username
FROM FROM
reminders reminders
LEFT JOIN INNER JOIN
channels channels
ON ON
reminders.channel_id = channels.id reminders.channel_id = channels.id
WHERE WHERE
reminders.`status` = 'pending' AND
reminders.`id` IN ( reminders.`id` IN (
SELECT SELECT
MIN(id) MIN(id)
FROM FROM
reminders reminders
WHERE WHERE
reminders.`utc_time` <= NOW() AND reminders.`utc_time` <= NOW()
`status` = 'pending' AND AND (
(
reminders.`interval_seconds` IS NOT NULL reminders.`interval_seconds` IS NOT NULL
OR reminders.`interval_months` IS NOT NULL OR reminders.`interval_months` IS NOT NULL
OR reminders.`interval_days` IS NOT NULL
OR reminders.enabled OR reminders.enabled
) )
GROUP BY channel_id GROUP BY channel_id
@ -344,8 +331,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 UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
WHERE channel = ?
", ",
self.channel_id self.channel_id
) )
@ -354,74 +340,56 @@ WHERE
} }
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() if self.interval_seconds.is_some() || self.interval_months.is_some() {
|| self.interval_months.is_some() let now = Utc::now().naive_local();
|| self.interval_days.is_some() let mut updated_reminder_time = self.utc_time;
{
// If all intervals are zero then dont care
if self.interval_seconds == Some(0)
&& self.interval_days == Some(0)
&& self.interval_months == Some(0)
{
self.set_sent(pool).await;
}
let now = Utc::now();
let mut updated_reminder_time =
self.utc_time.with_timezone(&self.timezone.parse().unwrap_or(Tz::UTC));
let mut fail_count = 0;
while updated_reminder_time < now && fail_count < 4 {
if let Some(interval) = self.interval_months { if let Some(interval) = self.interval_months {
if interval != 0 { match sqlx::query!(
updated_reminder_time = updated_reminder_time // use the second date_add to force return value to datetime
.checked_add_months(Months::new(interval)) "SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time",
.unwrap_or_else(|| { updated_reminder_time,
warn!( interval
"{}: Could not add {} months to a reminder", )
interval, self.id .fetch_one(pool)
); .await
fail_count += 1; {
Ok(row) => match row.new_time {
updated_reminder_time Some(datetime) => {
}); updated_reminder_time = datetime;
} }
None => {
warn!("Could not update interval by months: got NULL");
updated_reminder_time += Duration::days(30);
} }
},
if let Some(interval) = self.interval_days { Err(e) => {
if interval != 0 { 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", self.id, interval);
fail_count += 1;
updated_reminder_time // naively fallback to adding 30 days
}) updated_reminder_time += Duration::days(30);
}
} }
} }
if let Some(interval) = self.interval_seconds { if let Some(interval) = self.interval_seconds {
while updated_reminder_time < now {
updated_reminder_time += Duration::seconds(interval as i64); updated_reminder_time += Duration::seconds(interval as i64);
} }
} }
if fail_count >= 4 { if self.expires.map_or(false, |expires| {
self.log_error( NaiveDateTime::from_timestamp(updated_reminder_time.timestamp(), 0) > expires
pool, }) {
"Failed to update 4 times and so is being deleted", self.force_delete(pool).await;
None::<&'static str>,
)
.await;
self.set_failed(pool, "Failed to update 4 times and so is being deleted").await;
} else if self.expires.map_or(false, |expires| updated_reminder_time > expires) {
self.set_sent(pool).await;
} else { } else {
sqlx::query!( sqlx::query!(
" "
UPDATE reminders SET `utc_time` = ? WHERE `id` = ? UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
", ",
updated_reminder_time.with_timezone(&Utc), updated_reminder_time,
self.id self.id
) )
.execute(pool) .execute(pool)
@ -429,84 +397,15 @@ WHERE
.expect(&format!("Could not update time on Reminder {}", self.id)); .expect(&format!("Could not update time on Reminder {}", self.id));
} }
} else { } else {
self.set_sent(pool).await; self.force_delete(pool).await;
} }
} }
async fn log_error( async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) {
&self,
pool: impl Executor<'_, Database = Database> + Copy,
error: &'static str,
debug_info: Option<impl std::fmt::Debug>,
) {
let message = match debug_info {
Some(info) => format!(
"{}
{:?}",
error, info
),
None => error.to_string(),
};
error!("[Reminder {}] {}", self.id, message);
if *LOG_TO_DATABASE {
sqlx::query!( sqlx::query!(
" "
INSERT INTO stat (type, reminder_id, message) DELETE FROM reminders WHERE `id` = ?
VALUES ('reminder_failed', ?, ?)
", ",
self.id,
message,
)
.execute(pool)
.await
.expect("Could not log error to database");
}
}
async fn log_success(&self, pool: impl Executor<'_, Database = Database> + Copy) {
if *LOG_TO_DATABASE {
sqlx::query!(
"
INSERT INTO stat (type, reminder_id)
VALUES ('reminder_sent', ?)
",
self.id,
)
.execute(pool)
.await
.expect("Could not log success to database");
}
}
async fn set_sent(&self, pool: impl Executor<'_, Database = Database> + Copy) {
sqlx::query!(
"
UPDATE reminders
SET `status` = 'sent', `status_change_time` = NOW()
WHERE `id` = ?
",
self.id
)
.execute(pool)
.await
.expect(&format!("Could not delete Reminder {}", self.id));
}
async fn set_failed(
&self,
pool: impl Executor<'_, Database = Database> + Copy,
message: &'static str,
) {
sqlx::query!(
"
UPDATE reminders
SET `status` = 'failed', `status_message` = ?, `status_change_time` = NOW()
WHERE `id` = ?
",
message,
self.id self.id
) )
.execute(pool) .execute(pool)
@ -515,9 +414,7 @@ WHERE
} }
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>) {
if let Some(channel_id) = self.channel_id { let _ = http.as_ref().pin_message(self.channel_id, message_id.into(), None).await;
let _ = http.as_ref().pin_message(channel_id, message_id.into(), None).await;
}
} }
pub async fn send( pub async fn send(
@ -527,11 +424,10 @@ WHERE
) { ) {
async fn send_to_channel( async fn send_to_channel(
cache_http: impl CacheHttp, cache_http: impl CacheHttp,
channel_id: u64,
reminder: &Reminder, reminder: &Reminder,
embed: Option<CreateEmbed>, embed: Option<CreateEmbed>,
) -> Result<()> { ) -> Result<()> {
let channel = ChannelId(channel_id).to_channel(&cache_http).await; let channel = ChannelId(reminder.channel_id).to_channel(&cache_http).await;
match channel { match channel {
Ok(Channel::Guild(channel)) => { Ok(Channel::Guild(channel)) => {
@ -563,7 +459,6 @@ WHERE
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
Ok(Channel::Private(channel)) => { Ok(Channel::Private(channel)) => {
match channel match channel
.send_message(&cache_http.http(), |m| { .send_message(&cache_http.http(), |m| {
@ -593,9 +488,7 @@ WHERE
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
Err(e) => Err(e), Err(e) => Err(e),
_ => Err(Error::Other("Channel not of valid type")), _ => Err(Error::Other("Channel not of valid type")),
} }
} }
@ -611,10 +504,8 @@ WHERE
w.content(&reminder.content).tts(reminder.tts); w.content(&reminder.content).tts(reminder.tts);
if let Some(username) = &reminder.username { if let Some(username) = &reminder.username {
if !username.is_empty() {
w.username(username); w.username(username);
} }
}
if let Some(avatar) = &reminder.avatar { if let Some(avatar) = &reminder.avatar {
w.avatar_url(avatar); w.avatar_url(avatar);
@ -650,19 +541,15 @@ WHERE
} }
} }
match self.channel_id {
Some(channel_id) => {
if self.enabled if self.enabled
&& !(self.channel_paused.unwrap_or(false) && !(self.channel_paused
&& self && self
.channel_paused_until .channel_paused_until
.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` UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
SET paused = 0, paused_until = NULL
WHERE `channel` = ?
", ",
self.channel_id self.channel_id
) )
@ -674,112 +561,40 @@ WHERE
let result = if let (Some(webhook_id), Some(webhook_token)) = let result = if let (Some(webhook_id), Some(webhook_token)) =
(self.webhook_id, &self.webhook_token) (self.webhook_id, &self.webhook_token)
{ {
let webhook_res = cache_http let webhook_res =
.http() cache_http.http().get_webhook_with_token(webhook_id, webhook_token).await;
.get_webhook_with_token(webhook_id, webhook_token)
.await;
if let Ok(webhook) = webhook_res { if let Ok(webhook) = webhook_res {
send_to_webhook(cache_http, &self, webhook, embed).await send_to_webhook(cache_http, &self, webhook, embed).await
} else { } else {
warn!("Webhook vanished for reminder {}: {:?}", self.id, webhook_res); warn!("Webhook vanished: {:?}", webhook_res);
self.reset_webhook(pool).await; self.reset_webhook(pool).await;
send_to_channel(cache_http, channel_id, &self, embed).await send_to_channel(cache_http, &self, embed).await
} }
} else { } else {
send_to_channel(cache_http, channel_id, &self, embed).await send_to_channel(cache_http, &self, embed).await
}; };
if let Err(e) = result { if let Err(e) = result {
error!("Error sending reminder {}: {:?}", self.id, e);
if let Error::Http(error) = e { if let Error::Http(error) = e {
if let HttpError::UnsuccessfulRequest(http_error) = *error { if error.status_code() == Some(StatusCode::NOT_FOUND) {
match http_error.error.code { warn!("Seeing channel is deleted. Removing reminder");
10003 => { self.force_delete(pool).await;
self.log_error( } else if let HttpError::UnsuccessfulRequest(error) = *error {
pool, if error.error.code == 50007 {
"Could not be sent as channel does not exist", warn!("User cannot receive DMs");
None::<&'static str>, self.force_delete(pool).await;
) } else {
.await;
self.set_failed(
pool,
"Could not be sent as channel does not exist",
)
.await;
}
10004 => {
self.log_error(
pool,
"Could not be sent as guild does not exist",
None::<&'static str>,
)
.await;
self.set_failed(
pool,
"Could not be sent as guild does not exist",
)
.await;
}
50001 => {
self.log_error(
pool,
"Could not be sent as missing access",
None::<&'static str>,
)
.await;
self.set_failed(
pool,
"Could not be sent as missing access",
)
.await;
}
50007 => {
self.log_error(
pool,
"Could not be sent as user has DMs disabled",
None::<&'static str>,
)
.await;
self.set_failed(
pool,
"Could not be sent as user has DMs disabled",
)
.await;
}
50013 => {
self.log_error(
pool,
"Could not be sent as permissions are invalid",
None::<&'static str>,
)
.await;
self.set_failed(
pool,
"Could not be sent as permissions are invalid",
)
.await;
}
_ => {
self.log_error(
pool,
"HTTP error sending reminder",
Some(http_error),
)
.await;
self.refresh(pool).await; self.refresh(pool).await;
} }
} }
} else { } else {
self.log_error(pool, "(Likely) a parsing error", Some(error)).await;
self.refresh(pool).await; self.refresh(pool).await;
} }
} else { } else {
self.log_error(pool, "Non-HTTP error", Some(e)).await;
self.refresh(pool).await;
}
} else {
self.log_success(pool).await;
self.refresh(pool).await; self.refresh(pool).await;
} }
} else { } else {
@ -788,13 +603,4 @@ WHERE
self.refresh(pool).await; self.refresh(pool).await;
} }
} }
None => {
info!("Reminder {} is orphaned", self.id);
self.log_error(pool, "Orphaned", Option::<u8>::None).await;
self.set_failed(pool, "Could not be sent as channel was deleted").await;
}
}
}
} }

View File

@ -24,7 +24,7 @@ pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<Str
SELECT name SELECT name
FROM macro FROM macro
WHERE WHERE
guild_id = (SELECT id FROM guilds WHERE guild = ?) guild_id = ?
AND name LIKE CONCAT(?, '%')", AND name LIKE CONCAT(?, '%')",
ctx.guild_id().unwrap().0, ctx.guild_id().unwrap().0,
partial, partial,
@ -37,6 +37,20 @@ WHERE
.collect() .collect()
} }
pub async fn multiline_autocomplete(
_ctx: Context<'_>,
partial: &str,
) -> Vec<AutocompleteChoice<String>> {
if partial.is_empty() {
vec![AutocompleteChoice { name: "Multiline content...".to_string(), value: "".to_string() }]
} else {
vec![
AutocompleteChoice { name: partial.to_string(), value: partial.to_string() },
AutocompleteChoice { name: "Multiline content...".to_string(), value: "".to_string() },
]
}
}
pub async fn time_hint_autocomplete( pub async fn time_hint_autocomplete(
ctx: Context<'_>, ctx: Context<'_>,
partial: &str, partial: &str,
@ -55,7 +69,7 @@ pub async fn time_hint_autocomplete(
if diff < 0 { if diff < 0 {
vec![AutocompleteChoice { vec![AutocompleteChoice {
name: "Time is in the past".to_string(), name: "Time is in the past".to_string(),
value: "1 year ago".to_string(), value: "now".to_string(),
}] }]
} else { } else {
if diff > 86400 { if diff > 86400 {

View File

@ -17,7 +17,7 @@ pub async fn delete_macro(
) -> Result<(), Error> { ) -> Result<(), Error> {
match sqlx::query!( match sqlx::query!(
" "
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", SELECT id FROM macro WHERE guild_id = ? AND name = ?",
ctx.guild_id().unwrap().0, ctx.guild_id().unwrap().0,
name name
) )

View File

@ -24,10 +24,10 @@ pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
let aliases = sqlx::query_as!( let aliases = sqlx::query_as!(
Alias, Alias,
"SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", "SELECT name, command FROM command_aliases WHERE guild_id = ?",
guild_id.0 guild_id.0
) )
.fetch_all(&mut *transaction) .fetch_all(&mut transaction)
.await?; .await?;
let mut added_aliases = 0; let mut added_aliases = 0;
@ -36,13 +36,13 @@ pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
match parse_text_command(guild_id, alias.name, &alias.command) { match parse_text_command(guild_id, alias.name, &alias.command) {
Some(cmd_macro) => { Some(cmd_macro) => {
sqlx::query!( sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)", "INSERT INTO macro (guild_id, name, description, commands) VALUES (?, ?, ?, ?)",
cmd_macro.guild_id.0, cmd_macro.guild_id.0,
cmd_macro.name, cmd_macro.name,
cmd_macro.description, cmd_macro.description,
cmd_macro.commands cmd_macro.commands
) )
.execute(&mut *transaction) .execute(&mut transaction)
.await?; .await?;
added_aliases += 1; added_aliases += 1;

View File

@ -31,7 +31,7 @@ pub async fn record_macro(
let row = sqlx::query!( let row = sqlx::query!(
" "
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", SELECT 1 as _e FROM macro WHERE guild_id = ? AND name = ?",
guild_id.0, guild_id.0,
name name
) )
@ -121,7 +121,7 @@ pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
let json = serde_json::to_string(&command_macro.commands).unwrap(); let json = serde_json::to_string(&command_macro.commands).unwrap();
sqlx::query!( sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)", "INSERT INTO macro (guild_id, name, description, commands) VALUES (?, ?, ?, ?)",
command_macro.guild_id.0, command_macro.guild_id.0,
command_macro.name, command_macro.name,
command_macro.description, command_macro.description,

View File

@ -1,5 +1,5 @@
use super::super::autocomplete::macro_name_autocomplete; use super::super::autocomplete::macro_name_autocomplete;
use crate::{models::command_macro::guild_command_macro, Context, Data, Error, THEME_COLOR}; use crate::{models::command_macro::guild_command_macro, Context, Data, Error};
/// Run a recorded macro /// Run a recorded macro
#[poise::command( #[poise::command(
@ -17,17 +17,7 @@ pub async fn run_macro(
) -> Result<(), Error> { ) -> Result<(), Error> {
match guild_command_macro(&Context::Application(ctx), &name).await { match guild_command_macro(&Context::Application(ctx), &name).await {
Some(command_macro) => { Some(command_macro) => {
Context::Application(ctx) ctx.defer_response(false).await?;
.send(|b| {
b.embed(|e| {
e.title("Running Macro").color(*THEME_COLOR).description(format!(
"Running macro {} ({} commands)",
command_macro.name,
command_macro.commands.len()
))
})
})
.await?;
for command in command_macro.commands { for command in command_macro.commands {
if let Some(action) = command.action { if let Some(action) = command.action {

View File

@ -6,8 +6,8 @@ use crate::{models::CtxData, Context, Error, THEME_COLOR};
fn footer( fn footer(
ctx: Context<'_>, ctx: Context<'_>,
) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter { ) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
let shard_count = ctx.serenity_context().cache.shard_count(); let shard_count = ctx.discord().cache.shard_count();
let shard = ctx.serenity_context().shard_id; let shard = ctx.discord().shard_id;
move |f| { move |f| {
f.text(format!( f.text(format!(

View File

@ -2,6 +2,7 @@ use chrono::offset::Utc;
use chrono_tz::{Tz, TZ_VARIANTS}; use chrono_tz::{Tz, TZ_VARIANTS};
use levenshtein::levenshtein; use levenshtein::levenshtein;
use log::warn; use log::warn;
use poise::serenity_prelude::{ChannelId, Mentionable};
use super::autocomplete::timezone_autocomplete; use super::autocomplete::timezone_autocomplete;
use crate::{consts::THEME_COLOR, models::CtxData, Context, Error}; use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
@ -102,78 +103,6 @@ You may want to use one of the popular timezones below, otherwise click [here](h
Ok(()) Ok(())
} }
/// Configure server settings
#[poise::command(
slash_command,
rename = "settings",
identifying_name = "settings",
guild_only = true
)]
pub async fn settings(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Configure ephemeral setup
#[poise::command(
slash_command,
rename = "ephemeral",
identifying_name = "ephemeral_confirmations",
guild_only = true
)]
pub async fn ephemeral_confirmations(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Set reminder confirmations to be sent "ephemerally" (private and cleared automatically)
#[poise::command(
slash_command,
rename = "on",
identifying_name = "set_ephemeral_confirmations",
guild_only = true
)]
pub async fn set_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> {
let mut guild_data = ctx.guild_data().await.unwrap()?;
guild_data.ephemeral_confirmations = true;
guild_data.commit_changes(&ctx.data().database).await;
ctx.send(|r| {
r.ephemeral(true).embed(|e| {
e.title("Confirmations ephemeral")
.description("Reminder confirmations will be sent privately, and removed when your client restarts.")
.color(*THEME_COLOR)
})
})
.await?;
Ok(())
}
/// Set reminder confirmations to persist indefinitely
#[poise::command(
slash_command,
rename = "off",
identifying_name = "unset_ephemeral_confirmations",
guild_only = true
)]
pub async fn unset_ephemeral_confirmations(ctx: Context<'_>) -> Result<(), Error> {
let mut guild_data = ctx.guild_data().await.unwrap()?;
guild_data.ephemeral_confirmations = false;
guild_data.commit_changes(&ctx.data().database).await;
ctx.send(|r| {
r.ephemeral(true).embed(|e| {
e.title("Confirmations public")
.description(
"Reminder confirmations will be sent as regular messages, and won't be removed automatically.",
)
.color(*THEME_COLOR)
})
})
.await?;
Ok(())
}
/// Configure whether other users can set reminders to your direct messages /// Configure whether other users can set reminders to your direct messages
#[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")] #[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")]
pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> { pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
@ -181,7 +110,7 @@ pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
} }
/// Allow other users to set reminders in your direct messages /// Allow other users to set reminders in your direct messages
#[poise::command(slash_command, rename = "allow", identifying_name = "set_allowed_dm")] #[poise::command(slash_command, rename = "allow", identifying_name = "allowed_dm")]
pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
let mut user_data = ctx.author_data().await?; let mut user_data = ctx.author_data().await?;
user_data.allowed_dm = true; user_data.allowed_dm = true;
@ -200,7 +129,7 @@ pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
} }
/// Block other users from setting reminders in your direct messages /// Block other users from setting reminders in your direct messages
#[poise::command(slash_command, rename = "block", identifying_name = "unset_allowed_dm")] #[poise::command(slash_command, rename = "block", identifying_name = "allowed_dm")]
pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> { pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
let mut user_data = ctx.author_data().await?; let mut user_data = ctx.author_data().await?;
user_data.allowed_dm = false; user_data.allowed_dm = false;
@ -220,11 +149,51 @@ pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
/// Set defaults for commands
#[poise::command(
slash_command,
identifying_name = "default",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn default(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Set a default channel for reminders to be sent to
#[poise::command(
slash_command,
guild_only = true,
identifying_name = "default_channel",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn default_channel(
ctx: Context<'_>,
#[description = "Channel to send reminders to by default"] channel: Option<ChannelId>,
) -> Result<(), Error> {
if let Some(mut guild_data) = ctx.guild_data().await {
guild_data.default_channel = channel.map(|c| c.0);
guild_data.commit_changes(&ctx.data().database).await?;
if let Some(channel) = channel {
ctx.send(|r| {
r.ephemeral(true).content(format!("Default channel set to {}", channel.mention()))
})
.await?;
} else {
ctx.send(|r| r.ephemeral(true).content("Default channel unset.")).await?;
}
}
Ok(())
}
/// View the webhook being used to send reminders to this channel /// View the webhook being used to send reminders to this channel
#[poise::command( #[poise::command(
slash_command, slash_command,
identifying_name = "webhook_url", identifying_name = "webhook_url",
required_permissions = "ADMINISTRATOR" required_permissions = "ADMINISTRATOR",
default_member_permissions = "ADMINISTRATOR"
)] )]
pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> { pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
match ctx.channel_data().await { match ctx.channel_data().await {

View File

@ -1,8 +1,11 @@
use std::{collections::HashSet, string::ToString}; use std::{
collections::HashSet,
string::ToString,
time::{SystemTime, UNIX_EPOCH},
};
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::NaiveDateTime;
use chrono_tz::Tz; use chrono_tz::Tz;
use log::warn;
use num_integer::Integer; use num_integer::Integer;
use poise::{ use poise::{
serenity_prelude::{ serenity_prelude::{
@ -12,7 +15,9 @@ use poise::{
}; };
use crate::{ use crate::{
commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete}, commands::autocomplete::{
multiline_autocomplete, time_hint_autocomplete, timezone_autocomplete,
},
component_models::{ component_models::{
pager::{DelPager, LookPager, Pager}, pager::{DelPager, LookPager, Pager},
ComponentDataModel, DelSelector, UndoReminder, ComponentDataModel, DelSelector, UndoReminder,
@ -57,8 +62,8 @@ pub async fn pause(
let parsed = natural_parser(&until, &timezone.to_string()).await; let parsed = natural_parser(&until, &timezone.to_string()).await;
if let Some(timestamp) = parsed { if let Some(timestamp) = parsed {
match NaiveDateTime::from_timestamp_opt(timestamp, 0) { let dt = NaiveDateTime::from_timestamp(timestamp, 0);
Some(dt) => {
channel.paused = true; channel.paused = true;
channel.paused_until = Some(dt); channel.paused_until = Some(dt);
@ -69,15 +74,6 @@ pub async fn pause(
timestamp timestamp
)) ))
.await?; .await?;
}
None => {
ctx.say(format!(
"Time processed could not be interpreted as `DateTime`. Please write the time as clearly as possible",
))
.await?;
}
}
} else { } else {
ctx.say( ctx.say(
"Time could not be processed. Please write the time as clearly as possible", "Time could not be processed. Please write the time as clearly as possible",
@ -216,7 +212,7 @@ pub async fn look(
}), }),
}; };
let channel_opt = ctx.channel_id().to_channel_cached(&ctx); let channel_opt = ctx.channel_id().to_channel_cached(&ctx.discord());
let channel_id = if let Some(Channel::Guild(channel)) = channel_opt { let channel_id = if let Some(Channel::Guild(channel)) = channel_opt {
if Some(channel.guild_id) == ctx.guild_id() { if Some(channel.guild_id) == ctx.guild_id() {
@ -228,7 +224,8 @@ pub async fn look(
ctx.channel_id() ctx.channel_id()
}; };
let channel_name = if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx) { let channel_name =
if let Some(Channel::Guild(channel)) = channel_id.to_channel_cached(&ctx.discord()) {
Some(channel.name) Some(channel.name)
} else { } else {
None None
@ -250,7 +247,7 @@ pub async fn look(
char_count < EMBED_DESCRIPTION_MAX_LENGTH char_count < EMBED_DESCRIPTION_MAX_LENGTH
}) })
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(""); .join("\n");
let pages = reminders let pages = reminders
.iter() .iter()
@ -294,7 +291,8 @@ pub async fn delete(ctx: Context<'_>) -> Result<(), Error> {
let timezone = ctx.timezone().await; let timezone = ctx.timezone().await;
let reminders = let reminders =
Reminder::from_guild(&ctx, &ctx.data().database, ctx.guild_id(), ctx.author().id).await; Reminder::from_guild(&ctx.discord(), &ctx.data().database, ctx.guild_id(), ctx.author().id)
.await;
let resp = show_delete_page(&reminders, 0, timezone); let resp = show_delete_page(&reminders, 0, timezone);
@ -436,8 +434,11 @@ pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> Cr
reply reply
} }
fn time_difference(start_time: DateTime<Utc>) -> String { fn time_difference(start_time: NaiveDateTime) -> String {
let delta = (Utc::now() - start_time).num_seconds(); let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
let now = NaiveDateTime::from_timestamp(unix_time, 0);
let delta = (now - start_time).num_seconds();
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);
@ -561,17 +562,20 @@ struct ContentModal {
content: String, content: String,
} }
/// Create a reminder with multi-line content. Press "+4 more" for other options. /// Create a reminder. Press "+4 more" for other options.
#[poise::command( #[poise::command(
slash_command, slash_command,
identifying_name = "multiline", identifying_name = "remind",
default_member_permissions = "MANAGE_GUILD" default_member_permissions = "MANAGE_GUILD"
)] )]
pub async fn multiline( pub async fn remind(
ctx: ApplicationContext<'_>, ctx: ApplicationContext<'_>,
#[description = "A description of the time to set the reminder for"] #[description = "A description of the time to set the reminder for"]
#[autocomplete = "time_hint_autocomplete"] #[autocomplete = "time_hint_autocomplete"]
time: String, time: String,
#[description = "The message content to send"]
#[autocomplete = "multiline_autocomplete"]
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>,
@ -584,10 +588,10 @@ pub async fn multiline(
timezone: Option<String>, timezone: Option<String>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten(); let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
let data_opt = ContentModal::execute(ctx).await?;
match data_opt { if content.is_empty() {
Some(data) => { let data = ContentModal::execute(ctx).await?;
create_reminder( create_reminder(
Context::Application(ctx), Context::Application(ctx),
time, time,
@ -599,45 +603,19 @@ pub async fn multiline(
tz, tz,
) )
.await .await
} } else {
create_reminder(
None => { Context::Application(ctx),
warn!("Unexpected None encountered in /multiline"); time,
Ok(Context::Application(ctx) content,
.send(|m| m.content("Unexpected error.").ephemeral(true)) channels,
interval,
expires,
tts,
tz,
)
.await .await
.map(|_| ())?)
} }
}
}
/// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content.
#[poise::command(
slash_command,
identifying_name = "remind",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn remind(
ctx: ApplicationContext<'_>,
#[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 = "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();
create_reminder(Context::Application(ctx), time, content, channels, interval, expires, tts, tz)
.await
} }
async fn create_reminder( async fn create_reminder(
@ -656,13 +634,7 @@ async fn create_reminder(
return Ok(()); return Ok(());
} }
let ephemeral =
ctx.guild_data().await.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations));
if ephemeral {
ctx.defer_ephemeral().await?;
} else {
ctx.defer().await?; ctx.defer().await?;
}
let user_data = ctx.author_data().await.unwrap(); let user_data = ctx.author_data().await.unwrap();
let timezone = timezone.unwrap_or(ctx.timezone().await); let timezone = timezone.unwrap_or(ctx.timezone().await);
@ -681,7 +653,9 @@ async fn create_reminder(
let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default(); let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default();
if list.is_empty() { if list.is_empty() {
if ctx.guild_id().is_some() { if let Some(channel_id) = ctx.default_channel().await {
vec![ReminderScope::Channel(channel_id.0)]
} else if ctx.guild_id().is_some() {
vec![ReminderScope::Channel(ctx.channel_id().0)] vec![ReminderScope::Channel(ctx.channel_id().0)]
} else { } else {
vec![ReminderScope::User(ctx.author().id.0)] vec![ReminderScope::User(ctx.author().id.0)]
@ -692,9 +666,9 @@ async fn create_reminder(
}; };
let (processed_interval, processed_expires) = if let Some(repeat) = &interval { let (processed_interval, processed_expires) = if let Some(repeat) = &interval {
if check_subscription(&ctx, ctx.author().id).await if check_subscription(&ctx.discord(), ctx.author().id).await
|| (ctx.guild_id().is_some() || (ctx.guild_id().is_some()
&& check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await) && check_guild_subscription(&ctx.discord(), ctx.guild_id().unwrap()).await)
{ {
( (
parse_duration(repeat) parse_duration(repeat)
@ -709,10 +683,9 @@ async fn create_reminder(
}, },
) )
} else { } else {
ctx.send(|b| { ctx.say(
b.content( "`repeat` is only available to Patreon subscribers or self-hosted users",
"`repeat` is only available to Patreon subscribers or self-hosted users") )
})
.await?; .await?;
return Ok(()); return Ok(());
@ -722,17 +695,12 @@ async fn create_reminder(
}; };
if processed_interval.is_none() && interval.is_some() { if processed_interval.is_none() && interval.is_some() {
ctx.send(|b| { ctx.say(
b.content( "Repeat interval could not be processed. Try similar to `1 hour` or `4 days`",
"Repeat interval could not be processed. Try similar to `1 hour` or `4 days`") )
})
.await?; .await?;
} else if processed_expires.is_none() && expires.is_some() { } else if processed_expires.is_none() && expires.is_some() {
ctx.send(|b| { ctx.say("Expiry time failed to process. Please make it as clear as possible")
b.ephemeral(true).content(
"Expiry time failed to process. Please make it as clear as possible",
)
})
.await?; .await?;
} else { } else {
let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id()) let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id())

View File

@ -47,7 +47,7 @@ pub async fn todo_guild_add(
) -> Result<(), Error> { ) -> Result<(), Error> {
sqlx::query!( sqlx::query!(
"INSERT INTO todos (guild_id, value) "INSERT INTO todos (guild_id, value)
VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)", VALUES (?, ?)",
ctx.guild_id().unwrap().0, ctx.guild_id().unwrap().0,
task task
) )
@ -70,9 +70,7 @@ VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)",
)] )]
pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> { pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> {
let values = sqlx::query!( let values = sqlx::query!(
"SELECT todos.id, value FROM todos "SELECT todos.id, value FROM todos WHERE guild_id = ?",
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
ctx.guild_id().unwrap().0, ctx.guild_id().unwrap().0,
) )
.fetch_all(&ctx.data().database) .fetch_all(&ctx.data().database)
@ -122,7 +120,7 @@ pub async fn todo_channel_add(
sqlx::query!( sqlx::query!(
"INSERT INTO todos (guild_id, channel_id, value) "INSERT INTO todos (guild_id, channel_id, value)
VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)", VALUES (?, (SELECT id FROM channels WHERE channel = ?), ?)",
ctx.guild_id().unwrap().0, ctx.guild_id().unwrap().0,
ctx.channel_id().0, ctx.channel_id().0,
task task
@ -340,18 +338,7 @@ 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({ .description(disp.split_once(' ').unwrap_or(("", "")).1)
let c = disp.split_once(' ').unwrap_or(("", "")).1;
if c.len() > 100 {
format!(
"{}...",
c.chars().take(97).collect::<String>()
)
} else {
c.to_string()
}
})
}); });
} }

View File

@ -2,7 +2,6 @@ pub(crate) mod pager;
use std::io::Cursor; use std::io::Cursor;
use base64::{engine::general_purpose, Engine};
use chrono_tz::Tz; use chrono_tz::Tz;
use log::warn; use log::warn;
use poise::{ use poise::{
@ -52,12 +51,11 @@ impl ComponentDataModel {
pub fn to_custom_id(&self) -> String { pub fn to_custom_id(&self) -> String {
let mut buf = Vec::new(); let mut buf = Vec::new();
self.serialize(&mut Serializer::new(&mut buf)).unwrap(); self.serialize(&mut Serializer::new(&mut buf)).unwrap();
general_purpose::STANDARD.encode(buf) base64::encode(buf)
} }
pub fn from_custom_id(data: &String) -> Self { pub fn from_custom_id(data: &String) -> Self {
let buf = general_purpose::STANDARD let buf = base64::decode(data)
.decode(data)
.map_err(|e| format!("Could not decode `custom_id' {}: {:?}", data, e)) .map_err(|e| format!("Could not decode `custom_id' {}: {:?}", data, e))
.unwrap(); .unwrap();
let cur = Cursor::new(buf); let cur = Cursor::new(buf);
@ -115,7 +113,7 @@ impl ComponentDataModel {
char_count < EMBED_DESCRIPTION_MAX_LENGTH char_count < EMBED_DESCRIPTION_MAX_LENGTH
}) })
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(""); .join("\n");
let mut embed = CreateEmbed::default(); let mut embed = CreateEmbed::default();
embed embed
@ -166,21 +164,12 @@ impl ComponentDataModel {
.await; .await;
} }
ComponentDataModel::DelSelector(selector) => { ComponentDataModel::DelSelector(selector) => {
for id in &component.data.values { let selected_id = component.data.values.join(",");
match id.parse::<u32>() {
Ok(id) => {
if let Some(reminder) = Reminder::from_id(&data.database, id).await {
reminder.delete(&data.database).await.unwrap();
} else {
warn!("Attempt to delete non-existent reminder");
}
}
Err(e) => { sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id)
warn!("Error casting ID to integer: {:?}.", e); .execute(&data.database)
} .await
} .unwrap();
}
let reminders = Reminder::from_guild( let reminders = Reminder::from_guild(
&ctx, &ctx,
@ -233,9 +222,7 @@ WHERE channels.channel = ?",
.collect::<Vec<(usize, String)>>() .collect::<Vec<(usize, String)>>()
} else { } else {
sqlx::query!( sqlx::query!(
"SELECT todos.id, value FROM todos "SELECT todos.id, value FROM todos WHERE guild_id = ?",
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
pager.guild_id, pager.guild_id,
) )
.fetch_all(&data.database) .fetch_all(&data.database)

View File

@ -17,13 +17,17 @@ use regex::Regex;
lazy_static! { lazy_static! {
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/webhook.jpg")) as &[u8], include_bytes!(concat!(
"webhook.jpg", env!("CARGO_MANIFEST_DIR"),
"/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("PATREON_ROLE_ID") env::var("SUBSCRIPTION_ROLES")
.map(|var| var .map(|var| var
.split(',') .split(',')
.filter_map(|item| { item.parse::<u64>().ok() }) .filter_map(|item| { item.parse::<u64>().ok() })
@ -31,7 +35,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("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten(); env::var("CNC_GUILD").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")
@ -44,5 +48,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(|_| "/usr/bin/python3".to_string()); env::var("PYTHON_LOCATION").unwrap_or_else(|_| "venv/bin/python3".to_string());
} }

View File

@ -6,7 +6,9 @@ use poise::{
serenity_prelude::{model::application::interaction::Interaction, utils::shard_id}, serenity_prelude::{model::application::interaction::Interaction, utils::shard_id},
}; };
use crate::{component_models::ComponentDataModel, Data, Error, THEME_COLOR}; use crate::{
component_models::ComponentDataModel, models::guild_data::GuildData, Data, Error, THEME_COLOR,
};
pub async fn listener( pub async fn listener(
ctx: &serenity::Context, ctx: &serenity::Context,
@ -27,7 +29,7 @@ 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 IGNORE INTO guilds (guild) VALUES (?)", guild_id) sqlx::query!("INSERT IGNORE INTO guilds (id) VALUES (?)", guild_id)
.execute(&data.database) .execute(&data.database)
.await?; .await?;
@ -61,16 +63,28 @@ To stay up to date on the latest features and fixes, join our [Discord](https://
} }
} }
poise::Event::GuildDelete { incomplete, .. } => { poise::Event::GuildDelete { incomplete, .. } => {
let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0) let _ = sqlx::query!("DELETE FROM guilds WHERE id = ?", incomplete.id.0)
.execute(&data.database) .execute(&data.database)
.await; .await;
} }
poise::Event::InteractionCreate { interaction } => { poise::Event::InteractionCreate { interaction } => {
if let Interaction::MessageComponent(component) = interaction { match interaction {
let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); Interaction::ApplicationCommand(app_command) => {
if let Some(guild_id) = app_command.guild_id {
// check database guild exists
GuildData::from_guild(guild_id, &data.database).await?;
}
}
Interaction::MessageComponent(component) => {
let component_model =
ComponentDataModel::from_custom_id(&component.data.custom_id);
component_model.act(ctx, data, component).await; component_model.act(ctx, data, component).await;
} }
_ => {}
}
} }
_ => {} _ => {}
} }

View File

@ -47,26 +47,25 @@ async fn macro_check(ctx: Context<'_>) -> bool {
async fn check_self_permissions(ctx: Context<'_>) -> bool { async fn check_self_permissions(ctx: Context<'_>) -> bool {
if let Some(guild) = ctx.guild() { if let Some(guild) = ctx.guild() {
let user_id = ctx.serenity_context().cache.current_user_id(); let user_id = ctx.discord().cache.current_user_id();
let manage_webhooks =
guild.member_permissions(&ctx, user_id).await.map_or(false, |p| p.manage_webhooks());
let manage_webhooks = guild
.member_permissions(&ctx.discord(), user_id)
.await
.map_or(false, |p| p.manage_webhooks());
let (view_channel, send_messages, embed_links) = ctx let (view_channel, send_messages, embed_links) = ctx
.channel_id() .channel_id()
.to_channel(&ctx) .to_channel_cached(&ctx.discord())
.await
.ok()
.and_then(|c| { .and_then(|c| {
if let Channel::Guild(channel) = c { if let Channel::Guild(channel) = c {
let perms = channel.permissions_for_user(&ctx, user_id).ok()?; channel.permissions_for_user(&ctx.discord(), user_id).ok()
Some((perms.view_channel(), perms.send_messages(), perms.embed_links()))
} else { } else {
None None
} }
}) })
.unwrap_or((false, false, false)); .map_or((false, false, false), |p| {
(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
@ -82,8 +81,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 embed_links { "" } else { "" },
if manage_webhooks { "" } else { "" }, if manage_webhooks { "" } else { "" },
if embed_links { "" } else { "" },
)) ))
}) })
.await; .await;

View File

@ -110,14 +110,13 @@ 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, u64), current: (u64, u64, u64),
} }
impl<'a> Parser<'a> { impl<'a> Parser<'a> {
@ -141,17 +140,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 day, mut sec, nsec) = match &self.src[start..end] { let (mut month, mut sec, nsec) = match &self.src[start..end] {
"nanos" | "nsec" | "ns" => (0, 0u64, 0u64, n), "nanos" | "nsec" | "ns" => (0u64, 0u64, n),
"usec" | "us" => (0, 0, 0u64, n.mul(1000)?), "usec" | "us" => (0, 0u64, n.mul(1000)?),
"millis" | "msec" | "ms" => (0, 0, 0u64, n.mul(1_000_000)?), "millis" | "msec" | "ms" => (0, 0u64, n.mul(1_000_000)?),
"seconds" | "second" | "secs" | "sec" | "s" => (0, 0, n, 0), "seconds" | "second" | "secs" | "sec" | "s" => (0, n, 0),
"minutes" | "minute" | "min" | "mins" | "m" => (0, 0, n.mul(60)?, 0), "minutes" | "minute" | "min" | "mins" | "m" => (0, n.mul(60)?, 0),
"hours" | "hour" | "hr" | "hrs" | "h" => (0, 0, n.mul(3600)?, 0), "hours" | "hour" | "hr" | "hrs" | "h" => (0, n.mul(3600)?, 0),
"days" | "day" | "d" => (0, n, 0, 0), "days" | "day" | "d" => (0, n.mul(86400)?, 0),
"weeks" | "week" | "w" => (0, n.mul(7)?, 0, 0), "weeks" | "week" | "w" => (0, n.mul(86400 * 7)?, 0),
"months" | "month" | "M" => (n, 0, 0, 0), "months" | "month" | "M" => (n, 0, 0),
"years" | "year" | "y" => (n.mul(12)?, 0, 0, 0), "years" | "year" | "y" => (12, 0, 0),
_ => { _ => {
return Err(Error::UnknownUnit { return Err(Error::UnknownUnit {
start, start,
@ -161,16 +160,15 @@ impl<'a> Parser<'a> {
}); });
} }
}; };
let mut nsec = self.current.3 + nsec; let mut nsec = self.current.2 + 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.2; sec += self.current.1;
day += self.current.1;
month += self.current.0; month += self.current.0;
self.current = (month, day, sec, nsec); self.current = (month, sec, nsec);
Ok(()) Ok(())
} }
@ -217,13 +215,7 @@ 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 => { None => return Ok(Interval { month: self.current.0, sec: self.current.1 }),
return Ok(Interval {
month: self.current.0,
day: self.current.1,
sec: self.current.2,
})
}
}; };
} }
} }
@ -255,73 +247,5 @@ 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, 0) }.parse() Parser { iter: s.chars(), src: s, current: (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);
}
} }

View File

@ -18,10 +18,10 @@ use std::{
env, env,
error::Error as StdError, error::Error as StdError,
fmt::{Debug, Display, Formatter}, fmt::{Debug, Display, Formatter},
path::Path,
}; };
use chrono_tz::Tz; use chrono_tz::Tz;
use dotenv::dotenv;
use log::{error, warn}; use log::{error, warn};
use poise::serenity_prelude::model::{ use poise::serenity_prelude::model::{
gateway::GatewayIntents, gateway::GatewayIntents,
@ -75,7 +75,7 @@ impl Display for Ended {
impl StdError for Ended {} impl StdError for Ended {}
#[tokio::main(flavor = "multi_thread")] #[tokio::main]
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,11 +88,7 @@ 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();
if Path::new("/etc/reminder-rs/config.env").exists() { dotenv()?;
dotenv::from_path("/etc/reminder-rs/config.env")?;
} else {
let _ = dotenv::dotenv();
}
let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
@ -112,16 +108,6 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
], ],
..moderation_cmds::allowed_dm() ..moderation_cmds::allowed_dm()
}, },
poise::Command {
subcommands: vec![poise::Command {
subcommands: vec![
moderation_cmds::set_ephemeral_confirmations(),
moderation_cmds::unset_ephemeral_confirmations(),
],
..moderation_cmds::ephemeral_confirmations()
}],
..moderation_cmds::settings()
},
moderation_cmds::webhook(), moderation_cmds::webhook(),
poise::Command { poise::Command {
subcommands: vec![ subcommands: vec![
@ -134,6 +120,10 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
], ],
..command_macro::macro_base() ..command_macro::macro_base()
}, },
poise::Command {
subcommands: vec![moderation_cmds::default_channel()],
..moderation_cmds::default()
},
reminder_cmds::pause(), reminder_cmds::pause(),
reminder_cmds::offset(), reminder_cmds::offset(),
reminder_cmds::nudge(), reminder_cmds::nudge(),
@ -147,7 +137,6 @@ 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![
@ -175,29 +164,13 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
], ],
allowed_mentions: None, allowed_mentions: None,
command_check: Some(|ctx| Box::pin(all_checks(ctx))), command_check: Some(|ctx| Box::pin(all_checks(ctx))),
event_handler: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)), listener: |ctx, event, _framework, data| Box::pin(listener(ctx, event, data)),
on_error: |error| {
Box::pin(async move {
match error {
poise::FrameworkError::CommandCheckFailed { .. } => {
// suppress error
}
error => {
if let Err(e) = poise::builtins::on_error(error).await {
log::error!("Error while handling error: {}", e);
}
}
}
})
},
..Default::default() ..Default::default()
}; };
let database = let database =
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap(); Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
sqlx::migrate!().run(&database).await?;
let popular_timezones = sqlx::query!( let popular_timezones = sqlx::query!(
"SELECT IFNULL(timezone, 'UTC') AS timezone "SELECT IFNULL(timezone, 'UTC') AS timezone
FROM users FROM users
@ -215,7 +188,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
poise::Framework::builder() poise::Framework::builder()
.token(discord_token) .token(discord_token)
.setup(move |ctx, _bot, framework| { .user_data_setup(move |ctx, _bot, framework| {
Box::pin(async move { Box::pin(async move {
register_application_commands(ctx, framework, None).await.unwrap(); register_application_commands(ctx, framework, None).await.unwrap();

View File

@ -10,7 +10,6 @@ pub struct ChannelData {
pub webhook_id: Option<u64>, pub webhook_id: Option<u64>,
pub webhook_token: Option<String>, pub webhook_token: Option<String>,
pub paused: bool, pub paused: bool,
pub db_guild_id: Option<u32>,
pub paused_until: Option<NaiveDateTime>, pub paused_until: Option<NaiveDateTime>,
} }
@ -24,9 +23,7 @@ impl ChannelData {
if let Ok(c) = sqlx::query_as_unchecked!( if let Ok(c) = sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until, SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ?
guild_id AS db_guild_id
FROM channels WHERE channel = ?
", ",
channel_id channel_id
) )
@ -35,17 +32,13 @@ impl ChannelData {
{ {
Ok(c) Ok(c)
} else { } else {
let props = let props = channel.to_owned().guild().map(|g| (g.guild_id.as_u64().to_owned(), g.name));
channel.to_owned().guild().map(|g| (g.guild_id.as_u64().to_owned(), g.name));
let (guild_id, channel_name) = let (guild_id, channel_name) = if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) };
if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) };
sqlx::query!( sqlx::query!(
" "
INSERT IGNORE INTO channels INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, ?)
(channel, name, guild_id)
VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))
", ",
channel_id, channel_id,
channel_name, channel_name,
@ -57,10 +50,7 @@ impl ChannelData {
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until FROM channels WHERE channel = ?
paused_until, guild_id AS db_guild_id
FROM channels
WHERE channel = ?
", ",
channel_id channel_id
) )
@ -72,10 +62,8 @@ impl ChannelData {
pub async fn commit_changes(&self, pool: &MySqlPool) { pub async fn commit_changes(&self, pool: &MySqlPool) {
sqlx::query!( sqlx::query!(
" "
UPDATE channels UPDATE channels SET name = ?, nudge = ?, blacklisted = ?, webhook_id = ?, webhook_token = ?, paused = ?, paused_until \
SET name = ?, nudge = ?, blacklisted = ?, webhook_id = ?, webhook_token = ?, = ? WHERE id = ?
paused = ?, paused_until = ?
WHERE id = ?
", ",
self.name, self.name,
self.nudge, self.nudge,

View File

@ -43,7 +43,7 @@ pub async fn guild_command_macro(
) -> Option<CommandMacro<Data, Error>> { ) -> Option<CommandMacro<Data, Error>> {
let row = sqlx::query!( let row = sqlx::query!(
" "
SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ? SELECT * FROM macro WHERE guild_id = ? AND name = ?
", ",
ctx.guild_id().unwrap().0, ctx.guild_id().unwrap().0,
name name

View File

@ -1,48 +1,52 @@
use poise::serenity_prelude::GuildId;
use sqlx::MySqlPool; use sqlx::MySqlPool;
use crate::GuildId;
pub struct GuildData { pub struct GuildData {
pub ephemeral_confirmations: bool, pub id: u64,
pub id: u32, pub default_channel: Option<u64>,
} }
impl GuildData { impl GuildData {
pub async fn from_guild( pub async fn from_guild(guild: GuildId, pool: &MySqlPool) -> Result<Self, sqlx::Error> {
guild_id: GuildId, let guild_id = guild.0;
pool: &MySqlPool,
) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> { if let Ok(row) = sqlx::query_as_unchecked!(
if let Ok(c) = sqlx::query_as_unchecked!(
Self, Self,
"SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?", "
guild_id.0 SELECT id, default_channel FROM guilds WHERE id = ?
",
guild_id
) )
.fetch_one(pool) .fetch_one(pool)
.await .await
{ {
Ok(c) Ok(row)
} else { } else {
sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id.0) sqlx::query!(
"
INSERT IGNORE INTO guilds (id) VALUES (?)
",
guild_id
)
.execute(&pool.clone()) .execute(&pool.clone())
.await?; .await?;
Ok(sqlx::query_as_unchecked!( Ok(Self { id: guild_id, default_channel: None })
Self,
"SELECT id, ephemeral_confirmations FROM guilds WHERE guild = ?",
guild_id.0
)
.fetch_one(pool)
.await?)
} }
} }
pub async fn commit_changes(&self, pool: &MySqlPool) { pub async fn commit_changes(&self, pool: &MySqlPool) -> Result<(), sqlx::Error> {
sqlx::query!( sqlx::query!(
"UPDATE guilds SET ephemeral_confirmations = ? WHERE id = ?", "
self.ephemeral_confirmations, UPDATE guilds SET default_channel = ? WHERE id = ?
",
self.default_channel,
self.id self.id
) )
.execute(pool) .execute(pool)
.await .await?;
.unwrap();
Ok(())
} }
} }

View File

@ -6,7 +6,8 @@ pub mod timer;
pub mod user_data; pub mod user_data;
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelType}; use log::warn;
use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelId};
use crate::{ use crate::{
models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData}, models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData},
@ -16,35 +17,25 @@ use crate::{
#[async_trait] #[async_trait]
pub trait CtxData { pub trait CtxData {
async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error>; async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error>;
async fn author_data(&self) -> Result<UserData, Error>; async fn author_data(&self) -> Result<UserData, Error>;
async fn guild_data(&self) -> Option<Result<GuildData, Error>>;
async fn timezone(&self) -> Tz; async fn timezone(&self) -> Tz;
async fn channel_data(&self) -> Result<ChannelData, Error>; async fn channel_data(&self) -> Result<ChannelData, Error>;
async fn guild_data(&self) -> Option<GuildData>;
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error>; async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error>;
async fn default_channel(&self) -> Option<ChannelId>;
} }
#[async_trait] #[async_trait]
impl CtxData for Context<'_> { impl CtxData for Context<'_> {
async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error> { async fn user_data<U: Into<UserId> + Send>(
UserData::from_user(user_id, &self.serenity_context(), &self.data().database).await &self,
user_id: U,
) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> {
UserData::from_user(user_id, &self.discord(), &self.data().database).await
} }
async fn author_data(&self) -> Result<UserData, Error> { async fn author_data(&self) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>> {
UserData::from_user(&self.author().id, &self.serenity_context(), &self.data().database) UserData::from_user(&self.author().id, &self.discord(), &self.data().database).await
.await
}
async fn guild_data(&self) -> Option<Result<GuildData, Error>> {
if let Some(guild_id) = self.guild_id() {
Some(GuildData::from_guild(guild_id, &self.data().database).await)
} else {
None
}
} }
async fn timezone(&self) -> Tz { async fn timezone(&self) -> Tz {
@ -52,20 +43,7 @@ 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>> {
// If we're in a thread, get the parent channel. let channel = self.channel_id().to_channel_cached(&self.discord()).unwrap();
let recv_channel = self.channel_id().to_channel(&self).await?;
let channel = match recv_channel.guild() {
Some(guild_channel) => {
if guild_channel.kind == ChannelType::PublicThread {
guild_channel.parent_id.unwrap().to_channel_cached(&self).unwrap()
} else {
self.channel_id().to_channel_cached(&self).unwrap()
}
}
None => self.channel_id().to_channel_cached(&self).unwrap(),
};
ChannelData::from_channel(&channel, &self.data().database).await ChannelData::from_channel(&channel, &self.data().database).await
} }
@ -73,24 +51,55 @@ impl CtxData for Context<'_> {
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error> { async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error> {
self.data().command_macros(self.guild_id().unwrap()).await self.data().command_macros(self.guild_id().unwrap()).await
} }
async fn default_channel(&self) -> Option<ChannelId> {
match self.guild_id() {
Some(guild_id) => {
let guild_data = GuildData::from_guild(guild_id, &self.data().database).await;
match guild_data {
Ok(data) => data.default_channel.map(|c| ChannelId(c)),
Err(e) => {
warn!("SQL error: {:?}", e);
None
}
}
}
None => None,
}
}
async fn guild_data(&self) -> Option<GuildData> {
match self.guild_id() {
Some(guild_id) => GuildData::from_guild(guild_id, &self.data().database).await.ok(),
None => None,
}
}
} }
impl Data { impl Data {
pub(crate) async fn command_macros( pub async fn command_macros(
&self, &self,
guild_id: GuildId, guild_id: GuildId,
) -> Result<Vec<CommandMacro<Data, Error>>, Error> { ) -> Result<Vec<CommandMacro<Data, Error>>, Error> {
let rows = sqlx::query!( let rows = sqlx::query!(
"SELECT name, description, commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)", "SELECT name, description, commands FROM macro WHERE guild_id = ?",
guild_id.0 guild_id.0
) )
.fetch_all(&self.database) .fetch_all(&self.database)
.await?.iter().map(|row| CommandMacro { .await?
.iter()
.map(|row| CommandMacro {
guild_id, guild_id,
name: row.name.clone(), name: row.name.clone(),
description: row.description.clone(), description: row.description.clone(),
commands: serde_json::from_str(&row.commands).unwrap(), commands: serde_json::from_str(&row.commands).unwrap(),
}).collect(); })
.collect();
Ok(rows) Ok(rows)
} }

View File

@ -9,7 +9,7 @@ use poise::serenity_prelude::{
id::{ChannelId, GuildId, UserId}, id::{ChannelId, GuildId, UserId},
webhook::Webhook, webhook::Webhook,
}, },
ChannelType, Result as SerenityResult, Result as SerenityResult,
}; };
use sqlx::MySqlPool; use sqlx::MySqlPool;
@ -51,12 +51,9 @@ pub struct ReminderBuilder {
pool: MySqlPool, pool: MySqlPool,
uid: String, uid: String,
channel: u32, channel: u32,
guild: Option<u32>,
thread_id: Option<u64>,
utc_time: NaiveDateTime, utc_time: NaiveDateTime,
timezone: String, timezone: String,
interval_seconds: Option<i64>, interval_secs: 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,11 +84,9 @@ impl ReminderBuilder {
INSERT INTO reminders ( INSERT INTO reminders (
`uid`, `uid`,
`channel_id`, `channel_id`,
`guild_id`,
`utc_time`, `utc_time`,
`timezone`, `timezone`,
`interval_seconds`, `interval_seconds`,
`interval_days`,
`interval_months`, `interval_months`,
`expires`, `expires`,
`content`, `content`,
@ -111,18 +106,14 @@ INSERT INTO reminders (
?, ?,
?, ?,
?, ?,
?,
?,
? ?
) )
", ",
self.uid, self.uid,
self.channel, self.channel,
self.guild,
utc_time, utc_time,
self.timezone, self.timezone,
self.interval_seconds, self.interval_secs,
self.interval_days,
self.interval_months, self.interval_months,
self.expires, self.expires,
self.content, self.content,
@ -184,15 +175,17 @@ 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 {
if let Some(utc_time) = NaiveDateTime::from_timestamp_opt(time.into(), 0) { self.utc_time = NaiveDateTime::from_timestamp(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 {
self.expires = time.map(|t| NaiveDateTime::from_timestamp_opt(t.into(), 0)).flatten(); if let Some(t) = time {
self.expires = Some(NaiveDateTime::from_timestamp(t.into(), 0));
} else {
self.expires = None;
}
self self
} }
@ -219,78 +212,63 @@ impl<'a> MultiReminderBuilder<'a> {
let mut ok_locs = HashSet::new(); let mut ok_locs = HashSet::new();
if self if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) as i64) < *MIN_INTERVAL) {
.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 } else if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) as i64) > *MAX_TIME)
.interval
.map_or(false, |i| ((i.sec + i.day * DAY + i.month * 30 * DAY) as i64) > *MAX_TIME)
{ {
errors.insert(ReminderError::LongInterval); errors.insert(ReminderError::LongInterval);
} else { } else {
for scope in self.scopes { for scope in self.scopes {
let thread_id = None;
let db_channel_id = match scope { let db_channel_id = match scope {
ReminderScope::User(user_id) => { ReminderScope::User(user_id) => {
if let Ok(user) = UserId(user_id).to_user(&self.ctx).await { if let Ok(user) = UserId(user_id).to_user(&self.ctx.discord()).await {
let user_data = UserData::from_user( let user_data = UserData::from_user(
&user, &user,
&self.ctx.serenity_context(), &self.ctx.discord(),
&self.ctx.data().database, &self.ctx.data().database,
) )
.await .await
.unwrap(); .unwrap();
if let Some(guild_id) = self.guild_id { if let Some(guild_id) = self.guild_id {
if guild_id.member(&self.ctx, user).await.is_err() { if guild_id.member(&self.ctx.discord(), user).await.is_err() {
Err(ReminderError::InvalidTag) Err(ReminderError::InvalidTag)
} else if self.set_by.map_or(true, |i| i != user_data.id) } else if self.set_by.map_or(true, |i| i != user_data.id)
&& !user_data.allowed_dm && !user_data.allowed_dm
{ {
Err(ReminderError::UserBlockedDm) Err(ReminderError::UserBlockedDm)
} else { } else {
Ok((user_data.dm_channel, None)) Ok(user_data.dm_channel)
} }
} else { } else {
Ok((user_data.dm_channel, None)) Ok(user_data.dm_channel)
} }
} else { } else {
Err(ReminderError::InvalidTag) Err(ReminderError::InvalidTag)
} }
} }
ReminderScope::Channel(channel_id) => { ReminderScope::Channel(channel_id) => {
let channel = ChannelId(channel_id).to_channel(&self.ctx).await.unwrap(); let channel =
ChannelId(channel_id).to_channel(&self.ctx.discord()).await.unwrap();
if let Some(mut guild_channel) = channel.clone().guild() { if let Some(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 = if guild_channel.kind let mut channel_data =
== ChannelType::PublicThread
{
// fixme jesus christ
let parent = guild_channel
.parent_id
.unwrap()
.to_channel(&self.ctx)
.await
.unwrap();
guild_channel = parent.clone().guild().unwrap();
ChannelData::from_channel(&parent, &self.ctx.data().database)
.await
.unwrap()
} else {
ChannelData::from_channel(&channel, &self.ctx.data().database) ChannelData::from_channel(&channel, &self.ctx.data().database)
.await .await
.unwrap() .unwrap();
};
if channel_data.webhook_id.is_none() if channel_data.webhook_id.is_none()
|| channel_data.webhook_token.is_none() || channel_data.webhook_token.is_none()
{ {
match create_webhook(&self.ctx, guild_channel, "Reminder").await match create_webhook(
&self.ctx.discord(),
guild_channel,
"Reminder",
)
.await
{ {
Ok(webhook) => { Ok(webhook) => {
channel_data.webhook_id = channel_data.webhook_id =
@ -301,13 +279,13 @@ impl<'a> MultiReminderBuilder<'a> {
.commit_changes(&self.ctx.data().database) .commit_changes(&self.ctx.data().database)
.await; .await;
Ok((channel_data.id, channel_data.db_guild_id)) Ok(channel_data.id)
} }
Err(e) => Err(ReminderError::DiscordError(e.to_string())), Err(e) => Err(ReminderError::DiscordError(e.to_string())),
} }
} else { } else {
Ok((channel_data.id, channel_data.db_guild_id)) Ok(channel_data.id)
} }
} }
} else { } else {
@ -321,13 +299,10 @@ impl<'a> MultiReminderBuilder<'a> {
let builder = ReminderBuilder { let builder = ReminderBuilder {
pool: self.ctx.data().database.clone(), pool: self.ctx.data().database.clone(),
uid: generate_uid(), uid: generate_uid(),
channel: c.0, channel: c,
guild: c.1,
thread_id,
utc_time: self.utc_time, utc_time: self.utc_time,
timezone: self.timezone.to_string(), timezone: self.timezone.to_string(),
interval_seconds: self.interval.map(|i| i.sec as i64), interval_secs: 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(),

View File

@ -6,7 +6,7 @@ pub mod look_flags;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{NaiveDateTime, TimeZone};
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::serenity_prelude::{ use poise::serenity_prelude::{
model::id::{ChannelId, GuildId, UserId}, model::id::{ChannelId, GuildId, UserId},
@ -24,9 +24,8 @@ pub struct Reminder {
pub id: u32, pub id: u32,
pub uid: String, pub uid: String,
pub channel: u64, pub channel: u64,
pub utc_time: DateTime<Utc>, pub utc_time: NaiveDateTime,
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,
@ -60,7 +59,6 @@ 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,
@ -97,7 +95,6 @@ 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,
@ -141,7 +138,6 @@ 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,
@ -159,7 +155,6 @@ LEFT JOIN
ON ON
reminders.set_by = users.id reminders.set_by = users.id
WHERE WHERE
`status` = 'pending' AND
channels.channel = ? AND channels.channel = ? AND
FIND_IN_SET(reminders.enabled, ?) FIND_IN_SET(reminders.enabled, ?)
ORDER BY ORDER BY
@ -200,7 +195,6 @@ 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,
@ -218,7 +212,6 @@ LEFT JOIN
ON ON
reminders.set_by = users.id reminders.set_by = users.id
WHERE WHERE
`status` = 'pending' AND
FIND_IN_SET(channels.channel, ?) FIND_IN_SET(channels.channel, ?)
", ",
channels channels
@ -235,7 +228,6 @@ 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,
@ -253,8 +245,7 @@ LEFT JOIN
ON ON
reminders.set_by = users.id reminders.set_by = users.id
WHERE WHERE
`status` = 'pending' AND channels.guild_id = ?
channels.guild_id = (SELECT id FROM guilds WHERE guild = ?)
", ",
guild_id.as_u64() guild_id.as_u64()
) )
@ -271,7 +262,6 @@ 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,
@ -289,7 +279,6 @@ LEFT JOIN
ON ON
reminders.set_by = users.id reminders.set_by = users.id
WHERE WHERE
`status` = 'pending' AND
channels.id = (SELECT dm_channel FROM users WHERE user = ?) channels.id = (SELECT dm_channel FROM users WHERE user = ?)
", ",
user.as_u64() user.as_u64()
@ -304,13 +293,7 @@ WHERE
&self, &self,
db: impl Executor<'_, Database = Database>, db: impl Executor<'_, Database = Database>,
) -> Result<(), sqlx::Error> { ) -> Result<(), sqlx::Error> {
sqlx::query!( sqlx::query!("DELETE FROM reminders WHERE uid = ?", self.uid).execute(db).await.map(|_| ())
"UPDATE reminders SET `status` = 'deleted', `status_change_time` = NOW() WHERE uid = ?",
self.uid
)
.execute(db)
.await
.map(|_| ())
} }
pub fn display_content(&self) -> &str { pub fn display_content(&self) -> &str {
@ -327,32 +310,30 @@ WHERE
count + 1, count + 1,
self.display_content(), self.display_content(),
self.channel, self.channel,
self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S") timezone.timestamp(self.utc_time.timestamp(), 0).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 => { TimeDisplayType::Absolute => timezone
self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S").to_string() .timestamp(self.utc_time.timestamp(), 0)
} .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() if self.interval_seconds.is_some() || self.interval_months.is_some() {
|| self.interval_days.is_some()
|| self.interval_months.is_some()
{
format!( format!(
"'{}' *occurs next at* **{}**, repeating (set by {})\n", "'{}' *occurs next at* **{}**, repeating (set by {})",
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 {})\n", "'{}' *occurs next at* **{}** (set by {})",
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())

View File

@ -1,9 +1,9 @@
use chrono::{DateTime, Utc}; use chrono::NaiveDateTime;
use sqlx::MySqlPool; use sqlx::MySqlPool;
pub struct Timer { pub struct Timer {
pub name: String, pub name: String,
pub start_time: DateTime<Utc>, pub start_time: NaiveDateTime,
pub owner: u64, pub owner: u64,
} }

View File

@ -83,7 +83,7 @@ pub fn send_as_initial_response(
components, components,
ephemeral, ephemeral,
allowed_mentions, allowed_mentions,
reply: _, reference_message: _, // can't reply to a message in interactions
} = data; } = data;
if let Some(content) = content { if let Some(content) = content {

View File

@ -1,14 +0,0 @@
[Unit]
Description=Reminder Bot
[Service]
User=reminder
Type=simple
ExecStart=/usr/bin/reminder-rs
WorkingDirectory=/etc/reminder-rs
Restart=always
RestartSec=4
Environment="reminder_rs=warn,postman=warn"
[Install]
WantedBy=multi-user.target

View File

@ -7,14 +7,14 @@ edition = "2018"
[dependencies] [dependencies]
rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] } rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] }
rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] } rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] }
serenity = { version = "0.11", 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"] }
oauth2 = "4" oauth2 = "4"
log = "0.4" log = "0.4"
reqwest = "0.11" reqwest = "0.11"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.7", 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.8" chrono-tz = "0.5"
lazy_static = "1.4.0" lazy_static = "1.4.0"
rand = "0.7" rand = "0.7"
base64 = "0.13" base64 = "0.13"

View File

@ -2,7 +2,6 @@ pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/to
pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize"; pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize";
pub const DISCORD_API: &'static str = "https://discord.com/api"; pub const DISCORD_API: &'static str = "https://discord.com/api";
pub const MAX_NAME_LENGTH: usize = 100;
pub const MAX_CONTENT_LENGTH: usize = 2000; pub const MAX_CONTENT_LENGTH: usize = 2000;
pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096; pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096;
pub const MAX_EMBED_TITLE_LENGTH: usize = 256; pub const MAX_EMBED_TITLE_LENGTH: usize = 256;
@ -32,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("PATREON_ROLE_ID") env::var("SUBSCRIPTION_ROLES")
.map(|var| var .map(|var| var
.split(',') .split(',')
.filter_map(|item| { item.parse::<u64>().ok() }) .filter_map(|item| { item.parse::<u64>().ok() })
@ -40,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("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten(); env::var("CNC_GUILD").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())

View File

@ -6,7 +6,7 @@ mod consts;
mod macros; mod macros;
mod routes; mod routes;
use std::{collections::HashMap, env, path::Path}; use std::{collections::HashMap, env};
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use rocket::{ use rocket::{
@ -72,14 +72,10 @@ pub async fn initialize(
db_pool: Pool<Database>, db_pool: Pool<Database>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
info!("Checking environment variables..."); info!("Checking environment variables...");
if env::var("OFFLINE").map_or(true, |v| v != "1") {
env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied"); env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied");
env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied"); env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied");
env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied"); env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied");
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied"); env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD' not supplied");
}
info!("Done!"); info!("Done!");
let oauth2_client = BasicClient::new( let oauth2_client = BasicClient::new(
@ -92,9 +88,6 @@ pub async fn initialize(
let reqwest_client = reqwest::Client::new(); let reqwest_client = reqwest::Client::new();
let static_path =
if Path::new("web/static").exists() { "web/static" } else { "/lib/reminder-rs/static" };
rocket::build() rocket::build()
.attach(Template::fairing()) .attach(Template::fairing())
.register( .register(
@ -112,7 +105,7 @@ pub async fn initialize(
.manage(reqwest_client) .manage(reqwest_client)
.manage(serenity_context) .manage(serenity_context)
.manage(db_pool) .manage(db_pool)
.mount("/static", FileServer::from(static_path)) .mount("/static", FileServer::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static")))
.mount( .mount(
"/", "/",
routes![ routes![
@ -120,8 +113,7 @@ pub async fn initialize(
routes::cookies, routes::cookies,
routes::privacy, routes::privacy,
routes::terms, routes::terms,
routes::return_to_same_site, routes::return_to_same_site
routes::report::report_error,
], ],
) )
.mount( .mount(
@ -139,19 +131,11 @@ pub async fn initialize(
routes::help_iemanager, routes::help_iemanager,
], ],
) )
.mount( .mount("/login", routes![routes::login::discord_login, routes::login::discord_callback])
"/login",
routes![
routes::login::discord_login,
routes::login::discord_logout,
routes::login::discord_callback
],
)
.mount( .mount(
"/dashboard", "/dashboard",
routes![ routes![
routes::dashboard::dashboard_1, routes::dashboard::dashboard,
routes::dashboard::dashboard_2,
routes::dashboard::dashboard_home, routes::dashboard::dashboard_home,
routes::dashboard::user::get_user_info, routes::dashboard::user::get_user_info,
routes::dashboard::user::update_user_info, routes::dashboard::user::update_user_info,
@ -173,7 +157,6 @@ pub async fn initialize(
routes::dashboard::export::import_todos, routes::dashboard::export::import_todos,
], ],
) )
.mount("/admin", routes![routes::admin::admin_dashboard_home, routes::admin::bot_data])
.launch() .launch()
.await?; .await?;
@ -190,8 +173,6 @@ pub async fn initialize(
} }
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool { pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
offline!(true);
if let Some(subscription_guild) = *CNC_GUILD { if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await; let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
@ -213,8 +194,6 @@ pub async fn check_guild_subscription(
cache_http: impl CacheHttp, cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>, guild_id: impl Into<GuildId>,
) -> bool { ) -> bool {
offline!(true);
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) { if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
let owner = guild.owner_id; let owner = guild.owner_id;

View File

@ -1,11 +1,3 @@
macro_rules! offline {
($field:expr) => {
if std::env::var("OFFLINE").map_or(false, |v| v == "1") {
return $field;
}
};
}
macro_rules! check_length { macro_rules! check_length {
($max:ident, $field:expr) => { ($max:ident, $field:expr) => {
if $field.len() > $max { if $field.len() > $max {
@ -60,33 +52,18 @@ macro_rules! check_authorization {
let user_id = $cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten(); let user_id = $cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
if std::env::var("OFFLINE").map_or(true, |v| v != "1") {
match user_id { match user_id {
Some(user_id) => { Some(user_id) => {
match GuildId($guild).to_guild_cached($ctx) { match GuildId($guild).to_guild_cached($ctx) {
Some(guild) => { Some(guild) => {
let member_res = guild.member($ctx, UserId(user_id)).await; let member = guild.member($ctx, UserId(user_id)).await;
match member_res { match member {
Err(_) => { Err(_) => {
return Err(json!({"error": "User not in guild"})); return Err(json!({"error": "User not in guild"}));
} }
Ok(member) => { Ok(_) => {}
let permissions_res = member.permissions($ctx);
match permissions_res {
Err(_) => {
return Err(json!({"error": "Couldn't fetch permissions"}));
}
Ok(permissions) => {
if !(permissions.manage_messages() || permissions.manage_guild() || permissions.administrator()) {
return Err(json!({"error": "Incorrect permissions"}));
}
}
}
}
} }
} }
@ -101,7 +78,6 @@ macro_rules! check_authorization {
} }
} }
} }
}
} }
macro_rules! update_field { macro_rules! update_field {

View File

@ -1,218 +0,0 @@
use std::{collections::HashMap, env};
use chrono::{DateTime, Utc};
use rocket::{
http::{CookieJar, Status},
serde::json::json,
State,
};
use rocket_dyn_templates::Template;
use serde::Serialize;
use sqlx::{MySql, Pool};
use crate::routes::JsonResult;
fn is_admin(cookies: &CookieJar<'_>) -> bool {
cookies
.get_private("userid")
.map_or(false, |cookie| Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok())
}
#[get("/")]
pub async fn admin_dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Status> {
if let Some(cookie) = cookies.get_private("userid") {
let map: HashMap<&str, String> = HashMap::new();
if Some(cookie.value().to_string()) == env::var("ADMIN_ID").ok() {
Ok(Template::render("admin_dashboard", &map))
} else {
Err(Status::Forbidden)
}
} else {
Err(Status::Unauthorized)
}
}
#[derive(Serialize)]
struct TimeFrame {
time_key: DateTime<Utc>,
count: i64,
}
#[get("/data")]
pub async fn bot_data(cookies: &CookieJar<'_>, pool: &State<Pool<MySql>>) -> JsonResult {
if !is_admin(cookies) {
return json_err!("Not authorized");
}
let backlog = sqlx::query!(
"SELECT COUNT(1) AS backlog FROM reminders WHERE `utc_time` < NOW() AND enabled = 1 AND `status` = 'pending'"
)
.fetch_one(pool.inner())
.await
.unwrap();
let schedule_once = sqlx::query_as_unchecked!(
TimeFrame,
"SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`,
COUNT(1) AS `count`
FROM reminders
WHERE
`utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND
`utc_time` >= NOW() AND
`enabled` = 1 AND
`status` = 'pending' AND
`interval_seconds` IS NULL AND
`interval_months` IS NULL AND
`interval_days` IS NULL
GROUP BY `time_key`
ORDER BY `time_key`"
)
.fetch_all(pool.inner())
.await
.unwrap();
let schedule_interval = sqlx::query_as_unchecked!(
TimeFrame,
"SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 300) * 300) AS `time_key`,
COUNT(1) AS `count`
FROM reminders
WHERE
`utc_time` < DATE_ADD(NOW(), INTERVAL 1 DAY) AND
`utc_time` >= NOW() AND
`status` = 'pending' AND
`enabled` = 1 AND (
`interval_seconds` IS NOT NULL OR
`interval_months` IS NOT NULL OR
`interval_days` IS NOT NULL
)
GROUP BY `time_key`
ORDER BY `time_key`"
)
.fetch_all(pool.inner())
.await
.unwrap();
let schedule_once_long = sqlx::query_as_unchecked!(
TimeFrame,
"SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
COUNT(1) AS `count`
FROM reminders
WHERE
`utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND
`utc_time` >= NOW() AND
`enabled` = 1 AND
`status` = 'pending' AND
`interval_seconds` IS NULL AND
`interval_months` IS NULL AND
`interval_days` IS NULL
GROUP BY `time_key`
ORDER BY `time_key`"
)
.fetch_all(pool.inner())
.await
.unwrap();
let schedule_interval_long = sqlx::query_as_unchecked!(
TimeFrame,
"SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
COUNT(1) AS `count`
FROM reminders
WHERE
`utc_time` < DATE_ADD(NOW(), INTERVAL 31 DAY) AND
`utc_time` >= NOW() AND
`status` = 'pending' AND
`enabled` = 1 AND (
`interval_seconds` IS NOT NULL OR
`interval_months` IS NOT NULL OR
`interval_days` IS NOT NULL
)
GROUP BY `time_key`
ORDER BY `time_key`"
)
.fetch_all(pool.inner())
.await
.unwrap();
let history = sqlx::query_as_unchecked!(
TimeFrame,
"SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
COUNT(1) AS `count`
FROM stat
WHERE
`utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND
`type` = 'reminder_sent'
GROUP BY `time_key`
ORDER BY `time_key`"
)
.fetch_all(pool.inner())
.await
.unwrap();
let history_failed = sqlx::query_as_unchecked!(
TimeFrame,
"SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(`utc_time`) / 86400) * 86400) AS `time_key`,
COUNT(1) AS `count`
FROM stat
WHERE
`utc_time` > DATE_SUB(NOW(), INTERVAL 31 DAY) AND
`type` = 'reminder_failed'
GROUP BY `time_key`
ORDER BY `time_key`"
)
.fetch_all(pool.inner())
.await
.unwrap();
let interval_count = sqlx::query!(
"SELECT COUNT(1) AS count
FROM reminders
WHERE
`status` = 'pending' AND (
`interval_seconds` IS NOT NULL OR
`interval_months` IS NOT NULL OR
`interval_days` IS NOT NULL
)"
)
.fetch_one(pool.inner())
.await
.unwrap();
let reminder_count = sqlx::query!(
"SELECT COUNT(1) AS count
FROM reminders
WHERE
`status` = 'pending' AND
`interval_seconds` IS NULL AND
`interval_months` IS NULL AND
`interval_days` IS NULL"
)
.fetch_one(pool.inner())
.await
.unwrap();
Ok(json!({
"backlog": backlog.backlog,
"scheduleShort": {
"once": schedule_once,
"interval": schedule_interval
},
"scheduleLong": {
"once": schedule_once_long,
"interval": schedule_interval_long,
},
"historyLong": {
"sent": history,
"failed": history_failed,
},
"count": {
"reminders": reminder_count.count,
"intervals": interval_count.count,
}
}))
}

View File

@ -10,11 +10,9 @@ use serenity::{
}; };
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use crate::routes::{ use crate::routes::dashboard::{
dashboard::{ create_reminder, generate_uid, ImportBody, JsonResult, Reminder, ReminderCsv,
create_reminder, ImportBody, ReminderCreate, ReminderCsv, ReminderTemplateCsv, TodoCsv, ReminderTemplateCsv, TodoCsv,
},
JsonResult,
}; };
#[get("/api/guild/<id>/export/reminders")] #[get("/api/guild/<id>/export/reminders")]
@ -60,7 +58,6 @@ 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,
@ -140,7 +137,7 @@ pub async fn import_reminders(
match channel_id.parse::<u64>() { match channel_id.parse::<u64>() {
Ok(channel_id) => { Ok(channel_id) => {
let reminder = ReminderCreate { let reminder = Reminder {
attachment: record.attachment, attachment: record.attachment,
attachment_name: record.attachment_name, attachment_name: record.attachment_name,
avatar: record.avatar, avatar: record.avatar,
@ -162,11 +159,11 @@ 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,
tts: record.tts, tts: record.tts,
uid: generate_uid(),
username: record.username, username: record.username,
utc_time: record.utc_time, utc_time: record.utc_time,
}; };
@ -321,6 +318,13 @@ 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(",")

View File

@ -16,19 +16,14 @@ 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::{ routes::dashboard::{
dashboard::{
create_database_channel, create_reminder, template_name_default, DeleteReminder, create_database_channel, create_reminder, template_name_default, DeleteReminder,
DeleteReminderTemplate, PatchReminder, Reminder, ReminderCreate, ReminderTemplate, DeleteReminderTemplate, JsonResult, PatchReminder, Reminder, ReminderTemplate,
},
JsonResult,
}, },
}; };
@ -46,7 +41,6 @@ pub async fn get_guild_patreon(
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
ctx: &State<Context>, ctx: &State<Context>,
) -> JsonResult { ) -> JsonResult {
offline!(Ok(json!({ "patreon": true })));
check_authorization!(cookies, ctx.inner(), id); check_authorization!(cookies, ctx.inner(), id);
match GuildId(id).to_guild_cached(ctx.inner()) { match GuildId(id).to_guild_cached(ctx.inner()) {
@ -74,12 +68,6 @@ pub async fn get_guild_channels(
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
ctx: &State<Context>, ctx: &State<Context>,
) -> JsonResult { ) -> JsonResult {
offline!(Ok(json!(vec![ChannelInfo {
name: "general".to_string(),
id: "1".to_string(),
webhook_avatar: None,
webhook_name: None,
}])));
check_authorization!(cookies, ctx.inner(), id); check_authorization!(cookies, ctx.inner(), id);
match GuildId(id).to_guild_cached(ctx.inner()) { match GuildId(id).to_guild_cached(ctx.inner()) {
@ -118,7 +106,6 @@ struct RoleInfo {
#[get("/api/guild/<id>/roles")] #[get("/api/guild/<id>/roles")]
pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult { pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
offline!(Ok(json!(vec![RoleInfo { name: "@everyone".to_string(), id: "1".to_string() }])));
check_authorization!(cookies, ctx.inner(), id); check_authorization!(cookies, ctx.inner(), id);
let roles_res = ctx.cache.guild_roles(id); let roles_res = ctx.cache.guild_roles(id);
@ -260,9 +247,9 @@ pub async fn create_reminder_template(
Ok(json!({})) Ok(json!({}))
} }
Err(e) => { Err(e) => {
warn!("Could not create template for {}: {:?}", id, e); warn!("Could not fetch templates from {}: {:?}", id, e);
json_err!("Could not create template") json_err!("Could not get templates")
} }
} }
} }
@ -298,7 +285,7 @@ pub async fn delete_reminder_template(
#[post("/api/guild/<id>/reminders", data = "<reminder>")] #[post("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn create_guild_reminder( pub async fn create_guild_reminder(
id: u64, id: u64,
reminder: Json<ReminderCreate>, reminder: Json<Reminder>,
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
serenity_context: &State<Context>, serenity_context: &State<Context>,
pool: &State<Pool<MySql>>, pool: &State<Pool<MySql>>,
@ -318,22 +305,22 @@ pub async fn create_guild_reminder(
.await .await
} }
#[get("/api/guild/<id>/reminders?<status>")] #[get("/api/guild/<id>/reminders")]
pub async fn get_reminders( pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySql>>) -> JsonResult {
id: u64, let channels_res = GuildId(id).channels(&ctx.inner()).await;
cookies: &CookieJar<'_>,
serenity_context: &State<Context>,
pool: &State<Pool<MySql>>,
status: Option<String>,
) -> JsonResult {
check_authorization!(cookies, serenity_context.inner(), id);
let status = status.unwrap_or("pending".to_string()); match channels_res {
Ok(channels) => {
let channels = channels
.keys()
.into_iter()
.map(|k| k.as_u64().to_string())
.collect::<Vec<String>>()
.join(",");
sqlx::query_as_unchecked!( sqlx::query_as_unchecked!(
Reminder, Reminder,
" "SELECT
SELECT
reminders.attachment, reminders.attachment,
reminders.attachment_name, reminders.attachment_name,
reminders.avatar, reminders.avatar,
@ -348,26 +335,21 @@ pub async fn get_reminders(
reminders.embed_image_url, reminders.embed_image_url,
reminders.embed_thumbnail_url, reminders.embed_thumbnail_url,
reminders.embed_title, reminders.embed_title,
IFNULL(reminders.embed_fields, '[]') AS embed_fields, reminders.embed_fields,
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,
reminders.tts, reminders.tts,
reminders.uid, reminders.uid,
reminders.username, reminders.username,
reminders.utc_time, reminders.utc_time
reminders.status,
reminders.status_change_time,
reminders.status_message
FROM reminders FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE FIND_IN_SET(`status`, ?) AND reminders.guild_id = (SELECT id FROM guilds WHERE guild = ?)", WHERE FIND_IN_SET(channels.channel, ?)",
status, channels
id
) )
.fetch_all(pool.inner()) .fetch_all(pool.inner())
.await .await
@ -377,6 +359,13 @@ pub async fn get_reminders(
json_err!("Could not load reminders") json_err!("Could not load reminders")
}) })
}
Err(e) => {
warn!("Could not fetch channels from {}: {:?}", id, e);
Ok(json!([]))
}
}
} }
#[patch("/api/guild/<id>/reminders", data = "<reminder>")] #[patch("/api/guild/<id>/reminders", data = "<reminder>")]
@ -385,109 +374,35 @@ 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),
} * 86400 + match reminder.interval_months {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_months AS months FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(pool.inner())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.months
.unwrap_or(0),
} * 2592000 + match reminder.interval_seconds {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_seconds AS seconds FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(pool.inner())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.seconds
.unwrap_or(0),
};
if new_interval_length < *MIN_INTERVAL {
error.push(String::from("New interval is too short."));
} else {
update_field!(pool.inner(), error, reminder.[
interval_days,
interval_months,
interval_seconds
]);
}
}
}
if reminder.channel > 0 { 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 {
@ -550,8 +465,7 @@ pub async fn edit_reminder(
match sqlx::query_as_unchecked!( match sqlx::query_as_unchecked!(
Reminder, Reminder,
" "SELECT reminders.attachment,
SELECT reminders.attachment,
reminders.attachment_name, reminders.attachment_name,
reminders.avatar, reminders.avatar,
channels.channel, channels.channel,
@ -569,17 +483,13 @@ 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,
reminders.tts, reminders.tts,
reminders.uid, reminders.uid,
reminders.username, reminders.username,
reminders.utc_time, reminders.utc_time
reminders.status,
reminders.status_change_time,
reminders.status_message
FROM reminders FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?", WHERE uid = ?",
@ -603,10 +513,7 @@ pub async fn delete_reminder(
reminder: Json<DeleteReminder>, reminder: Json<DeleteReminder>,
pool: &State<Pool<MySql>>, pool: &State<Pool<MySql>>,
) -> JsonResult { ) -> JsonResult {
match sqlx::query!( match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid)
"UPDATE reminders SET `status` = 'deleted', `status_change_time` = NOW() WHERE uid = ?",
reminder.uid
)
.execute(pool.inner()) .execute(pool.inner())
.await .await
{ {

View File

@ -2,15 +2,19 @@ use std::collections::HashMap;
use chrono::{naive::NaiveDateTime, Utc}; use chrono::{naive::NaiveDateTime, Utc};
use rand::{rngs::OsRng, seq::IteratorRandom}; use rand::{rngs::OsRng, seq::IteratorRandom};
use rocket::{http::CookieJar, response::Redirect, serde::json::json}; use rocket::{
http::CookieJar,
response::Redirect,
serde::json::{json, Value as JsonValue},
};
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Serialize};
use serenity::{ use serenity::{
client::Context, client::Context,
http::Http, http::Http,
model::id::{ChannelId, GuildId, UserId}, model::id::{ChannelId, GuildId, UserId},
}; };
use sqlx::{types::Json, Executor}; use sqlx::{types::Json, Executor, MySql, Pool};
use crate::{ use crate::{
check_guild_subscription, check_subscription, check_guild_subscription, check_subscription,
@ -18,9 +22,8 @@ use crate::{
CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH,
MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH,
MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH,
MAX_NAME_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL,
}, },
routes::JsonResult,
Database, Error, Database, Error,
}; };
@ -28,6 +31,7 @@ pub mod export;
pub mod guild; pub mod guild;
pub mod user; pub mod user;
pub type JsonResult = Result<JsonValue, JsonValue>;
type Unset<T> = Option<T>; type Unset<T> = Option<T>;
fn name_default() -> String { fn name_default() -> String {
@ -46,18 +50,6 @@ 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")]
@ -118,8 +110,8 @@ pub struct EmbedField {
inline: bool, inline: bool,
} }
#[derive(Deserialize)] #[derive(Serialize, Deserialize)]
pub struct ReminderCreate { pub struct Reminder {
#[serde(with = "base64s")] #[serde(with = "base64s")]
attachment: Option<Vec<u8>>, attachment: Option<Vec<u8>>,
attachment_name: Option<String>, attachment_name: Option<String>,
@ -140,39 +132,6 @@ pub struct ReminderCreate {
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>,
#[serde(default = "name_default")]
name: String,
restartable: bool,
tts: bool,
username: Option<String>,
utc_time: NaiveDateTime,
}
#[derive(Serialize, Deserialize)]
pub struct Reminder {
#[serde(with = "base64s")]
attachment: Option<Vec<u8>>,
attachment_name: Option<String>,
avatar: Option<String>,
#[serde(with = "string_opt")]
channel: Option<u64>,
content: String,
embed_author: String,
embed_author_url: Option<String>,
embed_color: u32,
embed_description: String,
embed_footer: String,
embed_footer_url: Option<String>,
embed_image_url: Option<String>,
embed_thumbnail_url: Option<String>,
embed_title: String,
embed_fields: Option<Json<Vec<EmbedField>>>,
enabled: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>, interval_months: Option<u32>,
#[serde(default = "name_default")] #[serde(default = "name_default")]
name: String, name: String,
@ -182,9 +141,6 @@ pub struct Reminder {
uid: String, uid: String,
username: Option<String>, username: Option<String>,
utc_time: NaiveDateTime, utc_time: NaiveDateTime,
status: String,
status_message: Option<String>,
status_change_time: Option<NaiveDateTime>,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -208,7 +164,6 @@ 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,
@ -222,13 +177,10 @@ 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")]
@ -238,7 +190,6 @@ 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>,
@ -247,13 +198,10 @@ 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>,
@ -262,16 +210,10 @@ 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 = "interval_default")] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
interval_seconds: Unset<Option<u32>>, interval_seconds: Unset<Option<u32>>,
#[serde(default = "interval_default")] #[serde(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>,
@ -280,36 +222,11 @@ 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();
@ -323,7 +240,15 @@ pub fn generate_uid() -> String {
mod string { mod string {
use std::{fmt::Display, str::FromStr}; use std::{fmt::Display, str::FromStr};
use serde::{de, Deserialize, Deserializer}; use serde::{de, Deserialize, Deserializer, Serializer};
pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
where
T: Display,
S: Serializer,
{
serializer.collect_str(value)
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error> pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
where where
@ -335,34 +260,6 @@ mod string {
} }
} }
mod string_opt {
use std::{fmt::Display, str::FromStr};
use serde::{de, Deserialize, Deserializer, Serializer};
pub fn serialize<T, S>(value: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
where
T: Display,
S: Serializer,
{
match value {
Some(value) => serializer.collect_str(value),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
where
T: FromStr,
T::Err: Display,
D: Deserializer<'de>,
{
Option::deserialize(deserializer)?
.map(|d: String| d.parse().map_err(de::Error::custom))
.transpose()
}
}
mod base64s { mod base64s {
use serde::{de, Deserialize, Deserializer, Serializer}; use serde::{de, Deserialize, Deserializer, Serializer};
@ -404,28 +301,11 @@ pub struct TodoCsv {
pub async fn create_reminder( pub async fn create_reminder(
ctx: &Context, ctx: &Context,
pool: impl sqlx::Executor<'_, Database = Database> + Copy, pool: &Pool<MySql>,
guild_id: GuildId, guild_id: GuildId,
user_id: UserId, user_id: UserId,
reminder: ReminderCreate, 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();
@ -435,7 +315,7 @@ pub async fn create_reminder(
if !channel_matches_guild || !channel_exists { if !channel_matches_guild || !channel_exists {
warn!( warn!(
"Error in `create_reminder`: channel {:?} not found for guild {} (channel exists: {})", "Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})",
reminder.channel, guild_id, channel_exists reminder.channel, guild_id, channel_exists
); );
@ -455,7 +335,6 @@ pub async fn create_reminder(
let channel = channel.unwrap(); let channel = channel.unwrap();
// validate lengths // validate lengths
check_length!(MAX_NAME_LENGTH, reminder.name);
check_length!(MAX_CONTENT_LENGTH, reminder.content); check_length!(MAX_CONTENT_LENGTH, reminder.content);
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description); check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title); check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
@ -491,12 +370,8 @@ 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() if reminder.interval_seconds.is_some() || reminder.interval_months.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
{ {
@ -505,10 +380,7 @@ pub async fn create_reminder(
} }
// check patreon if necessary // check patreon if necessary
if reminder.interval_seconds.is_some() if reminder.interval_seconds.is_some() || reminder.interval_months.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
{ {
@ -519,23 +391,16 @@ 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();
// write to db // write to db
match sqlx::query!( match sqlx::query!(
" "INSERT INTO reminders (
INSERT INTO reminders (
uid, uid,
attachment, attachment,
attachment_name, attachment_name,
channel_id, channel_id,
guild_id,
avatar, avatar,
content, content,
embed_author, embed_author,
@ -551,21 +416,17 @@ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?)",
new_uid, new_uid,
attachment_data, attachment_data,
reminder.attachment_name, reminder.attachment_name,
channel, channel,
guild_id.0,
reminder.avatar, reminder.avatar,
reminder.content, reminder.content,
reminder.embed_author, reminder.embed_author,
@ -581,12 +442,11 @@ 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,
username, reminder.username,
reminder.utc_time, reminder.utc_time,
) )
.execute(pool) .execute(pool)
@ -613,17 +473,13 @@ 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,
reminders.tts, reminders.tts,
reminders.uid, reminders.uid,
reminders.username, reminders.username,
reminders.utc_time, reminders.utc_time
reminders.status,
reminders.status_change_time,
reminders.status_message
FROM reminders FROM reminders
LEFT JOIN channels ON channels.id = reminders.channel_id LEFT JOIN channels ON channels.id = reminders.channel_id
WHERE uid = ?", WHERE uid = ?",
@ -725,17 +581,7 @@ pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirec
} }
#[get("/<_>")] #[get("/<_>")]
pub async fn dashboard_1(cookies: &CookieJar<'_>) -> Result<Template, Redirect> { pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
if cookies.get_private("userid").is_some() {
let map: HashMap<&str, String> = HashMap::new();
Ok(Template::render("dashboard", &map))
} else {
Err(Redirect::to("/login/discord"))
}
}
#[get("/<_>/<_>")]
pub async fn dashboard_2(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
if cookies.get_private("userid").is_some() { if cookies.get_private("userid").is_some() {
let map: HashMap<&str, String> = HashMap::new(); let map: HashMap<&str, String> = HashMap::new();
Ok(Template::render("dashboard", &map)) Ok(Template::render("dashboard", &map))

View File

@ -54,8 +54,6 @@ pub async fn get_user_info(
ctx: &State<Context>, ctx: &State<Context>,
pool: &State<Pool<MySql>>, pool: &State<Pool<MySql>>,
) -> JsonValue { ) -> JsonValue {
offline!(json!(UserInfo { name: "Discord".to_string(), patreon: true, timezone: None }));
if let Some(user_id) = if let Some(user_id) =
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten() cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
{ {
@ -118,8 +116,6 @@ pub async fn update_user_info(
#[get("/api/user/guilds")] #[get("/api/user/guilds")]
pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue { pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue {
offline!(json!(vec![GuildInfo { id: "1".to_string(), name: "Guild".to_string() }]));
if let Some(access_token) = cookies.get_private("access_token") { if let Some(access_token) = cookies.get_private("access_token") {
let request_res = reqwest_client let request_res = reqwest_client
.get(format!("{}/users/@me/guilds", DISCORD_API)) .get(format!("{}/users/@me/guilds", DISCORD_API))

View File

@ -11,7 +11,7 @@ use rocket::{
}; };
use serenity::model::user::User; use serenity::model::user::User;
use crate::{consts::DISCORD_API, routes}; use crate::consts::DISCORD_API;
#[get("/discord")] #[get("/discord")]
pub async fn discord_login( pub async fn discord_login(
@ -52,15 +52,6 @@ pub async fn discord_login(
Redirect::to(auth_url.to_string()) Redirect::to(auth_url.to_string())
} }
#[get("/discord/logout")]
pub async fn discord_logout(cookies: &CookieJar<'_>) -> Redirect {
cookies.remove_private(Cookie::named("username"));
cookies.remove_private(Cookie::named("userid"));
cookies.remove_private(Cookie::named("access_token"));
Redirect::to(uri!(routes::index))
}
#[get("/discord/authorized?<code>&<state>")] #[get("/discord/authorized?<code>&<state>")]
pub async fn discord_callback( pub async fn discord_callback(
code: &str, code: &str,
@ -144,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. The server may be misconfigured. Please retry or alert us in Discord.", "Your login request was rejected",
)) ))
} }
} }
} else { } else {
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "danger", "Your request failed to validate, and so has been rejected (CSRF Validation Failure)")) 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)"))
} }
} else { } else {
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "warning", "Your request was missing information, and so has been rejected (CSRF Validation Tokens Missing)")) 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)"))
} }
} }

View File

@ -1,15 +1,11 @@
pub mod admin;
pub mod dashboard; pub mod dashboard;
pub mod login; pub mod login;
pub mod report;
use std::collections::HashMap; use std::collections::HashMap;
use rocket::{request::FlashMessage, serde::json::Value as JsonValue}; use rocket::request::FlashMessage;
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
pub type JsonResult = Result<JsonValue, JsonValue>;
#[get("/")] #[get("/")]
pub async fn index(flash: Option<FlashMessage<'_>>) -> Template { pub async fn index(flash: Option<FlashMessage<'_>>) -> Template {
let mut map: HashMap<&str, String> = HashMap::new(); let mut map: HashMap<&str, String> = HashMap::new();

View File

@ -1,48 +0,0 @@
use rocket::{
http::CookieJar,
serde::{
json::{json, Json},
Deserialize,
},
};
use crate::routes::JsonResult;
#[derive(Deserialize)]
pub struct ClientError {
#[serde(rename = "reporterId")]
reporter_id: String,
url: String,
#[serde(rename = "relativeTimestamp")]
relative_timestamp: i64,
#[serde(rename = "errorMessage")]
error_message: String,
#[serde(rename = "errorLine")]
error_line: u64,
#[serde(rename = "errorFile")]
error_file: String,
#[serde(rename = "errorType")]
error_type: String,
}
#[post("/report", data = "<client_error>")]
pub async fn report_error(cookies: &CookieJar<'_>, client_error: Json<ClientError>) -> JsonResult {
if let Some(user_id) = cookies.get_private("userid") {
error!(
"User {} reports a client-side error.
{}, {}:{} at {}ms
{}: {}
Chain: {}",
user_id,
client_error.url,
client_error.error_file,
client_error.error_line,
client_error.relative_timestamp,
client_error.error_type,
client_error.error_message,
client_error.reporter_id
);
}
Ok(json!({}))
}

View File

@ -11,22 +11,10 @@ div.reminderContent.is-collapsed .column.discord-frame {
display: none; display: none;
} }
div.reminderContent.is-collapsed .column.settings { div.reminderContent.is-collapsed .collapses {
display: none; display: none;
} }
div.reminderContent.is-collapsed .button-row {
display: none;
}
div.reminderContent.is-collapsed .button-row-edit {
display: none;
}
div.reminderContent.is-collapsed .reminder-topbar {
padding-bottom: 0;
}
div.reminderContent.is-collapsed .invert-collapses { div.reminderContent.is-collapsed .invert-collapses {
display: inline-flex; display: inline-flex;
} }
@ -35,42 +23,42 @@ div.reminderContent .invert-collapses {
display: none; display: none;
} }
div.reminderContent.is-collapsed .settings {
display: flex;
flex-direction: row;
padding-bottom: 0;
}
div.reminderContent.is-collapsed .channel-field {
display: inline-flex;
order: 1;
}
div.reminderContent.is-collapsed .reminder-topbar {
display: inline-flex;
margin-bottom: 0px;
flex-grow: 1;
order: 2;
}
div.reminderContent.is-collapsed input[name="name"] { div.reminderContent.is-collapsed input[name="name"] {
display: inline-flex; display: inline-flex;
flex-grow: 1; flex-grow: 1;
border: none; border: none;
font-weight: 700;
background: none; background: none;
box-shadow: none;
opacity: 1;
} }
div.reminderContent.is-collapsed .hide-box { div.reminderContent.is-collapsed button.hide-box {
display: inline-flex; display: inline-flex;
} }
div.reminderContent.is-collapsed .hide-box i { div.reminderContent.is-collapsed button.hide-box i {
transform: rotate(90deg); transform: rotate(90deg);
} }
/* END */ /* END */
/* dashboard styles */ /* dashboard styles */
.hide-box {
border: none;
background: none;
}
.hide-box:focus {
outline: none;
box-shadow: none !important;
}
.channel-bar {
display: flex;
justify-content: center;
flex-direction: column;
font-weight: bold;
}
button.inline-btn { button.inline-btn {
height: 100%; height: 100%;
padding: 5px; padding: 5px;
@ -97,86 +85,18 @@ div.discord-embed {
position: relative; position: relative;
} }
div.split-controls {
display: flex;
flex-direction: column;
justify-content: space-between;
flex-grow: 2;
}
.reminder-topbar > div {
padding-left: 6px;
padding-right: 6px;
}
.settings {
display: flex;
flex-direction: column;
}
.name-bar {
flex-grow: 1;
flex-shrink: 1;
}
.hide-button-bar {
flex-grow: 0;
flex-shrink: 0;
}
.patreon-only {
padding-bottom: 16px;
}
.tts-row {
padding-bottom: 10px;
}
.reminder-topbar {
display: flex;
margin-bottom: 0 !important;
}
.reminder-settings {
margin-top: 0 !important;
}
.reminder-settings > .column {
flex-grow: 0;
flex-shrink: 0;
flex-basis: 50%;
}
div.reminderContent { div.reminderContent {
margin-top: 10px; padding: 2px;
margin-bottom: 10px;
padding: 14px;
background-color: #f5f5f5; background-color: #f5f5f5;
border-radius: 8px; border-radius: 8px;
margin: 8px;
}
div.interval-group > button {
margin-left: auto;
} }
/* Interval inputs */ /* Interval inputs */
div.interval-group {
height: unset !important;
}
div.interval-group .clear:focus {
outline: none;
box-shadow: none !important;
}
div.interval-group .no-break {
text-wrap: avoid;
white-space: nowrap;
}
div.interval-group .clear {
border: none;
background: none;
padding: 1px;
margin-right: -3px;
}
div.interval-group > .interval-group-left input { div.interval-group > .interval-group-left input {
-webkit-appearance: none; -webkit-appearance: none;
border-style: none; border-style: none;
@ -190,13 +110,12 @@ div.interval-group > .interval-group-left input.w2 {
} }
div.interval-group > .interval-group-left input.w3 { div.interval-group > .interval-group-left input.w3 {
width: 3ch; width: 6ch;
} }
div.interval-group { div.interval-group {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between;
} }
/* !Interval inputs */ /* !Interval inputs */
@ -261,23 +180,6 @@ div#pageNavbar a {
text-align: center; text-align: center;
} }
.navbar-burger {
flex-shrink: 0;
}
.navbar-item.pageTitle {
flex-shrink: 1;
white-space: nowrap;
overflow: hidden;
}
.dashboard-burger, .dashboard-burger:active, .dashboard-burger.is-active {
background-color: #adc99c !important;
border-radius: 14px;
padding: 6px;
background-clip: content-box;
}
div#pageNavbar a:hover { div#pageNavbar a:hover {
background-color: #4a4a4a; background-color: #4a4a4a;
} }
@ -309,19 +211,10 @@ div.dashboard-sidebar:not(.mobile-sidebar) {
flex-direction: column; flex-direction: column;
} }
ul.guildList {
flex-grow: 1;
flex-shrink: 1;
overflow: auto;
}
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer { div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
flex-shrink: 0; position: fixed;
flex-grow: 0; bottom: 0;
} width: 226px;
div.dashboard-sidebar svg {
flex-shrink: 0;
} }
div.mobile-sidebar { div.mobile-sidebar {
@ -400,7 +293,10 @@ input.default-width {
} }
.message-input:placeholder-shown { .message-input:placeholder-shown {
font-style: italic; border-top: none;
border-left: none;
border-right: none;
border-bottom-style: dashed;
background-color: #40444b; background-color: #40444b;
color: #fff; color: #fff;
} }
@ -471,7 +367,8 @@ input.default-width {
.customizable.is-400x300 img { .customizable.is-400x300 img {
margin-top: 10px; margin-top: 10px;
width: 100%; width: 100%;
height: 100px; min-height: 100px;
max-height: 400px;
} }
.customizable.is-32x32 img { .customizable.is-32x32 img {
@ -565,7 +462,6 @@ input.default-width {
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; flex-shrink: 1;
flex-basis: auto; flex-basis: auto;
margin-right: 4px;
} }
.embed-body input, .embed-body textarea { .embed-body input, .embed-body textarea {
@ -615,84 +511,21 @@ input.default-width {
border-bottom: 1px solid #fff; border-bottom: 1px solid #fff;
} }
.channel-selector { @media only screen and (max-width: 768px) {
width: 100%;
}
.select {
width: 100%;
}
li.highlight {
margin-bottom: 0 !important;
}
.button-row {
display: flex;
}
.button-row .button-row-reminder {
flex-grow: 0;
padding: 2px;
}
.button-row-template {
display: flex;
flex-grow: 1;
justify-content: space-between;
}
.button-row .button-row-template > div {
padding: 2px;
}
@media only screen and (max-width: 1023px) {
p.title.pageTitle {
display: none;
}
.dashboard-frame {
margin-top: 4rem !important;
}
.customizable.thumbnail img { .customizable.thumbnail img {
width: 60px; width: 60px;
height: 60px; height: 60px;
} }
}
@media only screen and (max-width: 768px) { .customizable.is-24x24 img {
.button-row { width: 16px;
display: flex; height: 16px;
flex-direction: column;
}
.button-row .button-row-reminder {
width: 100%;
}
.button-row .button-row-template > div {
flex-basis: 0;
flex-grow: 1;
}
.button-row button {
width: 100%;
}
.reminder-settings {
margin-bottom: 0 !important;
}
.tts-row {
padding-bottom: 0;
} }
} }
/* loader */ /* loader */
#loader { #loader {
position: fixed; position: fixed;
top: 0;
background-color: rgba(255, 255, 255, 0.8); background-color: rgba(255, 255, 255, 0.8);
width: 100vw; width: 100vw;
z-index: 999; z-index: 999;
@ -704,86 +537,6 @@ li.highlight {
/* END */ /* END */
div.reminderError {
margin: 10px;
padding: 14px;
background-color: #f5f5f5;
border-radius: 8px;
}
div.reminderError .errorHead {
display: flex;
flex-direction: row;
}
div.reminderError .errorIcon {
padding: 8px;
border-radius: 4px;
margin-right: 12px;
}
div.reminderError .errorIcon .fas {
display: none
}
div.reminderError[data-case="deleted"] .errorIcon {
background-color: #e7e5e4;
}
div.reminderError[data-case="failed"] .errorIcon {
background-color: #fecaca;
}
div.reminderError[data-case="sent"] .errorIcon {
background-color: #d9f99d;
}
div.reminderError[data-case="deleted"] .errorIcon .fas.fa-trash {
display: block;
}
div.reminderError[data-case="failed"] .errorIcon .fas.fa-exclamation-triangle {
display: block;
}
div.reminderError[data-case="sent"] .errorIcon .fas.fa-check {
display: block;
}
div.reminderError .errorHead .reminderName {
font-size: 1rem;
display: flex;
flex-direction: column;
justify-content: center;
color: rgb(54, 54, 54);
flex-grow: 1;
}
div.reminderError .errorHead .reminderTime {
font-size: 1rem;
display: flex;
flex-direction: column;
flex-shrink: 1;
justify-content: center;
color: rgb(54, 54, 54);
background-color: #ffffff;
padding: 8px;
border-radius: 4px;
border-color: #e5e5e5;
border-width: 1px;
border-style: solid;
}
div.reminderError .reminderMessage {
font-size: 1rem;
display: flex;
flex-direction: column;
justify-content: center;
color: rgb(54, 54, 54);
flex-grow: 1;
font-style: italic;
}
/* other stuff */ /* other stuff */
.half-rem { .half-rem {
@ -815,44 +568,11 @@ div.reminderError .reminderMessage {
background-color: white; background-color: white;
} }
a.switch-pane {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.guild-submenu {
display: none;
}
.guild-submenu li {
font-size: 0.8rem;
}
a.switch-pane.is-active ~ .guild-submenu {
display: block;
}
.feedback {
background-color: #5865F2;
}
.is-locked { .is-locked {
pointer-events: none; pointer-events: none;
}
.is-locked > :not(.patreon-invert) {
opacity: 0.4; opacity: 0.4;
} }
.is-locked .patreon-invert {
display: block;
}
.patreon-invert {
display: none;
}
.is-locked .foreground { .is-locked .foreground {
pointer-events: auto; pointer-events: auto;
} }
@ -860,27 +580,3 @@ a.switch-pane.is-active ~ .guild-submenu {
.is-locked .field:last-of-type { .is-locked .field:last-of-type {
display: none; display: none;
} }
.stat-row {
display: flex;
flex-direction: row;
}
.stat-box {
flex-grow: 1;
border-radius: 6px;
background-color: #fcfcfc;
border-color: #efefef;
border-style: solid;
border-width: 1px;
margin: 4px;
padding: 4px;
}
.figure {
text-align: center;
}
.figure-num {
font-size: 2rem;
}

View File

@ -1,15 +1,14 @@
{ {
"name": "Reminder Bot Dashboard", "name": "",
"short_name": "Reminders", "short_name": "",
"start_url": "/dashboard",
"icons": [ "icons": [
{ {
"src": "/static/favicon/android-chrome-192x192.png", "src": "/android-chrome-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "/static/favicon/android-chrome-512x512.png", "src": "/android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

View File

@ -1,131 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
fetch("/admin/data")
.then((resp) => resp.json())
.then((data) => {
document.querySelector("#backlog").textContent = data.backlog;
document.querySelector("#reminders").textContent = data.count.reminders;
document.querySelector("#intervals").textContent = data.count.intervals;
let historySent = data.historyLong.sent.reduce(
(iv, frame) => iv + frame.count,
0
);
let historyFailed = data.historyLong.failed.reduce(
(iv, frame) => iv + frame.count,
0
);
let rate = historyFailed / (historySent + historyFailed);
let formatted = Math.round(rate * 10000) / 100;
document.querySelector("#historySent").textContent = historySent;
document.querySelector("#historyFailed").textContent = historyFailed;
document.querySelector("#failRate").textContent = `${formatted}%`;
new Chart(document.getElementById("schedule"), {
type: "bar",
data: {
labels: [
...data.scheduleShort.once,
...data.scheduleShort.interval,
].map((row) => luxon.DateTime.fromISO(row.time_key)),
datasets: [
{
label: "Reminders",
data: data.scheduleShort.once.map((row) => row.count),
},
{
label: "Intervals",
data: data.scheduleShort.interval.map((row) => row.count),
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
type: "time",
time: {
unit: "minute",
},
},
y: {
stacked: true,
},
},
},
});
new Chart(document.getElementById("scheduleLong"), {
type: "bar",
data: {
labels: [
...data.scheduleLong.once,
...data.scheduleLong.interval,
].map((row) => luxon.DateTime.fromISO(row.time_key)),
datasets: [
{
label: "Reminders",
data: data.scheduleLong.once.map((row) => row.count),
},
{
label: "Intervals",
data: data.scheduleLong.interval.map((row) => row.count),
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
type: "time",
time: {
unit: "day",
},
},
y: {
stacked: true,
},
},
},
});
new Chart(document.getElementById("historyLong"), {
type: "bar",
data: {
labels: [...data.historyLong.sent, ...data.historyLong.failed].map(
(row) => luxon.DateTime.fromISO(row.time_key)
),
datasets: [
{
label: "Success",
data: data.historyLong.sent.map((row) => row.count),
},
{
label: "Fail",
data: data.historyLong.failed.map((row) => row.count),
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
type: "time",
time: {
unit: "day",
},
},
y: {
stacked: true,
},
},
},
});
});
});

File diff suppressed because one or more lines are too long

View File

@ -1,7 +0,0 @@
/*!
* chartjs-adapter-luxon v1.3.1
* https://www.chartjs.org
* (c) 2023 chartjs-adapter-luxon Contributors
* Released under the MIT license
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("chart.js"),require("luxon")):"function"==typeof define&&define.amd?define(["chart.js","luxon"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Chart,e.luxon)}(this,(function(e,t){"use strict";const n={datetime:t.DateTime.DATETIME_MED_WITH_SECONDS,millisecond:"h:mm:ss.SSS a",second:t.DateTime.TIME_WITH_SECONDS,minute:t.DateTime.TIME_SIMPLE,hour:{hour:"numeric"},day:{day:"numeric",month:"short"},week:"DD",month:{month:"short",year:"numeric"},quarter:"'Q'q - yyyy",year:{year:"numeric"}};e._adapters._date.override({_id:"luxon",_create:function(e){return t.DateTime.fromMillis(e,this.options)},init(e){this.options.locale||(this.options.locale=e.locale)},formats:function(){return n},parse:function(e,n){const i=this.options,r=typeof e;return null===e||"undefined"===r?null:("number"===r?e=this._create(e):"string"===r?e="string"==typeof n?t.DateTime.fromFormat(e,n,i):t.DateTime.fromISO(e,i):e instanceof Date?e=t.DateTime.fromJSDate(e,i):"object"!==r||e instanceof t.DateTime||(e=t.DateTime.fromObject(e,i)),e.isValid?e.valueOf():null)},format:function(e,t){const n=this._create(e);return"string"==typeof t?n.toFormat(t):n.toLocaleString(t)},add:function(e,t,n){const i={};return i[n]=t,this._create(e).plus(i).valueOf()},diff:function(e,t,n){return this._create(e).diff(this._create(t)).as(n).valueOf()},startOf:function(e,t,n){if("isoWeek"===t){n=Math.trunc(Math.min(Math.max(0,n),6));const t=this._create(e);return t.minus({days:(t.weekday-n+7)%7}).startOf("day").valueOf()}return t?this._create(e).startOf(t).valueOf():e},endOf:function(e,t){return this._create(e).endOf(t).valueOf()}})}));

View File

@ -7,8 +7,8 @@ function get_interval(element) {
return { return {
months: parseInt(months) || null, months: parseInt(months) || null,
days: parseInt(days) || null,
seconds: seconds:
(parseInt(days) || 0) * 86400 +
(parseInt(hours) || 0) * 3600 + (parseInt(hours) || 0) * 3600 +
(parseInt(minutes) || 0) * 60 + (parseInt(minutes) || 0) * 60 +
(parseInt(seconds) || 0) || null, (parseInt(seconds) || 0) || null,
@ -22,15 +22,6 @@ function update_interval(element) {
let minutes = element.querySelector('input[name="interval_minutes"]'); let minutes = element.querySelector('input[name="interval_minutes"]');
let seconds = element.querySelector('input[name="interval_seconds"]'); let seconds = element.querySelector('input[name="interval_seconds"]');
let interval = get_interval(element);
if (interval.months === null && interval.days === null && interval.seconds === null) {
months.value = "";
days.value = "";
hours.value = "";
minutes.value = "";
seconds.value = "";
} else {
months.value = months.value.padStart(1, "0"); months.value = months.value.padStart(1, "0");
days.value = days.value.padStart(1, "0"); days.value = days.value.padStart(1, "0");
hours.value = hours.value.padStart(2, "0"); hours.value = hours.value.padStart(2, "0");
@ -42,10 +33,7 @@ function update_interval(element) {
let remainder = seconds.value % 60; let remainder = seconds.value % 60;
seconds.value = String(remainder).padStart(2, "0"); seconds.value = String(remainder).padStart(2, "0");
minutes.value = String(Number(minutes.value) + Number(quotient)).padStart( minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(2, "0");
2,
"0"
);
} }
if (minutes.value >= 60) { if (minutes.value >= 60) {
let quotient = Math.floor(minutes.value / 60); let quotient = Math.floor(minutes.value / 60);
@ -54,6 +42,12 @@ function update_interval(element) {
minutes.value = String(remainder).padStart(2, "0"); minutes.value = String(remainder).padStart(2, "0");
hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0"); hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0");
} }
if (hours.value >= 24) {
let quotient = Math.floor(hours.value / 24);
let remainder = hours.value % 24;
hours.value = String(remainder).padStart(2, "0");
days.value = Number(days.value) + Number(quotient);
} }
} }

View File

@ -18,7 +18,6 @@ const $downloader = document.querySelector("a#downloader");
const $uploader = document.querySelector("input#uploader"); const $uploader = document.querySelector("input#uploader");
let channels = []; let channels = [];
let reminderErrors = [];
let guildNames = {}; let guildNames = {};
let roles = []; let roles = [];
let templates = {}; let templates = {};
@ -34,11 +33,7 @@ let globalPatreon = false;
let guildPatreon = false; let guildPatreon = false;
function guildId() { function guildId() {
return document.querySelector("li > a.is-active").parentElement.dataset["guild"]; return document.querySelector(".guildList a.is-active").dataset["guild"];
}
function guildName() {
return guildNames[guildId()];
} }
function colorToInt(r, g, b) { function colorToInt(r, g, b) {
@ -57,40 +52,22 @@ function switch_pane(selector) {
el.classList.add("is-hidden"); el.classList.add("is-hidden");
}); });
document.querySelector(`*[data-name=${selector}]`).classList.remove("is-hidden"); document.getElementById(selector).classList.remove("is-hidden");
} }
function update_select(sel) { function update_select(sel) {
let channelDisplay = sel.closest("div.reminderContent").querySelector(".channel-bar");
if (channelDisplay !== null) {
channelDisplay.textContent = `#${sel.selectedOptions[0].textContent}`;
}
if (sel.selectedOptions[0] === undefined) {
return;
}
const avatarInput = sel.closest("div.reminderContent").querySelector("img.avatar");
if (!avatarInput.dataset["set"]) {
if (sel.selectedOptions[0].dataset["webhookAvatar"]) { if (sel.selectedOptions[0].dataset["webhookAvatar"]) {
avatarInput.src = sel.selectedOptions[0].dataset["webhookAvatar"]; sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
sel.selectedOptions[0].dataset["webhookAvatar"];
} else { } else {
avatarInput.src = "/static/img/icon.png"; sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = "";
} }
}
const usernameInput = sel
.closest("div.reminderContent")
.querySelector("input.discord-username");
if (usernameInput.value.length === 0) {
if (sel.selectedOptions[0].dataset["webhookName"]) { if (sel.selectedOptions[0].dataset["webhookName"]) {
usernameInput.value = sel.selectedOptions[0].dataset["webhookName"]; sel.closest("div.reminderContent").querySelector("input.discord-username").value =
sel.selectedOptions[0].dataset["webhookName"];
} else { } else {
usernameInput.value = "Reminder"; sel.closest("div.reminderContent").querySelector("input.discord-username").value =
} "";
} }
} }
@ -161,18 +138,12 @@ async function fetch_channels(guild_id) {
const event = new Event("channelsLoading"); const event = new Event("channelsLoading");
document.dispatchEvent(event); document.dispatchEvent(event);
let hasError = false;
await fetch(`/dashboard/api/guild/${guild_id}/channels`) await fetch(`/dashboard/api/guild/${guild_id}/channels`)
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.error) { if (data.error) {
if (data.error === "Bot not in guild") { if (data.error === "Bot not in guild") {
switch_pane("guild-error"); switch_pane("guild-error");
hasError = true;
} else if (data.error === "Incorrect permissions") {
switch_pane("user-error");
hasError = true;
} else { } else {
show_error(data.error); show_error(data.error);
} }
@ -184,8 +155,6 @@ async function fetch_channels(guild_id) {
const event = new Event("channelsLoaded"); const event = new Event("channelsLoaded");
document.dispatchEvent(event); document.dispatchEvent(event);
}); });
return hasError;
} }
async function fetch_reminders(guild_id) { async function fetch_reminders(guild_id) {
@ -236,18 +205,14 @@ async function serialize_reminder(node, mode) {
utc_time = luxon.DateTime.fromISO( utc_time = luxon.DateTime.fromISO(
node.querySelector('input[name="time"]').value node.querySelector('input[name="time"]').value
).setZone("UTC"); ).setZone("UTC");
if (utc_time.invalid) { if (utc_time.invalid) {
return { error: "Time provided invalid." }; return { error: "Time provided invalid." };
} else { } else {
utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"); utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
} }
let expiration = node.querySelector('input[name="expiration"]').value;
if (expiration) {
expiration_time = luxon.DateTime.fromISO( expiration_time = luxon.DateTime.fromISO(
node.querySelector('input[name="expiration"]').value node.querySelector('input[name="time"]').value
).setZone("UTC"); ).setZone("UTC");
if (expiration_time.invalid) { if (expiration_time.invalid) {
return { error: "Expiration provided invalid." }; return { error: "Expiration provided invalid." };
@ -255,12 +220,6 @@ async function serialize_reminder(node, mode) {
expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"); expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
} }
} }
}
let name = node.querySelector('input[name="name"]').value;
if (name.length > 100) {
return { error: "Name exceeds maximum length (100)." };
}
let rgb_color = window.getComputedStyle( let rgb_color = window.getComputedStyle(
node.querySelector("div.discord-embed") node.querySelector("div.discord-embed")
@ -324,17 +283,15 @@ async function serialize_reminder(node, mode) {
const embed_title = node.querySelector('textarea[name="embed_title"]').value; const embed_title = node.querySelector('textarea[name="embed_title"]').value;
if ( if (
content.length === 0 && attachment === null &&
embed_author.length === 0 && content.length == 0 &&
embed_title.length === 0 &&
embed_description.length === 0 &&
embed_footer.length === 0 &&
embed_author_url === null && embed_author_url === null &&
embed_author.length == 0 &&
embed_description.length == 0 &&
embed_footer.length == 0 &&
embed_footer_url === null && embed_footer_url === null &&
embed_image_url === null && embed_image_url === null &&
embed_thumbnail_url === null && embed_thumbnail_url === null
fields.length === 0 &&
attachment === null
) { ) {
return { error: "Reminder needs content." }; return { error: "Reminder needs content." };
} }
@ -347,7 +304,7 @@ async function serialize_reminder(node, mode) {
restartable: false, restartable: false,
attachment: attachment, attachment: attachment,
attachment_name: attachment_name, attachment_name: attachment_name,
avatar: has_source(node.querySelector("img.avatar").src), avatar: has_source(node.querySelector("img.discord-avatar").src),
channel: node.querySelector("select.channel-selector").value, channel: node.querySelector("select.channel-selector").value,
content: content, content: content,
embed_author_url: embed_author_url, embed_author_url: embed_author_url,
@ -362,7 +319,6 @@ 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,
@ -375,9 +331,6 @@ 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) {
@ -392,27 +345,15 @@ function deserialize_reminder(reminder, frame, mode) {
if ($input !== null) { if ($input !== null) {
$input.value = reminder[prop]; $input.value = reminder[prop];
} else if ($image !== null) { } else if ($image !== null) {
console.log(`loading img ${prop}`);
$image.src = reminder[prop]; $image.src = reminder[prop];
$image.dataset["set"] = "1";
} }
} }
} }
} }
update_interval(frame); const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box");
update_select(frame.querySelector(".channel-selector"));
const lastChild = frame.querySelector( for (let field of reminder["embed_fields"]) {
"div.embed-multifield-box .embed-field-box:last-child"
);
// Drop existing fields
frame
.querySelectorAll(".embed-field-box:not(:last-child)")
.forEach((el) => el.remove());
for (let field of reminder["embed_fields"] || []) {
let embed_field = $embedFieldTemplate.content.cloneNode(true); let embed_field = $embedFieldTemplate.content.cloneNode(true);
embed_field.querySelector("textarea.discord-field-title").value = field["title"]; embed_field.querySelector("textarea.discord-field-title").value = field["title"];
embed_field.querySelector("textarea.discord-field-value").value = field["value"]; embed_field.querySelector("textarea.discord-field-value").value = field["value"];
@ -438,7 +379,7 @@ function deserialize_reminder(reminder, frame, mode) {
timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss"); timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss");
if (reminder["expires"]) { if (reminder["expires"]) {
let expiresInput = frame.querySelector('input[name="expiration"]'); let expiresInput = frame.querySelector('input[name="time"]');
let expiresTime = luxon.DateTime.fromISO(reminder["expires"], { let expiresTime = luxon.DateTime.fromISO(reminder["expires"], {
zone: "UTC", zone: "UTC",
}).setZone(timezone); }).setZone(timezone);
@ -454,27 +395,13 @@ document.addEventListener("guildSwitched", async (e) => {
.querySelectorAll(".patreon-only") .querySelectorAll(".patreon-only")
.forEach((el) => el.classList.add("is-locked")); .forEach((el) => el.classList.add("is-locked"));
let $li = document.querySelectorAll(`li[data-guild="${e.detail.guild_id}"]`); let $anchor = document.querySelector(
`.switch-pane[data-guild="${e.detail.guild_id}"]`
);
if ($li.length === 0) { switch_pane($anchor.dataset["pane"]);
switch_pane("user-error");
return;
}
switch_pane(e.detail.pane);
reset_guild_pane(); reset_guild_pane();
document $anchor.classList.add("is-active");
.querySelectorAll(`li[data-guild="${e.detail.guild_id}"] > a`)
.forEach((el) => {
el.classList.add("is-active");
});
document
.querySelectorAll(
`li[data-guild="${e.detail.guild_id}"] *[data-pane="${e.detail.pane}"]`
)
.forEach((el) => {
el.classList.add("is-active");
});
if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) { if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) {
document document
@ -482,32 +409,19 @@ document.addEventListener("guildSwitched", async (e) => {
.forEach((el) => el.classList.remove("is-locked")); .forEach((el) => el.classList.remove("is-locked"));
} }
const event = new CustomEvent("paneLoad", { fetch_roles(e.detail.guild_id);
detail: { fetch_templates(e.detail.guild_id);
guild_id: e.detail.guild_id, await fetch_channels(e.detail.guild_id);
pane: e.detail.pane, fetch_reminders(e.detail.guild_id);
},
});
document.dispatchEvent(event);
});
document.addEventListener("paneLoad", async (ev) => {
const hasError = await fetch_channels(ev.detail.guild_id);
if (!hasError) {
fetch_roles(ev.detail.guild_id);
fetch_templates(ev.detail.guild_id);
fetch_reminders(ev.detail.guild_id);
document.querySelectorAll("p.pageTitle").forEach((el) => { document.querySelectorAll("p.pageTitle").forEach((el) => {
el.textContent = `${guildName()} Reminders`; el.textContent = `${e.detail.guild_name} Reminders`;
}); });
document.querySelectorAll("select.channel-selector").forEach((el) => { document.querySelectorAll("select.channel-selector").forEach((el) => {
el.addEventListener("change", (e) => { el.addEventListener("change", (e) => {
update_select(e.target); update_select(e.target);
}); });
}); });
}
$loader.classList.add("is-hidden"); $loader.classList.add("is-hidden");
}); });
@ -519,12 +433,6 @@ document.addEventListener("channelsLoaded", () => {
document.addEventListener("remindersLoaded", (event) => { document.addEventListener("remindersLoaded", (event) => {
const guild = guildId(); const guild = guildId();
document.querySelectorAll("select.channel-selector").forEach((el) => {
el.addEventListener("change", (e) => {
update_select(e.target);
});
});
for (let reminder of event.detail) { for (let reminder of event.detail) {
let node = reminder.node; let node = reminder.node;
@ -552,9 +460,9 @@ document.addEventListener("remindersLoaded", (event) => {
if (data.error) { if (data.error) {
show_error(data.error); show_error(data.error);
} else { } else {
enableBtn.dataset["action"] = data.reminder["enabled"] enableBtn.dataset["action"] = data["enabled"]
? "disable" ? "enable"
: "enable"; : "disable";
} }
}); });
}); });
@ -589,8 +497,6 @@ 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"];
@ -651,7 +557,7 @@ document.querySelectorAll(".show-modal").forEach((element) => {
}); });
}); });
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", () => {
$loader.classList.remove("is-hidden"); $loader.classList.remove("is-hidden");
mentions.attach(document.querySelectorAll("textarea")); mentions.attach(document.querySelectorAll("textarea"));
@ -671,7 +577,7 @@ document.addEventListener("DOMContentLoaded", async () => {
hideBox.closest(".reminderContent").classList.toggle("is-collapsed"); hideBox.closest(".reminderContent").classList.toggle("is-collapsed");
}); });
await fetch("/dashboard/api/user") fetch("/dashboard/api/user")
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.error) { if (data.error) {
@ -685,7 +591,7 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
}); });
await fetch("/dashboard/api/user/guilds") fetch("/dashboard/api/user/guilds")
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
if (data.error) { if (data.error) {
@ -706,56 +612,36 @@ document.addEventListener("DOMContentLoaded", async () => {
"%guildname%", "%guildname%",
guild.name guild.name
); );
$anchor.dataset["guild"] = guild.id;
$anchor.dataset["name"] = guild.name; $anchor.dataset["name"] = guild.name;
$anchor.href = `/dashboard/${guild.id}/reminders`; $anchor.href = `/dashboard/${guild.id}?name=${guild.name}`;
const $li = $anchor.parentElement; $anchor.addEventListener("click", async (e) => {
$li.dataset["guild"] = guild.id;
$li.querySelectorAll("a").forEach((el) => {
el.addEventListener("click", (e) => {
const pane = el.dataset["pane"];
const slug = el.dataset["slug"];
if (pane !== undefined && slug !== undefined) {
e.preventDefault(); e.preventDefault();
window.history.pushState({}, "", `/dashboard/${guild.id}`);
switch_pane(pane);
window.history.pushState(
{},
"",
`/dashboard/${guild.id}/${slug}`
);
const event = new CustomEvent("guildSwitched", { const event = new CustomEvent("guildSwitched", {
detail: { detail: {
guild_name: guild.name,
guild_id: guild.id, guild_id: guild.id,
pane,
}, },
}); });
document.dispatchEvent(event); document.dispatchEvent(event);
}
});
}); });
element.append($clone); element.append($clone);
}); });
} }
const matches = window.location.href.match( const matches = window.location.href.match(/dashboard\/(\d+)/);
/dashboard\/(\d+)(\/)?([a-zA-Z\-]+)?/
);
if (matches) { if (matches) {
let id = matches[1]; let id = matches[1];
let kind = matches[3];
let name = guildNames[id]; let name = guildNames[id];
const event = new CustomEvent("guildSwitched", { const event = new CustomEvent("guildSwitched", {
detail: { detail: {
guild_name: name, guild_name: name,
guild_id: id, guild_id: id,
pane: kind,
}, },
}); });
@ -829,7 +715,6 @@ $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;
} }
@ -887,14 +772,6 @@ $createTemplateBtn.addEventListener("click", async () => {
]; ];
let reminder = await serialize_reminder($createReminder, "template"); let reminder = await serialize_reminder($createReminder, "template");
if (reminder.error) {
show_error(reminder.error);
$createTemplateBtn.querySelector("span.icon > i").classList = [
"fas fa-file-spreadsheet",
];
return;
}
let guild = guildId(); let guild = guildId();
fetch(`/dashboard/api/guild/${guild}/templates`, { fetch(`/dashboard/api/guild/${guild}/templates`, {
@ -936,7 +813,6 @@ $loadTemplateBtn.addEventListener("click", (ev) => {
}); });
$deleteTemplateBtn.addEventListener("click", (ev) => { $deleteTemplateBtn.addEventListener("click", (ev) => {
if (parseInt($templateSelect.value) !== null) {
fetch(`/dashboard/api/guild/${guildId()}/templates`, { fetch(`/dashboard/api/guild/${guildId()}/templates`, {
method: "DELETE", method: "DELETE",
headers: { headers: {
@ -954,7 +830,13 @@ $deleteTemplateBtn.addEventListener("click", (ev) => {
.remove(); .remove();
} }
}); });
} });
document.querySelectorAll("textarea.autoresize").forEach((element) => {
element.addEventListener("input", () => {
element.style.height = "";
element.style.height = element.scrollHeight + 3 + "px";
});
}); });
let $img; let $img;
@ -1012,13 +894,6 @@ 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() {
@ -1094,13 +969,6 @@ document.addEventListener("click", (ev) => {
if (ev.target.closest("button.inline-btn") !== null) { if (ev.target.closest("button.inline-btn") !== null) {
let inlined = ev.target.closest(".embed-field-box").dataset["inlined"]; let inlined = ev.target.closest(".embed-field-box").dataset["inlined"];
ev.target.closest(".embed-field-box").dataset["inlined"] = ev.target.closest(".embed-field-box").dataset["inlined"] =
inlined === "1" ? "0" : "1"; inlined == "1" ? "0" : "1";
} }
}); });
document.addEventListener("DOMContentLoaded", () => {
let now = luxon.DateTime.now().setZone(timezone);
document.querySelectorAll(".prefill-now").forEach((el) => {
el.value = now.toFormat("yyyy-LL-dd'T'HH:mm:ss");
});
});

View File

@ -1,45 +0,0 @@
function loadErrors() {
return fetch(
`/dashboard/api/guild/${guildId()}/reminders?status=deleted,sent,failed`
).then((response) => response.json());
}
document.addEventListener("paneLoad", (ev) => {
if (ev.detail.pane !== "errors") {
return;
}
document.querySelectorAll(".reminderError").forEach((el) => el.remove());
const template = document.getElementById("reminderError");
const container = document.getElementById("reminderLog");
loadErrors()
.then((res) => {
res = res
.filter((r) => r.status_change_time !== null)
.sort((a, b) => a.status_change_time < b.status_change_time);
for (const reminder of res) {
const newRow = template.content.cloneNode(true);
newRow.querySelector(".reminderError").dataset["case"] = reminder.status;
const statusTime = new luxon.DateTime.fromISO(
reminder.status_change_time,
{ zone: "UTC" }
);
newRow.querySelector(".reminderName").textContent = reminder.name;
newRow.querySelector(".reminderMessage").textContent =
reminder.status_message;
newRow.querySelector(".reminderTime").textContent = statusTime
.toLocal()
.toLocaleString(luxon.DateTime.DATETIME_MED);
container.appendChild(newRow);
}
})
.finally(() => {
$loader.classList.add("is-hidden");
});
});

View File

@ -1,16 +0,0 @@
const REPORTER_ID = crypto.randomUUID();
window.addEventListener("error", async (ev) => {
await fetch("/report", {
method: "POST",
body: JSON.stringify({
reporterId: REPORTER_ID,
url: window.location.href,
relativeTimestamp: ev.timeStamp,
errorMessage: ev.message,
errorLine: ev.lineno,
errorFile: ev.filename,
errorType: ev.type,
}),
});
});

View File

@ -1,89 +0,0 @@
<!DOCTYPE html>
<html lang="EN">
<head>
<script src="/static/js/reporter.js" type="application/javascript"></script>
<meta name="description" content="The most powerful Discord Reminders Bot">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8">
<meta name="yandex-verification" content="bb77b8681eb64a90"/>
<meta name="google-site-verification" content="7h7UVTeEe0AOzHiH3cFtsqMULYGN-zCZdMT_YCkW1Ho"/>
<!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; font-src fonts.gstatic.com 'self'"> -->
<!-- favicon -->
<link rel="apple-touch-icon" sizes="180x180"
href="/static/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32"
href="/static/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16"
href="/static/favicon/favicon-16x16.png">
<link rel="manifest" href="/static/favicon/site.webmanifest">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<title>Reminder Bot | Admin</title>
<!-- styles -->
<link rel="stylesheet" href="/static/css/bulma.min.css">
<link rel="stylesheet" href="/static/css/fa.css">
<link rel="stylesheet" href="/static/css/font.css">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/dtsel.css">
<script src="/static/js/luxon.min.js"></script>
</head>
<body style="width: 100%;">
<p class="title pageTitle">Admin dashboard</p>
<section id="main">
<div class="stat-row">
<div class="stat-box" style="height: 400px;">
<canvas id="schedule"></canvas>
</div>
</div>
<div class="stat-row">
<div class="stat-box figure">
<p>Backlog</p>
<p class="figure-num" id="backlog">?</p>
</div>
<div class="stat-box figure">
<p>Reminders</p>
<p class="figure-num" id="reminders">?</p>
</div>
<div class="stat-box figure">
<p>Intervals</p>
<p class="figure-num" id="intervals">?</p>
</div>
</div>
<div class="stat-row">
<div class="stat-box" style="height: 400px;">
<canvas id="scheduleLong"></canvas>
</div>
</div>
<div class="stat-row">
<div class="stat-box figure">
<p>Last 31 days (success)</p>
<p class="figure-num" id="historySent">?</p>
</div>
<div class="stat-box figure">
<p>Last 31 days (failed)</p>
<p class="figure-num" id="historyFailed">?</p>
</div>
<div class="stat-box figure">
<p>Last 31 days (failure rate)</p>
<p class="figure-num" id="failRate">?</p>
</div>
</div>
<div class="stat-row">
<div class="stat-box" style="height: 400px;">
<canvas id="historyLong"></canvas>
</div>
</div>
</section>
<script src="/static/js/chart.js" defer></script>
<script src="/static/js/chartjs-adapter-luxon.js" defer></script>
<script src="/static/js/admin.js" defer></script>
</body>
</html>

View File

@ -13,7 +13,7 @@
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest"> <link rel="manifest" href="/static/favicon/site.webmanifest">
<meta name="msapplication-TileColor" content="#da532c"> <meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
@ -51,8 +51,8 @@
<a class="navbar-item" href="https://invite.reminder-bot.com"> <a class="navbar-item" href="https://invite.reminder-bot.com">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
</a> </a>
<a class="navbar-item" href="https://gitea.jellypro.xyz/jude"> <a class="navbar-item" href="https://github.com/jellywx">
<i class="fab fa-git-square"></i> <i class="fab fa-github"></i>
</a> </a>
<a class="navbar-item" href="https://discord.jellywx.com"> <a class="navbar-item" href="https://discord.jellywx.com">
<i class="fab fa-discord"></i> <i class="fab fa-discord"></i>
@ -128,7 +128,7 @@
</div> </div>
{% elif show_login %} {% elif show_login %}
<div class="hero-foot has-text-centered"> <div class="hero-foot has-text-centered">
<a class="button is-size-4 is-rounded is-light" href="/login/discord"> <a class="button is-size-4 is-rounded is-light" href="/oauth/login">
<p class="is-size-4"> <p class="is-size-4">
<span>Login with Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span> <span>Login with Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p> </p>
@ -155,7 +155,7 @@
<br> <br>
<a href="/cookies">Cookies</a> | <a href="/privacy">Privacy Policy</a> | <a href="/terms">Terms of Service</a> <a href="/cookies">Cookies</a> | <a href="/privacy">Privacy Policy</a> | <a href="/terms">Terms of Service</a>
<br> <br>
<a href="https://patreon.com/jellywx"><strong>Patreon</strong></a> | <a href="https://discord.jellywx.com"><strong>Discord</strong></a> | <a href="https://gitea.jellypro.xyz/jude"><strong>Gitea</strong></a> <a href="https://patreon.com/jellywx"><strong>Patreon</strong></a> | <a href="https://discord.jellywx.com"><strong>Discord</strong></a> | <a href="https://github.com/JellyWX"><strong>GitHub</strong></a>
<br> <br>
or, <a href="mailto:jude@jellywx.com">Email me</a> or, <a href="mailto:jude@jellywx.com">Email me</a>
</p> </p>

View File

@ -1,8 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="EN"> <html lang="EN">
<head> <head>
<script src="/static/js/reporter.js" type="application/javascript"></script>
<meta name="description" content="The most powerful Discord Reminders Bot"> <meta name="description" content="The most powerful Discord Reminders Bot">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8"> <meta charset="UTF-8">
@ -40,14 +38,14 @@
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item" href="/"> <a class="navbar-item" href="/">
<figure class="image"> <figure class="image">
<img width="28px" height="28px" src="/static/img/logo_nobg.webp" alt="Reminder Bot Logo"> <img src="/static/img/logo_flat.webp" alt="Reminder Bot Logo">
</figure> </figure>
</a> </a>
<p class="navbar-item pageTitle"> <p class="navbar-item pageTitle">
</p> </p>
<a role="button" class="dashboard-burger navbar-burger is-right" aria-label="menu" aria-expanded="false" <a role="button" class="navbar-burger is-right" aria-label="menu" aria-expanded="false"
data-target="mobileSidebar"> data-target="mobileSidebar">
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
@ -193,8 +191,19 @@
</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>
@ -233,54 +242,7 @@
<div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch"> <div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch">
<a href="/"> <a href="/">
<div class="brand"> <div class="brand">
<img src="/static/img/logo_nobg.webp" alt="Reminder bot logo" <img src="/static/img/logo_flat.webp" alt="Reminder bot logo"
width="52px" height="52px"
class="dashboard-brand">
</div>
</a>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 160">
<g transform="scale(1, 0.5)">
<path fill="#8fb677" fill-opacity="1"
d="M0,192L60,170.7C120,149,240,107,360,96C480,85,600,107,720,138.7C840,171,960,213,1080,197.3C1200,181,1320,107,1380,69.3L1440,32L1440,0L1380,0C1320,0,1200,0,1080,0C960,0,840,0,720,0C600,0,480,0,360,0C240,0,120,0,60,0L0,0Z"></path>
</g>
</svg>
<aside class="menu">
<p class="menu-label">
Servers
</p>
<ul class="menu-list guildList">
</ul>
<div class="aside-footer">
<p class="menu-label">
Options
</p>
<ul class="menu-list">
<li>
{#
<a class="show-modal" data-modal="dataManagerModal">
<span class="icon"><i class="fas fa-exchange"></i></span> Import/Export
</a>
#}
<a class="show-modal" data-modal="chooseTimezoneModal">
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
</a>
<a href="/login/discord/logout">
<span class="icon"><i class="fas fa-sign-out"></i></span> Log out
</a>
<a href="https://discord.jellywx.com" class="feedback">
<span class="icon"><i class="fab fa-discord"></i></span> Give feedback
</a>
</li>
</ul>
</div>
</aside>
</div>
<div class="dashboard-sidebar mobile-sidebar is-hidden-desktop" id="mobileSidebar">
<a href="/">
<div class="brand">
<img src="/static/img/logo_nobg.webp" alt="Reminder bot logo"
class="dashboard-brand"> class="dashboard-brand">
</div> </div>
</a> </a>
@ -303,19 +265,49 @@
</p> </p>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
{#
<a class="show-modal" data-modal="dataManagerModal"> <a class="show-modal" data-modal="dataManagerModal">
<span class="icon"><i class="fas fa-exchange"></i></span> Import/Export <span class="icon"><i class="fas fa-exchange"></i></span> Import/Export
</a> </a>
#}
<a class="show-modal" data-modal="chooseTimezoneModal"> <a class="show-modal" data-modal="chooseTimezoneModal">
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone <span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
</a> </a>
<a href="/login/discord/logout"> </li>
<span class="icon"><i class="fas fa-sign-out"></i></span> Log out </ul>
</div>
</aside>
</div>
<div class="dashboard-sidebar mobile-sidebar is-hidden-desktop" id="mobileSidebar">
<a href="/">
<div class="brand">
<img src="/static/img/logo_flat.webp" alt="Reminder bot logo"
class="dashboard-brand">
</div>
</a> </a>
<a href="https://discord.jellywx.com/" class="feedback"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 160">
<span class="icon"><i class="fab fa-discord"></i></span> Give feedback <g transform="scale(1, 0.5)">
<path fill="#8fb677" fill-opacity="1"
d="M0,192L60,170.7C120,149,240,107,360,96C480,85,600,107,720,138.7C840,171,960,213,1080,197.3C1200,181,1320,107,1380,69.3L1440,32L1440,0L1380,0C1320,0,1200,0,1080,0C960,0,840,0,720,0C600,0,480,0,360,0C240,0,120,0,60,0L0,0Z"></path>
</g>
</svg>
<aside class="menu">
<p class="menu-label">
Servers
</p>
<ul class="menu-list guildList">
</ul>
<div class="aside-footer">
<p class="menu-label">
Settings
</p>
<ul class="menu-list">
<li>
<a class="show-modal" data-modal="dataManagerModal">
<span class="icon"><i class="fas fa-exchange"></i></span> Import/Export
</a>
<a class="show-modal" data-modal="chooseTimezoneModal">
<span class="icon"><i class="fas fa-map-marked"></i></span> Timezone
</a> </a>
</li> </li>
</ul> </ul>
@ -333,17 +325,25 @@
<p class="subtitle is-hidden-desktop">Press the <span class="icon"><i class="fal fa-bars"></i></span> to get started</p> <p class="subtitle is-hidden-desktop">Press the <span class="icon"><i class="fal fa-bars"></i></span> to get started</p>
</div> </div>
</section> </section>
<section data-name="reminders" class="is-hidden"> <section id="guild" class="is-hidden">
{% include "reminder_dashboard/reminder_dashboard" %} {% include "reminder_dashboard/reminder_dashboard" %}
</section> </section>
<section data-name="errors" class="is-hidden"> <section id="guild-error" class="is-hidden hero is-fullheight">
{% include "reminder_dashboard/reminder_errors" %} <div class="hero-body">
</section> <div class="container has-text-centered">
<section data-name="guild-error" class="is-hidden"> <p class="title">
{% include "reminder_dashboard/guild_error" %} We couldn't get this server's data
</section> </p>
<section data-name="user-error" class="is-hidden"> <p class="subtitle">
{% include "reminder_dashboard/user_error" %} Please check Reminder Bot is in the server, and has correct permissions.
</p>
<a class="button is-size-4 is-rounded is-success" href="https://invite.reminder-bot.com">
<p class="is-size-4">
<span>Add to Server</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</div>
</section> </section>
</div> </div>
<!-- /main content --> <!-- /main content -->
@ -376,28 +376,14 @@
<template id="guildListEntry"> <template id="guildListEntry">
<li> <li>
<a class="switch-pane" data-pane="reminders" data-slug="reminders"> <a class="switch-pane" data-pane="guild">
<span class="icon"><i class="fas fa-map-pin"></i></span> <span class="guild-name">%guildname%</span> <span class="icon"><i class="fas fa-map-pin"></i></span> <span class="guild-name">%guildname%</span>
</a> </a>
<ul class="guild-submenu">
<li>
<a class="switch-pane" data-pane="reminders" data-slug="reminders">
<span class="icon"><i class="fas fa-calendar-alt"></i></span> Reminders
</a>
<a class="switch-pane" data-pane="errors" data-slug="errors">
<span class="icon"><i class="fas fa-file-alt"></i></span> Logs
</a>
</li>
</ul>
</li> </li>
</template> </template>
<template id="guildReminder"> <template id="guildReminder">
{% include "reminder_dashboard/templates/guild_reminder" %} {% include "reminder_dashboard/guild_reminder" %}
</template>
<template id="reminderError">
{% include "reminder_dashboard/templates/reminder_error" %}
</template> </template>
<script src="/static/js/iro.js"></script> <script src="/static/js/iro.js"></script>

View File

@ -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">Create reminders</p> <p class="title">Creating 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,23 +107,7 @@
</div> </div>
</article> </article>
</div> </div>
<div class="tile is-parent is-vertical">
{#
<article class="tile is-child notification">
<p class="title">Import/export</p>
<p class="subtitle">Learn how to import and export data from the dashboard</p>
<div class="content has-text-centered">
<a class="button is-size-4 is-rounded is-light" href="/help/iemanager">
<p class="is-size-4">
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</article>
#}
</div>
<div class="tile is-parent"> <div class="tile is-parent">
{#
<article class="tile is-child notification"> <article class="tile is-child notification">
<p class="title">Dashboard</p> <p class="title">Dashboard</p>
<p class="subtitle">Learn to use the interactive web dashboard</p> <p class="subtitle">Learn to use the interactive web dashboard</p>
@ -135,7 +119,19 @@
</a> </a>
</div> </div>
</article> </article>
#} </div>
<div class="tile is-parent is-vertical">
<article class="tile is-child notification">
<p class="title">Import/Export</p>
<p class="subtitle">Learn how to import and export data from the dashboard</p>
<div class="content has-text-centered">
<a class="button is-size-4 is-rounded is-light" href="/help/iemanager">
<p class="is-size-4">
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</article>
</div> </div>
</div> </div>
</div> </div>
@ -145,14 +141,14 @@
<div class="container has-text-centered"> <div class="container has-text-centered">
<p class="title">Need more help?</p> <p class="title">Need more help?</p>
<p class="content"> <p class="content">
Please come and ask us! Feel free to come and ask us!
</p> </p>
</div> </div>
</div> </div>
<div class="hero-foot has-text-centered"> <div class="hero-foot has-text-centered">
<a class="button is-size-6 is-rounded is-primary" href="https://discord.jellywx.com"> <a class="button is-size-6 is-rounded is-primary" href="https://discord.jellywx.com">
<p class="is-size-6"> <p class="is-size-6">
<span>Join Discord</span> <span class="icon"><i class="fas fa-chevron-right"></i></span> Join Discord <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p> </p>
</a> </a>
</div> </div>

View File

@ -16,7 +16,7 @@
<div class="tile is-parent"> <div class="tile is-parent">
<article class="tile is-child notification"> <article class="tile is-child notification">
<p class="title">Slash-command Ready <svg aria-hidden="false" width="28" height="28" viewBox="0 0 24 24"><path fill="#777" fill-rule="evenodd" clip-rule="evenodd" d="M5 3C3.89543 3 3 3.89543 3 5V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5ZM16.8995 8.41419L15.4853 6.99998L7 15.4853L8.41421 16.8995L16.8995 8.41419Z"></path></svg></p> <p class="title">Slash-command Ready <svg aria-hidden="false" width="28" height="28" viewBox="0 0 24 24"><path fill="#777" fill-rule="evenodd" clip-rule="evenodd" d="M5 3C3.89543 3 3 3.89543 3 5V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3H5ZM16.8995 8.41419L15.4853 6.99998L7 15.4853L8.41421 16.8995L16.8995 8.41419Z"></path></svg></p>
<p class="subtitle">Set reminders easily and quickly from anywhere.</p> <p class="subtitle">Set reminders easily and quickly from anywhere</p>
<figure class="image"> <figure class="image">
<img class="rounded-corners" src="/static/img/slash-commands.png" alt="Discord slash commands demonstration"> <img class="rounded-corners" src="/static/img/slash-commands.png" alt="Discord slash commands demonstration">
</figure> </figure>
@ -25,7 +25,7 @@
<div class="tile is-parent"> <div class="tile is-parent">
<article class="tile is-child notification"> <article class="tile is-child notification">
<p class="title">Advanced Options <span class="icon"><i class="fad fa-palette"></i></span></p> <p class="title">Advanced Options <span class="icon"><i class="fad fa-palette"></i></span></p>
<p class="subtitle">Decorate your announcements with our web dashboard.</p> <p class="subtitle">Decorate your announcements with our web dashboard</p>
<figure class="image"> <figure class="image">
<img class="rounded-corners" src="/static/img/tournament-demo.png" alt="Advanced options demonstration"> <img class="rounded-corners" src="/static/img/tournament-demo.png" alt="Advanced options demonstration">
</figure> </figure>
@ -34,62 +34,32 @@
<div class="tile is-parent is-vertical"> <div class="tile is-parent is-vertical">
<article class="tile is-child notification"> <article class="tile is-child notification">
<p class="title">Unlimited Reminders <span class="icon"><i class="far fa-infinity"></i></span></p> <p class="title">Unlimited Reminders <span class="icon"><i class="far fa-infinity"></i></span></p>
<p class="subtitle">Never forget a thing.</p> <p class="subtitle">Never forget a thing</p>
</article> </article>
<article class="tile is-child notification"> <article class="tile is-child notification">
<p class="title">Repeating Reminders <span class="icon"><i class="fas fa-repeat"></i></span></p> <p class="title">Repeating Reminders <span class="icon"><i class="fas fa-repeat"></i></span></p>
<p class="subtitle">Available to <a href="https://patreon.com/jellywx"><span class="patreon-color">Patreon <span class="icon"><i class="fab fa-patreon"></i></span></span></a> subscribers at <strong>$2/month</strong>.</p> <p class="subtitle">Available to <a href="https://patreon.com/jellywx"><span class="patreon-color">Patreon <span class="icon"><i class="fab fa-patreon"></i></span></span></a> subscribers at <strong>$2/month</strong></p>
</article> </article>
</div> </div>
</div> </div>
</div> </div>
<section class="hero is-medium"> <section class="hero is-small">
<div class="hero-body"> <div class="hero-body">
<div class="columns">
<div class="column">
<div class="container has-text-centered">
<p class="title">Technically-minded?</p>
<p class="content">
Install the bot on your own computer
</p>
<a class="button is-size-6 is-rounded is-link" href="https://gitea.jellypro.xyz/jude/reminder-bot">
<p class="is-size-6">
<span>Install</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</div>
<div class="column">
<div class="container has-text-centered"> <div class="container has-text-centered">
<p class="title">Ready to go?</p> <p class="title">Ready to go?</p>
<p class="content"> <p class="content">
Add the bot to get started Add the bot to get started!
</p> </p>
</div>
</div>
<div class="hero-foot has-text-centered">
<a class="button is-size-6 is-rounded is-success" href="https://invite.reminder-bot.com"> <a class="button is-size-6 is-rounded is-success" href="https://invite.reminder-bot.com">
<p class="is-size-6"> <p class="is-size-6">
<span>Add Now</span> <span class="icon"><i class="fas fa-chevron-right"></i></span> Add Now <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p> </p>
</a> </a>
</div> </div>
</div>
<div class="column">
<div class="container has-text-centered">
<p class="title">Need support?</p>
<p class="content">
Check out our guides, or join our Discord
</p>
<a class="button is-size-6 is-rounded is-primary" href="/help">
<p class="is-size-6">
<span>Guides</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</div>
</div>
</div>
</section> </section>
{% endblock %} {% endblock %}

View File

@ -13,7 +13,7 @@
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h2 class="title">Who we are</h2> <h2 class="title">Who we are</h2>
<p> <p class="is-size-5 pl-6">
Reminder Bot is operated solely by Jude Southworth. You can contact me by email at Reminder Bot is operated solely by Jude Southworth. You can contact me by email at
<a href="mailto:jude@jellywx.com">jude@jellywx.com</a>, or via private/public message on Discord at <a href="mailto:jude@jellywx.com">jude@jellywx.com</a>, or via private/public message on Discord at
<a href="https://discord.jellywx.com">https://discord.jellywx.com</a>. <a href="https://discord.jellywx.com">https://discord.jellywx.com</a>.
@ -24,16 +24,12 @@
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h2 class="title">What data we collect</h2> <h2 class="title">What data we collect</h2>
<p> <p class="is-size-5 pl-6">
Reminder Bot stores limited data necessary for the function of the bot. This data Reminder Bot stores limited data necessary for the function of the bot. This data
is your <strong>unique user ID</strong>, <strong>timezone</strong>, and <strong>direct message channel</strong>. is your <strong>unique user ID</strong>, <strong>timezone</strong>, and <strong>direct message channel</strong>.
<br> <br>
<br> <br>
Timezones are provided by the user or the user's browser. Timezones are provided by the user or the user's browser.
<br><br>
Some additional information is collected by the dashboard for the purpose of debugging. This is your
<strong>time spent on the website</strong>, <strong>current URL</strong>, <strong>unique user ID</strong>,
<strong>unique session token</strong>, <strong>contents of any client errors</strong>.
</p> </p>
</div> </div>
</section> </section>
@ -41,12 +37,10 @@
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h2 class="title">Why we collect this data</h2> <h2 class="title">Why we collect this data</h2>
<p> <p class="is-size-5 pl-6">
Unique user IDs are stored to <strong>keep track of who sets reminders</strong>. User timezones are Unique user IDs are stored to <strong>keep track of who sets reminders</strong>. User timezones are
stored to allow users to set reminders in their local timezone. Direct message channels are stored to stored to allow users to set reminders in their local timezone. Direct message channels are stored to
allow the setting of reminders for your direct message channel. allow the setting of reminders for your direct message channel.
<br>
Information collected by the dashboard is for resolving bugs.
</p> </p>
</div> </div>
</section> </section>
@ -54,7 +48,7 @@
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h2 class="title">Who your data is shared with</h2> <h2 class="title">Who your data is shared with</h2>
<p> <p class="is-size-5 pl-6">
Your data is also guarded by the privacy policies of <strong>MEGA</strong>, our backup provider, and Your data is also guarded by the privacy policies of <strong>MEGA</strong>, our backup provider, and
<strong>Hetzner</strong>, our hosting provider. <strong>Hetzner</strong>, our hosting provider.
</p> </p>
@ -64,13 +58,17 @@
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h2 class="title">Accessing or removing your data</h2> <h2 class="title">Accessing or removing your data</h2>
<p> <p class="is-size-5 pl-6">
Your timezone can be removed with the command <strong>/timezone UTC</strong>. Other data can be removed Your timezone can be removed with the command <strong>/timezone UTC</strong>. Other data can be removed
on request. Please contact me. on request. Please contact me.
<br> <br>
<br> <br>
Reminders created in a guild/channel will be removed automatically when the bot is removed from the Reminders created in a guild/channel will be removed automatically when the bot is removed from the
guild, the guild is deleted, or channel is deleted. Data is otherwise not removed automatically. guild, the guild is deleted, or channel is deleted. Data is otherwise not removed automatically.
<br>
<br>
Reminders deleted with <strong>/del</strong> or via the dashboard are removed from the live database
instantly, but may persist in backups for up to a year.
</p> </p>
</div> </div>
</section> </section>

View File

@ -1,17 +0,0 @@
<div class="hero is-fullheight">
<div class="hero-body">
<div class="container has-text-centered">
<p class="title">
We couldn't get this server's data
</p>
<p class="subtitle">
Please check Reminder Bot is in the server, and has correct permissions.
</p>
<a class="button is-size-4 is-rounded is-success" href="https://invite.reminder-bot.com">
<p class="is-size-4">
<span>Add to Server</span> <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</div>
</div>

View File

@ -0,0 +1,251 @@
<div class="columns reminderContent {% if creating %}creator{% endif %}">
<div class="column discord-frame">
<article class="media">
<figure class="media-left">
<p class="image is-32x32 customizable">
<a>
<img class="is-rounded discord-avatar" src="/static/img/bg.webp" alt="Image for discord avatar">
</a>
</p>
</figure>
<div class="media-content">
<div class="content">
<div class="discord-message-header">
<label class="is-sr-only">Username Override</label>
<input class="discord-username message-input" placeholder="Username Override"
maxlength="32" name="username">
</div>
<label class="is-sr-only">Message</label>
<textarea class="message-input autoresize discord-content"
placeholder="Message Content..."
maxlength="2000" name="content" rows="1"></textarea>
<div class="discord-embed">
<div class="embed-body">
<button class="change-color button is-rounded is-small">
<span class="is-sr-only">Choose embed color</span><i class="fas fa-eye-dropper"></i>
</button>
<div class="a">
<div class="embed-author-box">
<div class="a">
<p class="image is-24x24 customizable">
<a>
<img class="is-rounded embed_author_url" src="/static/img/bg.webp" alt="Image for embed author">
</a>
</p>
</div>
<div class="b">
<label class="is-sr-only" for="embedAuthor">Embed Author</label>
<textarea
class="discord-embed-author message-input autoresize"
placeholder="Embed Author..." rows="1" maxlength="256"
name="embed_author"></textarea>
</div>
</div>
<label class="is-sr-only" for="embedTitle">Embed Title</label>
<textarea class="discord-title message-input autoresize"
placeholder="Embed Title..."
maxlength="256" rows="1"
name="embed_title"></textarea>
<br>
<label class="is-sr-only" for="embedDescription">Embed Description</label>
<textarea class="discord-description message-input autoresize "
placeholder="Embed Description..."
maxlength="4096" name="embed_description"
rows="1"></textarea>
<br>
<div class="embed-multifield-box">
<div data-inlined="1" class="embed-field-box">
<label class="is-sr-only" for="embedFieldTitle">Field Title</label>
<div class="is-flex">
<textarea class="discord-field-title field-input message-input autoresize"
placeholder="Field Title..." rows="1"
maxlength="256" name="embed_field_title[]"></textarea>
<button class="button is-small inline-btn">
<span class="is-sr-only">Toggle field inline</span><i class="fas fa-arrows-h"></i>
</button>
</div>
<label class="is-sr-only" for="embedFieldValue">Field Value</label>
<textarea
class="discord-field-value field-input message-input autoresize "
placeholder="Field Value..."
maxlength="1024" name="embed_field_value[]"
rows="1"></textarea>
</div>
</div>
</div>
<div class="b">
<p class="image thumbnail customizable">
<a>
<img class="embed_thumbnail_url" src="/static/img/bg.webp" alt="Square thumbnail embedded image">
</a>
</p>
</div>
</div>
<p class="image is-400x300 customizable">
<a>
<img class="embed_image_url" src="/static/img/bg.webp" alt="Large embedded image">
</a>
</p>
<div class="embed-footer-box">
<p class="image is-20x20 customizable">
<a>
<img class="is-rounded embed_footer_url" src="/static/img/bg.webp" alt="Footer profile-like image">
</a>
</p>
<label class="is-sr-only" for="embedFooter">Embed Footer text</label>
<textarea class="discord-embed-footer message-input autoresize "
placeholder="Embed Footer..."
maxlength="2048" name="embed_footer" rows="1"></textarea>
</div>
</div>
</div>
</div>
</article>
</div>
<div class="column settings">
<div class="columns is-mobile reminder-topbar">
<div class="column">
<div class="field">
<div class="control">
<label class="label sr-only">Reminder Name</label>
<input class="input" type="text" name="name" placeholder="Reminder Name">
</div>
</div>
</div>
<div class="column is-narrow">
<button class="button is-rounded hide-box">
<span class="is-sr-only">Hide reminder</span><i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
<div class="columns">
<div class="column">
<div class="field channel-field">
<div class="collapses">
<label class="label" for="channelOption">Channel*</label>
</div>
<div class="control has-icons-left">
<div class="select">
<select name="channel" class="channel-selector">
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-hashtag"></i>
</div>
</div>
</div>
</div>
<div class="column">
<div class="field">
<div class="control">
<label class="label collapses">
Time*
<input class="input" type="datetime-local" step="1" name="time">
</label>
</div>
</div>
</div>
</div>
<div class="collapses">
<div class="patreon-only">
<div class="field">
<label class="label">Interval <a class="foreground" href="/help/intervals"><i class="fas fa-question-circle"></i></a></label>
<div class="control intervalSelector" style="min-width: 400px;" >
<div class="input interval-group">
<div class="interval-group-left">
<label>
<span class="is-sr-only">Interval months</span>
<input class="w2" type="text" pattern="\d*" name="interval_months" maxlength="2" placeholder=""> <span class="half-rem"></span> months, <span class="half-rem"></span>
</label>
<label>
<span class="is-sr-only">Interval days</span>
<input class="w3" type="text" pattern="\d*" name="interval_days" maxlength="4" placeholder=""> <span class="half-rem"></span> days, <span class="half-rem"></span>
</label>
<label>
<span class="is-sr-only">Interval hours</span>
<input class="w2" type="text" pattern="\d*" name="interval_hours" maxlength="2" placeholder="HH">:
</label>
<label>
<span class="is-sr-only">Interval minutes</span>
<input class="w2" type="text" pattern="\d*" name="interval_minutes" maxlength="2" placeholder="MM">:
</label>
<label>
<span class="is-sr-only">Interval seconds</span>
<input class="w2" type="text" pattern="\d*" name="interval_seconds" maxlength="2" placeholder="SS">
</label>
</div>
<button class="clear"><span class="is-sr-only">Clear interval</span><span class="icon"><i class="fas fa-trash"></i></span></button>
</div>
</div>
</div>
<div class="field">
<div class="control">
<label class="label">
Expiration
<input class="input" type="datetime-local" step="1" name="expiration">
</label>
</div>
</div>
</div>
<div class="columns">
<div class="column has-text-centered">
<div class="is-boxed">
<label class="label">Enable TTS <input type="checkbox" name="tts"></label>
</div>
</div>
<div class="column has-text-centered">
<div class="file is-small is-boxed">
<label class="file-label">
<input class="file-input" type="file" name="attachment">
<span class="file-cta">
<span class="file-label">
Add Attachment
</span>
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
</span>
</label>
</div>
</div>
</div>
<div>
<span class="pad-left"></span>
{% if creating %}
<button class="button is-success" id="createReminder">
<span>Create Reminder</span> <span class="icon"><i class="fas fa-sparkles"></i></span>
</button>
<button class="button is-success is-outlined" id="createTemplate">
<span>Create Template</span> <span class="icon"><i class="fas fa-file-spreadsheet"></i></span>
</button>
<button class="button is-outlined show-modal is-pulled-right" data-modal="chooseTemplateModal">
Load Template
</button>
{% else %}
<button class="button is-success save-btn">
<span>Save</span> <span class="icon"><i class="fas fa-save"></i></span>
</button>
<button class="button is-warning disable-enable">
</button>
<button class="button is-danger delete-reminder">
Delete
</button>
{% endif %}
</div>
</div>
</div>
</div>

View File

@ -2,7 +2,7 @@
<strong>Create Reminder</strong> <strong>Create Reminder</strong>
<div id="reminderCreator"> <div id="reminderCreator">
{% set creating = true %} {% set creating = true %}
{% include "reminder_dashboard/templates/guild_reminder" %} {% include "reminder_dashboard/guild_reminder" %}
{% set creating = false %} {% set creating = false %}
</div> </div>
<br> <br>
@ -46,10 +46,6 @@
<div id="guildReminders"> <div id="guildReminders">
</div> </div>
<div id="guildErrors">
</div>
</div> </div>
<script src="/static/js/sort.js"></script> <script src="/static/js/sort.js"></script>

View File

@ -1,5 +0,0 @@
<div id="reminderLog">
</div>
<script src="/static/js/reminder_errors.js"></script>

View File

@ -1,269 +0,0 @@
<div class="reminderContent {% if creating %}creator{% endif %}">
<div class="columns is-mobile column reminder-topbar">
{% if not creating %}
<div class="invert-collapses channel-bar">
#channel
</div>
{% endif %}
<div class="name-bar">
<div class="field">
<div class="control">
<label class="label sr-only">Reminder Name</label>
<input class="input" type="text" name="name" placeholder="Reminder Name" maxlength="100">
</div>
</div>
</div>
<div class="hide-button-bar">
<button class="button hide-box">
<span class="is-sr-only">Hide reminder</span><i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
<div class="columns reminder-settings">
<div class="column discord-frame">
<article class="media">
<figure class="media-left">
<p class="image is-32x32 customizable">
<a>
<img class="is-rounded avatar" src="/static/img/bg.webp" alt="Image for discord avatar">
</a>
</p>
</figure>
<div class="media-content">
<div class="content">
<div class="discord-message-header">
<label class="is-sr-only">Username Override</label>
<input class="discord-username message-input" placeholder="Username Override"
maxlength="32" name="username">
</div>
<label class="is-sr-only">Message</label>
<textarea class="message-input autoresize discord-content"
placeholder="Message Content..."
maxlength="2000" name="content" rows="1"></textarea>
<div class="discord-embed">
<div class="embed-body">
<button class="change-color button is-rounded is-small">
<span class="is-sr-only">Choose embed color</span><i class="fas fa-eye-dropper"></i>
</button>
<div class="a">
<div class="embed-author-box">
<div class="a">
<p class="image is-24x24 customizable">
<a>
<img class="is-rounded embed_author_url" src="/static/img/bg.webp" alt="Image for embed author">
</a>
</p>
</div>
<div class="b">
<label class="is-sr-only" for="embedAuthor">Embed Author</label>
<textarea
class="discord-embed-author message-input autoresize"
placeholder="Embed Author..." rows="1" maxlength="256"
name="embed_author"></textarea>
</div>
</div>
<label class="is-sr-only" for="embedTitle">Embed Title</label>
<textarea class="discord-title message-input autoresize"
placeholder="Embed Title..."
maxlength="256" rows="1"
name="embed_title"></textarea>
<br>
<label class="is-sr-only" for="embedDescription">Embed Description</label>
<textarea class="discord-description message-input autoresize "
placeholder="Embed Description..."
maxlength="4096" name="embed_description"
rows="1"></textarea>
<br>
<div class="embed-multifield-box">
<div data-inlined="1" class="embed-field-box">
<label class="is-sr-only" for="embedFieldTitle">Field Title</label>
<div class="is-flex">
<textarea class="discord-field-title field-input message-input autoresize"
placeholder="Field Title..." rows="1"
maxlength="256" name="embed_field_title[]"></textarea>
<button class="button is-small inline-btn">
<span class="is-sr-only">Toggle field inline</span><i class="fas fa-arrows-h"></i>
</button>
</div>
<label class="is-sr-only" for="embedFieldValue">Field Value</label>
<textarea
class="discord-field-value field-input message-input autoresize "
placeholder="Field Value..."
maxlength="1024" name="embed_field_value[]"
rows="1"></textarea>
</div>
</div>
</div>
<div class="b">
<p class="image thumbnail customizable">
<a>
<img class="embed_thumbnail_url" src="/static/img/bg.webp" alt="Square thumbnail embedded image">
</a>
</p>
</div>
</div>
<p class="image is-400x300 customizable">
<a>
<img class="embed_image_url" src="/static/img/bg.webp" alt="Large embedded image">
</a>
</p>
<div class="embed-footer-box">
<p class="image is-20x20 customizable">
<a>
<img class="is-rounded embed_footer_url" src="/static/img/bg.webp" alt="Footer profile-like image">
</a>
</p>
<label class="is-sr-only" for="embedFooter">Embed Footer text</label>
<textarea class="discord-embed-footer message-input autoresize "
placeholder="Embed Footer..."
maxlength="2048" name="embed_footer" rows="1"></textarea>
</div>
</div>
</div>
</div>
</article>
</div>
<div class="column settings">
<div class="field channel-field">
<div class="collapses">
<label class="label" for="channelOption">Channel*</label>
</div>
<div class="control has-icons-left">
<div class="select">
<select name="channel" class="channel-selector">
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-hashtag"></i>
</div>
</div>
</div>
<div class="field">
<div class="control">
<label class="label collapses">
Time*
<input class="input prefill-now" type="datetime-local" step="1" name="time">
</label>
</div>
</div>
<div class="collapses split-controls">
<div>
<div class="patreon-only">
<div class="patreon-invert foreground">
Intervals available on <a href="https://patreon.com/jellywx">Patreon</a> or <a href="https://gitea.jellypro.xyz/jude/reminder-bot">self-hosting</a>
</div>
<div class="field">
<label class="label">Interval <a class="foreground" href="/help/intervals"><i class="fas fa-question-circle"></i></a></label>
<div class="control intervalSelector">
<div class="input interval-group">
<div class="interval-group-left">
<span class="no-break">
<label>
<span class="is-sr-only">Interval months</span>
<input class="w2" type="text" pattern="\d*" name="interval_months" maxlength="2" placeholder=""> <span class="half-rem"></span> months, <span class="half-rem"></span>
</label>
<label>
<span class="is-sr-only">Interval days</span>
<input class="w3" type="text" pattern="\d*" name="interval_days" maxlength="4" placeholder=""> <span class="half-rem"></span> days, <span class="half-rem"></span>
</label>
</span>
<span class="no-break">
<label>
<span class="is-sr-only">Interval hours</span>
<input class="w2" type="text" pattern="\d*" name="interval_hours" maxlength="2" placeholder="HH">:
</label>
<label>
<span class="is-sr-only">Interval minutes</span>
<input class="w2" type="text" pattern="\d*" name="interval_minutes" maxlength="2" placeholder="MM">:
</label>
<label>
<span class="is-sr-only">Interval seconds</span>
<input class="w2" type="text" pattern="\d*" name="interval_seconds" maxlength="2" placeholder="SS">
</label>
</span>
</div>
<button class="clear"><span class="is-sr-only">Clear interval</span><span class="icon"><i class="fas fa-trash"></i></span></button>
</div>
</div>
</div>
<div class="field">
<div class="control">
<label class="label">
Expiration
<input class="input" type="datetime-local" step="1" name="expiration">
</label>
</div>
</div>
</div>
<div class="columns is-mobile tts-row">
<div class="column has-text-centered">
<div class="is-boxed">
<label class="label">Enable TTS <input type="checkbox" name="tts"></label>
</div>
</div>
<div class="column has-text-centered">
<div class="file is-small is-boxed">
<label class="file-label">
<input class="file-input" type="file" name="attachment">
<span class="file-cta">
<span class="file-label">
Add Attachment
</span>
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% if creating %}
<div class="button-row">
<div class="button-row-reminder">
<button class="button is-success" id="createReminder">
<span>Create Reminder</span> <span class="icon"><i class="fas fa-sparkles"></i></span>
</button>
</div>
<div class="button-row-template">
<div>
<button class="button is-success is-outlined" id="createTemplate">
<span>Create Template</span> <span class="icon"><i class="fas fa-file-spreadsheet"></i></span>
</button>
</div>
<div>
<button class="button is-outlined show-modal is-pulled-right" data-modal="chooseTemplateModal">
Load Template
</button>
</div>
</div>
</div>
{% else %}
<div class="button-row-edit">
<button class="button is-success save-btn">
<span>Save</span> <span class="icon"><i class="fas fa-save"></i></span>
</button>
<button class="button is-warning disable-enable">
</button>
<button class="button is-danger delete-reminder">
Delete
</button>
</div>
{% endif %}
</div>

View File

@ -1,20 +0,0 @@
<div class="reminderError" data-case="success">
<div class="errorHead">
<div class="errorIcon">
<span class="icon">
<i class="fas fa-trash"></i>
<i class="fas fa-check"></i>
<i class="fas fa-exclamation-triangle"></i>
</span>
</div>
<div class="reminderName">
Reminder
</div>
<div class="reminderMessage">
</div>
<div class="reminderTime">
</div>
</div>
</div>

View File

@ -1,12 +0,0 @@
<div class="hero is-fullheight">
<div class="hero-body">
<div class="container has-text-centered">
<p class="title">
You do not have permissions for this server
</p>
<p class="subtitle">
Ask an admin to grant you the "Manage Messages" permission.
</p>
</div>
</div>
</div>

View File

@ -28,10 +28,7 @@
<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. The dashboard offers more options for configuring Reminders can also be created on the dashboard.
reminders, and offers templates for quick recreation of reminders.
<a href="/dashboard">Access the dashboard.</a>
</p> </p>
</div> </div>
</div> </div>

View File

@ -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 data</p> <p class="title">Export your 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,7 +26,8 @@
<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, the new data will be added alongside existing data. You can import previous exports or modified exports. When importing a file, <strong>existing data
will be overwritten</strong>.
</p> </p>
</div> </div>
</div> </div>
@ -54,7 +55,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 top-most (title) row. Make edits to the spreadsheet. You can add, edit, and remove rows for reminders. Don't remove the 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>
@ -69,7 +70,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">

View File

@ -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 calendar events. for marking certain dates.
</p> </p>
</div> </div>
</div> </div>
@ -61,8 +61,7 @@
<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. Otherwise, the reminder This is optional, and if omitted, the reminder will repeat indefinitely.
will be deleted once the expiration date is reached.
</p> </p>
</div> </div>
</div> </div>

View File

@ -13,8 +13,8 @@
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h2 class="title">Outline</h2> <h2 class="title">Outline</h2>
<p class=""> <p class="is-size-5 pl-6">
The Terms of Service apply whenever you use the hosted edition of <strong>Reminder Bot</strong> and the The Terms of Service apply whenever you use <strong>Reminder Bot</strong> and the
<strong>JellyWX's Home</strong> Discord server. <strong>JellyWX's Home</strong> Discord server.
<br> <br>
<br> <br>
@ -25,7 +25,7 @@
<br> <br>
<br> <br>
The Terms of Service may be updated. Notice will be provided via the Discord server. You The Terms of Service may be updated. Notice will be provided via the Discord server. You
should consider the Terms of Service to be a guide for appropriate behaviour. should consider the Terms of Service to be a strong for appropriate behaviour.
</p> </p>
</div> </div>
</section> </section>
@ -33,43 +33,32 @@
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h2 class="title">Reminder Bot</h2> <h2 class="title">Reminder Bot</h2>
<p> <ul class="is-size-5 pl-6">
The Terms of Service <strong>do not</strong> apply to self-hosting users who are using the source code <li>Reasonably disclose potential exploits or bugs to me by email or by Discord private message</li>
or pre-packaged Debian files to run their own instance of Reminder Bot. <li>Do not use the bot to harass other Discord users</li>
</p> <li>Do not use the bot to transmit malware or other illegal content</li>
<br> <li>Do not use the bot to send more than 15 messages during a 60 second period</li>
<h3 class="subtitle">Your access to Reminder Bot may be restricted if you:</h3>
<ul class="pl-6" style="list-style: disc">
<li>Abuse exploits or bugs in Reminder Bot.</li>
<li>Use the bot to harass other Discord users.</li>
<li>Use the bot to transmit malware or other illegal content.</li>
<li>Use the bot to send more than 15 messages during a 60 second period.</li>
<li> <li>
Attempt to circumvent restrictions imposed by the bot or website, including trying to access Do not attempt to circumvent restrictions imposed by the bot or website, including trying to access
data of other users, circumvent Patreon restrictions, or uploading files and creating reminders that data of other users, circumvent Patreon restrictions, or uploading files and creating reminders that
are too large for the bot to send or process. are too large for the bot to send or process. Some or all of these actions may be illegal in your
country
</li> </li>
</ul> </ul>
<br>
<p>
Some or all of these actions may be illegal in your country.
</p>
</div> </div>
</section> </section>
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h2 class="title">JellyWX's Home</h2> <h2 class="title">JellyWX's Home</h2>
<h3 class="subtitle">Your access to the JellyWX's Home Discord server may be restricted if you:</h3> <ul class="is-size-5 pl-6">
<ul class="pl-6" style="list-style: disc"> <li>Do not discuss politics, harass other users, or use language intended to upset other users</li>
<li>Discuss politics, harass other users, or use language intended to upset other users.</li> <li>Do not share personal information about yourself or any other user. This includes but is not
<li>Abuse any exploits.</li>
<li>Share personal information about yourself or any other user. This includes but is not
limited to real names<sup>1</sup>, addresses, phone numbers, country of origin<sup>2</sup>, religion, email address, limited to real names<sup>1</sup>, addresses, phone numbers, country of origin<sup>2</sup>, religion, email address,
IP address.</li> IP address.</li>
<li>Send malicious links or attachments.</li> <li>Do not send malicious links or attachments</li>
<li>Advertise without permission.</li> <li>Do not advertise</li>
<li>Send unwarranted direct messages.</li> <li>Do not send unwarranted direct messages</li>
</ul> </ul>
<p class="small"> <p class="small">
<sup>1</sup> Some users may use their real name on their account. In this case, do not assert that <sup>1</sup> Some users may use their real name on their account. In this case, do not assert that