1 Commits

Author SHA1 Message Date
2d1668a63a new global models 2022-05-20 21:13:15 +01:00
410 changed files with 11793 additions and 89927 deletions

31
.gitignore vendored
View File

@ -1,30 +1,7 @@
target
/target
.env
/venv
.cargo
.idea
web/static/index.html
web/static/assets
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
assets
out.json
/.idea

3359
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,73 +1,32 @@
[package]
name = "reminder-rs"
version = "1.7.4"
authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021"
license = "AGPL-3.0 only"
description = "Reminder Bot for Discord, now in Rust"
name = "reminder_rs"
version = "1.6.0"
authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018"
workspaces = [".", "postman", "web", "entity", "migration"]
[dependencies]
poise = "0.6.1"
poise = "0.2"
dotenv = "0.15"
tokio = { version = "1", features = ["process", "full"] }
reqwest = { version = "0.12", features = ["json"] }
regex = "1.10"
reqwest = "0.11"
regex = "1.4"
log = "0.4"
env_logger = "0.11"
env_logger = "0.8"
chrono = "0.4"
chrono-tz = { version = "0.8", features = ["serde"] }
chrono-tz = { version = "0.5", features = ["serde"] }
lazy_static = "1.4"
num-integer = "0.1"
serde = "1.0"
serde_json = "1.0"
serde_repr = "0.1"
rmp-serde = "1.1"
rand = "0.8"
rmp-serde = "0.15"
rand = "0.7"
levenshtein = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "migrate"] }
base64 = "0.21"
secrecy = "0.8.0"
futures = "0.3.30"
prometheus = "0.13.3"
rocket = { version = "0.5.0", features = ["tls", "secrets", "json"] }
rocket_dyn_templates = { version = "0.1.0", features = ["tera"] }
serenity = { version = "0.12", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
oauth2 = "4"
csv = "1.2"
axum = "0.7"
base64 = "0.13.0"
[dependencies.extract_derive]
path = "extract_derive"
[dependencies.postman]
path = "postman"
[dependencies.recordable_derive]
path = "recordable_derive"
[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"],
["static/css/*", "lib/reminder-rs/static/css", "644"],
["static/favicon/*", "lib/reminder-rs/static/favicon", "644"],
["static/img/*", "lib/reminder-rs/static/img", "644"],
["static/js/*", "lib/reminder-rs/static/js", "644"],
["static/webfonts/*", "lib/reminder-rs/static/webfonts", "644"],
["static/site.webmanifest", "lib/reminder-rs/static/site.webmanifest", "644"],
["templates/**/*", "lib/reminder-rs/templates", "644"],
["reminder-dashboard/dist/static/assets/*", "lib/reminder-rs/static/assets", "644"],
["reminder-dashboard/dist/index.html", "lib/reminder-rs/static/index.html", "644"],
["conf/default.env", "etc/reminder-rs/config.env", "600"],
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
["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
[dependencies.reminder_web]
path = "web"

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 npm
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)
### 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`.
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`
3. Install SQLx CLI: `cargo install sqlx-cli`
4. From the source code directory, execute `sqlx migrate run`
5. Build container image: `podman build -t reminder-rs .`
6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
### Compiling for other target
1. Install requirements:
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser`
2. Install rustup from https://rustup.rs
3. Install the nightly toolchain: `rustup toolchain default nightly`
4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`.
5. Install `sqlx-cli`: `cargo install sqlx-cli`.
6. Run migrations: `sqlx migrate run`.
7. Set environment variables:
#### Compilation environment variables
These environment variables must be provided when compiling the bot
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
8. Build: `cargo build --release`
* `WEBHOOK_AVATAR` - accepts the name of an image file located in `$CARGO_MANIFEST_DIR/assets/` to be used as the avatar when creating webhooks. **IMPORTANT: image file must be 128x128 or smaller in size**
### Setting up Python
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
### Configuring
### Environment Variables
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
__Required Variables__
@ -48,5 +37,10 @@ __Other Variables__
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
* `PYTHON_LOCATION` - default `/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
* `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages
### Todo List
* Convert aliases to macros

View File

@ -1,28 +1,28 @@
[default]
address = "0.0.0.0"
port = 18920
template_dir = "templates"
port = 5000
template_dir = "web/templates"
limits = { json = "10MiB" }
[debug]
secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="
[debug.tls]
certs = "private/rsa_sha256_cert.pem"
key = "private/rsa_sha256_key.pem"
certs = "web/private/rsa_sha256_cert.pem"
key = "web/private/rsa_sha256_key.pem"
[debug.rsa_sha256.tls]
certs = "private/rsa_sha256_cert.pem"
key = "private/rsa_sha256_key.pem"
[rsa_sha256.tls]
certs = "web/private/rsa_sha256_cert.pem"
key = "web/private/rsa_sha256_key.pem"
[debug.ecdsa_nistp256_sha256.tls]
certs = "private/ecdsa_nistp256_sha256_cert.pem"
key = "private/ecdsa_nistp256_sha256_key_pkcs8.pem"
[ecdsa_nistp256_sha256.tls]
certs = "web/private/ecdsa_nistp256_sha256_cert.pem"
key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem"
[debug.ecdsa_nistp384_sha384.tls]
certs = "private/ecdsa_nistp384_sha384_cert.pem"
key = "private/ecdsa_nistp384_sha384_key_pkcs8.pem"
[ecdsa_nistp384_sha384.tls]
certs = "web/private/ecdsa_nistp384_sha384_cert.pem"
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
[debug.ed25519.tls]
certs = "private/ed25519_cert.pem"
key = "private/ed25519_key.pem"
[ed25519.tls]
certs = "web/private/ed25519_cert.pem"
key = "eb/private/ed25519_key.pem"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1,13 +0,0 @@
use std::{path::Path, process::Command};
fn main() {
println!("cargo:rerun-if-changed=migrations");
println!("cargo:rerun-if-changed=reminder-dashboard");
Command::new("npm")
.arg("run")
.arg("build")
.current_dir(Path::new("reminder-dashboard"))
.spawn()
.expect("Failed to build NPM");
}

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,46 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "extract_macro"
version = "0.1.0"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915aea9e586f80826ee59f8453c1101f9d1c4b3964cd2460185ee8e299ada496"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"

View File

@ -1,11 +0,0 @@
[package]
name = "extract_derive"
version = "0.1.0"
edition = "2021"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.35"
syn = { version = "2.0.49", features = ["full"] }

View File

@ -1,53 +0,0 @@
use proc_macro::TokenStream;
use syn::{spanned::Spanned, Data, Fields};
#[proc_macro_derive(Extract)]
pub fn extract(input: TokenStream) -> TokenStream {
let ast = syn::parse_macro_input!(input);
impl_extract(&ast)
}
fn impl_extract(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
match &ast.data {
// Dispatch over struct: extract args directly from context
Data::Struct(st) => match &st.fields {
Fields::Named(fields) => {
let extracted = fields.named.iter().map(|field| {
let ident = &field.ident;
let ty = &field.ty;
quote::quote_spanned! {field.span()=>
#ident : crate::utils::extract_arg!(ctx, #ident, #ty)
}
});
TokenStream::from(quote::quote! {
impl Extract for #name {
fn extract(ctx: crate::ApplicationContext) -> Self {
Self {
#(#extracted,)*
}
}
}
})
}
Fields::Unit => TokenStream::from(quote::quote! {
impl Extract for #name {
fn extract(ctx: crate::ApplicationContext) -> Self {
Self {}
}
}
}),
_ => {
panic!("Only named/unit structs can derive Extract");
}
},
_ => {
panic!("Only structs can derive Extract");
}
}
}

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,229 +0,0 @@
SET FOREIGN_KEY_CHECKS=0;
CREATE TABLE guilds (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
guild BIGINT UNSIGNED UNIQUE NOT NULL,
name VARCHAR(100),
prefix VARCHAR(5) DEFAULT '$' NOT NULL,
timezone VARCHAR(32) DEFAULT 'UTC' NOT NULL,
default_channel_id INT UNSIGNED,
default_username VARCHAR(32) DEFAULT 'Reminder' NOT NULL,
default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (default_channel_id) REFERENCES channels(id) ON DELETE SET NULL
);
CREATE TABLE channels (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
channel BIGINT UNSIGNED UNIQUE NOT NULL,
name VARCHAR(100),
nudge SMALLINT NOT NULL DEFAULT 0,
blacklisted BOOL NOT NULL DEFAULT FALSE,
webhook_id BIGINT UNSIGNED UNIQUE,
webhook_token TEXT,
paused BOOL NOT NULL DEFAULT 0,
paused_until TIMESTAMP,
guild_id INT UNSIGNED,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
);
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
user BIGINT UNSIGNED UNIQUE NOT NULL,
name VARCHAR(37) NOT NULL,
dm_channel INT UNSIGNED UNIQUE NOT NULL,
language VARCHAR(2) DEFAULT 'EN' NOT NULL,
timezone VARCHAR(32) DEFAULT 'UTC' NOT NULL,
meridian_time BOOLEAN DEFAULT 0 NOT NULL,
allowed_dm BOOLEAN DEFAULT 1 NOT NULL,
patreon BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id),
FOREIGN KEY (dm_channel) REFERENCES channels(id) ON DELETE RESTRICT
);
CREATE TABLE roles (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
role BIGINT UNSIGNED UNIQUE NOT NULL,
name VARCHAR(100),
guild_id INT UNSIGNED NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE
);
CREATE TABLE embeds (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '',
description VARCHAR(2048) NOT NULL DEFAULT '',
image_url VARCHAR(512),
thumbnail_url VARCHAR(512),
footer VARCHAR(2048) NOT NULL DEFAULT '',
footer_icon VARCHAR(512),
color MEDIUMINT UNSIGNED NOT NULL DEFAULT 0x0,
PRIMARY KEY (id)
);
CREATE TABLE embed_fields (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '',
value VARCHAR(1024) NOT NULL DEFAULT '',
inline BOOL NOT NULL DEFAULT 0,
embed_id INT UNSIGNED NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE CASCADE
);
CREATE TABLE messages (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
content VARCHAR(2048) NOT NULL DEFAULT '',
tts BOOL NOT NULL DEFAULT 0,
embed_id INT UNSIGNED,
attachment MEDIUMBLOB,
attachment_name VARCHAR(260),
PRIMARY KEY (id),
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE SET NULL
);
CREATE TABLE reminders (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
uid VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(24) NOT NULL DEFAULT 'Reminder',
message_id INT UNSIGNED NOT NULL,
channel_id INT UNSIGNED NOT NULL,
`time` INT UNSIGNED DEFAULT 0 NOT NULL,
`interval` INT UNSIGNED DEFAULT NULL,
expires TIMESTAMP DEFAULT NULL,
enabled BOOLEAN DEFAULT 1 NOT NULL,
avatar VARCHAR(512),
username VARCHAR(32),
method ENUM('remind', 'natural', 'dashboard', 'todo', 'countdown'),
set_at TIMESTAMP DEFAULT NOW(),
set_by INT UNSIGNED,
PRIMARY KEY (id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE RESTRICT,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
FOREIGN KEY (set_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TRIGGER message_cleanup AFTER DELETE ON reminders
FOR EACH ROW
DELETE FROM messages WHERE id = OLD.message_id;
CREATE TRIGGER embed_cleanup AFTER DELETE ON messages
FOR EACH ROW
DELETE FROM embeds WHERE id = OLD.embed_id;
CREATE TABLE todos (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
user_id INT UNSIGNED,
guild_id INT UNSIGNED,
channel_id INT UNSIGNED,
value VARCHAR(2000) NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL
);
CREATE TABLE command_restrictions (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
role_id INT UNSIGNED NOT NULL,
command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
UNIQUE KEY (`role_id`, `command`)
);
CREATE TABLE timers (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
start_time TIMESTAMP NOT NULL DEFAULT NOW(),
name VARCHAR(32) NOT NULL,
owner BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE events (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
`time` TIMESTAMP NOT NULL DEFAULT NOW(),
event_name ENUM('edit', 'enable', 'disable', 'delete') NOT NULL,
bulk_count INT UNSIGNED,
guild_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED,
reminder_id INT UNSIGNED,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE SET NULL
);
CREATE TABLE command_aliases (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
guild_id INT UNSIGNED NOT NULL,
name VARCHAR(12) NOT NULL,
command VARCHAR(2048) NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
UNIQUE KEY (`guild_id`, `name`)
);
CREATE TABLE guild_users (
guild INT UNSIGNED NOT NULL,
user INT UNSIGNED NOT NULL,
can_access BOOL NOT NULL DEFAULT 0,
FOREIGN KEY (guild) REFERENCES guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY (guild, user)
);
CREATE EVENT event_cleanup
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
ON COMPLETION PRESERVE
DO DELETE FROM events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);

File diff suppressed because one or more lines are too long

View File

@ -1,11 +0,0 @@
CREATE TABLE macro (
id INT UNSIGNED AUTO_INCREMENT,
guild_id INT UNSIGNED NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(100),
commands TEXT NOT NULL,
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
PRIMARY KEY (id)
);

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,49 +0,0 @@
CREATE TABLE reminder_template (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(24) NOT NULL DEFAULT 'Reminder',
`guild_id` INT UNSIGNED NOT NULL,
`username` VARCHAR(32) DEFAULT NULL,
`avatar` VARCHAR(512) DEFAULT NULL,
`content` VARCHAR(2048) NOT NULL DEFAULT '',
`tts` BOOL NOT NULL DEFAULT 0,
`attachment` MEDIUMBLOB,
`attachment_name` VARCHAR(260),
`embed_title` VARCHAR(256) NOT NULL DEFAULT '',
`embed_description` VARCHAR(2048) NOT NULL DEFAULT '',
`embed_image_url` VARCHAR(512),
`embed_thumbnail_url` VARCHAR(512),
`embed_footer` VARCHAR(2048) NOT NULL DEFAULT '',
`embed_footer_url` VARCHAR(512),
`embed_author` VARCHAR(256) NOT NULL DEFAULT '',
`embed_author_url` VARCHAR(512),
`embed_color` INT UNSIGNED NOT NULL DEFAULT 0x0,
`embed_fields` JSON,
PRIMARY KEY (id),
FOREIGN KEY (`guild_id`) REFERENCES guilds (`id`) ON DELETE CASCADE
);
ALTER TABLE reminders ADD COLUMN embed_fields JSON;
update reminders
inner join embed_fields as E
on E.reminder_id = reminders.id
set embed_fields = (
select JSON_ARRAYAGG(
JSON_OBJECT(
'title', E.title,
'value', E.value,
'inline',
if(inline = 1, cast(TRUE as json), cast(FALSE as json))
)
)
from embed_fields
group by reminder_id
having reminder_id = reminders.id
);

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,3 +0,0 @@
ALTER TABLE `reminder_template` ADD COLUMN `interval_seconds` INT UNSIGNED;
ALTER TABLE `reminder_template` ADD COLUMN `interval_days` INT UNSIGNED;
ALTER TABLE `reminder_template` ADD COLUMN `interval_months` INT UNSIGNED;

View File

@ -1,50 +0,0 @@
CREATE TABLE command_macro (
id INT UNSIGNED AUTO_INCREMENT,
guild_id INT UNSIGNED NOT NULL,
name VARCHAR(100) NOT NULL,
description VARCHAR(100),
commands JSON NOT NULL,
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
PRIMARY KEY (id)
);
# New JSON structure is {command_name: "Remind", "<option name>": "<option value>", ...}
INSERT INTO command_macro (guild_id, description, name, commands)
SELECT
guild_id,
description,
name,
(
SELECT JSON_ARRAYAGG(
(
SELECT JSON_OBJECTAGG(t2.name, t2.value)
FROM JSON_TABLE(
JSON_ARRAY_APPEND(t1.options, '$', JSON_OBJECT('name', 'command_name', 'value', t1.command_name)),
'$[*]' COLUMNS (
name VARCHAR(64) PATH '$.name' ERROR ON ERROR,
value TEXT PATH '$.value' ERROR ON ERROR
)) AS t2
)
)
FROM macro m2
JOIN JSON_TABLE(
commands,
'$[*]' COLUMNS (
command_name VARCHAR(64) PATH '$.command_name' ERROR ON ERROR,
options JSON PATH '$.options' ERROR ON ERROR
)) AS t1
WHERE m1.id = m2.id
)
FROM macro m1;
# # Check which commands are used in macros
# SELECT DISTINCT command_name
# FROM macro m2
# JOIN JSON_TABLE(
# commands,
# '$[*]' COLUMNS (
# command_name VARCHAR(64) PATH '$.command_name' ERROR ON ERROR,
# options JSON PATH '$.options' ERROR ON ERROR
# )) AS t1

View File

@ -1,5 +0,0 @@
-- Add migration script here
ALTER TABLE reminders
ADD INDEX `utc_time_index` (`utc_time`);
ALTER TABLE reminders
ADD INDEX `status_index` (`status`);

7
models/Cargo.lock generated Normal file
View File

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "models"
version = "0.1.0"

8
models/Cargo.toml Normal file
View File

@ -0,0 +1,8 @@
[package]
name = "models"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]

8
models/entity/Cargo.toml Normal file
View File

@ -0,0 +1,8 @@
[package]
name = "entity"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono-tz = "^0.6"
sea-orm = { version = "^0.8", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] }

View File

@ -0,0 +1,60 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "channel")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: i64,
pub guild_id: Option<i64>,
pub nudge: i32,
pub webhook_id: Option<i64>,
pub webhook_token: Option<String>,
pub paused: bool,
pub paused_until: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild,
#[sea_orm(has_many = "super::user::Entity")]
User,
#[sea_orm(has_many = "super::reminder::Entity")]
Reminder,
#[sea_orm(has_many = "super::todo::Entity")]
Todo,
}
impl Related<super::guild::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guild.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::reminder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Reminder.def()
}
}
impl Related<super::todo::Entity> for Entity {
fn to() -> RelationDef {
Relation::Todo.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,34 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "command_macro")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub guild_id: i64,
pub name: String,
pub description: Option<String>,
pub commands: Option<Json>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild,
}
impl Related<super::guild::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guild.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,48 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "guild")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::reminder_template::Entity")]
ReminderTemplate,
#[sea_orm(has_many = "super::channel::Entity")]
Channel,
#[sea_orm(has_many = "super::todo::Entity")]
Todo,
#[sea_orm(has_many = "super::command_macro::Entity")]
CommandMacro,
}
impl Related<super::reminder_template::Entity> for Entity {
fn to() -> RelationDef {
Relation::ReminderTemplate.def()
}
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::todo::Entity> for Entity {
fn to() -> RelationDef {
Relation::Todo.def()
}
}
impl Related<super::command_macro::Entity> for Entity {
fn to() -> RelationDef {
Relation::CommandMacro.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

1
models/entity/src/lib.rs Normal file
View File

@ -0,0 +1 @@

14
models/entity/src/mod.rs Normal file
View File

@ -0,0 +1,14 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
pub mod prelude;
pub mod channel;
pub mod command_macro;
pub mod guild;
pub mod reminder;
pub mod reminder_template;
pub mod sea_orm_active_enums;
pub mod seaql_migrations;
pub mod timer;
pub mod todo;
pub mod user;

View File

@ -0,0 +1,8 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
pub use super::{
channel::Entity as Channel, command_macro::Entity as CommandMacro, guild::Entity as Guild,
reminder::Entity as Reminder, reminder_template::Entity as ReminderTemplate,
seaql_migrations::Entity as SeaqlMigrations, timer::Entity as Timer, todo::Entity as Todo,
user::Entity as User,
};

View File

@ -0,0 +1,73 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
use super::sea_orm_active_enums::Timezone;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "reminder")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub uid: String,
pub name: String,
pub channel_id: i64,
pub utc_time: DateTimeUtc,
pub timezone: Timezone,
pub interval_seconds: Option<i32>,
pub interval_months: Option<i32>,
pub enabled: bool,
pub expires: Option<DateTimeUtc>,
pub username: Option<String>,
pub avatar: Option<String>,
pub content: Option<String>,
pub tts: bool,
pub attachment: Option<Vec<u8>>,
pub attachment_name: Option<String>,
pub embed_title: Option<String>,
pub embed_description: Option<String>,
pub embed_image_url: Option<String>,
pub embed_thumbnail_url: Option<String>,
pub embed_footer: Option<String>,
pub embed_footer_url: Option<String>,
pub embed_author: Option<String>,
pub embed_author_url: Option<String>,
pub embed_color: Option<i32>,
pub embed_fields: Option<Json>,
pub set_at: DateTimeUtc,
pub set_by: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Channel,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::SetBy",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,48 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "reminder_template")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub guild_id: i64,
pub name: String,
pub username: Option<String>,
pub avatar: Option<String>,
pub content: Option<String>,
pub tts: bool,
pub attachment: Option<Vec<u8>>,
pub attachment_name: Option<String>,
pub embed_title: Option<String>,
pub embed_description: Option<String>,
pub embed_image_url: Option<String>,
pub embed_thumbnail_url: Option<String>,
pub embed_footer: Option<String>,
pub embed_footer_url: Option<String>,
pub embed_author: Option<String>,
pub embed_author_url: Option<String>,
pub embed_color: Option<i32>,
pub embed_fields: Option<Json>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild,
}
impl Related<super::guild::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guild.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "seaql_migrations")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub version: String,
pub applied_at: i64,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
panic!("No RelationDef")
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,36 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "timer")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub start_time: DateTimeUtc,
pub name: String,
pub user_id: Option<i64>,
pub guild_id: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild2,
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::UserId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild1,
}
impl ActiveModelBehavior for ActiveModel {}

62
models/entity/src/todo.rs Normal file
View File

@ -0,0 +1,62 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "todo")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: Option<i64>,
pub guild_id: Option<i64>,
pub channel_id: Option<i64>,
pub value: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Channel,
#[sea_orm(
belongs_to = "super::guild::Entity",
from = "Column::GuildId",
to = "super::guild::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Guild,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::guild::Entity> for Entity {
fn to() -> RelationDef {
Relation::Guild.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

50
models/entity/src/user.rs Normal file
View File

@ -0,0 +1,50 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.8.0
use sea_orm::entity::prelude::*;
use super::sea_orm_active_enums::Timezone;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: i64,
pub dm_channel: i64,
pub timezone: Timezone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::DmChannel",
to = "super::channel::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Channel,
#[sea_orm(has_many = "super::reminder::Entity")]
Reminder,
#[sea_orm(has_many = "super::todo::Entity")]
Todo,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::reminder::Entity> for Entity {
fn to() -> RelationDef {
Relation::Reminder.def()
}
}
impl Related<super::todo::Entity> for Entity {
fn to() -> RelationDef {
Relation::Todo.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

2400
models/migration/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
entity = { path = "../entity" }
chrono-tz = "^0.6"
[dependencies.sea-orm-migration]
version = "^0.8.0"

View File

@ -0,0 +1,37 @@
# Running Migrator CLI
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

View File

@ -0,0 +1,12 @@
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20220101_000001_create_table::Migration)]
}
}

View File

@ -0,0 +1,553 @@
use chrono_tz::{Tz, TZ_VARIANTS};
use sea_orm_migration::prelude::*;
use crate::extension::postgres::Type;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20220101_000001_create_table"
}
}
#[derive(Iden)]
pub enum Guild {
Table,
Id,
}
#[derive(Iden)]
pub enum Channel {
Table,
Id,
GuildId,
Nudge,
WebhookId,
WebhookToken,
Paused,
PausedUntil,
}
#[derive(Iden)]
pub enum User {
Table,
Id,
DmChannel,
Timezone,
}
#[derive(Iden)]
pub enum Reminder {
Table,
Id,
Uid,
Name,
ChannelId,
UtcTime,
Timezone,
IntervalSeconds,
IntervalMonths,
Enabled,
Expires,
Username,
Avatar,
Content,
Tts,
Attachment,
AttachmentName,
EmbedTitle,
EmbedDescription,
EmbedImageUrl,
EmbedThumbnailUrl,
EmbedFooter,
EmbedFooterUrl,
EmbedAuthor,
EmbedAuthorUrl,
EmbedColor,
EmbedFields,
SetAt,
SetBy,
}
#[derive(Iden)]
pub enum ReminderTemplate {
Table,
Id,
GuildId,
Name,
Username,
Avatar,
Content,
Tts,
Attachment,
AttachmentName,
EmbedTitle,
EmbedDescription,
EmbedImageUrl,
EmbedThumbnailUrl,
EmbedFooter,
EmbedFooterUrl,
EmbedAuthor,
EmbedAuthorUrl,
EmbedColor,
EmbedFields,
}
#[derive(Iden)]
pub enum Timer {
Table,
Id,
StartTime,
Name,
UserId,
GuildId,
}
#[derive(Iden)]
pub enum Todo {
Table,
Id,
UserId,
GuildId,
ChannelId,
Value,
}
#[derive(Iden)]
pub enum CommandMacro {
Table,
Id,
GuildId,
Name,
Description,
Commands,
}
pub enum Timezone {
Type,
Tz(Tz),
}
impl Iden for Timezone {
fn unquoted(&self, s: &mut dyn Write) {
write!(
s,
"{}",
match self {
Self::Type => "timezone".to_string(),
Self::Tz(tz) => tz.to_string(),
}
)
.unwrap();
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_type(
Type::create()
.as_enum(Timezone::Type)
.values(TZ_VARIANTS.iter().map(|tz| Timezone::Tz(tz.to_owned())))
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Guild::Table)
.if_not_exists()
.col(ColumnDef::new(Guild::Id).big_integer().not_null().primary_key())
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Channel::Table)
.if_not_exists()
.col(ColumnDef::new(Channel::Id).big_integer().not_null().primary_key())
.col(ColumnDef::new(Channel::GuildId).big_integer())
.col(ColumnDef::new(Channel::Nudge).integer().not_null().default(0))
.col(ColumnDef::new(Channel::WebhookId).big_integer())
.col(ColumnDef::new(Channel::WebhookToken).string())
.col(ColumnDef::new(Channel::Paused).boolean().not_null().default(false))
.col(ColumnDef::new(Channel::PausedUntil).date_time())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_channel_guild")
.from(Channel::Table, Channel::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(ColumnDef::new(User::Id).big_integer().not_null().primary_key())
.col(ColumnDef::new(User::DmChannel).big_integer().not_null())
.col(
ColumnDef::new(User::Timezone)
.custom(Timezone::Type)
.not_null()
.default("UTC"),
)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_user_channel")
.from(User::Table, User::DmChannel)
.to(Channel::Table, Channel::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Reminder::Table)
.if_not_exists()
.col(
ColumnDef::new(Reminder::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Reminder::Uid).string().char_len(64).not_null())
.col(
ColumnDef::new(Reminder::Name)
.string()
.char_len(24)
.default("Reminder")
.not_null(),
)
.col(ColumnDef::new(Reminder::ChannelId).big_integer().not_null())
.col(ColumnDef::new(Reminder::UtcTime).date_time().not_null())
.col(
ColumnDef::new(Reminder::Timezone)
.custom(Timezone::Type)
.not_null()
.default("UTC"),
)
.col(ColumnDef::new(Reminder::IntervalSeconds).integer())
.col(ColumnDef::new(Reminder::IntervalMonths).integer())
.col(ColumnDef::new(Reminder::Enabled).boolean().not_null().default(false))
.col(ColumnDef::new(Reminder::Expires).date_time())
.col(ColumnDef::new(Reminder::Username).string_len(32))
.col(ColumnDef::new(Reminder::Avatar).string_len(512))
.col(ColumnDef::new(Reminder::Content).string_len(2000))
.col(ColumnDef::new(Reminder::Tts).boolean().not_null().default(false))
.col(ColumnDef::new(Reminder::Attachment).binary_len(8 * 1024 * 1024))
.col(ColumnDef::new(Reminder::AttachmentName).string_len(260))
.col(ColumnDef::new(Reminder::EmbedTitle).string_len(256))
.col(ColumnDef::new(Reminder::EmbedDescription).string_len(4096))
.col(ColumnDef::new(Reminder::EmbedImageUrl).string_len(500))
.col(ColumnDef::new(Reminder::EmbedThumbnailUrl).string_len(500))
.col(ColumnDef::new(Reminder::EmbedFooter).string_len(2048))
.col(ColumnDef::new(Reminder::EmbedFooterUrl).string_len(500))
.col(ColumnDef::new(Reminder::EmbedAuthor).string_len(256))
.col(ColumnDef::new(Reminder::EmbedAuthorUrl).string_len(500))
.col(ColumnDef::new(Reminder::EmbedColor).integer())
.col(ColumnDef::new(Reminder::EmbedFields).json())
.col(ColumnDef::new(Reminder::SetAt).date_time().not_null().default("NOW()"))
.col(ColumnDef::new(Reminder::SetBy).big_integer().not_null())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_reminder_channel")
.from(Reminder::Table, Reminder::ChannelId)
.to(Channel::Table, Channel::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_reminder_user")
.from(Reminder::Table, Reminder::SetBy)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(ReminderTemplate::Table)
.if_not_exists()
.col(
ColumnDef::new(ReminderTemplate::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(ReminderTemplate::GuildId).big_integer().not_null())
.col(
ColumnDef::new(ReminderTemplate::Name)
.string()
.char_len(24)
.default("Reminder")
.not_null(),
)
.col(ColumnDef::new(ReminderTemplate::Username).string_len(32))
.col(ColumnDef::new(ReminderTemplate::Avatar).string_len(512))
.col(ColumnDef::new(ReminderTemplate::Content).string_len(2000))
.col(ColumnDef::new(ReminderTemplate::Tts).boolean().not_null().default(false))
.col(ColumnDef::new(ReminderTemplate::Attachment).binary_len(8 * 1024 * 1024))
.col(ColumnDef::new(ReminderTemplate::AttachmentName).string_len(260))
.col(ColumnDef::new(ReminderTemplate::EmbedTitle).string_len(256))
.col(ColumnDef::new(ReminderTemplate::EmbedDescription).string_len(4096))
.col(ColumnDef::new(ReminderTemplate::EmbedImageUrl).string_len(500))
.col(ColumnDef::new(ReminderTemplate::EmbedThumbnailUrl).string_len(500))
.col(ColumnDef::new(ReminderTemplate::EmbedFooter).string_len(2048))
.col(ColumnDef::new(ReminderTemplate::EmbedFooterUrl).string_len(500))
.col(ColumnDef::new(ReminderTemplate::EmbedAuthor).string_len(256))
.col(ColumnDef::new(ReminderTemplate::EmbedAuthorUrl).string_len(500))
.col(ColumnDef::new(ReminderTemplate::EmbedColor).integer())
.col(ColumnDef::new(ReminderTemplate::EmbedFields).json())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_reminder_template_guild")
.from(ReminderTemplate::Table, ReminderTemplate::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Timer::Table)
.if_not_exists()
.col(
ColumnDef::new(Timer::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Timer::StartTime).date_time().not_null().default("NOW()"))
.col(ColumnDef::new(Timer::Name).string_len(32).not_null().default("Timer"))
.col(ColumnDef::new(Timer::UserId).big_integer())
.col(ColumnDef::new(Timer::GuildId).big_integer())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_timer_user")
.from(Timer::Table, Timer::UserId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_timer_guild")
.from(Timer::Table, Timer::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Todo::Table)
.if_not_exists()
.col(
ColumnDef::new(Todo::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Todo::UserId).big_integer())
.col(ColumnDef::new(Todo::GuildId).big_integer())
.col(ColumnDef::new(Todo::ChannelId).big_integer())
.col(ColumnDef::new(Todo::Value).string_len(2000).not_null())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_todo_user")
.from(Todo::Table, Todo::UserId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_todo_guild")
.from(Todo::Table, Todo::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_todo_channel")
.from(Todo::Table, Todo::ChannelId)
.to(Channel::Table, Channel::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(CommandMacro::Table)
.if_not_exists()
.col(
ColumnDef::new(CommandMacro::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(CommandMacro::GuildId).big_integer().not_null())
.col(ColumnDef::new(CommandMacro::Name).string_len(100).not_null())
.col(ColumnDef::new(CommandMacro::Description).string_len(100))
.col(ColumnDef::new(CommandMacro::Commands).json())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_command_macro_guild")
.from(CommandMacro::Table, CommandMacro::GuildId)
.to(Guild::Table, Guild::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_foreign_key(
ForeignKey::drop().table(Channel::Table).name("fk_channel_guild").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(User::Table).name("fk_user_channel").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Reminder::Table).name("fk_reminder_channel").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Reminder::Table).name("fk_reminder_user").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop()
.table(ReminderTemplate::Table)
.name("fk_reminder_template_guild")
.to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Timer::Table).name("fk_timer_user").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Timer::Table).name("fk_timer_guild").to_owned(),
)
.await?;
manager
.drop_foreign_key(ForeignKey::drop().table(Todo::Table).name("fk_todo_user").to_owned())
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Todo::Table).name("fk_todo_guild").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop().table(Todo::Table).name("fk_todo_channel").to_owned(),
)
.await?;
manager
.drop_foreign_key(
ForeignKey::drop()
.table(CommandMacro::Table)
.name("fk_command_macro_guild")
.to_owned(),
)
.await?;
manager.drop_table(Table::drop().table(Guild::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Channel::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(User::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Reminder::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(ReminderTemplate::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Timer::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(Todo::Table).to_owned()).await?;
manager.drop_table(Table::drop().table(CommandMacro::Table).to_owned()).await?;
manager.drop_type(Type::drop().name(Timezone::Type).to_owned()).await?;
Ok(())
}
}

View File

@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

1
models/src/lib.rs Normal file
View File

@ -0,0 +1 @@

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;
}
}

18
postman/Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "postman"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["process", "full"] }
regex = "1.4"
log = "0.4"
env_logger = "0.8"
chrono = "0.4"
chrono-tz = { version = "0.5", features = ["serde"] }
lazy_static = "1.4"
num-integer = "0.1"
serde = "1.0"
serde_json = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }

View File

@ -3,7 +3,7 @@ mod sender;
use std::env;
use log::{info, warn};
use poise::serenity_prelude::client::Context;
use serenity::client::Context;
use sqlx::{Executor, MySql};
use tokio::{
sync::broadcast::Receiver,

595
postman/src/sender.rs Normal file
View File

@ -0,0 +1,595 @@
use chrono::Duration;
use chrono_tz::Tz;
use lazy_static::lazy_static;
use log::{error, info, warn};
use num_integer::Integer;
use regex::{Captures, Regex};
use serde::Deserialize;
use serenity::{
builder::CreateEmbed,
http::{CacheHttp, Http, HttpError, StatusCode},
model::{
channel::{Channel, Embed as SerenityEmbed},
id::ChannelId,
webhook::Webhook,
},
Error, Result,
};
use sqlx::{
types::{
chrono::{NaiveDateTime, Utc},
Json,
},
Executor,
};
use crate::Database;
lazy_static! {
pub static ref TIMEFROM_REGEX: Regex =
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
pub static ref TIMENOW_REGEX: Regex =
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
}
fn fmt_displacement(format: &str, seconds: u64) -> String {
let mut seconds = seconds;
let mut days: u64 = 0;
let mut hours: u64 = 0;
let mut minutes: u64 = 0;
for (rep, time_type, div) in
[("%d", &mut days, 86400), ("%h", &mut hours, 3600), ("%m", &mut minutes, 60)].iter_mut()
{
if format.contains(*rep) {
let (divided, new_seconds) = seconds.div_rem(&div);
**time_type = divided;
seconds = new_seconds;
}
}
format
.replace("%s", &seconds.to_string())
.replace("%m", &minutes.to_string())
.replace("%h", &hours.to_string())
.replace("%d", &days.to_string())
}
pub fn substitute(string: &str) -> String {
let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| {
let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str());
if let (Some(final_time), Some(format)) = (final_time, format) {
let dt = NaiveDateTime::from_timestamp(final_time, 0);
let now = Utc::now().naive_utc();
let difference = {
if now < dt {
dt - Utc::now().naive_utc()
} else {
Utc::now().naive_utc() - dt
}
};
fmt_displacement(format, difference.num_seconds() as u64)
} else {
String::new()
}
});
TIMENOW_REGEX
.replace(&new, |caps: &Captures| {
let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten();
let format = caps.name("format").map(|m| m.as_str());
if let (Some(timezone), Some(format)) = (timezone, format) {
let now = Utc::now().with_timezone(&timezone);
now.format(format).to_string()
} else {
String::new()
}
})
.to_string()
}
struct Embed {
title: String,
description: String,
image_url: Option<String>,
thumbnail_url: Option<String>,
footer: String,
footer_url: Option<String>,
author: String,
author_url: Option<String>,
color: u32,
fields: Json<Vec<EmbedField>>,
}
#[derive(Deserialize)]
struct EmbedField {
title: String,
value: String,
inline: bool,
}
impl Embed {
pub async fn from_id(
pool: impl Executor<'_, Database = Database> + Copy,
id: u32,
) -> Option<Self> {
match sqlx::query_as!(
Self,
r#"
SELECT
`embed_title` AS title,
`embed_description` AS description,
`embed_image_url` AS image_url,
`embed_thumbnail_url` AS thumbnail_url,
`embed_footer` AS footer,
`embed_footer_url` AS footer_url,
`embed_author` AS author,
`embed_author_url` AS author_url,
`embed_color` AS color,
IFNULL(`embed_fields`, '[]') AS "fields:_"
FROM reminders
WHERE `id` = ?"#,
id
)
.fetch_one(pool)
.await
{
Ok(mut embed) => {
embed.title = substitute(&embed.title);
embed.description = substitute(&embed.description);
embed.footer = substitute(&embed.footer);
embed.fields.iter_mut().for_each(|mut field| {
field.title = substitute(&field.title);
field.value = substitute(&field.value);
});
if embed.has_content() {
Some(embed)
} else {
None
}
}
Err(e) => {
warn!("Error loading embed from reminder: {:?}", e);
None
}
}
}
pub fn has_content(&self) -> bool {
if self.title.is_empty()
&& self.description.is_empty()
&& self.image_url.is_none()
&& self.thumbnail_url.is_none()
&& self.footer.is_empty()
&& self.footer_url.is_none()
&& self.author.is_empty()
&& self.author_url.is_none()
&& self.fields.0.is_empty()
{
false
} else {
true
}
}
}
impl Into<CreateEmbed> for Embed {
fn into(self) -> CreateEmbed {
let mut c = CreateEmbed::default();
c.title(&self.title)
.description(&self.description)
.color(self.color)
.author(|a| {
a.name(&self.author);
if let Some(author_icon) = &self.author_url {
a.icon_url(author_icon);
}
a
})
.footer(|f| {
f.text(&self.footer);
if let Some(footer_icon) = &self.footer_url {
f.icon_url(footer_icon);
}
f
});
for field in &self.fields.0 {
c.field(&field.title, &field.value, field.inline);
}
if let Some(image_url) = &self.image_url {
c.image(image_url);
}
if let Some(thumbnail_url) = &self.thumbnail_url {
c.thumbnail(thumbnail_url);
}
c
}
}
#[derive(Debug)]
pub struct Reminder {
id: u32,
channel_id: u64,
webhook_id: Option<u64>,
webhook_token: Option<String>,
channel_paused: bool,
channel_paused_until: Option<NaiveDateTime>,
enabled: bool,
tts: bool,
pin: bool,
content: String,
attachment: Option<Vec<u8>>,
attachment_name: Option<String>,
utc_time: NaiveDateTime,
timezone: String,
restartable: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_months: Option<u32>,
avatar: Option<String>,
username: Option<String>,
}
impl Reminder {
pub async fn fetch_reminders(pool: impl Executor<'_, Database = Database> + Copy) -> Vec<Self> {
match sqlx::query_as_unchecked!(
Reminder,
r#"
SELECT
reminders.`id` AS id,
channels.`channel` AS channel_id,
channels.`webhook_id` AS webhook_id,
channels.`webhook_token` AS webhook_token,
channels.`paused` AS 'channel_paused',
channels.`paused_until` AS 'channel_paused_until',
reminders.`enabled` AS 'enabled',
reminders.`tts` AS tts,
reminders.`pin` AS pin,
reminders.`content` AS content,
reminders.`attachment` AS attachment,
reminders.`attachment_name` AS attachment_name,
reminders.`utc_time` AS 'utc_time',
reminders.`timezone` AS timezone,
reminders.`restartable` AS restartable,
reminders.`expires` AS 'expires',
reminders.`interval_seconds` AS 'interval_seconds',
reminders.`interval_months` AS 'interval_months',
reminders.`avatar` AS avatar,
reminders.`username` AS username
FROM
reminders
INNER JOIN
channels
ON
reminders.channel_id = channels.id
WHERE
reminders.`utc_time` < NOW()
LIMIT 25
"#,
)
.fetch_all(pool)
.await
{
Ok(reminders) => reminders
.into_iter()
.map(|mut rem| {
rem.content = substitute(&rem.content);
rem
})
.collect::<Vec<Self>>(),
Err(e) => {
warn!("Could not fetch reminders: {:?}", e);
vec![]
}
}
}
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
let _ = sqlx::query!(
"
UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
",
self.channel_id
)
.execute(pool)
.await;
}
async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
if self.interval_seconds.is_some() || self.interval_months.is_some() {
let now = Utc::now().naive_local();
let mut updated_reminder_time = self.utc_time;
if let Some(interval) = self.interval_months {
match sqlx::query!(
// use the second date_add to force return value to datetime
"SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time",
updated_reminder_time,
interval
)
.fetch_one(pool)
.await
{
Ok(row) => match row.new_time {
Some(datetime) => {
updated_reminder_time = datetime;
}
None => {
warn!("Could not update interval by months: got NULL");
updated_reminder_time += Duration::days(30);
}
},
Err(e) => {
warn!("Could not update interval by months: {:?}", e);
// naively fallback to adding 30 days
updated_reminder_time += Duration::days(30);
}
}
}
if let Some(interval) = self.interval_seconds {
while updated_reminder_time < now {
updated_reminder_time += Duration::seconds(interval as i64);
}
}
if self.expires.map_or(false, |expires| {
NaiveDateTime::from_timestamp(updated_reminder_time.timestamp(), 0) > expires
}) {
self.force_delete(pool).await;
} else {
sqlx::query!(
"
UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
",
updated_reminder_time,
self.id
)
.execute(pool)
.await
.expect(&format!("Could not update time on Reminder {}", self.id));
}
} else {
self.force_delete(pool).await;
}
}
async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) {
sqlx::query!(
"
DELETE FROM reminders WHERE `id` = ?
",
self.id
)
.execute(pool)
.await
.expect(&format!("Could not delete Reminder {}", self.id));
}
async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) {
let _ = http.as_ref().pin_message(self.channel_id, message_id.into(), None).await;
}
pub async fn send(
&self,
pool: impl Executor<'_, Database = Database> + Copy,
cache_http: impl CacheHttp,
) {
async fn send_to_channel(
cache_http: impl CacheHttp,
reminder: &Reminder,
embed: Option<CreateEmbed>,
) -> Result<()> {
let channel = ChannelId(reminder.channel_id).to_channel(&cache_http).await;
match channel {
Ok(Channel::Guild(channel)) => {
match channel
.send_message(&cache_http, |m| {
m.content(&reminder.content).tts(reminder.tts);
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
m.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
m.set_embed(embed);
}
m
})
.await
{
Ok(m) => {
if reminder.pin {
reminder.pin_message(m.id, cache_http.http()).await;
}
Ok(())
}
Err(e) => Err(e),
}
}
Ok(Channel::Private(channel)) => {
match channel
.send_message(&cache_http.http(), |m| {
m.content(&reminder.content).tts(reminder.tts);
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
m.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
m.set_embed(embed);
}
m
})
.await
{
Ok(m) => {
if reminder.pin {
reminder.pin_message(m.id, cache_http.http()).await;
}
Ok(())
}
Err(e) => Err(e),
}
}
Err(e) => Err(e),
_ => Err(Error::Other("Channel not of valid type")),
}
}
async fn send_to_webhook(
cache_http: impl CacheHttp,
reminder: &Reminder,
webhook: Webhook,
embed: Option<CreateEmbed>,
) -> Result<()> {
match webhook
.execute(&cache_http.http(), reminder.pin || reminder.restartable, |w| {
w.content(&reminder.content).tts(reminder.tts);
if let Some(username) = &reminder.username {
w.username(username);
}
if let Some(avatar) = &reminder.avatar {
w.avatar_url(avatar);
}
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
w.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
w.embeds(vec![SerenityEmbed::fake(|c| {
*c = embed;
c
})]);
}
w
})
.await
{
Ok(m) => {
if reminder.pin {
if let Some(message) = m {
reminder.pin_message(message.id, cache_http.http()).await;
}
}
Ok(())
}
Err(e) => Err(e),
}
}
if self.enabled
&& !(self.channel_paused
&& self
.channel_paused_until
.map_or(true, |inner| inner >= Utc::now().naive_local()))
{
let _ = sqlx::query!(
"
UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
",
self.channel_id
)
.execute(pool)
.await;
let embed = Embed::from_id(pool, self.id).await.map(|e| e.into());
let result = if let (Some(webhook_id), Some(webhook_token)) =
(self.webhook_id, &self.webhook_token)
{
let webhook_res =
cache_http.http().get_webhook_with_token(webhook_id, webhook_token).await;
if let Ok(webhook) = webhook_res {
send_to_webhook(cache_http, &self, webhook, embed).await
} else {
warn!("Webhook vanished: {:?}", webhook_res);
self.reset_webhook(pool).await;
send_to_channel(cache_http, &self, embed).await
}
} else {
send_to_channel(cache_http, &self, embed).await
};
if let Err(e) = result {
error!("Error sending {:?}: {:?}", self, e);
if let Error::Http(error) = e {
if error.status_code() == Some(StatusCode::NOT_FOUND) {
warn!("Seeing channel is deleted. Removing reminder");
self.force_delete(pool).await;
} else if let HttpError::UnsuccessfulRequest(error) = *error {
if error.error.code == 50007 {
warn!("User cannot receive DMs");
self.force_delete(pool).await;
} else {
self.refresh(pool).await;
}
}
} else {
self.refresh(pool).await;
}
} else {
self.refresh(pool).await;
}
} else {
info!("Reminder {} is paused", self.id);
self.refresh(pool).await;
}
}
}

View File

@ -1,11 +0,0 @@
[package]
name = "recordable_derive"
version = "0.1.0"
edition = "2021"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.35"
syn = { version = "2.0.49", features = ["full"] }

View File

@ -1,42 +0,0 @@
use proc_macro::TokenStream;
use syn::{spanned::Spanned, Data};
/// Macro to allow Recordable to be implemented on an enum by dispatching over each variant.
#[proc_macro_derive(Recordable)]
pub fn extract(input: TokenStream) -> TokenStream {
let ast = syn::parse_macro_input!(input);
impl_recordable(&ast)
}
fn impl_recordable(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
match &ast.data {
Data::Enum(en) => {
let extracted = en.variants.iter().map(|var| {
let ident = &var.ident;
quote::quote_spanned! {var.span()=>
Self::#ident (opt) => opt.run(ctx).await?
}
});
TokenStream::from(quote::quote! {
impl Recordable for #name {
async fn run(self, ctx: crate::Context<'_>) -> Result<(), crate::Error> {
match self {
#(#extracted,)*
}
Ok(())
}
}
})
}
_ => {
panic!("Only enums can derive Recordable");
}
}
}

View File

@ -1,2 +0,0 @@
printWidth = 100
tabWidth = 4

View File

@ -1,19 +0,0 @@
# reminder-dashboard
The re-re-rewrite of the dashboard.
## Why
The existing beta variant of the dashboard is written using vanilla JavaScript. This is fine,
but annoying to update. This would've been okay if I was more dedicated to updating the vanilla
JavaScript too, but I want to experiment with "new" things.
This also allows me to expand my frontend skills, which is relevant to part of my job.
## Developing
1. Run both `npm run dev` and `cargo run`
2. Symlink assets: assuming cloned
into `$HOME`, `ln -s $HOME/reminder-bot/reminder-dashboard/dist/index.html $HOME/reminder-bot/web/static/index.html`
and
`ln -s $HOME/reminder-bot/reminder-dashboard/dist/static/assets $HOME/reminder-bot/web/static/assets`

View File

@ -1,35 +0,0 @@
<!DOCTYPE html>
<html lang="EN">
<head>
<meta name="description" content="The most powerful Discord Reminders Bot">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8">
<meta name="yandex-verification" content="bb77b8681eb64a90"/>
<meta name="google-site-verification" content="7h7UVTeEe0AOzHiH3cFtsqMULYGN-zCZdMT_YCkW1Ho"/>
<!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; font-src fonts.gstatic.com 'self'"> -->
<!-- favicon -->
<link rel="apple-touch-icon" sizes="180x180"
href="/static/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32"
href="/static/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16"
href="/static/favicon/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<title>Reminder Bot | Dashboard</title>
<!-- styles -->
<link rel="stylesheet" href="/static/css/bulma.min.css">
<link rel="stylesheet" href="/static/css/fa.css">
<link rel="stylesheet" href="/static/css/font.css">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/dtsel.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +0,0 @@
{
"name": "example",
"private": true,
"type": "module",
"scripts": {
"dev": "vite build --watch --mode development",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.5.1",
"bulma": "^0.9.4",
"luxon": "^3.4.3",
"preact": "^10.13.1",
"react-colorful": "^5.6.1",
"react-query": "^3.39.3",
"tributejs": "^5.1.3",
"use-debounce": "^10.0.0",
"wouter": "^3.0"
},
"devDependencies": {
"@preact/preset-vite": "^2.5.0",
"@types/luxon": "^3.3.2",
"eslint": "^8.50.0",
"eslint-config-preact": "^1.3.0",
"prettier": "^3.0.3",
"react-datepicker": "^4.21.0",
"sass": "^1.71.1",
"typescript": "^5.2.2",
"vite": "^5.1"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 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

@ -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,20 +0,0 @@
{
"name": "Reminder Bot Dashboard",
"short_name": "Reminders",
"start_url": "/dashboard",
"icons": [
{
"src": "/static/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@ -1,193 +0,0 @@
import axios from "axios";
import { DateTime } from "luxon";
type UserInfo = {
name: string;
patreon: boolean;
timezone: string | null;
};
export type GuildInfo = {
id: string;
patreon: boolean;
name: string;
error?: string;
};
type EmbedField = {
title: string;
value: string;
inline: boolean;
};
export type Reminder = {
attachment: string | null;
attachment_name: string | null;
avatar: string | null;
channel: string;
content: string;
embed_author: string;
embed_author_url: string | null;
embed_color: number;
embed_description: string;
embed_footer: string;
embed_footer_url: string | null;
embed_image_url: string | null;
embed_thumbnail_url: string | null;
embed_title: string;
embed_fields: EmbedField[] | null;
enabled: boolean;
expires: string | null;
interval_seconds: number | null;
interval_days: number | null;
interval_months: number | null;
name: string;
restartable: boolean;
tts: boolean;
uid: string;
username: string;
utc_time: string;
};
export type ChannelInfo = {
id: string;
name: string;
};
type RoleInfo = {
id: string;
name: string;
};
type Template = {
id: number;
name: string;
attachment: string | null;
attachment_name: string | null;
avatar: string | null;
channel: string;
content: string;
embed_author: string;
embed_author_url: string | null;
embed_color: number;
embed_description: string;
embed_footer: string;
embed_footer_url: string | null;
embed_image_url: string | null;
embed_thumbnail_url: string | null;
embed_title: string;
embed_fields: EmbedField[] | null;
};
const USER_INFO_STALE_TIME = 120_000;
const GUILD_INFO_STALE_TIME = 300_000;
const OTHER_STALE_TIME = 15_000;
export const fetchUserInfo = () => ({
queryKey: ["USER_INFO"],
queryFn: () => axios.get("/dashboard/api/user").then((resp) => resp.data) as Promise<UserInfo>,
staleTime: USER_INFO_STALE_TIME,
});
export const patchUserInfo = () => ({
mutationFn: (timezone: string) => axios.patch(`/dashboard/api/user`, { timezone }),
});
export const fetchUserGuilds = () => ({
queryKey: ["USER_GUILDS"],
queryFn: () =>
axios.get("/dashboard/api/user/guilds").then((resp) => resp.data) as Promise<GuildInfo[]>,
staleTime: USER_INFO_STALE_TIME,
});
export const fetchGuildInfo = (guild: string) => ({
queryKey: ["GUILD_INFO", guild],
queryFn: () =>
axios.get(`/dashboard/api/guild/${guild}`).then((resp) => resp.data) as Promise<GuildInfo>,
staleTime: GUILD_INFO_STALE_TIME,
});
export const fetchGuildChannels = (guild: string) => ({
queryKey: ["GUILD_CHANNELS", guild],
queryFn: () =>
axios.get(`/dashboard/api/guild/${guild}/channels`).then((resp) => resp.data) as Promise<
ChannelInfo[]
>,
staleTime: GUILD_INFO_STALE_TIME,
});
export const fetchGuildRoles = (guild: string) => ({
queryKey: ["GUILD_ROLES", guild],
queryFn: () =>
axios.get(`/dashboard/api/guild/${guild}/roles`).then((resp) => resp.data) as Promise<
RoleInfo[]
>,
staleTime: GUILD_INFO_STALE_TIME,
});
export const fetchGuildReminders = (guild: string) => ({
queryKey: ["GUILD_REMINDERS", guild],
queryFn: () =>
axios.get(`/dashboard/api/guild/${guild}/reminders`).then((resp) => resp.data) as Promise<
Reminder[]
>,
staleTime: OTHER_STALE_TIME,
});
export const patchGuildReminder = (guild: string) => ({
mutationFn: (reminder: Reminder) =>
axios.patch(`/dashboard/api/guild/${guild}/reminders`, reminder),
});
export const postGuildReminder = (guild: string) => ({
mutationFn: (reminder: Reminder) =>
axios.post(`/dashboard/api/guild/${guild}/reminders`, reminder).then((resp) => resp.data),
});
export const deleteReminder = () => ({
mutationFn: (reminder: Reminder) =>
axios.delete(`/dashboard/api/reminders`, {
data: {
uid: reminder.uid,
},
}),
});
export const fetchGuildTemplates = (guild: string) => ({
queryKey: ["GUILD_TEMPLATES", guild],
queryFn: () =>
axios.get(`/dashboard/api/guild/${guild}/templates`).then((resp) => resp.data) as Promise<
Template[]
>,
staleTime: OTHER_STALE_TIME,
});
export const postGuildTemplate = (guild: string) => ({
mutationFn: (reminder: Reminder) =>
axios.post(`/dashboard/api/guild/${guild}/templates`, reminder).then((resp) => resp.data),
});
export const deleteGuildTemplate = (guild: string) => ({
mutationFn: (template: Template) =>
axios.delete(`/dashboard/api/guild/${guild}/templates`, {
data: {
id: template.id,
},
}),
});
export const fetchUserReminders = () => ({
queryKey: ["USER_REMINDERS"],
queryFn: () =>
axios.get(`/dashboard/api/user/reminders`).then((resp) => resp.data) as Promise<Reminder[]>,
staleTime: OTHER_STALE_TIME,
});
export const postUserReminder = () => ({
mutationFn: (reminder: Reminder) =>
axios.post(`/dashboard/api/user/reminders`, reminder).then((resp) => resp.data),
});
export const patchUserReminder = () => ({
mutationFn: (reminder: Reminder) => axios.patch(`/dashboard/api/user/reminders`, reminder),
});

View File

@ -1,7 +0,0 @@
import { createContext } from "preact";
import { useContext } from "preact/compat";
import { Message } from "./FlashProvider";
export const FlashContext = createContext(null as (message: Message) => void);
export const useFlash = () => useContext(FlashContext);

View File

@ -1,43 +0,0 @@
import { FlashContext } from "./FlashContext";
import { useState } from "preact/hooks";
import { MESSAGE_FLASH_TIME } from "../../consts";
export type Message = {
message: string;
type: "error" | "success";
};
export const FlashProvider = ({ children }) => {
const [messages, setMessages] = useState([] as Message[]);
return (
<FlashContext.Provider
value={(message: Message) => {
setMessages((messages: Message[]) => [...messages, message]);
setTimeout(() => {
setMessages((messages) => [...messages].splice(1));
}, MESSAGE_FLASH_TIME);
}}
>
<>
{children}
<div class="flash-container">
{messages.map((message) => {
const className = message.type === "error" ? "is-danger" : "is-success";
const icon =
message.type === "error" ? "fa-exclamation-circle" : "fa-check";
return (
<div class={`notification flash-message is-active ${className}`}>
<span class="icon">
<i class={`far ${icon}`}></i>
</span>{" "}
<span class="error-message">{message.message}</span>
</div>
);
})}
</div>
</>
</FlashContext.Provider>
);
};

View File

@ -1,42 +0,0 @@
import { useEffect, useMemo } from "preact/hooks";
import { useQuery } from "react-query";
import { fetchGuildChannels, fetchGuildRoles } from "../../api";
import Tribute from "tributejs";
import { useGuild } from "./useGuild";
export const Mentions = ({ input }) => {
const guild = useGuild();
const { data: roles } = useQuery(fetchGuildRoles(guild));
const { data: channels } = useQuery(fetchGuildChannels(guild));
const tribute = useMemo(() => {
return new Tribute({
collection: [
{
trigger: "@",
values: (roles || []).map(({ id, name }) => ({ key: name, value: id })),
allowSpaces: true,
selectTemplate: (item) => `<@&${item.original.value}>`,
menuItemTemplate: (item) => `@${item.original.key}`,
},
{
trigger: "#",
values: (channels || []).map(({ id, name }) => ({ key: name, value: id })),
allowSpaces: true,
selectTemplate: (item) => `<#${item.original.value}>`,
menuItemTemplate: (item) => `#${item.original.key}`,
},
],
});
}, [roles, channels]);
useEffect(() => {
tribute.detach(input.current);
if (input.current !== null) {
tribute.attach(input.current);
}
}, [tribute]);
return <></>;
};

