3 Commits

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

1211
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,20 @@
[package]
name = "reminder_rs"
version = "1.6.10"
authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021"
version = "1.6.6"
authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018"
[dependencies]
poise = "0.4"
poise = "0.3"
dotenv = "0.15"
tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11"
lazy-regex = "2.3.0"
regex = "1.6"
log = "0.4"
env_logger = "0.10"
env_logger = "0.9"
chrono = "0.4"
chrono-tz = { version = "0.8", features = ["serde"] }
chrono-tz = { version = "0.6", features = ["serde"] }
lazy_static = "1.4"
num-integer = "0.1"
serde = "1.0"
@ -23,7 +23,7 @@ serde_repr = "0.1"
rmp-serde = "1.1"
rand = "0.8"
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"
[dependencies.postman]
@ -31,16 +31,3 @@ path = "postman"
[dependencies.reminder_web]
path = "web"
[package.metadata.deb]
depends = "$auto, nginx, python3, python3-venv"
suggests = "mysql-server-8.0"
maintainer-scripts = "debian"
assets = [
["target/release/reminder-rs", "usr/bin/reminder-rs", "755"],
["conf/default.env", "etc/reminder-rs/default.env", "600"]
]
[package.metadata.deb.systemd-units]
unit-scripts = "systemd"
start = false

View File

