1 Commits

Author SHA1 Message Date
e2bf23f194 Add macro install command stub 2022-09-12 17:20:43 +01:00
55 changed files with 1070 additions and 1622 deletions

2
.gitignore vendored
View File

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

1213
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,9 +0,0 @@
FROM ubuntu:20.04
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN apt update && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt install -y gcc gcc-multilib cmake pkg-config libssl-dev curl mysql-client-8.0
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain nightly
RUN cargo install cargo-deb

View File

@ -7,30 +7,25 @@ reminders are paid on the hosted version of the bot. Keep reading if you want to
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust) You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust)
### Compiling for local target ### Compiling
1. Install requirements: Install build requirements:
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential python3-dateparser` `sudo apt install gcc gcc-multilib cmake libssl-dev build-essential`
2. Install rustup from https://rustup.rs
3. Install the nightly toolchain: `rustup toolchain default nightly`
4. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders`.
5. Install `sqlx-cli`: `cargo install sqlx-cli`.
6. Run migrations: `sqlx migrate run`.
7. Set environment variables:
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
8. Build: `cargo build --release`
### Compiling for other target 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.
1. Install container software: `sudo apt install podman`. Reminder Bot can then be built by running `cargo build --release` in the top level directory. It is necessary to create a
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called `reminders` folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of
3. Install SQLx CLI: `cargo install sqlx-cli` dimensions 128x128px to be used as the webhook avatar.
4. From the source code directory, execute `sqlx migrate run`
5. Build container image: `podman build -t reminder-rs .`
6. Build with podman: `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
#### Compilation environment variables
These environment variables must be provided when compiling the bot
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
* `WEBHOOK_AVATAR` - accepts the name of an image file located in `$CARGO_MANIFEST_DIR/assets/` to be used as the avatar when creating webhooks. **IMPORTANT: image file must be 128x128 or smaller in size**
### Configuring ### Setting up Python
Reminder Bot by default looks for a venv within it's working directory to run Python out of. To set up a venv, install `python3-venv` and run `python3 -m venv venv`. Then, run `source venv/bin/activate` to activate the venv, and do `pip install dateparser` to install the required library
### Environment Variables
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory. Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded fallbacks. Environment variables can be loaded from a .env file in the working directory.
__Required Variables__ __Required Variables__
@ -42,5 +37,10 @@ __Other Variables__
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor * `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users * `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to * `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
* `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is located somewhere else * `PYTHON_LOCATION` - default `venv/bin/python3`. Can be changed if your Python executable is located somewhere else
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds * `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds
* `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages
### Todo List
* Convert aliases to macros

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

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

View File

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

13
debian/postinst vendored
View File

@ -1,13 +0,0 @@
#!/bin/bash
set -e
id -u reminder &>/dev/null || useradd -r -M reminder
if [ ! -f /etc/reminder-rs/config.env ]; then
cp /etc/reminder-rs/default.env /etc/reminder-rs/config.env
fi
chown reminder /etc/reminder-rs/config.env
#DEBHELPER#

11
debian/postrm vendored
View File

@ -1,11 +0,0 @@
#!/bin/bash
set -e
id -u reminder &>/dev/null || userdel reminder
if [ -f /etc/reminder-rs/config.env ]; then
rm /etc/reminder-rs/config.env
fi
#DEBHELPER#

View File

@ -1,6 +1,10 @@
CREATE DATABASE IF NOT EXISTS reminders;
SET FOREIGN_KEY_CHECKS=0; SET FOREIGN_KEY_CHECKS=0;
CREATE TABLE guilds ( USE reminders;
CREATE TABLE reminders.guilds (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
guild BIGINT UNSIGNED UNIQUE NOT NULL, guild BIGINT UNSIGNED UNIQUE NOT NULL,
@ -14,10 +18,10 @@ CREATE TABLE guilds (
default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL, default_avatar VARCHAR(512) DEFAULT 'https://raw.githubusercontent.com/reminder-bot/logos/master/Remind_Me_Bot_Logo_PPic.jpg' NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (default_channel_id) REFERENCES channels(id) ON DELETE SET NULL FOREIGN KEY (default_channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL
); );
CREATE TABLE channels ( CREATE TABLE reminders.channels (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
channel BIGINT UNSIGNED UNIQUE NOT NULL, channel BIGINT UNSIGNED UNIQUE NOT NULL,
@ -35,10 +39,10 @@ CREATE TABLE channels (
guild_id INT UNSIGNED, guild_id INT UNSIGNED,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE
); );
CREATE TABLE users ( CREATE TABLE reminders.users (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
user BIGINT UNSIGNED UNIQUE NOT NULL, user BIGINT UNSIGNED UNIQUE NOT NULL,
@ -55,10 +59,10 @@ CREATE TABLE users (
patreon BOOLEAN NOT NULL DEFAULT 0, patreon BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (dm_channel) REFERENCES channels(id) ON DELETE RESTRICT FOREIGN KEY (dm_channel) REFERENCES reminders.channels(id) ON DELETE RESTRICT
); );
CREATE TABLE roles ( CREATE TABLE reminders.roles (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT, id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
role BIGINT UNSIGNED UNIQUE NOT NULL, role BIGINT UNSIGNED UNIQUE NOT NULL,
@ -67,10 +71,10 @@ CREATE TABLE roles (
guild_id INT UNSIGNED NOT NULL, guild_id INT UNSIGNED NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE
); );
CREATE TABLE embeds ( CREATE TABLE reminders.embeds (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '', title VARCHAR(256) NOT NULL DEFAULT '',
@ -87,7 +91,7 @@ CREATE TABLE embeds (
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE TABLE embed_fields ( CREATE TABLE reminders.embed_fields (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '', title VARCHAR(256) NOT NULL DEFAULT '',
@ -96,10 +100,10 @@ CREATE TABLE embed_fields (
embed_id INT UNSIGNED NOT NULL, embed_id INT UNSIGNED NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE CASCADE FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE CASCADE
); );
CREATE TABLE messages ( CREATE TABLE reminders.messages (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
content VARCHAR(2048) NOT NULL DEFAULT '', content VARCHAR(2048) NOT NULL DEFAULT '',
@ -110,10 +114,10 @@ CREATE TABLE messages (
attachment_name VARCHAR(260), attachment_name VARCHAR(260),
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (embed_id) REFERENCES embeds(id) ON DELETE SET NULL FOREIGN KEY (embed_id) REFERENCES reminders.embeds(id) ON DELETE SET NULL
); );
CREATE TABLE reminders ( CREATE TABLE reminders.reminders (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
uid VARCHAR(64) UNIQUE NOT NULL, uid VARCHAR(64) UNIQUE NOT NULL,
@ -136,20 +140,20 @@ CREATE TABLE reminders (
set_by INT UNSIGNED, set_by INT UNSIGNED,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE RESTRICT, FOREIGN KEY (message_id) REFERENCES reminders.messages(id) ON DELETE RESTRICT,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE CASCADE,
FOREIGN KEY (set_by) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (set_by) REFERENCES reminders.users(id) ON DELETE SET NULL
); );
CREATE TRIGGER message_cleanup AFTER DELETE ON reminders CREATE TRIGGER message_cleanup AFTER DELETE ON reminders.reminders
FOR EACH ROW FOR EACH ROW
DELETE FROM messages WHERE id = OLD.message_id; DELETE FROM reminders.messages WHERE id = OLD.message_id;
CREATE TRIGGER embed_cleanup AFTER DELETE ON messages CREATE TRIGGER embed_cleanup AFTER DELETE ON reminders.messages
FOR EACH ROW FOR EACH ROW
DELETE FROM embeds WHERE id = OLD.embed_id; DELETE FROM reminders.embeds WHERE id = OLD.embed_id;
CREATE TABLE todos ( CREATE TABLE reminders.todos (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
user_id INT UNSIGNED, user_id INT UNSIGNED,
guild_id INT UNSIGNED, guild_id INT UNSIGNED,
@ -157,23 +161,23 @@ CREATE TABLE todos (
value VARCHAR(2000) NOT NULL, value VARCHAR(2000) NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE SET NULL
); );
CREATE TABLE command_restrictions ( CREATE TABLE reminders.command_restrictions (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
role_id INT UNSIGNED NOT NULL, role_id INT UNSIGNED NOT NULL,
command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL, command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, FOREIGN KEY (role_id) REFERENCES reminders.roles(id) ON DELETE CASCADE,
UNIQUE KEY (`role_id`, `command`) UNIQUE KEY (`role_id`, `command`)
); );
CREATE TABLE timers ( CREATE TABLE reminders.timers (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
start_time TIMESTAMP NOT NULL DEFAULT NOW(), start_time TIMESTAMP NOT NULL DEFAULT NOW(),
name VARCHAR(32) NOT NULL, name VARCHAR(32) NOT NULL,
@ -182,7 +186,7 @@ CREATE TABLE timers (
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE TABLE events ( CREATE TABLE reminders.events (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
`time` TIMESTAMP NOT NULL DEFAULT NOW(), `time` TIMESTAMP NOT NULL DEFAULT NOW(),
@ -194,12 +198,12 @@ CREATE TABLE events (
reminder_id INT UNSIGNED, reminder_id INT UNSIGNED,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE SET NULL FOREIGN KEY (reminder_id) REFERENCES reminders.reminders(id) ON DELETE SET NULL
); );
CREATE TABLE command_aliases ( CREATE TABLE reminders.command_aliases (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL, id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
guild_id INT UNSIGNED NOT NULL, guild_id INT UNSIGNED NOT NULL,
@ -208,22 +212,22 @@ CREATE TABLE command_aliases (
command VARCHAR(2048) NOT NULL, command VARCHAR(2048) NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE, FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
UNIQUE KEY (`guild_id`, `name`) UNIQUE KEY (`guild_id`, `name`)
); );
CREATE TABLE guild_users ( CREATE TABLE reminders.guild_users (
guild INT UNSIGNED NOT NULL, guild INT UNSIGNED NOT NULL,
user INT UNSIGNED NOT NULL, user INT UNSIGNED NOT NULL,
can_access BOOL NOT NULL DEFAULT 0, can_access BOOL NOT NULL DEFAULT 0,
FOREIGN KEY (guild) REFERENCES guilds(id) ON DELETE CASCADE, FOREIGN KEY (guild) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (user) REFERENCES reminders.users(id) ON DELETE CASCADE,
UNIQUE KEY (guild, user) UNIQUE KEY (guild, user)
); );
CREATE EVENT event_cleanup CREATE EVENT reminders.event_cleanup
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
ON COMPLETION PRESERVE ON COMPLETION PRESERVE
DO DELETE FROM events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY); DO DELETE FROM reminders.events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
use chrono::{DateTime, Days, Duration, Months}; use chrono::Duration;
use chrono_tz::Tz; use chrono_tz::Tz;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use log::{error, info, warn}; use log::{error, info, warn};
@ -62,23 +62,18 @@ pub fn substitute(string: &str) -> String {
let format = caps.name("format").map(|m| m.as_str()); let format = caps.name("format").map(|m| m.as_str());
if let (Some(final_time), Some(format)) = (final_time, format) { if let (Some(final_time), Some(format)) = (final_time, format) {
match NaiveDateTime::from_timestamp_opt(final_time, 0) { let dt = NaiveDateTime::from_timestamp(final_time, 0);
Some(dt) => { let now = Utc::now().naive_utc();
let now = Utc::now().naive_utc();
let difference = { let difference = {
if now < dt { if now < dt {
dt - Utc::now().naive_utc() dt - Utc::now().naive_utc()
} else { } else {
Utc::now().naive_utc() - dt Utc::now().naive_utc() - dt
}
};
fmt_displacement(format, difference.num_seconds() as u64)
} }
};
None => String::new(), fmt_displacement(format, difference.num_seconds() as u64)
}
} else { } else {
String::new() String::new()
} }
@ -248,12 +243,11 @@ pub struct Reminder {
attachment: Option<Vec<u8>>, attachment: Option<Vec<u8>>,
attachment_name: Option<String>, attachment_name: Option<String>,
utc_time: DateTime<Utc>, utc_time: NaiveDateTime,
timezone: String, timezone: String,
restartable: bool, restartable: bool,
expires: Option<DateTime<Utc>>, expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>, interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>, interval_months: Option<u32>,
avatar: Option<String>, avatar: Option<String>,
@ -287,7 +281,6 @@ SELECT
reminders.`restartable` AS restartable, reminders.`restartable` AS restartable,
reminders.`expires` AS 'expires', reminders.`expires` AS 'expires',
reminders.`interval_seconds` AS 'interval_seconds', reminders.`interval_seconds` AS 'interval_seconds',
reminders.`interval_days` AS 'interval_days',
reminders.`interval_months` AS 'interval_months', reminders.`interval_months` AS 'interval_months',
reminders.`avatar` AS avatar, reminders.`avatar` AS avatar,
@ -337,7 +330,9 @@ WHERE
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) { async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
let _ = sqlx::query!( let _ = sqlx::query!(
"UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?", "
UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
",
self.channel_id self.channel_id
) )
.execute(pool) .execute(pool)
@ -346,43 +341,55 @@ WHERE
async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) { async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
if self.interval_seconds.is_some() || self.interval_months.is_some() { if self.interval_seconds.is_some() || self.interval_months.is_some() {
let now = Utc::now(); let now = Utc::now().naive_local();
let mut updated_reminder_time = let mut updated_reminder_time = self.utc_time;
self.utc_time.with_timezone(&self.timezone.parse().unwrap_or(Tz::UTC));
while updated_reminder_time < now { if let Some(interval) = self.interval_months {
if let Some(interval) = self.interval_months { match sqlx::query!(
updated_reminder_time = updated_reminder_time // use the second date_add to force return value to datetime
.checked_add_months(Months::new(interval)) "SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time",
.unwrap_or_else(|| { updated_reminder_time,
warn!("Could not add months to a reminder"); 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 updated_reminder_time += Duration::days(30);
}); }
} },
if let Some(interval) = self.interval_days { Err(e) => {
updated_reminder_time = updated_reminder_time warn!("Could not update interval by months: {:?}", e);
.checked_add_days(Days::new(interval as u64))
.unwrap_or_else(|| {
warn!("Could not add days to a reminder");
updated_reminder_time // naively fallback to adding 30 days
}); updated_reminder_time += Duration::days(30);
} }
if let Some(interval) = self.interval_seconds {
updated_reminder_time =
updated_reminder_time + Duration::seconds(interval as i64);
} }
} }
if self.expires.map_or(false, |expires| updated_reminder_time > expires) { 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; self.force_delete(pool).await;
} else { } else {
sqlx::query!( sqlx::query!(
"UPDATE reminders SET `utc_time` = ? WHERE `id` = ?", "
updated_reminder_time.with_timezone(&Utc), UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
",
updated_reminder_time,
self.id self.id
) )
.execute(pool) .execute(pool)
@ -395,10 +402,15 @@ WHERE
} }
async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) { async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) {
sqlx::query!("DELETE FROM reminders WHERE `id` = ?", self.id) sqlx::query!(
.execute(pool) "
.await DELETE FROM reminders WHERE `id` = ?
.expect(&format!("Could not delete Reminder {}", self.id)); ",
self.id
)
.execute(pool)
.await
.expect(&format!("Could not delete Reminder {}", self.id));
} }
async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) { async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) {
@ -492,9 +504,7 @@ WHERE
w.content(&reminder.content).tts(reminder.tts); w.content(&reminder.content).tts(reminder.tts);
if let Some(username) = &reminder.username { if let Some(username) = &reminder.username {
if !username.is_empty() { w.username(username);
w.username(username);
}
} }
if let Some(avatar) = &reminder.avatar { if let Some(avatar) = &reminder.avatar {
@ -538,7 +548,9 @@ WHERE
.map_or(true, |inner| inner >= Utc::now().naive_local())) .map_or(true, |inner| inner >= Utc::now().naive_local()))
{ {
let _ = sqlx::query!( let _ = sqlx::query!(
"UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?", "
UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
",
self.channel_id self.channel_id
) )
.execute(pool) .execute(pool)

View File

@ -1,9 +1,6 @@
use std::time::{SystemTime, UNIX_EPOCH};
use chrono_tz::TZ_VARIANTS; use chrono_tz::TZ_VARIANTS;
use poise::AutocompleteChoice;
use crate::{models::CtxData, time_parser::natural_parser, Context}; use crate::Context;
pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> { pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
if partial.is_empty() { if partial.is_empty() {
@ -36,82 +33,3 @@ WHERE
.map(|s| s.name.clone()) .map(|s| s.name.clone())
.collect() .collect()
} }
pub async fn time_hint_autocomplete(
ctx: Context<'_>,
partial: &str,
) -> Vec<AutocompleteChoice<String>> {
if partial.is_empty() {
vec![AutocompleteChoice {
name: "Start typing a time...".to_string(),
value: "now".to_string(),
}]
} else {
match natural_parser(partial, &ctx.timezone().await.to_string()).await {
Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(now) => {
let diff = timestamp - now.as_secs() as i64;
if diff < 0 {
vec![AutocompleteChoice {
name: "Time is in the past".to_string(),
value: "now".to_string(),
}]
} else {
if diff > 86400 {
vec![
AutocompleteChoice {
name: partial.to_string(),
value: partial.to_string(),
},
AutocompleteChoice {
name: format!(
"In approximately {} days, {} hours",
diff / 86400,
(diff % 86400) / 3600
),
value: partial.to_string(),
},
]
} else if diff > 3600 {
vec![
AutocompleteChoice {
name: partial.to_string(),
value: partial.to_string(),
},
AutocompleteChoice {
name: format!("In approximately {} hours", diff / 3600),
value: partial.to_string(),
},
]
} else {
vec![
AutocompleteChoice {
name: partial.to_string(),
value: partial.to_string(),
},
AutocompleteChoice {
name: format!("In approximately {} minutes", diff / 60),
value: partial.to_string(),
},
]
}
}
}
Err(_) => {
vec![AutocompleteChoice {
name: partial.to_string(),
value: partial.to_string(),
}]
}
},
None => {
vec![AutocompleteChoice {
name: "Time not recognised".to_string(),
value: "now".to_string(),
}]
}
}
}
}

View File

@ -0,0 +1,38 @@
use poise::serenity_prelude::CommandType;
use crate::{
commands::autocomplete::macro_name_autocomplete, models::command_macro::guild_command_macro,
Context, Error,
};
/// Add a macro as a slash-command to this server. Enables controlling permissions per-macro.
#[poise::command(
slash_command,
rename = "install",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "install_macro"
)]
pub async fn install_macro(
ctx: Context<'_>,
#[description = "Name of macro to install"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();
if let Some(command_macro) = guild_command_macro(&ctx, &name).await {
guild_id
.create_application_command(&ctx.discord(), |a| {
a.kind(CommandType::ChatInput)
.name(command_macro.name)
.description(command_macro.description.unwrap_or_else(|| "".to_string()))
})
.await?;
ctx.send(|r| r.ephemeral(true).content("Macro installed. Go to Server Settings 🠚 Integrations 🠚 Reminder Bot to configure permissions.")).await?;
} else {
ctx.send(|r| r.ephemeral(true).content("No macro found with that name")).await?;
}
Ok(())
}

View File

@ -2,7 +2,7 @@ use poise::CreateReply;
use crate::{ use crate::{
component_models::pager::{MacroPager, Pager}, component_models::pager::{MacroPager, Pager},
consts::THEME_COLOR, consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
models::{command_macro::CommandMacro, CtxData}, models::{command_macro::CommandMacro, CtxData},
Context, Error, Context, Error,
}; };
@ -30,7 +30,27 @@ pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
} }
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize { pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
((macros.len() as f64) / 25.0).ceil() as usize let mut skipped_char_count = 0;
macros
.iter()
.map(|m| {
if let Some(description) = &m.description {
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
} else {
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
}
})
.fold(1, |mut pages, p| {
skipped_char_count += p.len();
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
skipped_char_count = p.len();
pages += 1;
}
pages
})
} }
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply { pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
@ -55,27 +75,45 @@ pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> Crea
page = pages - 1; page = pages - 1;
} }
let lower = (page * 25).min(macros.len()); let mut char_count = 0;
let upper = ((page + 1) * 25).min(macros.len()); let mut skipped_char_count = 0;
let fields = macros[lower..upper].iter().map(|m| { let mut skipped_pages = 0;
if let Some(description) = &m.description {
( let display_vec: Vec<String> = macros
m.name.clone(), .iter()
format!("*{}*\n- Has {} commands", description, m.commands.len()), .map(|m| {
true, if let Some(description) = &m.description {
) format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
} else { } else {
(m.name.clone(), format!("- Has {} commands", m.commands.len()), true) format!("**{}**\n- Has {} commands", m.name, m.commands.len())
} }
}); })
.skip_while(|p| {
skipped_char_count += p.len();
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
skipped_char_count = p.len();
skipped_pages += 1;
}
skipped_pages < page
})
.take_while(|p| {
char_count += p.len();
char_count < EMBED_DESCRIPTION_MAX_LENGTH
})
.collect::<Vec<String>>();
let display = display_vec.join("\n");
let mut reply = CreateReply::default(); let mut reply = CreateReply::default();
reply reply
.embed(|e| { .embed(|e| {
e.title("Macros") e.title("Macros")
.fields(fields) .description(display)
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages))) .footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
.color(*THEME_COLOR) .color(*THEME_COLOR)
}) })

View File

@ -1,6 +1,7 @@
use crate::{Context, Error}; use crate::{Context, Error};
pub mod delete; pub mod delete;
pub mod install;
pub mod list; pub mod list;
pub mod migrate; pub mod migrate;
pub mod record; pub mod record;

View File

@ -15,18 +15,6 @@ pub async fn record_macro(
#[description = "Name for the new macro"] name: String, #[description = "Name for the new macro"] name: String,
#[description = "Description for the new macro"] description: Option<String>, #[description = "Description for the new macro"] description: Option<String>,
) -> Result<(), Error> { ) -> Result<(), Error> {
if name.len() > 100 {
ctx.say("Name must be less than 100 characters").await?;
return Ok(());
}
if description.as_ref().map_or(0, |d| d.len()) > 100 {
ctx.say("Description must be less than 100 characters").await?;
return Ok(());
}
let guild_id = ctx.guild_id().unwrap(); let guild_id = ctx.guild_id().unwrap();
let row = sqlx::query!( let row = sqlx::query!(

View File

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

View File

@ -1,4 +1,4 @@
mod autocomplete; pub mod autocomplete;
pub mod command_macro; pub mod command_macro;
pub mod info_cmds; pub mod info_cmds;
pub mod moderation_cmds; pub mod moderation_cmds;

View File

@ -1,7 +1,6 @@
use chrono::offset::Utc; use chrono::offset::Utc;
use chrono_tz::{Tz, TZ_VARIANTS}; use chrono_tz::{Tz, TZ_VARIANTS};
use levenshtein::levenshtein; use levenshtein::levenshtein;
use log::warn;
use super::autocomplete::timezone_autocomplete; use super::autocomplete::timezone_autocomplete;
use crate::{consts::THEME_COLOR, models::CtxData, Context, Error}; use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
@ -158,24 +157,23 @@ pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
match ctx.channel_data().await { match ctx.channel_data().await {
Ok(data) => { Ok(data) => {
if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) { if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
ctx.send(|b| { let _ = ctx
b.ephemeral(true).content(format!( .send(|b| {
"**Warning!** b.ephemeral(true).content(format!(
"**Warning!**
This link can be used by users to anonymously send messages, with or without permissions. This link can be used by users to anonymously send messages, with or without permissions.
Do not share it! Do not share it!
|| https://discord.com/api/webhooks/{}/{} ||", || https://discord.com/api/webhooks/{}/{} ||",
id, token, id, token,
)) ))
}) })
.await?; .await;
} else { } else {
ctx.say("No webhook configured on this channel.").await?; let _ = ctx.say("No webhook configured on this channel.").await;
} }
} }
Err(e) => { Err(_) => {
warn!("Error fetching channel data: {:?}", e); let _ = ctx.say("No webhook configured on this channel.").await;
ctx.say("No webhook configured on this channel.").await?;
} }
} }

View File

@ -1,17 +1,21 @@
use std::{collections::HashSet, string::ToString}; use std::{
collections::HashSet,
string::ToString,
time::{SystemTime, UNIX_EPOCH},
};
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::NaiveDateTime;
use chrono_tz::Tz; use chrono_tz::Tz;
use num_integer::Integer; use num_integer::Integer;
use poise::{ use poise::{
serenity_prelude::{ serenity_prelude::{
builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType, builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType,
}, },
CreateReply, Modal, AutocompleteChoice, CreateReply, Modal,
}; };
use super::autocomplete::timezone_autocomplete;
use crate::{ use crate::{
commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete},
component_models::{ component_models::{
pager::{DelPager, LookPager, Pager}, pager::{DelPager, LookPager, Pager},
ComponentDataModel, DelSelector, UndoReminder, ComponentDataModel, DelSelector, UndoReminder,
@ -56,27 +60,18 @@ pub async fn pause(
let parsed = natural_parser(&until, &timezone.to_string()).await; let parsed = natural_parser(&until, &timezone.to_string()).await;
if let Some(timestamp) = parsed { if let Some(timestamp) = parsed {
match NaiveDateTime::from_timestamp_opt(timestamp, 0) { let dt = NaiveDateTime::from_timestamp(timestamp, 0);
Some(dt) => {
channel.paused = true;
channel.paused_until = Some(dt);
channel.commit_changes(&ctx.data().database).await; channel.paused = true;
channel.paused_until = Some(dt);
ctx.say(format!( channel.commit_changes(&ctx.data().database).await;
"Reminders in this channel have been silenced until **<t:{}:D>**",
timestamp
))
.await?;
}
None => { ctx.say(format!(
ctx.say(format!( "Reminders in this channel have been silenced until **<t:{}:D>**",
"Time processed could not be interpreted as `DateTime`. Please write the time as clearly as possible", timestamp
)) ))
.await?; .await?;
}
}
} else { } else {
ctx.say( ctx.say(
"Time could not be processed. Please write the time as clearly as possible", "Time could not be processed. Please write the time as clearly as possible",
@ -250,7 +245,7 @@ pub async fn look(
char_count < EMBED_DESCRIPTION_MAX_LENGTH char_count < EMBED_DESCRIPTION_MAX_LENGTH
}) })
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(""); .join("\n");
let pages = reminders let pages = reminders
.iter() .iter()
@ -437,8 +432,11 @@ pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> Cr
reply reply
} }
fn time_difference(start_time: DateTime<Utc>) -> String { fn time_difference(start_time: NaiveDateTime) -> String {
let delta = (Utc::now() - start_time).num_seconds(); let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
let now = NaiveDateTime::from_timestamp(unix_time, 0);
let delta = (now - start_time).num_seconds();
let (minutes, seconds) = delta.div_rem(&60); let (minutes, seconds) = delta.div_rem(&60);
let (hours, minutes) = minutes.div_rem(&60); let (hours, minutes) = minutes.div_rem(&60);
@ -552,6 +550,20 @@ pub async fn delete_timer(
Ok(()) Ok(())
} }
async fn multiline_autocomplete(
_ctx: Context<'_>,
partial: &str,
) -> Vec<AutocompleteChoice<String>> {
if partial.is_empty() {
vec![AutocompleteChoice { name: "Multiline content...".to_string(), value: "".to_string() }]
} else {
vec![
AutocompleteChoice { name: partial.to_string(), value: partial.to_string() },
AutocompleteChoice { name: "Multiline content...".to_string(), value: "".to_string() },
]
}
}
#[derive(poise::Modal)] #[derive(poise::Modal)]
#[name = "Reminder"] #[name = "Reminder"]
struct ContentModal { struct ContentModal {
@ -562,45 +574,7 @@ struct ContentModal {
content: String, content: String,
} }
/// Create a reminder with multi-line content. Press "+4 more" for other options. /// Create a reminder. Press "+5 more" for other options. A modal will open if "content" is not provided
#[poise::command(
slash_command,
identifying_name = "multiline",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn multiline(
ctx: ApplicationContext<'_>,
#[description = "A description of the time to set the reminder for"]
#[autocomplete = "time_hint_autocomplete"]
time: String,
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
interval: Option<String>,
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"]
expires: Option<String>,
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
tts: Option<bool>,
#[description = "Set a timezone override for this reminder only"]
#[autocomplete = "timezone_autocomplete"]
timezone: Option<String>,
) -> Result<(), Error> {
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
let data = ContentModal::execute(ctx).await?;
create_reminder(
Context::Application(ctx),
time,
data.content,
channels,
interval,
expires,
tts,
tz,
)
.await
}
/// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content.
#[poise::command( #[poise::command(
slash_command, slash_command,
identifying_name = "remind", identifying_name = "remind",
@ -608,10 +582,10 @@ pub async fn multiline(
)] )]
pub async fn remind( pub async fn remind(
ctx: ApplicationContext<'_>, ctx: ApplicationContext<'_>,
#[description = "A description of the time to set the reminder for"] #[description = "A description of the time to set the reminder for"] time: String,
#[autocomplete = "time_hint_autocomplete"] #[description = "The message content to send"]
time: String, #[autocomplete = "multiline_autocomplete"]
#[description = "The message content to send"] content: String, content: String,
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>, #[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"] #[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
interval: Option<String>, interval: Option<String>,
@ -625,8 +599,33 @@ pub async fn remind(
) -> Result<(), Error> { ) -> Result<(), Error> {
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten(); let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
create_reminder(Context::Application(ctx), time, content, channels, interval, expires, tts, tz) if content.is_empty() {
let data = ContentModal::execute(ctx).await?;
create_reminder(
Context::Application(ctx),
time,
data.content,
channels,
interval,
expires,
tts,
tz,
)
.await .await
} else {
create_reminder(
Context::Application(ctx),
time,
content,
channels,
interval,
expires,
tts,
tz,
)
.await
}
} }
async fn create_reminder( async fn create_reminder(

View File

@ -340,18 +340,7 @@ pub fn show_todo_page(
opt.create_option(|o| { opt.create_option(|o| {
o.label(format!("Mark {} complete", count + first_num)) o.label(format!("Mark {} complete", count + first_num))
.value(id) .value(id)
.description({ .description(disp.split_once(' ').unwrap_or(("", "")).1)
let c = disp.split_once(' ').unwrap_or(("", "")).1;
if c.len() > 100 {
format!(
"{}...",
c.chars().take(97).collect::<String>()
)
} else {
c.to_string()
}
})
}); });
} }

View File

@ -113,7 +113,7 @@ impl ComponentDataModel {
char_count < EMBED_DESCRIPTION_MAX_LENGTH char_count < EMBED_DESCRIPTION_MAX_LENGTH
}) })
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(""); .join("\n");
let mut embed = CreateEmbed::default(); let mut embed = CreateEmbed::default();
embed embed

View File

@ -2,7 +2,7 @@ pub const DAY: u64 = 86_400;
pub const HOUR: u64 = 3_600; pub const HOUR: u64 = 3_600;
pub const MINUTE: u64 = 60; pub const MINUTE: u64 = 60;
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4096; pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000;
pub const SELECT_MAX_ENTRIES: usize = 25; pub const SELECT_MAX_ENTRIES: usize = 25;
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
@ -17,13 +17,17 @@ use regex::Regex;
lazy_static! { lazy_static! {
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = ( pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/webhook.jpg")) as &[u8], include_bytes!(concat!(
"webhook.jpg", env!("CARGO_MANIFEST_DIR"),
"/assets/",
env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation")
)) as &[u8],
env!("WEBHOOK_AVATAR"),
) )
.into(); .into();
pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap(); pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap();
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter( pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
env::var("PATREON_ROLE_ID") env::var("SUBSCRIPTION_ROLES")
.map(|var| var .map(|var| var
.split(',') .split(',')
.filter_map(|item| { item.parse::<u64>().ok() }) .filter_map(|item| { item.parse::<u64>().ok() })
@ -31,7 +35,7 @@ lazy_static! {
.unwrap_or_else(|_| Vec::new()) .unwrap_or_else(|_| Vec::new())
); );
pub static ref CNC_GUILD: Option<u64> = pub static ref CNC_GUILD: Option<u64> =
env::var("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten(); env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
pub static ref MIN_INTERVAL: i64 = pub static ref MIN_INTERVAL: i64 =
env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600); env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
pub static ref MAX_TIME: i64 = env::var("MAX_TIME") pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
@ -44,5 +48,5 @@ lazy_static! {
.map_or(THEME_COLOR_FALLBACK, |inner| u32::from_str_radix(&inner, 16) .map_or(THEME_COLOR_FALLBACK, |inner| u32::from_str_radix(&inner, 16)
.unwrap_or(THEME_COLOR_FALLBACK)); .unwrap_or(THEME_COLOR_FALLBACK));
pub static ref PYTHON_LOCATION: String = pub static ref PYTHON_LOCATION: String =
env::var("PYTHON_LOCATION").unwrap_or_else(|_| "/usr/bin/python3".to_string()); env::var("PYTHON_LOCATION").unwrap_or_else(|_| "venv/bin/python3".to_string());
} }

View File

@ -4,7 +4,7 @@ use poise::{
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error}; use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
async fn macro_check(ctx: Context<'_>) -> bool { async fn recording_macro_check(ctx: Context<'_>) -> bool {
if let Context::Application(app_ctx) = ctx { if let Context::Application(app_ctx) = ctx {
if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) = if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) =
app_ctx.interaction app_ctx.interaction
@ -95,5 +95,5 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
} }
pub async fn all_checks(ctx: Context<'_>) -> Result<bool, Error> { pub async fn all_checks(ctx: Context<'_>) -> Result<bool, Error> {
Ok(macro_check(ctx).await && check_self_permissions(ctx).await) Ok(recording_macro_check(ctx).await && check_self_permissions(ctx).await)
} }

View File

@ -110,14 +110,13 @@ impl OverflowOp for u64 {
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub struct Interval { pub struct Interval {
pub month: u64, pub month: u64,
pub day: u64,
pub sec: u64, pub sec: u64,
} }
struct Parser<'a> { struct Parser<'a> {
iter: Chars<'a>, iter: Chars<'a>,
src: &'a str, src: &'a str,
current: (u64, u64, u64, u64), current: (u64, u64, u64),
} }
impl<'a> Parser<'a> { impl<'a> Parser<'a> {
@ -141,17 +140,17 @@ impl<'a> Parser<'a> {
Ok(None) Ok(None)
} }
fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> { fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> {
let (mut month, mut day, mut sec, nsec) = match &self.src[start..end] { let (mut month, mut sec, nsec) = match &self.src[start..end] {
"nanos" | "nsec" | "ns" => (0, 0u64, 0u64, n), "nanos" | "nsec" | "ns" => (0u64, 0u64, n),
"usec" | "us" => (0, 0, 0u64, n.mul(1000)?), "usec" | "us" => (0, 0u64, n.mul(1000)?),
"millis" | "msec" | "ms" => (0, 0, 0u64, n.mul(1_000_000)?), "millis" | "msec" | "ms" => (0, 0u64, n.mul(1_000_000)?),
"seconds" | "second" | "secs" | "sec" | "s" => (0, 0, n, 0), "seconds" | "second" | "secs" | "sec" | "s" => (0, n, 0),
"minutes" | "minute" | "min" | "mins" | "m" => (0, 0, n.mul(60)?, 0), "minutes" | "minute" | "min" | "mins" | "m" => (0, n.mul(60)?, 0),
"hours" | "hour" | "hr" | "hrs" | "h" => (0, 0, n.mul(3600)?, 0), "hours" | "hour" | "hr" | "hrs" | "h" => (0, n.mul(3600)?, 0),
"days" | "day" | "d" => (0, n, 0, 0), "days" | "day" | "d" => (0, n.mul(86400)?, 0),
"weeks" | "week" | "w" => (0, n.mul(7)?, 0, 0), "weeks" | "week" | "w" => (0, n.mul(86400 * 7)?, 0),
"months" | "month" | "M" => (n, 0, 0, 0), "months" | "month" | "M" => (n, 0, 0),
"years" | "year" | "y" => (n.mul(12)?, 0, 0, 0), "years" | "year" | "y" => (12, 0, 0),
_ => { _ => {
return Err(Error::UnknownUnit { return Err(Error::UnknownUnit {
start, start,
@ -161,16 +160,15 @@ impl<'a> Parser<'a> {
}); });
} }
}; };
let mut nsec = self.current.3 + nsec; let mut nsec = self.current.2 + nsec;
if nsec > 1_000_000_000 { if nsec > 1_000_000_000 {
sec += nsec / 1_000_000_000; sec += nsec / 1_000_000_000;
nsec %= 1_000_000_000; nsec %= 1_000_000_000;
} }
sec += self.current.2; sec += self.current.1;
day += self.current.1;
month += self.current.0; month += self.current.0;
self.current = (month, day, sec, nsec); self.current = (month, sec, nsec);
Ok(()) Ok(())
} }
@ -217,13 +215,7 @@ impl<'a> Parser<'a> {
self.parse_unit(n, start, off)?; self.parse_unit(n, start, off)?;
n = match self.parse_first_char()? { n = match self.parse_first_char()? {
Some(n) => n, Some(n) => n,
None => { None => return Ok(Interval { month: self.current.0, sec: self.current.1 }),
return Ok(Interval {
month: self.current.0,
day: self.current.1,
sec: self.current.2,
})
}
}; };
} }
} }
@ -255,73 +247,5 @@ impl<'a> Parser<'a> {
/// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000))); /// assert_eq!(parse_duration("32ms"), Ok(Duration::new(0, 32_000_000)));
/// ``` /// ```
pub fn parse_duration(s: &str) -> Result<Interval, Error> { pub fn parse_duration(s: &str) -> Result<Interval, Error> {
Parser { iter: s.chars(), src: s, current: (0, 0, 0, 0) }.parse() Parser { iter: s.chars(), src: s, current: (0, 0, 0) }.parse()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_seconds() {
let interval = parse_duration("10 seconds").unwrap();
assert_eq!(interval.sec, 10);
assert_eq!(interval.day, 0);
assert_eq!(interval.month, 0);
}
#[test]
fn parse_minutes() {
let interval = parse_duration("10 minutes").unwrap();
assert_eq!(interval.sec, 600);
assert_eq!(interval.day, 0);
assert_eq!(interval.month, 0);
}
#[test]
fn parse_hours() {
let interval = parse_duration("10 hours").unwrap();
assert_eq!(interval.sec, 36_000);
assert_eq!(interval.day, 0);
assert_eq!(interval.month, 0);
}
#[test]
fn parse_days() {
let interval = parse_duration("10 days").unwrap();
assert_eq!(interval.sec, 0);
assert_eq!(interval.day, 10);
assert_eq!(interval.month, 0);
}
#[test]
fn parse_weeks() {
let interval = parse_duration("10 weeks").unwrap();
assert_eq!(interval.sec, 0);
assert_eq!(interval.day, 70);
assert_eq!(interval.month, 0);
}
#[test]
fn parse_months() {
let interval = parse_duration("10 months").unwrap();
assert_eq!(interval.sec, 0);
assert_eq!(interval.day, 0);
assert_eq!(interval.month, 10);
}
#[test]
fn parse_years() {
let interval = parse_duration("10 years").unwrap();
assert_eq!(interval.sec, 0);
assert_eq!(interval.day, 0);
assert_eq!(interval.month, 120);
}
} }