View File

@ -1,20 +0,0 @@
import { createContext } from "preact";
import { useContext } from "preact/compat";
import { useState } from "preact/hooks";
import { DateTime } from "luxon";
type TTimezoneContext = [string, (tz: string) => void];
const TimezoneContext = createContext(["UTC", () => {}] as TTimezoneContext);
export const TimezoneProvider = ({ children }) => {
const [timezone, setTimezone] = useState(DateTime.now().zoneName);
return (
<TimezoneContext.Provider value={[timezone, setTimezone]}>
{children}
</TimezoneContext.Provider>
);
};
export const useTimezone = () => useContext(TimezoneContext);

View File

@ -1,35 +0,0 @@
import { Sidebar } from "../Sidebar";
import { QueryClient, QueryClientProvider } from "react-query";
import { Route, Router, Switch } from "wouter";
import { Welcome } from "../Welcome";
import { Guild } from "../Guild";
import { FlashProvider } from "./FlashProvider";
import { TimezoneProvider } from "./TimezoneProvider";
import { User } from "../User";
export function App() {
const queryClient = new QueryClient();
return (
<TimezoneProvider>
<FlashProvider>
<QueryClientProvider client={queryClient}>
<Router base={"/dashboard"}>
<div class="columns is-gapless dashboard-frame">
<Sidebar />
<div class="column is-main-content">
<Switch>
<Route path={"/@me/reminders"} component={User}></Route>
<Route path={"/:guild/reminders"} component={Guild}></Route>
<Route>
<Welcome />
</Route>
</Switch>
</div>
</div>
</Router>
</QueryClientProvider>
</FlashProvider>
</TimezoneProvider>
);
}