@ -22,15 +22,8 @@ These environment variables must be provided when compiling the bot
* `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
* `WEBHOOK_AVATAR` - accepts the name of an image file located in `$CARGO_MANIFEST_DIR/assets/` to be used as the avatar when creating webhooks. **IMPORTANT: image file must be 128x128 or smaller in size**
### Setting up database
Use MySQL 8. MariaDB is confirmed not working at the moment.
Load the SQL files in order from "migrations" to generate the database schema.
### 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.
Remember where you create the venv! You may need to change the `PYTHON_LOCATION` variable in the next step to point to your Python binary if the venv is not in your working directory.
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.

View File

@ -1,6 +1,6 @@
[default]
address = "0.0.0.0"
port = 18920
port = 5000
template_dir = "web/templates"
limits = { json = "10MiB" }
@ -11,18 +11,18 @@ secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="
certs = "web/private/rsa_sha256_cert.pem"
key = "web/private/rsa_sha256_key.pem"
[debug.rsa_sha256.tls]
[rsa_sha256.tls]
certs = "web/private/rsa_sha256_cert.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"
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"
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
[debug.ed25519.tls]
[ed25519.tls]
certs = "web/private/ed25519_cert.pem"
key = "eb/private/ed25519_key.pem"

View File

@ -1,15 +0,0 @@
DATABASE_URL=
DISCORD_TOKEN=
PATREON_GUILD_ID=
PATREON_ROLE_ID=
LOCAL_TIMEZONE=
MIN_INTERVAL=
PYTHON_LOCATION=
SECRET_KEY=
REMIND_INTERVAL=
OAUTH2_DISCORD_CALLBACK=
OAUTH2_CLIENT_ID=
OAUTH2_CLIENT_SECRET=

2
debian/.gitignore vendored
View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

@ -1,6 +1,10 @@
CREATE DATABASE IF NOT EXISTS reminders;
SET FOREIGN_KEY_CHECKS=0;
CREATE TABLE guilds (
USE reminders;
CREATE TABLE reminders.guilds (
id INT UNSIGNED UNIQUE NOT NULL AUTO_INCREMENT,
guild BIGINT UNSIGNED UNIQUE NOT NULL,
@ -17,7 +21,7 @@ CREATE TABLE guilds (
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,
channel BIGINT UNSIGNED UNIQUE NOT NULL,
@ -38,7 +42,7 @@ CREATE TABLE channels (
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,
user BIGINT UNSIGNED UNIQUE NOT NULL,
@ -58,7 +62,7 @@ CREATE TABLE users (
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,
role BIGINT UNSIGNED UNIQUE NOT NULL,
@ -70,7 +74,7 @@ CREATE TABLE roles (
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,
title VARCHAR(256) NOT NULL DEFAULT '',
@ -87,7 +91,7 @@ CREATE TABLE embeds (
PRIMARY KEY (id)
);
CREATE TABLE embed_fields (
CREATE TABLE reminders.embed_fields (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '',
@ -96,10 +100,10 @@ CREATE TABLE embed_fields (
embed_id INT UNSIGNED NOT NULL,
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,
content VARCHAR(2048) NOT NULL DEFAULT '',
@ -110,10 +114,10 @@ CREATE TABLE messages (
attachment_name VARCHAR(260),
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,
uid VARCHAR(64) UNIQUE NOT NULL,
@ -136,20 +140,20 @@ CREATE TABLE reminders (
set_by INT UNSIGNED,
PRIMARY KEY (id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE RESTRICT,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
FOREIGN KEY (set_by) REFERENCES users(id) ON DELETE SET NULL
FOREIGN KEY (message_id) REFERENCES reminders.messages(id) ON DELETE RESTRICT,
FOREIGN KEY (channel_id) REFERENCES reminders.channels(id) ON DELETE CASCADE,
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
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
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,
user_id INT UNSIGNED,
guild_id INT UNSIGNED,
@ -157,23 +161,23 @@ CREATE TABLE todos (
value VARCHAR(2000) NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL
FOREIGN KEY (user_id) REFERENCES reminders.users(id) ON DELETE SET NULL,
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
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,
role_id INT UNSIGNED NOT NULL,
command ENUM('todos', 'natural', 'remind', 'interval', 'timer', 'del', 'look', 'alias', 'countdown') NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES reminders.roles(id) ON DELETE CASCADE,
UNIQUE KEY (`role_id`, `command`)
);
CREATE TABLE timers (
CREATE TABLE reminders.timers (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
start_time TIMESTAMP NOT NULL DEFAULT NOW(),
name VARCHAR(32) NOT NULL,
@ -182,7 +186,7 @@ CREATE TABLE timers (
PRIMARY KEY (id)
);
CREATE TABLE events (
CREATE TABLE reminders.events (
id INT UNSIGNED AUTO_INCREMENT UNIQUE NOT NULL,
`time` TIMESTAMP NOT NULL DEFAULT NOW(),
@ -194,12 +198,12 @@ CREATE TABLE events (
reminder_id INT UNSIGNED,
PRIMARY KEY (id),
FOREIGN KEY (guild_id) REFERENCES guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE SET NULL
FOREIGN KEY (guild_id) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES reminders.users(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,
guild_id INT UNSIGNED NOT NULL,
@ -208,22 +212,22 @@ CREATE TABLE command_aliases (
command VARCHAR(2048) NOT NULL,
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`)
);
CREATE TABLE guild_users (
CREATE TABLE reminders.guild_users (
guild INT UNSIGNED NOT NULL,
user INT UNSIGNED NOT NULL,
can_access BOOL NOT NULL DEFAULT 0,
FOREIGN KEY (guild) REFERENCES guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (guild) REFERENCES reminders.guilds(id) ON DELETE CASCADE,
FOREIGN KEY (user) REFERENCES reminders.users(id) ON DELETE CASCADE,
UNIQUE KEY (guild, user)
);
CREATE EVENT event_cleanup
CREATE EVENT reminders.event_cleanup
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 1 DAY
ON COMPLETION PRESERVE
DO DELETE FROM events WHERE `time` < DATE_SUB(NOW(), INTERVAL 5 DAY);
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;
DROP TABLE IF EXISTS reminders_new;

View File

@ -1,3 +1,5 @@
USE reminders;
CREATE TABLE macro (
id INT UNSIGNED AUTO_INCREMENT,
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 (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ use chrono::offset::Utc;
use chrono_tz::{Tz, TZ_VARIANTS};
use levenshtein::levenshtein;
use log::warn;
use poise::serenity_prelude::{ChannelId, Mentionable};
use super::autocomplete::timezone_autocomplete;
use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
@ -148,11 +149,51 @@ pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Set defaults for commands
#[poise::command(
slash_command,
identifying_name = "default",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn default(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Set a default channel for reminders to be sent to
#[poise::command(
slash_command,
guild_only = true,
identifying_name = "default_channel",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn default_channel(
ctx: Context<'_>,
#[description = "Channel to send reminders to by default"] channel: Option<ChannelId>,
) -> Result<(), Error> {
if let Some(mut guild_data) = ctx.guild_data().await {
guild_data.default_channel = channel.map(|c| c.0);
guild_data.commit_changes(&ctx.data().database).await?;
if let Some(channel) = channel {
ctx.send(|r| {
r.ephemeral(true).content(format!("Default channel set to {}", channel.mention()))
})
.await?;
} else {
ctx.send(|r| r.ephemeral(true).content("Default channel unset.")).await?;
}
}
Ok(())
}
/// View the webhook being used to send reminders to this channel
#[poise::command(
slash_command,
identifying_name = "webhook_url",
required_permissions = "ADMINISTRATOR"
required_permissions = "ADMINISTRATOR",
default_member_permissions = "ADMINISTRATOR"
)]
pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
match ctx.channel_data().await {

View File

@ -1,6 +1,10 @@
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 num_integer::Integer;
use poise::{
@ -11,7 +15,9 @@ use poise::{
};
use crate::{
commands::autocomplete::{time_hint_autocomplete, timezone_autocomplete},
commands::autocomplete::{
multiline_autocomplete, time_hint_autocomplete, timezone_autocomplete,
},
component_models::{
pager::{DelPager, LookPager, Pager},
ComponentDataModel, DelSelector, UndoReminder,
@ -56,27 +62,18 @@ pub async fn pause(
let parsed = natural_parser(&until, &timezone.to_string()).await;
if let Some(timestamp) = parsed {
match NaiveDateTime::from_timestamp_opt(timestamp, 0) {
Some(dt) => {
channel.paused = true;
channel.paused_until = Some(dt);
let dt = NaiveDateTime::from_timestamp(timestamp, 0);
channel.commit_changes(&ctx.data().database).await;
channel.paused = true;
channel.paused_until = Some(dt);
ctx.say(format!(
"Reminders in this channel have been silenced until **<t:{}:D>**",
timestamp
))
.await?;
}
channel.commit_changes(&ctx.data().database).await;
None => {
ctx.say(format!(
"Time processed could not be interpreted as `DateTime`. Please write the time as clearly as possible",
))
.await?;
}
}
ctx.say(format!(
"Reminders in this channel have been silenced until **<t:{}:D>**",
timestamp
))
.await?;
} else {
ctx.say(
"Time could not be processed. Please write the time as clearly as possible",
@ -250,7 +247,7 @@ pub async fn look(
char_count < EMBED_DESCRIPTION_MAX_LENGTH
})
.collect::<Vec<String>>()
.join("");
.join("\n");
let pages = reminders
.iter()
@ -437,8 +434,11 @@ pub fn show_delete_page(reminders: &[Reminder], page: usize, timezone: Tz) -> Cr
reply
}
fn time_difference(start_time: DateTime<Utc>) -> String {
let delta = (Utc::now() - start_time).num_seconds();
fn time_difference(start_time: NaiveDateTime) -> String {
let unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
let now = NaiveDateTime::from_timestamp(unix_time, 0);
let delta = (now - start_time).num_seconds();
let (minutes, seconds) = delta.div_rem(&60);
let (hours, minutes) = minutes.div_rem(&60);
@ -562,45 +562,7 @@ struct ContentModal {
content: String,
}
/// Create a reminder with multi-line content. Press "+4 more" for other options.
#[poise::command(
slash_command,
identifying_name = "multiline",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn multiline(
ctx: ApplicationContext<'_>,
#[description = "A description of the time to set the reminder for"]
#[autocomplete = "time_hint_autocomplete"]
time: String,
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
interval: Option<String>,
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"]
expires: Option<String>,
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
tts: Option<bool>,
#[description = "Set a timezone override for this reminder only"]
#[autocomplete = "timezone_autocomplete"]
timezone: Option<String>,
) -> Result<(), Error> {
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
let data = ContentModal::execute(ctx).await?;
create_reminder(
Context::Application(ctx),
time,
data.content,
channels,
interval,
expires,
tts,
tz,
)
.await
}
/// Create a reminder. Press "+4 more" for other options. Use "/multiline" for multiline content.
/// Create a reminder. Press "+4 more" for other options.
#[poise::command(
slash_command,
identifying_name = "remind",
@ -611,7 +573,9 @@ pub async fn remind(
#[description = "A description of the time to set the reminder for"]
#[autocomplete = "time_hint_autocomplete"]
time: String,
#[description = "The message content to send"] content: String,
#[description = "The message content to send"]
#[autocomplete = "multiline_autocomplete"]
content: String,
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
interval: Option<String>,
@ -625,8 +589,33 @@ pub async fn remind(
) -> Result<(), Error> {
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
} else {
create_reminder(
Context::Application(ctx),
time,
content,
channels,
interval,
expires,
tts,
tz,
)
.await
}
}
async fn create_reminder(
@ -664,7 +653,9 @@ async fn create_reminder(
let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default();
if list.is_empty() {
if ctx.guild_id().is_some() {
if let Some(channel_id) = ctx.default_channel().await {
vec![ReminderScope::Channel(channel_id.0)]
} else if ctx.guild_id().is_some() {
vec![ReminderScope::Channel(ctx.channel_id().0)]
} else {
vec![ReminderScope::User(ctx.author().id.0)]

View File

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

View File

@ -113,7 +113,7 @@ impl ComponentDataModel {
char_count < EMBED_DESCRIPTION_MAX_LENGTH
})
.collect::<Vec<String>>()
.join("");
.join("\n");
let mut embed = CreateEmbed::default();
embed
@ -222,9 +222,7 @@ WHERE channels.channel = ?",
.collect::<Vec<(usize, String)>>()
} else {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
"SELECT todos.id, value FROM todos WHERE guild_id = ?",
pager.guild_id,
)
.fetch_all(&data.database)

View File

@ -27,7 +27,7 @@ lazy_static! {
.into();
pub static ref REGEX_CHANNEL_USER: Regex = Regex::new(r#"\s*<(#|@)(?:!)?(\d+)>\s*"#).unwrap();
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
env::var("PATREON_ROLE_ID")
env::var("SUBSCRIPTION_ROLES")
.map(|var| var
.split(',')
.filter_map(|item| { item.parse::<u64>().ok() })
@ -35,7 +35,7 @@ lazy_static! {
.unwrap_or_else(|_| Vec::new())
);
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 =
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")

View File

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

View File

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

View File

@ -18,10 +18,10 @@ use std::{
env,
error::Error as StdError,
fmt::{Debug, Display, Formatter},
path::Path,
};
use chrono_tz::Tz;
use dotenv::dotenv;
use log::{error, warn};
use poise::serenity_prelude::model::{
gateway::GatewayIntents,
@ -75,7 +75,7 @@ impl Display for Ended {
impl StdError for Ended {}
#[tokio::main(flavor = "multi_thread")]
#[tokio::main]
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
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>> {
env_logger::init();
if Path::new("/etc/soundfx-rs/default.env").exists() {
dotenv::from_path("/etc/soundfx-rs/default.env")?;
}
dotenv()?;
let discord_token = env::var("DISCORD_TOKEN").expect("Missing DISCORD_TOKEN from environment");
@ -122,6 +120,10 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
],
..command_macro::macro_base()
},
poise::Command {
subcommands: vec![moderation_cmds::default_channel()],
..moderation_cmds::default()
},
reminder_cmds::pause(),
reminder_cmds::offset(),
reminder_cmds::nudge(),
@ -135,7 +137,6 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
],
..reminder_cmds::timer_base()
},
reminder_cmds::multiline(),
reminder_cmds::remind(),
poise::Command {
subcommands: vec![
@ -170,8 +171,6 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
let database =
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
sqlx::migrate!().run(&database).await?;
let popular_timezones = sqlx::query!(
"SELECT IFNULL(timezone, 'UTC') AS timezone
FROM users

View File

@ -38,7 +38,7 @@ SELECT id, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_u
sqlx::query!(
"
INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))
INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, ?)
",
channel_id,
channel_name,

View File

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

52
src/models/guild_data.rs Normal file
View File

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

View File

@ -1,28 +1,28 @@
pub mod channel_data;
pub mod command_macro;
pub mod guild_data;
pub mod reminder;
pub mod timer;
pub mod user_data;
use chrono_tz::Tz;
use poise::serenity_prelude::{async_trait, model::id::UserId};
use log::warn;
use poise::serenity_prelude::{async_trait, model::id::UserId, ChannelId};
use crate::{
models::{channel_data::ChannelData, user_data::UserData},
models::{channel_data::ChannelData, guild_data::GuildData, user_data::UserData},
CommandMacro, Context, Data, Error, GuildId,
};
#[async_trait]
pub trait CtxData {
async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error>;
async fn author_data(&self) -> Result<UserData, Error>;
async fn timezone(&self) -> Tz;
async fn channel_data(&self) -> Result<ChannelData, Error>;
async fn guild_data(&self) -> Option<GuildData>;
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error>;
async fn default_channel(&self) -> Option<ChannelId>;
}
#[async_trait]
@ -51,24 +51,55 @@ impl CtxData for Context<'_> {
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error> {
self.data().command_macros(self.guild_id().unwrap()).await
}
async fn default_channel(&self) -> Option<ChannelId> {
match self.guild_id() {
Some(guild_id) => {
let guild_data = GuildData::from_guild(guild_id, &self.data().database).await;
match guild_data {
Ok(data) => data.default_channel.map(|c| ChannelId(c)),
Err(e) => {
warn!("SQL error: {:?}", e);
None
}
}
}
None => None,
}
}
async fn guild_data(&self) -> Option<GuildData> {
match self.guild_id() {
Some(guild_id) => GuildData::from_guild(guild_id, &self.data().database).await.ok(),
None => None,
}
}
}
impl Data {
pub(crate) async fn command_macros(
pub async fn command_macros(
&self,
guild_id: GuildId,
) -> Result<Vec<CommandMacro<Data, Error>>, Error> {
let rows = sqlx::query!(
"SELECT name, description, commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
"SELECT name, description, commands FROM macro WHERE guild_id = ?",
guild_id.0
)
.fetch_all(&self.database)
.await?.iter().map(|row| CommandMacro {
.await?
.iter()
.map(|row| CommandMacro {
guild_id,
name: row.name.clone(),
description: row.description.clone(),
commands: serde_json::from_str(&row.commands).unwrap(),
}).collect();
})
.collect();
Ok(rows)
}

View File

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

View File

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

View File

@ -1,12 +0,0 @@
[Unit]
Description=Reminder Bot
[Service]
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();
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
env::var("PATREON_ROLE_ID")
env::var("SUBSCRIPTION_ROLES")
.map(|var| var
.split(',')
.filter_map(|item| { item.parse::<u64>().ok() })
@ -39,7 +39,7 @@ lazy_static! {
.unwrap_or_else(|_| Vec::new())
);
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")
.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_SECRET").expect("`OAUTH2_CLIENT_SECRET' not supplied");
env::var("OAUTH2_DISCORD_CALLBACK").expect("`OAUTH2_DISCORD_CALLBACK' not supplied");
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD_ID' not supplied");
env::var("PATREON_GUILD_ID").expect("`PATREON_GUILD' not supplied");
info!("Done!");
let oauth2_client = BasicClient::new(

View File

@ -58,7 +58,6 @@ pub async fn export_reminders(
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,
@ -160,7 +159,6 @@ pub async fn import_reminders(
enabled: record.enabled,
expires: record.expires,
interval_seconds: record.interval_seconds,
interval_days: record.interval_days,
interval_months: record.interval_months,
name: record.name,
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!(
"INSERT INTO todos (value, channel_id, guild_id) VALUES {}",
vec![query_placeholder].repeat(query_params.len()).join(",")

View File

@ -16,12 +16,10 @@ use serenity::{
use sqlx::{MySql, Pool};
use crate::{
check_guild_subscription, check_subscription,
consts::{
MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
MIN_INTERVAL,
},
routes::dashboard::{
create_database_channel, create_reminder, template_name_default, DeleteReminder,
@ -249,9 +247,9 @@ pub async fn create_reminder_template(
Ok(json!({}))
}
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.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,
@ -377,109 +374,35 @@ pub async fn edit_reminder(
reminder: Json<PatchReminder>,
serenity_context: &State<Context>,
pool: &State<Pool<MySql>>,
cookies: &CookieJar<'_>,
) -> JsonResult {
check_authorization!(cookies, serenity_context.inner(), id);
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.[
attachment,
attachment_name,
avatar,
content,
embed_author,
embed_author_url,
embed_color,
embed_description,
embed_footer,
embed_footer_url,
embed_image_url,
embed_thumbnail_url,
embed_title,
embed_fields,
enabled,
expires,
interval_seconds,
interval_months,
name,
restartable,
tts,
username,
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 {
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
match channel {
@ -560,7 +483,6 @@ pub async fn edit_reminder(
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,

View File

@ -8,13 +8,13 @@ use rocket::{
serde::json::{json, Value as JsonValue},
};
use rocket_dyn_templates::Template;
use serde::{Deserialize, Deserializer, Serialize};
use serde::{Deserialize, Serialize};
use serenity::{
client::Context,
http::Http,
model::id::{ChannelId, GuildId, UserId},
};
use sqlx::{types::Json, Executor};
use sqlx::{types::Json, Executor, MySql, Pool};
use crate::{
check_guild_subscription, check_subscription,
@ -50,18 +50,6 @@ fn id_default() -> u32 {
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)]
pub struct ReminderTemplate {
#[serde(default = "id_default")]
@ -144,7 +132,6 @@ pub struct Reminder {
enabled: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
#[serde(default = "name_default")]
name: String,
@ -177,7 +164,6 @@ pub struct ReminderCsv {
enabled: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_days: Option<u32>,
interval_months: Option<u32>,
#[serde(default = "name_default")]
name: String,
@ -191,13 +177,10 @@ pub struct ReminderCsv {
pub struct PatchReminder {
uid: String,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
attachment: Unset<Option<String>>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
attachment_name: Unset<Option<String>>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
avatar: Unset<Option<String>>,
#[serde(default = "channel_default")]
#[serde(with = "string")]
@ -207,7 +190,6 @@ pub struct PatchReminder {
#[serde(default)]
embed_author: Unset<String>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_author_url: Unset<Option<String>>,
#[serde(default)]
embed_color: Unset<u32>,
@ -216,13 +198,10 @@ pub struct PatchReminder {
#[serde(default)]
embed_footer: Unset<String>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_footer_url: Unset<Option<String>>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_image_url: Unset<Option<String>>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
embed_thumbnail_url: Unset<Option<String>>,
#[serde(default)]
embed_title: Unset<String>,
@ -231,16 +210,10 @@ pub struct PatchReminder {
#[serde(default)]
enabled: Unset<bool>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
expires: Unset<Option<NaiveDateTime>>,
#[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
#[serde(default)]
interval_seconds: Unset<Option<u32>>,
#[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
interval_days: Unset<Option<u32>>,
#[serde(default = "interval_default")]
#[serde(deserialize_with = "deserialize_optional_field")]
#[serde(default)]
interval_months: Unset<Option<u32>>,
#[serde(default)]
name: Unset<String>,
@ -249,36 +222,11 @@ pub struct PatchReminder {
#[serde(default)]
tts: Unset<bool>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_optional_field")]
username: Unset<Option<String>>,
#[serde(default)]
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 {
let mut generator: OsRng = Default::default();
@ -353,28 +301,11 @@ pub struct TodoCsv {
pub async fn create_reminder(
ctx: &Context,
pool: impl sqlx::Executor<'_, Database = Database> + Copy,
pool: &Pool<MySql>,
guild_id: GuildId,
user_id: UserId,
reminder: Reminder,
) -> 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
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx);
let channel_exists = channel.is_some();
@ -439,12 +370,8 @@ pub async fn create_reminder(
if reminder.utc_time < Utc::now().naive_utc() {
return Err(json!({"error": "Time must be in the future"}));
}
if reminder.interval_seconds.is_some()
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
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)
< *MIN_INTERVAL
{
@ -453,10 +380,7 @@ pub async fn create_reminder(
}
// check patreon if necessary
if reminder.interval_seconds.is_some()
|| reminder.interval_days.is_some()
|| reminder.interval_months.is_some()
{
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
if !check_guild_subscription(&ctx, guild_id).await
&& !check_subscription(&ctx, user_id).await
{
@ -467,11 +391,6 @@ pub async fn create_reminder(
// base64 decode error dropped here
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 username = if reminder.username.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
None
} else {
reminder.username
};
let new_uid = generate_uid();
@ -497,14 +416,13 @@ pub async fn create_reminder(
enabled,
expires,
interval_seconds,
interval_days,
interval_months,
name,
restartable,
tts,
username,
`utc_time`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
new_uid,
attachment_data,
reminder.attachment_name,
@ -524,12 +442,11 @@ pub async fn create_reminder(
reminder.enabled,
reminder.expires,
reminder.interval_seconds,
reminder.interval_days,
reminder.interval_months,
name,
reminder.restartable,
reminder.tts,
username,
reminder.username,
reminder.utc_time,
)
.execute(pool)
@ -556,7 +473,6 @@ pub async fn create_reminder(
reminders.enabled,
reminders.expires,
reminders.interval_seconds,
reminders.interval_days,
reminders.interval_months,
reminders.name,
reminders.restartable,

View File

@ -135,14 +135,14 @@ pub async fn discord_callback(
Err(Flash::new(
Redirect::to(uri!(super::return_to_same_site(""))),
"warning",
"Your login request was rejected. The server may be misconfigured. Please retry or alert us in Discord.",
"Your login request was rejected",
))
}
}
} 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 {
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 {
months: parseInt(months) || null,
days: parseInt(days) || null,
seconds:
(parseInt(hours) || 0) * 3600 +
(parseInt(days) || 0) * 86400 +
(parseInt(hours) || 0) * 3600 +
(parseInt(minutes) || 0) * 60 +
(parseInt(seconds) || 0) || null,
};
@ -22,38 +22,32 @@ function update_interval(element) {
let minutes = element.querySelector('input[name="interval_minutes"]');
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) {
months.value = "";
days.value = "";
hours.value = "";
minutes.value = "";
seconds.value = "";
} else {
months.value = months.value.padStart(1, "0");
days.value = days.value.padStart(1, "0");
hours.value = hours.value.padStart(2, "0");
minutes.value = minutes.value.padStart(2, "0");
seconds.value = seconds.value.padStart(2, "0");
if (seconds.value >= 60) {
let quotient = Math.floor(seconds.value / 60);
let remainder = seconds.value % 60;
if (seconds.value >= 60) {
let quotient = Math.floor(seconds.value / 60);
let remainder = seconds.value % 60;
seconds.value = String(remainder).padStart(2, "0");
minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(2, "0");
}
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(Number(minutes.value) + Number(quotient)).padStart(
2,
"0"
);
}
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(Number(hours.value) + Number(quotient)).padStart(2, "0");
}
if (hours.value >= 24) {
let quotient = Math.floor(hours.value / 24);
let remainder = hours.value % 24;
minutes.value = String(remainder).padStart(2, "0");
hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0");
}
hours.value = String(remainder).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.selectedOptions[0].dataset["webhookAvatar"];
} else {
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
"/static/img/icon.png";
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = "";
}
if (sel.selectedOptions[0].dataset["webhookName"]) {
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
sel.selectedOptions[0].dataset["webhookName"];
} else {
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
"Reminder";
"";
}
}
@ -320,7 +319,6 @@ async function serialize_reminder(node, mode) {
embed_fields: fields,
expires: expiration_time,
interval_seconds: mode !== "template" ? interval.seconds : null,
interval_days: mode !== "template" ? interval.days : null,
interval_months: mode !== "template" ? interval.months : null,
name: node.querySelector('input[name="name"]').value,
tts: node.querySelector('input[name="tts"]').checked,
@ -333,9 +331,6 @@ function deserialize_reminder(reminder, frame, mode) {
// populate channels
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
for (let prop in reminder) {
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");
for (let field of reminder["embed_fields"]) {
@ -504,8 +497,6 @@ document.addEventListener("remindersLoaded", (event) => {
.then((response) => response.json())
.then((data) => {
for (let error of data.errors) show_error(error);
deserialize_reminder(data.reminder, node, "reload");
});
$saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"];
@ -724,7 +715,6 @@ $createReminderBtn.addEventListener("click", async () => {
let reminder = await serialize_reminder($createReminder, "create");
if (reminder.error) {
show_error(reminder.error);
$createReminderBtn.querySelector("span.icon > i").classList = ["fas fa-sparkles"];
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;
const $urlModal = document.querySelector("div#addImageModal");
const $urlInput = $urlModal.querySelector("input");
@ -897,13 +894,6 @@ document.addEventListener("remindersLoaded", () => {
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() {

View File

@ -191,8 +191,19 @@
</label>
</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>
<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">
Please first read the <a href="/help/iemanager">support page</a>
</div>

View File

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

View File

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

View File

@ -12,7 +12,7 @@
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<p class="title">Export data</p>
<p class="title">Export your data</p>
<p class="content">
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.
@ -26,7 +26,8 @@
<div class="container">
<p class="title">Import data</p>
<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>
</div>
</div>
@ -54,7 +55,7 @@
</figure>
</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>
<img src="/static/img/support/iemanager/edit_spreadsheet.png" alt="Editing spreadsheet">
</figure>
@ -69,7 +70,7 @@
Other spreadsheet tools can also be used to edit exports, as long as they are properly configured:
<ul>
<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:
<figure>
<img src="/static/img/support/iemanager/sheets_settings.png" alt="Google sheets import settings">

View File

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