View File

@ -18,10 +18,10 @@ use std::{
env, env,
error::Error as StdError, error::Error as StdError,
fmt::{Debug, Display, Formatter}, fmt::{Debug, Display, Formatter},
path::Path,
}; };
use chrono_tz::Tz; use chrono_tz::Tz;
use dotenv::dotenv;
use log::{error, warn}; use log::{error, warn};
use poise::serenity_prelude::model::{ use poise::serenity_prelude::model::{
gateway::GatewayIntents, gateway::GatewayIntents,
@ -75,7 +75,7 @@ impl Display for Ended {
impl StdError for Ended {} impl StdError for Ended {}
#[tokio::main(flavor = "multi_thread")] #[tokio::main]
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> { async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
let (tx, mut rx) = broadcast::channel(16); let (tx, mut rx) = broadcast::channel(16);
@ -88,9 +88,7 @@ async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> { async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
env_logger::init(); env_logger::init();
if Path::new("/etc/reminder-rs/config.env").exists() { dotenv()?;
dotenv::from_path("/etc/reminder-rs/config.env")?;
}
let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment"); let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
@ -119,6 +117,7 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
command_macro::record::record_macro(), command_macro::record::record_macro(),
command_macro::run::run_macro(), command_macro::run::run_macro(),
command_macro::migrate::migrate_macro(), command_macro::migrate::migrate_macro(),
command_macro::install::install_macro(),
], ],
..command_macro::macro_base() ..command_macro::macro_base()
}, },
@ -135,7 +134,6 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
], ],
..reminder_cmds::timer_base() ..reminder_cmds::timer_base()
}, },
reminder_cmds::multiline(),
reminder_cmds::remind(), reminder_cmds::remind(),
poise::Command { poise::Command {
subcommands: vec![ subcommands: vec![
@ -170,15 +168,8 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
let database = let database =
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap(); Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
sqlx::migrate!().run(&database).await?;
let popular_timezones = sqlx::query!( let popular_timezones = sqlx::query!(
"SELECT IFNULL(timezone, 'UTC') AS timezone "SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21"
FROM users
WHERE timezone IS NOT NULL
GROUP BY timezone
ORDER BY COUNT(timezone) DESC
LIMIT 21"
) )
.fetch_all(&database) .fetch_all(&database)
.await .await

View File

@ -37,6 +37,7 @@ pub struct RawCommandMacro {
pub commands: Value, pub commands: Value,
} }
/// Get a macro by name form a guild.
pub async fn guild_command_macro( pub async fn guild_command_macro(
ctx: &Context<'_>, ctx: &Context<'_>,
name: &str, name: &str,

View File

@ -53,8 +53,7 @@ pub struct ReminderBuilder {
channel: u32, channel: u32,
utc_time: NaiveDateTime, utc_time: NaiveDateTime,
timezone: String, timezone: String,
interval_seconds: Option<i64>, interval_secs: Option<i64>,
interval_days: Option<i64>,
interval_months: Option<i64>, interval_months: Option<i64>,
expires: Option<NaiveDateTime>, expires: Option<NaiveDateTime>,
content: String, content: String,
@ -88,7 +87,6 @@ INSERT INTO reminders (
`utc_time`, `utc_time`,
`timezone`, `timezone`,
`interval_seconds`, `interval_seconds`,
`interval_days`,
`interval_months`, `interval_months`,
`expires`, `expires`,
`content`, `content`,
@ -108,7 +106,6 @@ INSERT INTO reminders (
?, ?,
?, ?,
?, ?,
?,
? ?
) )
", ",
@ -116,8 +113,7 @@ INSERT INTO reminders (
self.channel, self.channel,
utc_time, utc_time,
self.timezone, self.timezone,
self.interval_seconds, self.interval_secs,
self.interval_days,
self.interval_months, self.interval_months,
self.expires, self.expires,
self.content, self.content,
@ -179,15 +175,17 @@ impl<'a> MultiReminderBuilder<'a> {
} }
pub fn time<T: Into<i64>>(mut self, time: T) -> Self { pub fn time<T: Into<i64>>(mut self, time: T) -> Self {
if let Some(utc_time) = NaiveDateTime::from_timestamp_opt(time.into(), 0) { self.utc_time = NaiveDateTime::from_timestamp(time.into(), 0);
self.utc_time = utc_time;
}
self self
} }
pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self { pub fn expires<T: Into<i64>>(mut self, time: Option<T>) -> Self {
self.expires = time.map(|t| NaiveDateTime::from_timestamp_opt(t.into(), 0)).flatten(); if let Some(t) = time {
self.expires = Some(NaiveDateTime::from_timestamp(t.into(), 0));
} else {
self.expires = None;
}
self self
} }
@ -214,14 +212,9 @@ impl<'a> MultiReminderBuilder<'a> {
let mut ok_locs = HashSet::new(); let mut ok_locs = HashSet::new();
if self if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) as i64) < *MIN_INTERVAL) {
.interval
.map_or(false, |i| ((i.sec + i.day * DAY + i.month * 30 * DAY) as i64) < *MIN_INTERVAL)
{
errors.insert(ReminderError::ShortInterval); errors.insert(ReminderError::ShortInterval);
} else if self } else if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) as i64) > *MAX_TIME)
.interval
.map_or(false, |i| ((i.sec + i.day * DAY + i.month * 30 * DAY) as i64) > *MAX_TIME)
{ {
errors.insert(ReminderError::LongInterval); errors.insert(ReminderError::LongInterval);
} else { } else {
@ -309,8 +302,7 @@ impl<'a> MultiReminderBuilder<'a> {
channel: c, channel: c,
utc_time: self.utc_time, utc_time: self.utc_time,
timezone: self.timezone.to_string(), timezone: self.timezone.to_string(),
interval_seconds: self.interval.map(|i| i.sec as i64), interval_secs: self.interval.map(|i| i.sec as i64),
interval_days: self.interval.map(|i| i.day as i64),
interval_months: self.interval.map(|i| i.month as i64), interval_months: self.interval.map(|i| i.month as i64),
expires: self.expires, expires: self.expires,
content: self.content.content.clone(), content: self.content.content.clone(),

View File

@ -6,7 +6,7 @@ pub mod look_flags;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{NaiveDateTime, TimeZone};
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::serenity_prelude::{ use poise::serenity_prelude::{
model::id::{ChannelId, GuildId, UserId}, model::id::{ChannelId, GuildId, UserId},
@ -24,9 +24,8 @@ pub struct Reminder {
pub id: u32, pub id: u32,
pub uid: String, pub uid: String,
pub channel: u64, pub channel: u64,
pub utc_time: DateTime<Utc>, pub utc_time: NaiveDateTime,
pub interval_seconds: Option<u32>, pub interval_seconds: Option<u32>,
pub interval_days: Option<u32>,
pub interval_months: Option<u32>, pub interval_months: Option<u32>,
pub expires: Option<NaiveDateTime>, pub expires: Option<NaiveDateTime>,
pub enabled: bool, pub enabled: bool,
@ -60,7 +59,6 @@ SELECT
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
@ -97,7 +95,6 @@ SELECT
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
@ -141,7 +138,6 @@ SELECT
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
@ -199,7 +195,6 @@ SELECT
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
@ -233,7 +228,6 @@ SELECT
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
@ -268,7 +262,6 @@ SELECT
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
@ -317,32 +310,30 @@ WHERE
count + 1, count + 1,
self.display_content(), self.display_content(),
self.channel, self.channel,
self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S") timezone.timestamp(self.utc_time.timestamp(), 0).format("%Y-%m-%d %H:%M:%S")
) )
} }
pub fn display(&self, flags: &LookFlags, timezone: &Tz) -> String { pub fn display(&self, flags: &LookFlags, timezone: &Tz) -> String {
let time_display = match flags.time_display { let time_display = match flags.time_display {
TimeDisplayType::Absolute => { TimeDisplayType::Absolute => timezone
self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S").to_string() .timestamp(self.utc_time.timestamp(), 0)
} .format("%Y-%m-%d %H:%M:%S")
.to_string(),
TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()), TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()),
}; };
if self.interval_seconds.is_some() if self.interval_seconds.is_some() || self.interval_months.is_some() {
|| self.interval_days.is_some()
|| self.interval_months.is_some()
{
format!( format!(
"'{}' *occurs next at* **{}**, repeating (set by {})\n", "'{}' *occurs next at* **{}**, repeating (set by {})",
self.display_content(), self.display_content(),
time_display, time_display,
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string()) self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
) )
} else { } else {
format!( format!(
"'{}' *occurs next at* **{}** (set by {})\n", "'{}' *occurs next at* **{}** (set by {})",
self.display_content(), self.display_content(),
time_display, time_display,
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string()) self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())

View File

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

View File

@ -22,7 +22,7 @@ impl UserData {
match sqlx::query!( match sqlx::query!(
" "
SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ? SELECT timezone FROM users WHERE user = ?
", ",
user_id user_id
) )

View File

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

View File

@ -31,7 +31,7 @@ lazy_static! {
) )
.into(); .into();
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter( pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
env::var("PATREON_ROLE_ID") env::var("SUBSCRIPTION_ROLES")
.map(|var| var .map(|var| var
.split(',') .split(',')
.filter_map(|item| { item.parse::<u64>().ok() }) .filter_map(|item| { item.parse::<u64>().ok() })
@ -39,7 +39,7 @@ lazy_static! {
.unwrap_or_else(|_| Vec::new()) .unwrap_or_else(|_| Vec::new())
); );
pub static ref CNC_GUILD: Option<u64> = pub static ref CNC_GUILD: Option<u64> =
env::var("PATREON_GUILD_ID").map(|var| var.parse::<u64>().ok()).ok().flatten(); env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL") pub static ref MIN_INTERVAL: u32 = env::var("MIN_INTERVAL")
.ok() .ok()
.map(|inner| inner.parse::<u32>().ok()) .map(|inner| inner.parse::<u32>().ok())

View File

@ -75,7 +75,7 @@ pub async fn initialize(
env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied"); env::var("OAUTH2_CLIENT_ID").expect("`OAUTH2_CLIENT_ID' not supplied");
env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied"); env::var("OAUTH2_CLIENT_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied");
env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied"); env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied");
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied"); env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD' not supplied");
info!("Done!"); info!("Done!");
let oauth2_client = BasicClient::new( let oauth2_client = BasicClient::new(

View File

@ -58,7 +58,6 @@ pub async fn export_reminders(
reminders.enabled, reminders.enabled,
reminders.expires, reminders.expires,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.name, reminders.name,
reminders.restartable, reminders.restartable,
@ -160,7 +159,6 @@ pub async fn import_reminders(
enabled: record.enabled, enabled: record.enabled,
expires: record.expires, expires: record.expires,
interval_seconds: record.interval_seconds, interval_seconds: record.interval_seconds,
interval_days: record.interval_days,
interval_months: record.interval_months, interval_months: record.interval_months,
name: record.name, name: record.name,
restartable: record.restartable, restartable: record.restartable,
@ -320,6 +318,13 @@ pub async fn import_todos(
} }
} }
let _ = sqlx::query!(
"DELETE FROM todos WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
id
)
.execute(pool.inner())
.await;
let query_str = format!( let query_str = format!(
"INSERT INTO todos (value, channel_id, guild_id) VALUES {}", "INSERT INTO todos (value, channel_id, guild_id) VALUES {}",
vec![query_placeholder].repeat(query_params.len()).join(",") vec![query_placeholder].repeat(query_params.len()).join(",")

View File

@ -16,12 +16,10 @@ use serenity::{
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use crate::{ use crate::{
check_guild_subscription, check_subscription,
consts::{ consts::{
MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
MIN_INTERVAL,
}, },
routes::dashboard::{ routes::dashboard::{
create_database_channel, create_reminder, template_name_default, DeleteReminder, create_database_channel, create_reminder, template_name_default, DeleteReminder,
@ -249,9 +247,9 @@ pub async fn create_reminder_template(
Ok(json!({})) Ok(json!({}))
} }
Err(e) => { Err(e) => {
warn!("Could not create template for {}: {:?}", id, e); warn!("Could not fetch templates from {}: {:?}", id, e);
json_err!("Could not create template") json_err!("Could not get templates")
} }
} }
} }
@ -341,7 +339,6 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq
reminders.enabled, reminders.enabled,
reminders.expires, reminders.expires,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.name, reminders.name,
reminders.restartable, reminders.restartable,
@ -377,109 +374,35 @@ pub async fn edit_reminder(
reminder: Json<PatchReminder>, reminder: Json<PatchReminder>,
serenity_context: &State<Context>, serenity_context: &State<Context>,
pool: &State<Pool<MySql>>, pool: &State<Pool<MySql>>,
cookies: &CookieJar<'_>,
) -> JsonResult { ) -> JsonResult {
check_authorization!(cookies, serenity_context.inner(), id);
let mut error = vec![]; let mut error = vec![];
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
if reminder.message_ok() {
update_field!(pool.inner(), error, reminder.[
content,
embed_author,
embed_description,
embed_footer,
embed_title,
embed_fields,
username
]);
} else {
error.push("Message exceeds limits.".to_string());
}
update_field!(pool.inner(), error, reminder.[ update_field!(pool.inner(), error, reminder.[
attachment, attachment,
attachment_name, attachment_name,
avatar, avatar,
content,
embed_author,
embed_author_url, embed_author_url,
embed_color, embed_color,
embed_description,
embed_footer,
embed_footer_url, embed_footer_url,
embed_image_url, embed_image_url,
embed_thumbnail_url, embed_thumbnail_url,
embed_title,
embed_fields,
enabled, enabled,
expires, expires,
interval_seconds,
interval_months,
name, name,
restartable, restartable,
tts, tts,
username,
utc_time utc_time
]); ]);
if reminder.interval_days.flatten().is_some()
|| reminder.interval_months.flatten().is_some()
|| reminder.interval_seconds.flatten().is_some()
{
if check_guild_subscription(&serenity_context.inner(), id).await
|| check_subscription(&serenity_context.inner(), user_id).await
{
let new_interval_length = match reminder.interval_days {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_days AS days FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(pool.inner())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.days
.unwrap_or(0),
} + match reminder.interval_months {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_months AS months FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(pool.inner())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.months
.unwrap_or(0),
} + match reminder.interval_seconds {
Some(interval) => interval.unwrap_or(0),
None => sqlx::query!(
"SELECT interval_seconds AS seconds FROM reminders WHERE uid = ?",
reminder.uid
)
.fetch_one(pool.inner())
.await
.map_err(|e| {
warn!("Error updating reminder interval: {:?}", e);
json!({ "reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"] })
})?
.seconds
.unwrap_or(0),
};
if new_interval_length < *MIN_INTERVAL {
error.push(String::from("New interval is too short."));
} else {
update_field!(pool.inner(), error, reminder.[
interval_days,
interval_months,
interval_seconds
]);
}
}
}
if reminder.channel > 0 { if reminder.channel > 0 {
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner()); let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
match channel { match channel {
@ -560,7 +483,6 @@ pub async fn edit_reminder(
reminders.enabled, reminders.enabled,
reminders.expires, reminders.expires,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.name, reminders.name,
reminders.restartable, reminders.restartable,

View File

@ -8,13 +8,13 @@ use rocket::{
serde::json::{json, Value as JsonValue}, serde::json::{json, Value as JsonValue},
}; };
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Serialize};
use serenity::{ use serenity::{
client::Context, client::Context,
http::Http, http::Http,
model::id::{ChannelId, GuildId, UserId}, model::id::{ChannelId, GuildId, UserId},
}; };
use sqlx::{types::Json, Executor}; use sqlx::{types::Json, Executor, MySql, Pool};
use crate::{ use crate::{
check_guild_subscription, check_subscription, check_guild_subscription, check_subscription,
@ -50,18 +50,6 @@ fn id_default() -> u32 {
0 0
} }
fn interval_default() -> Unset<Option<u32>> {
None
}
fn deserialize_optional_field<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Ok(Some(Option::deserialize(deserializer)?))
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct ReminderTemplate { pub struct ReminderTemplate {
#[serde(default = "id_default")] #[serde(default = "id_default")]
@ -144,7 +132,6 @@ pub struct Reminder {
enabled: bool, enabled: bool,
expires: Option<NaiveDateTime>, expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>, interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>, interval_months: Option<u32>,
#[serde(default = "name_default")] #[serde(default = "name_default")]
name: String, name: String,
@ -177,7 +164,6 @@ pub struct ReminderCsv {
enabled: bool, enabled: bool,
expires: Option<NaiveDateTime>, expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>, interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>, interval_months: Option<u32>,
#[serde(default = "name_default")] #[serde(default = "name_default")]
name: String, name: String,
@ -191,13 +177,10 @@ pub struct ReminderCsv {
pub struct PatchReminder { pub struct PatchReminder {
uid: String, uid: String,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
attachment: Unset<Option<String>>, attachment: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
attachment_name: Unset<Option<String>>, attachment_name: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
avatar: Unset<Option<String>>, avatar: Unset<Option<String>>,
#[serde(default = "channel_default")] #[serde(default = "channel_default")]
#[serde(with = "string")] #[serde(with = "string")]
@ -207,7 +190,6 @@ pub struct PatchReminder {
#[serde(default)] #[serde(default)]
embed_author: Unset<String>, embed_author: Unset<String>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_author_url: Unset<Option<String>>, embed_author_url: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
embed_color: Unset<u32>, embed_color: Unset<u32>,
@ -216,13 +198,10 @@ pub struct PatchReminder {
#[serde(default)] #[serde(default)]
embed_footer: Unset<String>, embed_footer: Unset<String>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_footer_url: Unset<Option<String>>, embed_footer_url: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_image_url: Unset<Option<String>>, embed_image_url: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_thumbnail_url: Unset<Option<String>>, embed_thumbnail_url: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
embed_title: Unset<String>, embed_title: Unset<String>,
@ -231,16 +210,10 @@ pub struct PatchReminder {
#[serde(default)] #[serde(default)]
enabled: Unset<bool>, enabled: Unset<bool>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
expires: Unset<Option<NaiveDateTime>>, expires: Unset<Option<NaiveDateTime>>,
#[serde(default = "interval_default")] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
interval_seconds: Unset<Option<u32>>, interval_seconds: Unset<Option<u32>>,
#[serde(default = "interval_default")] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
interval_days: Unset<Option<u32>>,
#[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
interval_months: Unset<Option<u32>>, interval_months: Unset<Option<u32>>,
#[serde(default)] #[serde(default)]
name: Unset<String>, name: Unset<String>,
@ -249,36 +222,11 @@ pub struct PatchReminder {
#[serde(default)] #[serde(default)]
tts: Unset<bool>, tts: Unset<bool>,
#[serde(default)] #[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
username: Unset<Option<String>>, username: Unset<Option<String>>,
#[serde(default)] #[serde(default)]
utc_time: Unset<NaiveDateTime>, utc_time: Unset<NaiveDateTime>,
} }
impl PatchReminder {
fn message_ok(&self) -> bool {
self.content.as_ref().map_or(true, |c| c.len() <= MAX_CONTENT_LENGTH)
&& self.embed_author.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_AUTHOR_LENGTH)
&& self
.embed_description
.as_ref()
.map_or(true, |c| c.len() <= MAX_EMBED_DESCRIPTION_LENGTH)
&& self.embed_footer.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_FOOTER_LENGTH)
&& self.embed_title.as_ref().map_or(true, |c| c.len() <= MAX_EMBED_TITLE_LENGTH)
&& self.embed_fields.as_ref().map_or(true, |c| {
c.0.len() <= MAX_EMBED_FIELDS
&& c.0.iter().all(|f| {
f.title.len() <= MAX_EMBED_FIELD_TITLE_LENGTH
&& f.value.len() <= MAX_EMBED_FIELD_VALUE_LENGTH
})
})
&& self
.username
.as_ref()
.map_or(true, |c| c.as_ref().map_or(true, |v| v.len() <= MAX_USERNAME_LENGTH))
}
}
pub fn generate_uid() -> String { pub fn generate_uid() -> String {
let mut generator: OsRng = Default::default(); let mut generator: OsRng = Default::default();
@ -353,28 +301,11 @@ pub struct TodoCsv {
pub async fn create_reminder( pub async fn create_reminder(
ctx: &Context, ctx: &Context,
pool: impl sqlx::Executor<'_, Database = Database> + Copy, pool: &Pool<MySql>,
guild_id: GuildId, guild_id: GuildId,
user_id: UserId, user_id: UserId,
reminder: Reminder, reminder: Reminder,
) -> JsonResult { ) -> JsonResult {
// check guild in db
match sqlx::query!("SELECT 1 as A FROM guilds WHERE guild = ?", guild_id.0)
.fetch_one(pool)
.await
{
Err(sqlx::Error::RowNotFound) => {
if sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id.0)
.execute(pool)
.await
.is_err()
{
return Err(json!({"error": "Guild could not be created"}));
}
}
_ => {}
}
// validate channel // validate channel
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx); let channel = ChannelId(reminder.channel).to_channel_cached(&ctx);
let channel_exists = channel.is_some(); let channel_exists = channel.is_some();
@ -439,12 +370,8 @@ pub async fn create_reminder(
if reminder.utc_time < Utc::now().naive_utc() { if reminder.utc_time < Utc::now().naive_utc() {
return Err(json!({"error": "Time must be in the future"})); return Err(json!({"error": "Time must be in the future"}));
} }
if reminder.interval_seconds.is_some() if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32 if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32
+ reminder.interval_days.unwrap_or(0) * DAY as u32
+ reminder.interval_seconds.unwrap_or(0) + reminder.interval_seconds.unwrap_or(0)
< *MIN_INTERVAL < *MIN_INTERVAL
{ {
@ -453,10 +380,7 @@ pub async fn create_reminder(
} }
// check patreon if necessary // check patreon if necessary
if reminder.interval_seconds.is_some() if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if !check_guild_subscription(&ctx, guild_id).await if !check_guild_subscription(&ctx, guild_id).await
&& !check_subscription(&ctx, user_id).await && !check_subscription(&ctx, user_id).await
{ {
@ -467,11 +391,6 @@ pub async fn create_reminder(
// base64 decode error dropped here // base64 decode error dropped here
let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten(); let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten();
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() }; let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
let username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
None
} else {
reminder.username
};
let new_uid = generate_uid(); let new_uid = generate_uid();
@ -497,14 +416,13 @@ pub async fn create_reminder(
enabled, enabled,
expires, expires,
interval_seconds, interval_seconds,
interval_days,
interval_months, interval_months,
name, name,
restartable, restartable,
tts, tts,
username, username,
`utc_time` `utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_uid, new_uid,
attachment_data, attachment_data,
reminder.attachment_name, reminder.attachment_name,
@ -524,12 +442,11 @@ pub async fn create_reminder(
reminder.enabled, reminder.enabled,
reminder.expires, reminder.expires,
reminder.interval_seconds, reminder.interval_seconds,
reminder.interval_days,
reminder.interval_months, reminder.interval_months,
name, name,
reminder.restartable, reminder.restartable,
reminder.tts, reminder.tts,
username, reminder.username,
reminder.utc_time, reminder.utc_time,
) )
.execute(pool) .execute(pool)
@ -556,7 +473,6 @@ pub async fn create_reminder(
reminders.enabled, reminders.enabled,
reminders.expires, reminders.expires,
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months, reminders.interval_months,
reminders.name, reminders.name,
reminders.restartable, reminders.restartable,

View File

@ -61,13 +61,10 @@ pub async fn get_user_info(
.member(&ctx.inner(), user_id) .member(&ctx.inner(), user_id)
.await; .await;
let timezone = sqlx::query!( let timezone = sqlx::query!("SELECT timezone FROM users WHERE user = ?", user_id)
"SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?", .fetch_one(pool.inner())
user_id .await
) .map_or(None, |q| Some(q.timezone));
.fetch_one(pool.inner())
.await
.map_or(None, |q| Some(q.timezone));
let user_info = UserInfo { let user_info = UserInfo {
name: cookies name: cookies

View File

@ -135,14 +135,14 @@ pub async fn discord_callback(
Err(Flash::new( Err(Flash::new(
Redirect::to(uri!(super::return_to_same_site(""))), Redirect::to(uri!(super::return_to_same_site(""))),
"warning", "warning",
"Your login request was rejected. The server may be misconfigured. Please retry or alert us in Discord.", "Your login request was rejected",
)) ))
} }
} }
} else { } else {
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "danger", "Your request failed to validate, and so has been rejected (CSRF Validation Failure)")) Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "danger", "Your request failed to validate, and so has been rejected (error: CSRF Validation Failure)"))
} }
} else { } else {
Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "warning", "Your request was missing information, and so has been rejected (CSRF Validation Tokens Missing)")) Err(Flash::new(Redirect::to(uri!(super::return_to_same_site(""))), "warning", "Your request was missing information, and so has been rejected (error: CSRF Validation Tokens Missing)"))
} }
} }