View File

@ -1,6 +0,0 @@
import { useParams } from "wouter";
export const useGuild = () => {
const { guild } = useParams() as { guild?: string };
return guild || null;
};

View File

@ -1,25 +0,0 @@
export const GuildError = () => {
return (
<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

@ -1,142 +0,0 @@
import { useQuery } from "react-query";
import { fetchGuildChannels, fetchGuildReminders } from "../../api";
import { EditReminder } from "../Reminder/EditReminder";
import { CreateReminder } from "../Reminder/CreateReminder";
import { useState } from "preact/hooks";
import { Loader } from "../Loader";
import { useGuild } from "../App/useGuild";
enum Sort {
Time = "time",
Name = "name",
Channel = "channel",
}
export const GuildReminders = () => {
const guild = useGuild();
const {
isSuccess,
isFetching,
isFetched,
data: guildReminders,
} = useQuery(fetchGuildReminders(guild));
const { data: channels } = useQuery(fetchGuildChannels(guild));
const [collapsed, setCollapsed] = useState(false);
const [sort, setSort] = useState(Sort.Time);
let prevReminder = null;
return (
<>
{!isFetched && <Loader />}
<div style={{ margin: "0 12px 12px 12px" }}>
<strong>Create Reminder</strong>
<div id={"reminderCreator"}>
<CreateReminder />
</div>
<br></br>
<div class={"field"}>
<div class={"columns is-mobile"}>
<div class={"column"}>
<strong>Reminders</strong>
</div>
<div class={"column is-narrow"}>
<div class="control has-icons-left">
<div class="select is-small">
<select
id="orderBy"
onInput={(ev) => {
setSort(ev.currentTarget.value as Sort);
}}
>
<option value={Sort.Time} selected={sort == Sort.Time}>
Time
</option>
<option value={Sort.Name} selected={sort == Sort.Name}>
Name
</option>
<option
value={Sort.Channel}
selected={sort == Sort.Channel}
>
Channel
</option>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-sort-amount-down"></i>
</div>
</div>
</div>
<div class={"column is-narrow"}>
<div class="control has-icons-left">
<div class="select is-small">
<select
id="expandAll"
onInput={(ev) => {
if (ev.currentTarget.value === "expand") {
setCollapsed(false);
} else if (ev.currentTarget.value === "collapse") {
setCollapsed(true);
}
}}
>
<option value="" selected></option>
<option value="expand">Expand All</option>
<option value="collapse">Collapse All</option>
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-expand-arrows"></i>
</div>
</div>
</div>
</div>
</div>
<div id={"guildReminders"} className={isFetching ? "loading" : ""}>
{isSuccess &&
guildReminders
.sort((r1, r2) => {
if (sort === Sort.Time) {
return r1.utc_time > r2.utc_time ? 1 : -1;
} else if (sort === Sort.Name) {
return r1.name > r2.name ? 1 : -1;
} else {
return r1.channel > r2.channel ? 1 : -1;
}
})
.map((reminder) => {
let breaker = <></>;
if (sort === Sort.Channel && channels) {
if (
prevReminder === null ||
prevReminder.channel !== reminder.channel
) {
const channel = channels.find(
(ch) => ch.id === reminder.channel,
);
breaker = <div class={"channel-tag"}>#{channel.name}</div>;
}
}
prevReminder = reminder;
return (
<>
{breaker}
<EditReminder
key={reminder.uid}
reminder={reminder}
globalCollapse={collapsed}
/>
</>
);
})}
</div>
</div>
</>
);
};

View File

@ -1,27 +0,0 @@
import { useQuery } from "react-query";
import { fetchGuildInfo } from "../../api";
import { GuildReminders } from "./GuildReminders";
import { GuildError } from "./GuildError";
import { createPortal } from "preact/compat";
import { Import } from "../Import";
import { useGuild } from "../App/useGuild";
export const Guild = () => {
const guild = useGuild();
const { isSuccess, data: guildInfo } = useQuery(fetchGuildInfo(guild));
if (!isSuccess) {
return <></>;
} else if (guildInfo.error) {
return <GuildError />;
} else {
const importModal = createPortal(<Import />, document.getElementById("bottom-sidebar"));
return (
<>
{importModal}
<GuildReminders />
</>
);
}
};

View File

@ -1,144 +0,0 @@
import { Modal } from "../Modal";
import { useRef, useState } from "preact/hooks";
import { useParams } from "wouter";
import axios from "axios";
import { useFlash } from "../App/FlashContext";
export const Import = () => {
const [modalOpen, setModalOpen] = useState(false);
return (
<>
<a
class="show-modal"
data-modal="chooseTimezoneModal"
onClick={() => {
setModalOpen(true);
}}
>
<span class="icon">
<i class="fas fa-exchange"></i>
</span>{" "}
Import/Export
</a>
{modalOpen && <ImportModal setModalOpen={setModalOpen} />}
</>
);
};
const ImportModal = ({ setModalOpen }) => {
const { guild } = useParams();
const aRef = useRef<HTMLAnchorElement>();
const inputRef = useRef<HTMLInputElement>();
const flash = useFlash();
const [isImporting, setIsImporting] = useState(false);
return (
<Modal
setModalOpen={setModalOpen}
title={
<>
Import/Export Manager{" "}
<a href="/help/iemanager">
<span>
<i class="fa fa-question-circle"></i>
</span>
</a>
</>
}
>
<>
<div class="control">
<div class="field">
<label>
<input
type="radio"
class="default-width"
name="exportSelect"
value="reminders"
checked
/>
Reminders
</label>
</div>
</div>
<br />
<div class="has-text-centered">
<div style="color: red">
Please first read the <a href="/help/iemanager">support page</a>
</div>
<button
class="button is-success is-outlined"
style={{ margin: "2px" }}
id="import-data"
disabled={isImporting}
onClick={() => {
inputRef.current.click();
}}
>
Import Data
</button>
<button
class="button is-success"
style={{ margin: "2px" }}
id="export-data"
onClick={() =>
axios
.get(`/dashboard/api/guild/${guild}/export/reminders`)
.then(({ data, status }) => {
if (status === 200) {
aRef.current.href = `data:text/plain;charset=utf-8,${encodeURIComponent(
data.body,
)}`;
aRef.current.click();
} else {
flash({
message: `Unexpected status ${status}`,
type: "error",
});
}
})
}
>
Export Data
</button>
</div>
<a ref={aRef} id="downloader" download="export.csv" class="is-hidden" />
<input
ref={inputRef}
id="uploader"
type="file"
hidden
onChange={() => {
new Promise((resolve) => {
let fileReader = new FileReader();
fileReader.onload = (e) => resolve(fileReader.result);
fileReader.readAsDataURL(inputRef.current.files[0]);
}).then((dataUrl: string) => {
setIsImporting(true);
axios
.put(`/dashboard/api/guild/${guild}/export/reminders`, {
body: JSON.stringify({ body: dataUrl.split(",")[1] }),
})
.then(({ data }) => {
setIsImporting(false);
if (data.error) {
flash({ message: data.error, type: "error" });
} else {
flash({ message: data.message, type: "success" });
}
})
.then(() => {
delete inputRef.current.files[0];
});
});
}}
/>
</>
</Modal>
);
};

View File

@ -1,8 +0,0 @@
export const Loader = () => (
<div className={"load-screen"}>
<div>
<p>Loading...</p>
<i className={"fa fa-cog fa-spin"}></i>
</div>
</div>
);

View File

@ -1,61 +0,0 @@
import {JSX} from "preact";
import {createPortal} from "preact/compat";
type Props = {
setModalOpen: (open: boolean) => never;
title: string | JSX.Element;
onSubmitText?: string;
onSubmit?: () => void;
children: string | JSX.Element | JSX.Element[] | (() => JSX.Element);
};
export const Modal = ({setModalOpen, title, onSubmit, onSubmitText, children}: Props) => {
const body = document.querySelector("body");
return createPortal(
<div class="modal is-active">
<div
class="modal-background"
onClick={() => {
setModalOpen(false);
}}
></div>
<div class="modal-card">
<header class="modal-card-head">
<label class="modal-card-title">{title}</label>
<button
class="delete close-modal"
aria-label="close"
onClick={() => {
setModalOpen(false);
}}
></button>
</header>
<section class="modal-card-body">{children}</section>
{onSubmit && (
<footer class="modal-card-foot">
<button class="button is-success" onClick={onSubmit}>
{onSubmitText || "Save"}
</button>
<button
class="button close-modal"
onClick={() => {
setModalOpen(false);
}}
>
Cancel
</button>
</footer>
)}
</div>
<button
class="modal-close is-large close-modal"
aria-label="close"
onClick={() => {
setModalOpen(false);
}}
></button>
</div>,
body,
);
};

View File

@ -1,50 +0,0 @@
import { useReminder } from "./ReminderContext";
import { useFlash } from "../App/FlashContext";
export const Attachment = () => {
const [{ attachment_name }, setReminder] = useReminder();
const flash = useFlash();
return (
<div class="file is-small is-boxed">
<label class="file-label">
<input
class="file-input"
type="file"
name="attachment"
onInput={async (ev) => {
const input = ev.currentTarget;
let file = input.files[0];
if (file.size >= 8 * 1024 * 1024) {
flash({ message: "File too large (max. 8MB).", type: "error" });
return;
}
let attachment: string = await new Promise((resolve) => {
let fileReader = new FileReader();
fileReader.onload = () => resolve(fileReader.result as string);
fileReader.readAsDataURL(file);
});
attachment = attachment.split(",")[1];
const attachment_name = file.name;
setReminder((reminder) => ({
...reminder,
attachment,
attachment_name,
}));
}}
></input>
<span class="file-cta">
<span class="file-label">{attachment_name || "Add Attachment"}</span>
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
</span>
</label>
</div>
);
};

View File

@ -1,28 +0,0 @@
import { ImagePicker } from "./ImagePicker";
import { useReminder } from "./ReminderContext";
import { useGuild } from "../App/useGuild";
export const Avatar = () => {
const guild = useGuild();
const [reminder, setReminder] = useReminder();
return guild ? (
<ImagePicker
class="is-rounded avatar"
url={reminder.avatar || "/static/img/icon.png"}
alt="Image for discord avatar"
setImage={(url: string) => {
setReminder((reminder) => ({
...reminder,
avatar: url,
}));
}}
></ImagePicker>
) : (
<img
class="is-rounded avatar"
alt="Image for discord avatar"
src={"/static/img/icon.png"}
></img>
);
};

View File

@ -1,130 +0,0 @@
import { LoadTemplate } from "../LoadTemplate";
import { useReminder } from "../ReminderContext";
import { useMutation, useQueryClient } from "react-query";
import { postGuildReminder, postGuildTemplate, postUserReminder } from "../../../api";
import { useState } from "preact/hooks";
import { ICON_FLASH_TIME } from "../../../consts";
import { useFlash } from "../../App/FlashContext";
import { useGuild } from "../../App/useGuild";
export const CreateButtonRow = () => {
const guild = useGuild();
const [reminder] = useReminder();
const [recentlyCreated, setRecentlyCreated] = useState(false);
const [templateRecentlyCreated, setTemplateRecentlyCreated] = useState(false);
const flash = useFlash();
const queryClient = useQueryClient();
const mutation = useMutation({
...(guild ? postGuildReminder(guild) : postUserReminder()),
onSuccess: (data) => {
if (data.error) {
flash({
message: data.error,
type: "error",
});
} else {
flash({
message: "Reminder created",
type: "success",
});
if (guild) {
queryClient.invalidateQueries({
queryKey: ["GUILD_REMINDERS", guild],
});
} else {
queryClient.invalidateQueries({
queryKey: ["USER_REMINDERS"],
});
}
setRecentlyCreated(true);
setTimeout(() => {
setRecentlyCreated(false);
}, ICON_FLASH_TIME);
}
},
});
const templateMutation = useMutation({
...postGuildTemplate(guild),
onSuccess: (data) => {
if (data.error) {
flash({
message: data.error,
type: "error",
});
} else {
flash({
message: "Template created",
type: "success",
});
queryClient.invalidateQueries({
queryKey: ["GUILD_TEMPLATES", guild],
});
setTemplateRecentlyCreated(true);
setTimeout(() => {
setTemplateRecentlyCreated(false);
}, ICON_FLASH_TIME);
}
},
});
return (
<div class="button-row">
<div class="button-row-reminder">
<button
class="button is-success"
onClick={() => {
mutation.mutate(reminder);
}}
>
<span>Create Reminder</span>{" "}
{mutation.isLoading ? (
<span class="icon">
<i class="fas fa-spin fa-cog"></i>
</span>
) : recentlyCreated ? (
<span class="icon">
<i class="fas fa-check"></i>
</span>
) : (
<span class="icon">
<i class="fas fa-sparkles"></i>
</span>
)}
</button>
</div>
{guild && (
<div class="button-row-template">
<div>
<button
class="button is-success is-outlined"
onClick={() => {
templateMutation.mutate(reminder);
}}
>
<span>Create Template</span>{" "}
{templateMutation.isLoading ? (
<span class="icon">
<i class="fas fa-spin fa-cog"></i>
</span>
) : templateRecentlyCreated ? (
<span class="icon">
<i class="fas fa-check"></i>
</span>
) : (
<span class="icon">
<i class="fas fa-file-spreadsheet"></i>
</span>
)}
</button>
</div>
<div>
<LoadTemplate />
</div>
</div>
)}
</div>
);
};

View File

@ -1,81 +0,0 @@
import { useState } from "preact/hooks";
import { Modal } from "../../Modal";
import { useMutation, useQueryClient } from "react-query";
import { useReminder } from "../ReminderContext";
import { deleteReminder } from "../../../api";
import { useParams } from "wouter";
import { useFlash } from "../../App/FlashContext";
import { useGuild } from "../../App/useGuild";
export const DeleteButton = () => {
const [modalOpen, setModalOpen] = useState(false);
return (
<>
<button
class="button is-danger delete-reminder"
onClick={() => {
setModalOpen(true);
}}
>
Delete
</button>
{modalOpen && <DeleteModal setModalOpen={setModalOpen}></DeleteModal>}
</>
);
};
const DeleteModal = ({ setModalOpen }) => {
const [reminder] = useReminder();
const guild = useGuild();
const flash = useFlash();
const queryClient = useQueryClient();
const mutation = useMutation({
...deleteReminder(),
onSuccess: () => {
flash({
message: "Reminder deleted",
type: "success",
});
if (guild) {
queryClient.invalidateQueries({
queryKey: ["GUILD_REMINDERS", guild],
});
} else {
queryClient.invalidateQueries({
queryKey: ["USER_REMINDERS"],
});
}
setModalOpen(false);
},
});
return (
<Modal setModalOpen={setModalOpen} title={"Delete Reminder"}>
<>
<p>This reminder will be permanently deleted. Are you sure?</p>
<br></br>
<div class="has-text-centered">
<button
class="button is-danger"
onClick={() => {
mutation.mutate(reminder);
}}
disabled={mutation.isLoading}
>
Delete
</button>
<button
class="button is-light close-modal"
onClick={() => {
setModalOpen(false);
}}
>
Cancel
</button>
</div>
</>
</Modal>
);
};

View File

@ -1,94 +0,0 @@
import { useRef, useState } from "preact/hooks";
import { useMutation, useQueryClient } from "react-query";
import { patchGuildReminder, patchUserReminder } from "../../../api";
import { ICON_FLASH_TIME } from "../../../consts";
import { DeleteButton } from "./DeleteButton";
import { useReminder } from "../ReminderContext";
import { useFlash } from "../../App/FlashContext";
import { useGuild } from "../../App/useGuild";
export const EditButtonRow = () => {
const guild = useGuild();
const [reminder, setReminder] = useReminder();
const [recentlySaved, setRecentlySaved] = useState(false);
const queryClient = useQueryClient();
const iconFlashTimeout = useRef(0);
const flash = useFlash();
const mutation = useMutation({
...(guild ? patchGuildReminder(guild) : patchUserReminder()),
onSuccess: (response) => {
if (guild) {
queryClient.invalidateQueries({
queryKey: ["GUILD_REMINDERS", guild],
});
} else {
queryClient.invalidateQueries({
queryKey: ["USER_REMINDERS"],
});
}
if (iconFlashTimeout.current !== null) {
clearTimeout(iconFlashTimeout.current);
}
if (response.data.errors.length > 0) {
setRecentlySaved(false);
for (const error of response.data.errors) {
flash({ message: error, type: "error" });
}
} else {
setRecentlySaved(true);
iconFlashTimeout.current = setTimeout(() => {
setRecentlySaved(false);
}, ICON_FLASH_TIME);
setReminder(response.data.reminder);
}
},
});
return (
<div class="button-row-edit">
<button
class="button is-success save-btn"
onClick={() => {
mutation.mutate(reminder);
}}
disabled={mutation.isLoading}
>
<span>Save</span>{" "}
{mutation.isLoading ? (
<span class="icon">
<i class="fas fa-spin fa-cog"></i>
</span>
) : recentlySaved ? (
<span class="icon">
<i class="fas fa-check"></i>
</span>
) : (
<span class="icon">
<i class="fas fa-save"></i>
</span>
)}
</button>
<button
class="button is-warning"
onClick={() => {
mutation.mutate({
...reminder,
enabled: !reminder.enabled,
});
}}
disabled={mutation.isLoading}
>
{reminder.enabled ? "Disable" : "Enable"}
</button>
<DeleteButton />
</div>
);
};

View File

@ -1,32 +0,0 @@
import { useQuery } from "react-query";
import { useParams } from "wouter";
import { fetchGuildChannels } from "../../api";
export const ChannelSelector = ({ channel, setChannel }) => {
const { guild } = useParams();
const { isSuccess, data } = useQuery(fetchGuildChannels(guild));
return (
<div class="control has-icons-left">
<div class="select">
<select
name="channel"
class="channel-selector"
onInput={(ev) => {
setChannel(ev.currentTarget.value);
}}
>
{isSuccess &&
data.map((c) => (
<option value={c.id} selected={c.id === channel}>
{c.name}
</option>
))}
</select>
</div>
<div class="icon is-small is-left">
<i class="fas fa-hashtag"></i>
</div>
</div>
);
};

View File

@ -1,32 +0,0 @@
import { useReminder } from "./ReminderContext";
import { useRef } from "preact/hooks";
import { Mentions } from "../App/Mentions";
import { useGuild } from "../App/useGuild";
export const Content = () => {
const guild = useGuild();
const [reminder, setReminder] = useReminder();
const input = useRef(null);
return (
<>
{guild && <Mentions input={input} />}
<label class="is-sr-only">Content</label>
<textarea
class="message-input autoresize discord-content"
placeholder="Content..."
maxlength={2000}
name="content"
rows={1}
ref={input}
value={reminder.content}
onInput={(ev) => {
setReminder((reminder) => ({
...reminder,
content: ev.currentTarget.value,
}));
}}
></textarea>
</>
);
};

View File

@ -1,76 +0,0 @@
import { useState } from "preact/hooks";
import { fetchGuildChannels, Reminder } from "../../api";
import { DateTime } from "luxon";
import { CreateButtonRow } from "./ButtonRow/CreateButtonRow";
import { TopBar } from "./TopBar";
import { Message } from "./Message";
import { Settings } from "./Settings";
import { ReminderContext } from "./ReminderContext";
import { useQuery } from "react-query";
import "./styles.scss";
import { useGuild } from "../App/useGuild";
import { DEFAULT_COLOR } from "./Embed";
function defaultReminder(): Reminder {
return {
attachment: null,
attachment_name: null,
avatar: null,
channel: null,
content: "",
embed_author: "",
embed_author_url: null,
embed_color: DEFAULT_COLOR,
embed_description: "",
embed_fields: [],
embed_footer: "",
embed_footer_url: null,
embed_image_url: null,
embed_thumbnail_url: null,
embed_title: "",
enabled: true,
expires: null,
interval_days: null,
interval_months: null,
interval_seconds: null,
name: "",
restartable: false,
tts: false,
uid: "",
username: "",
utc_time: DateTime.now().toFormat("yyyy-LL-dd'T'HH:mm:ss"),
};
}
export const CreateReminder = () => {
const guild = useGuild();
const [reminder, setReminder] = useState(defaultReminder());
const [collapsed, setCollapsed] = useState(false);
const { isSuccess, data: guildChannels } = useQuery(fetchGuildChannels(guild));
if (isSuccess && reminder.channel === null) {
setReminder((reminder) => ({
...reminder,
channel: reminder.channel || guildChannels[0].id,
}));
}
return (
<ReminderContext.Provider value={[reminder, setReminder]}>
<div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}>
<TopBar
toggleCollapsed={() => {
setCollapsed(!collapsed);
}}
/>
<div class="columns reminder-settings">
<Message />
<Settings />
</div>
<CreateButtonRow />
</div>
</ReminderContext.Provider>
);
};

View File

@ -1,46 +0,0 @@
import { Reminder } from "../../api";
import { useEffect, useState } from "preact/hooks";
import { EditButtonRow } from "./ButtonRow/EditButtonRow";
import { Message } from "./Message";
import { Settings } from "./Settings";
import { ReminderContext } from "./ReminderContext";
import { TopBar } from "./TopBar";
import "./styles.scss";
type Props = {
reminder: Reminder;
globalCollapse: boolean;
};
export const EditReminder = ({ reminder: initialReminder, globalCollapse }: Props) => {
const [propReminder, setPropReminder] = useState(initialReminder);
const [reminder, setReminder] = useState(initialReminder);
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
setCollapsed(globalCollapse);
}, [globalCollapse]);
// Reminder updated from web response
if (propReminder !== initialReminder) {
setReminder(initialReminder);
setPropReminder(initialReminder);
}
return (
<ReminderContext.Provider value={[reminder, setReminder]}>
<div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}>
<TopBar
toggleCollapsed={() => {
setCollapsed(!collapsed);
}}
/>
<div class="columns reminder-settings">
<Message />
<Settings />
</div>
<EditButtonRow />
</div>
</ReminderContext.Provider>
);
};

View File

@ -1,58 +0,0 @@
import { ImagePicker } from "../ImagePicker";
import { Reminder } from "../../../api";
import { Mentions } from "../../App/Mentions";
import { useRef } from "preact/hooks";
import { useGuild } from "../../App/useGuild";
type Props = {
name: string;
icon: string;
setReminder: (r: (reminder: Reminder) => Reminder) => void;
};
export const Author = ({ name, icon, setReminder }: Props) => {
const guild = useGuild();
const input = useRef(null);
return (
<div class="embed-author-box">
<div class="a">
<p class="image is-24x24 customizable">
<ImagePicker
class="is-rounded embed_author_url"
url={icon}
alt="Image for embed author"
setImage={(url: string) => {
setReminder((reminder) => ({
...reminder,
embed_author_url: url,
}));
}}
></ImagePicker>
</p>
</div>
<div class="b">
{guild && <Mentions input={input} />}
<label class="is-sr-only" for="embedAuthor">
Embed Author
</label>
<textarea
class="discord-embed-author message-input autoresize"
placeholder="Embed Author..."
rows={1}
ref={input}
maxlength={256}
name="embed_author"
value={name}
onInput={(ev) => {
setReminder((reminder) => ({
...reminder,
embed_author: ev.currentTarget.value,
}));
}}
></textarea>
</div>
</div>
);
};