View File

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

View File

@ -60,15 +60,14 @@ function update_select(sel) {
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
sel.selectedOptions[0].dataset["webhookAvatar"]; sel.selectedOptions[0].dataset["webhookAvatar"];
} else { } else {
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = "";
"/static/img/icon.png";
} }
if (sel.selectedOptions[0].dataset["webhookName"]) { if (sel.selectedOptions[0].dataset["webhookName"]) {
sel.closest("div.reminderContent").querySelector("input.discord-username").value = sel.closest("div.reminderContent").querySelector("input.discord-username").value =
sel.selectedOptions[0].dataset["webhookName"]; sel.selectedOptions[0].dataset["webhookName"];
} else { } else {
sel.closest("div.reminderContent").querySelector("input.discord-username").value = sel.closest("div.reminderContent").querySelector("input.discord-username").value =
"Reminder"; "";
} }
} }
@ -320,7 +319,6 @@ async function serialize_reminder(node, mode) {
embed_fields: fields, embed_fields: fields,
expires: expiration_time, expires: expiration_time,
interval_seconds: mode !== "template" ? interval.seconds : null, interval_seconds: mode !== "template" ? interval.seconds : null,
interval_days: mode !== "template" ? interval.days : null,
interval_months: mode !== "template" ? interval.months : null, interval_months: mode !== "template" ? interval.months : null,
name: node.querySelector('input[name="name"]').value, name: node.querySelector('input[name="name"]').value,
tts: node.querySelector('input[name="tts"]').checked, tts: node.querySelector('input[name="tts"]').checked,
@ -333,9 +331,6 @@ function deserialize_reminder(reminder, frame, mode) {
// populate channels // populate channels
set_channels(frame.querySelector("select.channel-selector")); set_channels(frame.querySelector("select.channel-selector"));
frame.querySelector(`*[name="interval_hours"]`).value = 0;
frame.querySelector(`*[name="interval_minutes"]`).value = 0;
// populate majority of items // populate majority of items
for (let prop in reminder) { for (let prop in reminder) {
if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) { if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) {
@ -356,8 +351,6 @@ function deserialize_reminder(reminder, frame, mode) {
} }
} }
update_interval(frame);
const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box"); const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box");
for (let field of reminder["embed_fields"]) { for (let field of reminder["embed_fields"]) {
@ -504,8 +497,6 @@ document.addEventListener("remindersLoaded", (event) => {
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
for (let error of data.errors) show_error(error); for (let error of data.errors) show_error(error);
deserialize_reminder(data.reminder, node, "reload");
}); });
$saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"]; $saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"];
@ -724,7 +715,6 @@ $createReminderBtn.addEventListener("click", async () => {
let reminder = await serialize_reminder($createReminder, "create"); let reminder = await serialize_reminder($createReminder, "create");
if (reminder.error) { if (reminder.error) {
show_error(reminder.error); show_error(reminder.error);
$createReminderBtn.querySelector("span.icon > i").classList = ["fas fa-sparkles"];
return; return;
} }
@ -842,6 +832,13 @@ $deleteTemplateBtn.addEventListener("click", (ev) => {
}); });
}); });
document.querySelectorAll("textarea.autoresize").forEach((element) => {
element.addEventListener("input", () => {
element.style.height = "";
element.style.height = element.scrollHeight + 3 + "px";
});
});
let $img; let $img;
const $urlModal = document.querySelector("div#addImageModal"); const $urlModal = document.querySelector("div#addImageModal");
const $urlInput = $urlModal.querySelector("input"); const $urlInput = $urlModal.querySelector("input");
@ -897,13 +894,6 @@ document.addEventListener("remindersLoaded", () => {
window.getComputedStyle($discordFrame).borderLeftColor; window.getComputedStyle($discordFrame).borderLeftColor;
}); });
}); });
document.querySelectorAll("textarea.autoresize").forEach((element) => {
element.addEventListener("input", () => {
element.style.height = "";
element.style.height = element.scrollHeight + 3 + "px";
});
});
}); });
function check_embed_fields() { function check_embed_fields() {

View File

@ -191,8 +191,19 @@
</label> </label>
</div> </div>
</div> </div>
<div class="control">
<div class="field">
<label>
<input type="radio" class="default-width" name="exportSelect" value="reminder_templates">
Reminder templates
</label>
</div>
</div>
<br> <br>
<div class="has-text-centered"> <div class="has-text-centered">
<div style="color: red; font-weight: bold;">
By selecting "Import", you understand that this will overwrite existing data.
</div>
<div style="color: red"> <div style="color: red">
Please first read the <a href="/help/iemanager">support page</a> Please first read the <a href="/help/iemanager">support page</a>
</div> </div>

View File

@ -27,7 +27,7 @@
</div> </div>
<div class="tile is-parent"> <div class="tile is-parent">
<article class="tile is-child notification"> <article class="tile is-child notification">
<p class="title">Create reminders</p> <p class="title">Creating reminders</p>
<p class="subtitle">Learn to create reminders for your server</p> <p class="subtitle">Learn to create reminders for your server</p>
<div class="content has-text-centered"> <div class="content has-text-centered">
<a class="button is-size-4 is-rounded is-light" href="/help/create_reminder"> <a class="button is-size-4 is-rounded is-light" href="/help/create_reminder">
@ -52,47 +52,47 @@
</article> </article>
</div> </div>
</div> </div>
<!-- <div class="tile is-ancestor">--> <div class="tile is-ancestor">
<!-- <div class="tile is-parent">--> <div class="tile is-parent">
<!-- <article class="tile is-child notification">--> <article class="tile is-child notification">
<!-- <p class="title">Timers</p>--> <p class="title">Timers</p>
<!-- <p class="subtitle">Learn to manage timers</p>--> <p class="subtitle">Learn to manage timers</p>
<!-- <div class="content has-text-centered">--> <div class="content has-text-centered">
<!-- <a class="button is-size-4 is-rounded is-light" href="/help/timers">--> <a class="button is-size-4 is-rounded is-light" href="/help/timers">
<!-- <p class="is-size-4">--> <p class="is-size-4">
<!-- Read <span class="icon"><i class="fas fa-chevron-right"></i></span>--> Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
<!-- </p>--> </p>
<!-- </a>--> </a>
<!-- </div>--> </div>
<!-- </article>--> </article>
<!-- </div>--> </div>
<!-- <div class="tile is-parent">--> <div class="tile is-parent">
<!-- <article class="tile is-child notification">--> <article class="tile is-child notification">
<!-- <p class="title">Todo Lists</p>--> <p class="title">Todo Lists</p>
<!-- <p class="subtitle">Learn to manage various todo lists</p>--> <p class="subtitle">Learn to manage various todo lists</p>
<!-- <div class="content has-text-centered">--> <div class="content has-text-centered">
<!-- <a class="button is-size-4 is-rounded is-light" href="/help/todo_lists">--> <a class="button is-size-4 is-rounded is-light" href="/help/todo_lists">
<!-- <p class="is-size-4">--> <p class="is-size-4">
<!-- Read <span class="icon"><i class="fas fa-chevron-right"></i></span>--> Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
<!-- </p>--> </p>
<!-- </a>--> </a>
<!-- </div>--> </div>
<!-- </article>--> </article>
<!-- </div>--> </div>
<!-- <div class="tile is-parent is-vertical">--> <div class="tile is-parent is-vertical">
<!-- <article class="tile is-child notification">--> <article class="tile is-child notification">
<!-- <p class="title">Macros</p>--> <p class="title">Macros</p>
<!-- <p class="subtitle">Learn how to create combination commands called macros, to suit advanced use cases</p>--> <p class="subtitle">Learn how to create combination commands called macros, to suit advanced use cases</p>
<!-- <div class="content has-text-centered">--> <div class="content has-text-centered">
<!-- <a class="button is-size-4 is-rounded is-light" href="/help/macros">--> <a class="button is-size-4 is-rounded is-light" href="/help/macros">
<!-- <p class="is-size-4">--> <p class="is-size-4">
<!-- Read <span class="icon"><i class="fas fa-chevron-right"></i></span>--> Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
<!-- </p>--> </p>
<!-- </a>--> </a>
<!-- </div>--> </div>
<!-- </article>--> </article>
<!-- </div>--> </div>
<!-- </div>--> </div>
<div class="tile is-ancestor"> <div class="tile is-ancestor">
<div class="tile is-parent"> <div class="tile is-parent">
<article class="tile is-child notification"> <article class="tile is-child notification">
@ -107,6 +107,19 @@
</div> </div>
</article> </article>
</div> </div>
<div class="tile is-parent">
<article class="tile is-child notification">
<p class="title">Dashboard</p>
<p class="subtitle">Learn to use the interactive web dashboard</p>
<div class="content has-text-centered">
<a class="button is-size-4 is-rounded is-light" href="/help/dashboard">
<p class="is-size-4">
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
</p>
</a>
</div>
</article>
</div>
<div class="tile is-parent is-vertical"> <div class="tile is-parent is-vertical">
<article class="tile is-child notification"> <article class="tile is-child notification">
<p class="title">Import/Export</p> <p class="title">Import/Export</p>
@ -120,19 +133,6 @@
</div> </div>
</article> </article>
</div> </div>
<div class="tile is-parent">
<!-- <article class="tile is-child notification">-->
<!-- <p class="title">Dashboard</p>-->
<!-- <p class="subtitle">Learn to use the interactive web dashboard</p>-->
<!-- <div class="content has-text-centered">-->
<!-- <a class="button is-size-4 is-rounded is-light" href="/help/dashboard">-->
<!-- <p class="is-size-4">-->
<!-- Read <span class="icon"><i class="fas fa-chevron-right"></i></span>-->
<!-- </p>-->
<!-- </a>-->
<!-- </div>-->
<!-- </article>-->
</div>
</div> </div>
</div> </div>

View File

@ -28,10 +28,7 @@
<div class="container"> <div class="container">
<p class="title">Create reminders via the dashboard</p> <p class="title">Create reminders via the dashboard</p>
<p class="content"> <p class="content">
Reminders can also be created on the dashboard. The dashboard offers more options for configuring Reminders can also be created on the dashboard.
reminders, and offers templates for quick recreation of reminders.
<a href="/dashboard">Access the dashboard.</a>
</p> </p>
</div> </div>
</div> </div>

View File

@ -12,7 +12,7 @@
<section class="hero is-small"> <section class="hero is-small">
<div class="hero-body"> <div class="hero-body">
<div class="container"> <div class="container">
<p class="title">Export data</p> <p class="title">Export your data</p>
<p class="content"> <p class="content">
You can export data associated with your server from the dashboard. The data will export as a CSV You can export data associated with your server from the dashboard. The data will export as a CSV
file. The CSV file can then be edited and imported to bulk edit server data. file. The CSV file can then be edited and imported to bulk edit server data.
@ -26,7 +26,8 @@
<div class="container"> <div class="container">
<p class="title">Import data</p> <p class="title">Import data</p>
<p class="content"> <p class="content">
You can import previous exports or modified exports. When importing a file, the new data will be added alongside existing data. You can import previous exports or modified exports. When importing a file, <strong>existing data
will be overwritten</strong>.
</p> </p>
</div> </div>
</div> </div>
@ -54,7 +55,7 @@
</figure> </figure>
</li> </li>
<li> <li>
Make edits to the spreadsheet. You can add, edit, and remove rows for reminders. Don't remove the top-most (title) row. Make edits to the spreadsheet. You can add, edit, and remove rows for reminders. Don't remove the title row.
<figure> <figure>
<img src="/static/img/support/iemanager/edit_spreadsheet.png" alt="Editing spreadsheet"> <img src="/static/img/support/iemanager/edit_spreadsheet.png" alt="Editing spreadsheet">
</figure> </figure>
@ -69,7 +70,7 @@
Other spreadsheet tools can also be used to edit exports, as long as they are properly configured: Other spreadsheet tools can also be used to edit exports, as long as they are properly configured:
<ul> <ul>
<li> <li>
<strong>Google Sheets</strong>: Create a new blank spreadsheet. Select <strong>File > Import > Upload > export.csv</strong>. <strong>Google Sheets</strong>: Create a new blank spreadsheet. Select <strong>File >> Import >> Upload >> export.csv</strong>.
Use the following import settings: Use the following import settings:
<figure> <figure>
<img src="/static/img/support/iemanager/sheets_settings.png" alt="Google sheets import settings"> <img src="/static/img/support/iemanager/sheets_settings.png" alt="Google sheets import settings">

View File

@ -49,7 +49,7 @@
<p class="content"> <p class="content">
Monthly or yearly intervals are configured the same as fixed intervals. Instead of a fixed time Monthly or yearly intervals are configured the same as fixed intervals. Instead of a fixed time
interval, these reminders repeat on a certain day each month or each year. This makes them ideal interval, these reminders repeat on a certain day each month or each year. This makes them ideal
for marking calendar events. for marking certain dates.
</p> </p>
</div> </div>
</div> </div>
@ -61,8 +61,7 @@
<p class="title">Interval expiration</p> <p class="title">Interval expiration</p>
<p class="content"> <p class="content">
An expiration time can also be specified, both via commands and dashboard, for repeating reminders. An expiration time can also be specified, both via commands and dashboard, for repeating reminders.
This is optional, and if omitted, the reminder will repeat indefinitely. Otherwise, the reminder This is optional, and if omitted, the reminder will repeat indefinitely.
will be deleted once the expiration date is reached.
</p> </p>
</div> </div>
</div> </div>