View File

@ -1,70 +0,0 @@
import { useState } from "preact/hooks";
import { HexColorPicker } from "react-colorful";
import { Modal } from "../../Modal";
import { Reminder } from "../../../api";
import { useDebouncedCallback } from "use-debounce";
type Props = {
color: string;
setReminder: (r: (reminder: Reminder) => Reminder) => void;
};
function colorToInt(hex: string) {
return parseInt(hex.substring(1), 16);
}
export const Color = ({ color, setReminder }: Props) => {
const [modalOpen, setModalOpen] = useState(false);
return (
<>
{modalOpen && (
<ColorModal
color={color}
setModalOpen={setModalOpen}
setReminder={setReminder}
></ColorModal>
)}
<button
class="change-color button is-rounded is-small"
onClick={() => {
setModalOpen(true);
}}
>
<span class="is-sr-only">Choose embed color</span>
<i class="fas fa-eye-dropper"></i>
</button>
</>
);
};
const ColorModal = ({ setModalOpen, color, setReminder }) => {
const setDebounced = useDebouncedCallback((color) => {
setReminder((reminder: Reminder) => ({
...reminder,
embed_color: colorToInt(color),
}));
}, 100);
return (
<Modal setModalOpen={setModalOpen} title={"Select Color"}>
<div class="colorpicker-container">
<HexColorPicker
color={color}
onChange={(color: string) => {
setDebounced(color);
}}
></HexColorPicker>
</div>
<br></br>
<input
class="input"
id="colorInput"
value={color}
onInput={(ev) => {
setDebounced(ev.currentTarget.value);
}}
></input>
</Modal>
);
};

Some files were not shown because too many files have changed in this diff Show More