Compare commits
76 Commits
poise
...
jellywx/gu
Author | SHA1 | Date | |
---|---|---|---|
b0a04bb289 | |||
eef1f6f3e8 | |||
3d08027325 | |||
94bfd39085 | |||
40cd5f8a36 | |||
133b00a2ce | |||
57336f5c81 | |||
b62d24c024 | |||
8f8235a86e | |||
c8f646a8fa | |||
ecaa382a1e | |||
8991198fd3 | |||
f20b95a482 | |||
8dd7dc6409 | |||
c799d10727 | |||
ceb6fb7b12 | |||
6708abdb0f | |||
a38f6024c1 | |||
7d8748e3ef | |||
bb3386c4e8 | |||
25b84880a5 | |||
7b6e967a5d | |||
2781f2923e | |||
03f08f0a18 | |||
79c86d43f2 | |||
e19af54caf | |||
f4213c6a83 | |||
f56db14720 | |||
6f7d0f67b3 | |||
bfc2d71ca0 | |||
8eb46f1f23 | |||
c4087bf569 | |||
f25cfed8d7 | |||
d2a8bd1982 | |||
437ee6b446 | |||
7d43aa5918 | |||
8bad95510d | |||
d7a0b727fb | |||
1c1f5662d3 | |||
ded750aa2d | |||
4c4f0927f1 | |||
0f05018cab | |||
85d27c5bba | |||
d946ef1dca | |||
f21d522435 | |||
3add718cdf | |||
f4ef7afea0 | |||
f8547bba70 | |||
08fd88ce54 | |||
abfe492192 | |||
afb2fbe4ff | |||
878ea11502 | |||
93da746bdc | |||
9e6a387f82 | |||
af9d8bea62 | |||
318be1fa5e | |||
3b6e02e16e | |||
a56f84f659 | |||
3e4dd0fa48 | |||
d0d2d50966 | |||
e2e5b022a0 | |||
6ae2353c92 | |||
06c4deeaa9 | |||
afc376c44f | |||
84ee7e77c5 | |||
620f054703 | |||
cb471c52f3 | |||
37420b2b1f | |||
49974b7153 | |||
a3844dde9e | |||
d62c8c95c2 | |||
05606dfec1 | |||
68ee42f244 | |||
fad28faabb | |||
e5ab99f67b | |||
e47715917e |
2
.prettierrc.toml
Normal file
@ -0,0 +1,2 @@
|
||||
printWidth = 90
|
||||
tabWidth = 4
|
2523
Cargo.lock
generated
27
Cargo.toml
@ -1,28 +1,33 @@
|
||||
[package]
|
||||
name = "reminder_rs"
|
||||
version = "1.6.0-beta2"
|
||||
version = "1.6.6"
|
||||
authors = ["jellywx <judesouthworth@pm.me>"]
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
songbird = { git = "https://github.com/serenity-rs/songbird", branch = "next" }
|
||||
poise = { git = "https://github.com/kangalioo/poise", branch = "master" }
|
||||
poise = "0.3"
|
||||
dotenv = "0.15"
|
||||
humantime = "2.1"
|
||||
tokio = { version = "1", features = ["process", "full"] }
|
||||
reqwest = "0.11"
|
||||
regex = "1.4"
|
||||
lazy-regex = "2.3.0"
|
||||
regex = "1.6"
|
||||
log = "0.4"
|
||||
env_logger = "0.8"
|
||||
env_logger = "0.9"
|
||||
chrono = "0.4"
|
||||
chrono-tz = { version = "0.5", features = ["serde"] }
|
||||
chrono-tz = { version = "0.6", features = ["serde"] }
|
||||
lazy_static = "1.4"
|
||||
num-integer = "0.1"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
serde_repr = "0.1"
|
||||
rmp-serde = "0.15"
|
||||
rand = "0.7"
|
||||
rmp-serde = "1.1"
|
||||
rand = "0.8"
|
||||
levenshtein = "1.0"
|
||||
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
|
||||
base64 = "0.13.0"
|
||||
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
|
||||
base64 = "0.13"
|
||||
|
||||
[dependencies.postman]
|
||||
path = "postman"
|
||||
|
||||
[dependencies.reminder_web]
|
||||
path = "web"
|
||||
|
16
README.md
@ -2,13 +2,20 @@
|
||||
Reminder Bot for Discord.
|
||||
|
||||
## How do I use it?
|
||||
We offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating
|
||||
I offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating
|
||||
reminders are paid on the hosted version of the bot. Keep reading if you want to host it yourself.
|
||||
|
||||
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
|
||||
Reminder Bot can be built by running `cargo build --release` in the top level directory. It is necessary to create a folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of dimensions 128x128px to be used as the webhook avatar.
|
||||
Install build requirements:
|
||||
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential`
|
||||
|
||||
Install Rust from https://rustup.rs
|
||||
|
||||
Reminder Bot can then be built by running `cargo build --release` in the top level directory. It is necessary to create a
|
||||
folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of
|
||||
dimensions 128x128px to be used as the webhook avatar.
|
||||
|
||||
#### Compilation environment variables
|
||||
These environment variables must be provided when compiling the bot
|
||||
@ -30,15 +37,10 @@ __Other Variables__
|
||||
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
|
||||
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
|
||||
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
|
||||
* `IGNORE_BOTS` - default `1`, if `1`, Reminder Bot will ignore all other bots
|
||||
* `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
|
||||
* `SHARD_COUNT` - default `None`, accepts the number of shards that are being ran
|
||||
* `SHARD_RANGE` - default `None`, if `SHARD_COUNT` is specified, specifies what range of shards to start on this process
|
||||
* `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages
|
||||
|
||||
### Todo List
|
||||
|
||||
* Convert aliases to macros
|
||||
* Help command
|
||||
* Test everything
|
||||
|
28
Rocket.toml
Normal file
@ -0,0 +1,28 @@
|
||||
[default]
|
||||
address = "0.0.0.0"
|
||||
port = 5000
|
||||
template_dir = "web/templates"
|
||||
limits = { json = "10MiB" }
|
||||
|
||||
[debug]
|
||||
secret_key = "tR8krio5FXTnnyIZNiJDXPondz0kI1v6X6BXZcBGIRY="
|
||||
|
||||
[debug.tls]
|
||||
certs = "web/private/rsa_sha256_cert.pem"
|
||||
key = "web/private/rsa_sha256_key.pem"
|
||||
|
||||
[rsa_sha256.tls]
|
||||
certs = "web/private/rsa_sha256_cert.pem"
|
||||
key = "web/private/rsa_sha256_key.pem"
|
||||
|
||||
[ecdsa_nistp256_sha256.tls]
|
||||
certs = "web/private/ecdsa_nistp256_sha256_cert.pem"
|
||||
key = "web/private/ecdsa_nistp256_sha256_key_pkcs8.pem"
|
||||
|
||||
[ecdsa_nistp384_sha384.tls]
|
||||
certs = "web/private/ecdsa_nistp384_sha384_cert.pem"
|
||||
key = "web/private/ecdsa_nistp384_sha384_key_pkcs8.pem"
|
||||
|
||||
[ed25519.tls]
|
||||
certs = "web/private/ed25519_cert.pem"
|
||||
key = "eb/private/ed25519_key.pem"
|
@ -157,4 +157,9 @@ CREATE TABLE events (
|
||||
FOREIGN KEY (reminder_id) REFERENCES reminders_new(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
DROP TABLE reminders;
|
||||
DROP TABLE embed_fields;
|
||||
RENAME TABLE reminders_new TO reminders;
|
||||
RENAME TABLE embed_fields_new TO embed_fields;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
4
migration/03-reminder_variable_intervals.sql
Normal 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;
|
51
migration/04-reminder_templates.sql
Normal file
@ -0,0 +1,51 @@
|
||||
USE reminders;
|
||||
|
||||
CREATE TABLE reminder_template (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
`name` VARCHAR(24) NOT NULL DEFAULT 'Reminder',
|
||||
|
||||
`guild_id` INT UNSIGNED NOT NULL,
|
||||
|
||||
`username` VARCHAR(32) DEFAULT NULL,
|
||||
`avatar` VARCHAR(512) DEFAULT NULL,
|
||||
|
||||
`content` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
`tts` BOOL NOT NULL DEFAULT 0,
|
||||
`attachment` MEDIUMBLOB,
|
||||
`attachment_name` VARCHAR(260),
|
||||
|
||||
`embed_title` VARCHAR(256) NOT NULL DEFAULT '',
|
||||
`embed_description` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
`embed_image_url` VARCHAR(512),
|
||||
`embed_thumbnail_url` VARCHAR(512),
|
||||
`embed_footer` VARCHAR(2048) NOT NULL DEFAULT '',
|
||||
`embed_footer_url` VARCHAR(512),
|
||||
`embed_author` VARCHAR(256) NOT NULL DEFAULT '',
|
||||
`embed_author_url` VARCHAR(512),
|
||||
`embed_color` INT UNSIGNED NOT NULL DEFAULT 0x0,
|
||||
`embed_fields` JSON,
|
||||
|
||||
PRIMARY KEY (id),
|
||||
|
||||
FOREIGN KEY (`guild_id`) REFERENCES guilds (`id`) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
ALTER TABLE reminders ADD COLUMN embed_fields JSON;
|
||||
|
||||
update reminders
|
||||
inner join embed_fields as E
|
||||
on E.reminder_id = reminders.id
|
||||
set embed_fields = (
|
||||
select JSON_ARRAYAGG(
|
||||
JSON_OBJECT(
|
||||
'title', E.title,
|
||||
'value', E.value,
|
||||
'inline',
|
||||
if(inline = 1, cast(TRUE as json), cast(FALSE as json))
|
||||
)
|
||||
)
|
||||
from embed_fields
|
||||
group by reminder_id
|
||||
having reminder_id = reminders.id
|
||||
);
|
92
migration/05-restructure-guild-table.sql
Normal 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;
|
16
postman/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "postman"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["process", "full"] }
|
||||
regex = "1.4"
|
||||
log = "0.4"
|
||||
chrono = "0.4"
|
||||
chrono-tz = { version = "0.5", features = ["serde"] }
|
||||
lazy_static = "1.4"
|
||||
num-integer = "0.1"
|
||||
serde = "1.0"
|
||||
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
|
||||
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
50
postman/src/lib.rs
Normal file
@ -0,0 +1,50 @@
|
||||
mod sender;
|
||||
|
||||
use std::env;
|
||||
|
||||
use log::{info, warn};
|
||||
use serenity::client::Context;
|
||||
use sqlx::{Executor, MySql};
|
||||
use tokio::{
|
||||
sync::broadcast::Receiver,
|
||||
time::{sleep_until, Duration, Instant},
|
||||
};
|
||||
|
||||
type Database = MySql;
|
||||
|
||||
pub async fn initialize(
|
||||
mut kill: Receiver<()>,
|
||||
ctx: Context,
|
||||
pool: impl Executor<'_, Database = Database> + Copy,
|
||||
) -> Result<(), &'static str> {
|
||||
tokio::select! {
|
||||
output = _initialize(ctx, pool) => Ok(output),
|
||||
_ = kill.recv() => {
|
||||
warn!("Received terminate signal. Goodbye");
|
||||
Err("Received terminate signal. Goodbye")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||
let remind_interval = env::var("REMIND_INTERVAL")
|
||||
.map(|inner| inner.parse::<u64>().ok())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(10);
|
||||
|
||||
loop {
|
||||
let sleep_to = Instant::now() + Duration::from_secs(remind_interval);
|
||||
let reminders = sender::Reminder::fetch_reminders(pool).await;
|
||||
|
||||
if reminders.len() > 0 {
|
||||
info!("Preparing to send {} reminders.", reminders.len());
|
||||
|
||||
for reminder in reminders {
|
||||
reminder.send(pool, ctx.clone()).await;
|
||||
}
|
||||
}
|
||||
|
||||
sleep_until(sleep_to).await;
|
||||
}
|
||||
}
|
606
postman/src/sender.rs
Normal file
@ -0,0 +1,606 @@
|
||||
use chrono::Duration;
|
||||
use chrono_tz::Tz;
|
||||
use lazy_static::lazy_static;
|
||||
use log::{error, info, warn};
|
||||
use num_integer::Integer;
|
||||
use regex::{Captures, Regex};
|
||||
use serde::Deserialize;
|
||||
use serenity::{
|
||||
builder::CreateEmbed,
|
||||
http::{CacheHttp, Http, HttpError, StatusCode},
|
||||
model::{
|
||||
channel::{Channel, Embed as SerenityEmbed},
|
||||
id::ChannelId,
|
||||
webhook::Webhook,
|
||||
},
|
||||
Error, Result,
|
||||
};
|
||||
use sqlx::{
|
||||
types::{
|
||||
chrono::{NaiveDateTime, Utc},
|
||||
Json,
|
||||
},
|
||||
Executor,
|
||||
};
|
||||
|
||||
use crate::Database;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref TIMEFROM_REGEX: Regex =
|
||||
Regex::new(r#"<<timefrom:(?P<time>\d+):(?P<format>.+)?>>"#).unwrap();
|
||||
pub static ref TIMENOW_REGEX: Regex =
|
||||
Regex::new(r#"<<timenow:(?P<timezone>(?:\w|/|_)+):(?P<format>.+)?>>"#).unwrap();
|
||||
}
|
||||
|
||||
fn fmt_displacement(format: &str, seconds: u64) -> String {
|
||||
let mut seconds = seconds;
|
||||
let mut days: u64 = 0;
|
||||
let mut hours: u64 = 0;
|
||||
let mut minutes: u64 = 0;
|
||||
|
||||
for (rep, time_type, div) in
|
||||
[("%d", &mut days, 86400), ("%h", &mut hours, 3600), ("%m", &mut minutes, 60)].iter_mut()
|
||||
{
|
||||
if format.contains(*rep) {
|
||||
let (divided, new_seconds) = seconds.div_rem(&div);
|
||||
|
||||
**time_type = divided;
|
||||
seconds = new_seconds;
|
||||
}
|
||||
}
|
||||
|
||||
format
|
||||
.replace("%s", &seconds.to_string())
|
||||
.replace("%m", &minutes.to_string())
|
||||
.replace("%h", &hours.to_string())
|
||||
.replace("%d", &days.to_string())
|
||||
}
|
||||
|
||||
pub fn substitute(string: &str) -> String {
|
||||
let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| {
|
||||
let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten();
|
||||
let format = caps.name("format").map(|m| m.as_str());
|
||||
|
||||
if let (Some(final_time), Some(format)) = (final_time, format) {
|
||||
let dt = NaiveDateTime::from_timestamp(final_time, 0);
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let difference = {
|
||||
if now < dt {
|
||||
dt - Utc::now().naive_utc()
|
||||
} else {
|
||||
Utc::now().naive_utc() - dt
|
||||
}
|
||||
};
|
||||
|
||||
fmt_displacement(format, difference.num_seconds() as u64)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
});
|
||||
|
||||
TIMENOW_REGEX
|
||||
.replace(&new, |caps: &Captures| {
|
||||
let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten();
|
||||
let format = caps.name("format").map(|m| m.as_str());
|
||||
|
||||
if let (Some(timezone), Some(format)) = (timezone, format) {
|
||||
let now = Utc::now().with_timezone(&timezone);
|
||||
|
||||
now.format(format).to_string()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
struct Embed {
|
||||
title: String,
|
||||
description: String,
|
||||
image_url: Option<String>,
|
||||
thumbnail_url: Option<String>,
|
||||
footer: String,
|
||||
footer_url: Option<String>,
|
||||
author: String,
|
||||
author_url: Option<String>,
|
||||
color: u32,
|
||||
fields: Json<Vec<EmbedField>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmbedField {
|
||||
title: String,
|
||||
value: String,
|
||||
inline: bool,
|
||||
}
|
||||
|
||||
impl Embed {
|
||||
pub async fn from_id(
|
||||
pool: impl Executor<'_, Database = Database> + Copy,
|
||||
id: u32,
|
||||
) -> Option<Self> {
|
||||
match sqlx::query_as!(
|
||||
Self,
|
||||
r#"
|
||||
SELECT
|
||||
`embed_title` AS title,
|
||||
`embed_description` AS description,
|
||||
`embed_image_url` AS image_url,
|
||||
`embed_thumbnail_url` AS thumbnail_url,
|
||||
`embed_footer` AS footer,
|
||||
`embed_footer_url` AS footer_url,
|
||||
`embed_author` AS author,
|
||||
`embed_author_url` AS author_url,
|
||||
`embed_color` AS color,
|
||||
IFNULL(`embed_fields`, '[]') AS "fields:_"
|
||||
FROM reminders
|
||||
WHERE `id` = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
{
|
||||
Ok(mut embed) => {
|
||||
embed.title = substitute(&embed.title);
|
||||
embed.description = substitute(&embed.description);
|
||||
embed.footer = substitute(&embed.footer);
|
||||
|
||||
embed.fields.iter_mut().for_each(|mut field| {
|
||||
field.title = substitute(&field.title);
|
||||
field.value = substitute(&field.value);
|
||||
});
|
||||
|
||||
if embed.has_content() {
|
||||
Some(embed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error loading embed from reminder: {:?}", e);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_content(&self) -> bool {
|
||||
if self.title.is_empty()
|
||||
&& self.description.is_empty()
|
||||
&& self.image_url.is_none()
|
||||
&& self.thumbnail_url.is_none()
|
||||
&& self.footer.is_empty()
|
||||
&& self.footer_url.is_none()
|
||||
&& self.author.is_empty()
|
||||
&& self.author_url.is_none()
|
||||
&& self.fields.0.is_empty()
|
||||
{
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<CreateEmbed> for Embed {
|
||||
fn into(self) -> CreateEmbed {
|
||||
let mut c = CreateEmbed::default();
|
||||
|
||||
c.title(&self.title)
|
||||
.description(&self.description)
|
||||
.color(self.color)
|
||||
.author(|a| {
|
||||
a.name(&self.author);
|
||||
|
||||
if let Some(author_icon) = &self.author_url {
|
||||
a.icon_url(author_icon);
|
||||
}
|
||||
|
||||
a
|
||||
})
|
||||
.footer(|f| {
|
||||
f.text(&self.footer);
|
||||
|
||||
if let Some(footer_icon) = &self.footer_url {
|
||||
f.icon_url(footer_icon);
|
||||
}
|
||||
|
||||
f
|
||||
});
|
||||
|
||||
for field in &self.fields.0 {
|
||||
c.field(&field.title, &field.value, field.inline);
|
||||
}
|
||||
|
||||
if let Some(image_url) = &self.image_url {
|
||||
c.image(image_url);
|
||||
}
|
||||
|
||||
if let Some(thumbnail_url) = &self.thumbnail_url {
|
||||
c.thumbnail(thumbnail_url);
|
||||
}
|
||||
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Reminder {
|
||||
id: u32,
|
||||
|
||||
channel_id: u64,
|
||||
webhook_id: Option<u64>,
|
||||
webhook_token: Option<String>,
|
||||
|
||||
channel_paused: bool,
|
||||
channel_paused_until: Option<NaiveDateTime>,
|
||||
enabled: bool,
|
||||
|
||||
tts: bool,
|
||||
pin: bool,
|
||||
content: String,
|
||||
attachment: Option<Vec<u8>>,
|
||||
attachment_name: Option<String>,
|
||||
|
||||
utc_time: NaiveDateTime,
|
||||
timezone: String,
|
||||
restartable: bool,
|
||||
expires: Option<NaiveDateTime>,
|
||||
interval_seconds: Option<u32>,
|
||||
interval_months: Option<u32>,
|
||||
|
||||
avatar: Option<String>,
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
impl Reminder {
|
||||
pub async fn fetch_reminders(pool: impl Executor<'_, Database = Database> + Copy) -> Vec<Self> {
|
||||
match sqlx::query_as_unchecked!(
|
||||
Reminder,
|
||||
r#"
|
||||
SELECT
|
||||
reminders.`id` AS id,
|
||||
|
||||
channels.`channel` AS channel_id,
|
||||
channels.`webhook_id` AS webhook_id,
|
||||
channels.`webhook_token` AS webhook_token,
|
||||
|
||||
channels.`paused` AS 'channel_paused',
|
||||
channels.`paused_until` AS 'channel_paused_until',
|
||||
reminders.`enabled` AS 'enabled',
|
||||
|
||||
reminders.`tts` AS tts,
|
||||
reminders.`pin` AS pin,
|
||||
reminders.`content` AS content,
|
||||
reminders.`attachment` AS attachment,
|
||||
reminders.`attachment_name` AS attachment_name,
|
||||
|
||||
reminders.`utc_time` AS 'utc_time',
|
||||
reminders.`timezone` AS timezone,
|
||||
reminders.`restartable` AS restartable,
|
||||
reminders.`expires` AS 'expires',
|
||||
reminders.`interval_seconds` AS 'interval_seconds',
|
||||
reminders.`interval_months` AS 'interval_months',
|
||||
|
||||
reminders.`avatar` AS avatar,
|
||||
reminders.`username` AS username
|
||||
FROM
|
||||
reminders
|
||||
INNER JOIN
|
||||
channels
|
||||
ON
|
||||
reminders.channel_id = channels.id
|
||||
WHERE
|
||||
reminders.`id` IN (
|
||||
SELECT
|
||||
MIN(id)
|
||||
FROM
|
||||
reminders
|
||||
WHERE
|
||||
reminders.`utc_time` <= NOW()
|
||||
AND (
|
||||
reminders.`interval_seconds` IS NOT NULL
|
||||
OR reminders.`interval_months` IS NOT NULL
|
||||
OR reminders.enabled
|
||||
)
|
||||
GROUP BY channel_id
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
{
|
||||
Ok(reminders) => reminders
|
||||
.into_iter()
|
||||
.map(|mut rem| {
|
||||
rem.content = substitute(&rem.content);
|
||||
|
||||
rem
|
||||
})
|
||||
.collect::<Vec<Self>>(),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Could not fetch reminders: {:?}", e);
|
||||
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||
let _ = sqlx::query!(
|
||||
"
|
||||
UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
|
||||
",
|
||||
self.channel_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn refresh(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||
if self.interval_seconds.is_some() || self.interval_months.is_some() {
|
||||
let now = Utc::now().naive_local();
|
||||
let mut updated_reminder_time = self.utc_time;
|
||||
|
||||
if let Some(interval) = self.interval_months {
|
||||
match sqlx::query!(
|
||||
// use the second date_add to force return value to datetime
|
||||
"SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time",
|
||||
updated_reminder_time,
|
||||
interval
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
{
|
||||
Ok(row) => match row.new_time {
|
||||
Some(datetime) => {
|
||||
updated_reminder_time = datetime;
|
||||
}
|
||||
None => {
|
||||
warn!("Could not update interval by months: got NULL");
|
||||
|
||||
updated_reminder_time += Duration::days(30);
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Could not update interval by months: {:?}", e);
|
||||
|
||||
// naively fallback to adding 30 days
|
||||
updated_reminder_time += Duration::days(30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(interval) = self.interval_seconds {
|
||||
while updated_reminder_time < now {
|
||||
updated_reminder_time += Duration::seconds(interval as i64);
|
||||
}
|
||||
}
|
||||
|
||||
if self.expires.map_or(false, |expires| {
|
||||
NaiveDateTime::from_timestamp(updated_reminder_time.timestamp(), 0) > expires
|
||||
}) {
|
||||
self.force_delete(pool).await;
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
|
||||
",
|
||||
updated_reminder_time,
|
||||
self.id
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect(&format!("Could not update time on Reminder {}", self.id));
|
||||
}
|
||||
} else {
|
||||
self.force_delete(pool).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM reminders WHERE `id` = ?
|
||||
",
|
||||
self.id
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect(&format!("Could not delete Reminder {}", self.id));
|
||||
}
|
||||
|
||||
async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) {
|
||||
let _ = http.as_ref().pin_message(self.channel_id, message_id.into(), None).await;
|
||||
}
|
||||
|
||||
pub async fn send(
|
||||
&self,
|
||||
pool: impl Executor<'_, Database = Database> + Copy,
|
||||
cache_http: impl CacheHttp,
|
||||
) {
|
||||
async fn send_to_channel(
|
||||
cache_http: impl CacheHttp,
|
||||
reminder: &Reminder,
|
||||
embed: Option<CreateEmbed>,
|
||||
) -> Result<()> {
|
||||
let channel = ChannelId(reminder.channel_id).to_channel(&cache_http).await;
|
||||
|
||||
match channel {
|
||||
Ok(Channel::Guild(channel)) => {
|
||||
match channel
|
||||
.send_message(&cache_http, |m| {
|
||||
m.content(&reminder.content).tts(reminder.tts);
|
||||
|
||||
if let (Some(attachment), Some(name)) =
|
||||
(&reminder.attachment, &reminder.attachment_name)
|
||||
{
|
||||
m.add_file((attachment as &[u8], name.as_str()));
|
||||
}
|
||||
|
||||
if let Some(embed) = embed {
|
||||
m.set_embed(embed);
|
||||
}
|
||||
|
||||
m
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(m) => {
|
||||
if reminder.pin {
|
||||
reminder.pin_message(m.id, cache_http.http()).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
Ok(Channel::Private(channel)) => {
|
||||
match channel
|
||||
.send_message(&cache_http.http(), |m| {
|
||||
m.content(&reminder.content).tts(reminder.tts);
|
||||
|
||||
if let (Some(attachment), Some(name)) =
|
||||
(&reminder.attachment, &reminder.attachment_name)
|
||||
{
|
||||
m.add_file((attachment as &[u8], name.as_str()));
|
||||
}
|
||||
|
||||
if let Some(embed) = embed {
|
||||
m.set_embed(embed);
|
||||
}
|
||||
|
||||
m
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(m) => {
|
||||
if reminder.pin {
|
||||
reminder.pin_message(m.id, cache_http.http()).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
_ => Err(Error::Other("Channel not of valid type")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_to_webhook(
|
||||
cache_http: impl CacheHttp,
|
||||
reminder: &Reminder,
|
||||
webhook: Webhook,
|
||||
embed: Option<CreateEmbed>,
|
||||
) -> Result<()> {
|
||||
match webhook
|
||||
.execute(&cache_http.http(), reminder.pin || reminder.restartable, |w| {
|
||||
w.content(&reminder.content).tts(reminder.tts);
|
||||
|
||||
if let Some(username) = &reminder.username {
|
||||
w.username(username);
|
||||
}
|
||||
|
||||
if let Some(avatar) = &reminder.avatar {
|
||||
w.avatar_url(avatar);
|
||||
}
|
||||
|
||||
if let (Some(attachment), Some(name)) =
|
||||
(&reminder.attachment, &reminder.attachment_name)
|
||||
{
|
||||
w.add_file((attachment as &[u8], name.as_str()));
|
||||
}
|
||||
|
||||
if let Some(embed) = embed {
|
||||
w.embeds(vec![SerenityEmbed::fake(|c| {
|
||||
*c = embed;
|
||||
c
|
||||
})]);
|
||||
}
|
||||
|
||||
w
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(m) => {
|
||||
if reminder.pin {
|
||||
if let Some(message) = m {
|
||||
reminder.pin_message(message.id, cache_http.http()).await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
if self.enabled
|
||||
&& !(self.channel_paused
|
||||
&& self
|
||||
.channel_paused_until
|
||||
.map_or(true, |inner| inner >= Utc::now().naive_local()))
|
||||
{
|
||||
let _ = sqlx::query!(
|
||||
"
|
||||
UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
|
||||
",
|
||||
self.channel_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
let embed = Embed::from_id(pool, self.id).await.map(|e| e.into());
|
||||
|
||||
let result = if let (Some(webhook_id), Some(webhook_token)) =
|
||||
(self.webhook_id, &self.webhook_token)
|
||||
{
|
||||
let webhook_res =
|
||||
cache_http.http().get_webhook_with_token(webhook_id, webhook_token).await;
|
||||
|
||||
if let Ok(webhook) = webhook_res {
|
||||
send_to_webhook(cache_http, &self, webhook, embed).await
|
||||
} else {
|
||||
warn!("Webhook vanished: {:?}", webhook_res);
|
||||
|
||||
self.reset_webhook(pool).await;
|
||||
send_to_channel(cache_http, &self, embed).await
|
||||
}
|
||||
} else {
|
||||
send_to_channel(cache_http, &self, embed).await
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
error!("Error sending reminder {}: {:?}", self.id, e);
|
||||
|
||||
if let Error::Http(error) = e {
|
||||
if error.status_code() == Some(StatusCode::NOT_FOUND) {
|
||||
warn!("Seeing channel is deleted. Removing reminder");
|
||||
self.force_delete(pool).await;
|
||||
} else if let HttpError::UnsuccessfulRequest(error) = *error {
|
||||
if error.error.code == 50007 {
|
||||
warn!("User cannot receive DMs");
|
||||
self.force_delete(pool).await;
|
||||
} else {
|
||||
self.refresh(pool).await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.refresh(pool).await;
|
||||
}
|
||||
} else {
|
||||
self.refresh(pool).await;
|
||||
}
|
||||
} else {
|
||||
info!("Reminder {} is paused", self.id);
|
||||
|
||||
self.refresh(pool).await;
|
||||
}
|
||||
}
|
||||
}
|
131
src/commands/autocomplete.rs
Normal file
@ -0,0 +1,131 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use chrono_tz::TZ_VARIANTS;
|
||||
use poise::AutocompleteChoice;
|
||||
|
||||
use crate::{models::CtxData, time_parser::natural_parser, Context};
|
||||
|
||||
pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
|
||||
if partial.is_empty() {
|
||||
ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
|
||||
} else {
|
||||
TZ_VARIANTS
|
||||
.iter()
|
||||
.filter(|tz| tz.to_string().contains(&partial))
|
||||
.take(25)
|
||||
.map(|t| t.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT name
|
||||
FROM macro
|
||||
WHERE
|
||||
guild_id = ?
|
||||
AND name LIKE CONCAT(?, '%')",
|
||||
ctx.guild_id().unwrap().0,
|
||||
partial,
|
||||
)
|
||||
.fetch_all(&ctx.data().database)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn 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,
|
||||
) -> Vec<AutocompleteChoice<String>> {
|
||||
if partial.is_empty() {
|
||||
vec![AutocompleteChoice {
|
||||
name: "Start typing a time...".to_string(),
|
||||
value: "now".to_string(),
|
||||
}]
|
||||
} else {
|
||||
match natural_parser(partial, &ctx.timezone().await.to_string()).await {
|
||||
Some(timestamp) => match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
Ok(now) => {
|
||||
let diff = timestamp - now.as_secs() as i64;
|
||||
|
||||
if diff < 0 {
|
||||
vec![AutocompleteChoice {
|
||||
name: "Time is in the past".to_string(),
|
||||
value: "now".to_string(),
|
||||
}]
|
||||
} else {
|
||||
if diff > 86400 {
|
||||
vec![
|
||||
AutocompleteChoice {
|
||||
name: partial.to_string(),
|
||||
value: partial.to_string(),
|
||||
},
|
||||
AutocompleteChoice {
|
||||
name: format!(
|
||||
"In approximately {} days, {} hours",
|
||||
diff / 86400,
|
||||
(diff % 86400) / 3600
|
||||
),
|
||||
value: partial.to_string(),
|
||||
},
|
||||
]
|
||||
} else if diff > 3600 {
|
||||
vec![
|
||||
AutocompleteChoice {
|
||||
name: partial.to_string(),
|
||||
value: partial.to_string(),
|
||||
},
|
||||
AutocompleteChoice {
|
||||
name: format!("In approximately {} hours", diff / 3600),
|
||||
value: partial.to_string(),
|
||||
},
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
AutocompleteChoice {
|
||||
name: partial.to_string(),
|
||||
value: partial.to_string(),
|
||||
},
|
||||
AutocompleteChoice {
|
||||
name: format!("In approximately {} minutes", diff / 60),
|
||||
value: partial.to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
vec![AutocompleteChoice {
|
||||
name: partial.to_string(),
|
||||
value: partial.to_string(),
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
None => {
|
||||
vec![AutocompleteChoice {
|
||||
name: "Time not recognised".to_string(),
|
||||
value: "now".to_string(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
46
src/commands/command_macro/delete.rs
Normal file
@ -0,0 +1,46 @@
|
||||
use super::super::autocomplete::macro_name_autocomplete;
|
||||
use crate::{Context, Error};
|
||||
|
||||
/// Delete a recorded macro
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "delete",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "delete_macro"
|
||||
)]
|
||||
pub async fn delete_macro(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Name of macro to delete"]
|
||||
#[autocomplete = "macro_name_autocomplete"]
|
||||
name: String,
|
||||
) -> Result<(), Error> {
|
||||
match sqlx::query!(
|
||||
"
|
||||
SELECT id FROM macro WHERE guild_id = ? AND name = ?",
|
||||
ctx.guild_id().unwrap().0,
|
||||
name
|
||||
)
|
||||
.fetch_one(&ctx.data().database)
|
||||
.await
|
||||
{
|
||||
Ok(row) => {
|
||||
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
|
||||
.execute(&ctx.data().database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
|
||||
}
|
||||
|
||||
Err(sqlx::Error::RowNotFound) => {
|
||||
ctx.say(format!("Macro \"{}\" not found", name)).await?;
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
panic!("{}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
89
src/commands/command_macro/list.rs
Normal file
@ -0,0 +1,89 @@
|
||||
use poise::CreateReply;
|
||||
|
||||
use crate::{
|
||||
component_models::pager::{MacroPager, Pager},
|
||||
consts::THEME_COLOR,
|
||||
models::{command_macro::CommandMacro, CtxData},
|
||||
Context, Error,
|
||||
};
|
||||
|
||||
/// List recorded macros
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "list",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "list_macro"
|
||||
)]
|
||||
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let macros = ctx.command_macros().await?;
|
||||
|
||||
let resp = show_macro_page(¯os, 0);
|
||||
|
||||
ctx.send(|m| {
|
||||
*m = resp;
|
||||
m
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
|
||||
((macros.len() as f64) / 25.0).ceil() as usize
|
||||
}
|
||||
|
||||
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
|
||||
let pager = MacroPager::new(page);
|
||||
|
||||
if macros.is_empty() {
|
||||
let mut reply = CreateReply::default();
|
||||
|
||||
reply.embed(|e| {
|
||||
e.title("Macros")
|
||||
.description("No Macros Set Up. Use `/macro record` to get started.")
|
||||
.color(*THEME_COLOR)
|
||||
});
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
let pages = max_macro_page(macros);
|
||||
|
||||
let mut page = page;
|
||||
if page >= pages {
|
||||
page = pages - 1;
|
||||
}
|
||||
|
||||
let lower = (page * 25).min(macros.len());
|
||||
let upper = ((page + 1) * 25).min(macros.len());
|
||||
|
||||
let fields = macros[lower..upper].iter().map(|m| {
|
||||
if let Some(description) = &m.description {
|
||||
(
|
||||
m.name.clone(),
|
||||
format!("*{}*\n- Has {} commands", description, m.commands.len()),
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
(m.name.clone(), format!("- Has {} commands", m.commands.len()), true)
|
||||
}
|
||||
});
|
||||
|
||||
let mut reply = CreateReply::default();
|
||||
|
||||
reply
|
||||
.embed(|e| {
|
||||
e.title("Macros")
|
||||
.fields(fields)
|
||||
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
.components(|comp| {
|
||||
pager.create_button_row(pages, comp);
|
||||
|
||||
comp
|
||||
});
|
||||
|
||||
reply
|
||||
}
|
229
src/commands/command_macro/migrate.rs
Normal file
@ -0,0 +1,229 @@
|
||||
use lazy_regex::regex;
|
||||
use poise::serenity_prelude::command::CommandOptionType;
|
||||
use regex::Captures;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{models::command_macro::RawCommandMacro, Context, Error, GuildId};
|
||||
|
||||
struct Alias {
|
||||
name: String,
|
||||
command: String,
|
||||
}
|
||||
|
||||
/// Migrate old $alias reminder commands to macros. Only macro names that are not taken will be used.
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "migrate",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "migrate_macro"
|
||||
)]
|
||||
pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let guild_id = ctx.guild_id().unwrap();
|
||||
let mut transaction = ctx.data().database.begin().await?;
|
||||
|
||||
let aliases = sqlx::query_as!(
|
||||
Alias,
|
||||
"SELECT name, command FROM command_aliases WHERE guild_id = ?",
|
||||
guild_id.0
|
||||
)
|
||||
.fetch_all(&mut transaction)
|
||||
.await?;
|
||||
|
||||
let mut added_aliases = 0;
|
||||
|
||||
for alias in aliases {
|
||||
match parse_text_command(guild_id, alias.name, &alias.command) {
|
||||
Some(cmd_macro) => {
|
||||
sqlx::query!(
|
||||
"INSERT INTO macro (guild_id, name, description, commands) VALUES (?, ?, ?, ?)",
|
||||
cmd_macro.guild_id.0,
|
||||
cmd_macro.name,
|
||||
cmd_macro.description,
|
||||
cmd_macro.commands
|
||||
)
|
||||
.execute(&mut transaction)
|
||||
.await?;
|
||||
|
||||
added_aliases += 1;
|
||||
}
|
||||
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
ctx.send(|b| b.content(format!("Added {} macros.", added_aliases))).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_text_command(
|
||||
guild_id: GuildId,
|
||||
alias_name: String,
|
||||
command: &str,
|
||||
) -> Option<RawCommandMacro> {
|
||||
match command.split_once(" ") {
|
||||
Some((command_word, args)) => {
|
||||
let command_word = command_word.to_lowercase();
|
||||
|
||||
if command_word == "r"
|
||||
|| command_word == "i"
|
||||
|| command_word == "remind"
|
||||
|| command_word == "interval"
|
||||
{
|
||||
let matcher = regex!(
|
||||
r#"(?P<mentions>(?:<@\d+>\s+|<@!\d+>\s+|<#\d+>\s+)*)(?P<time>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+)(?:\s+(?P<interval>(?:(?:\d+)(?:s|m|h|d|))+))?(?:\s+(?P<expires>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+))?\s+(?P<content>.*)"#s
|
||||
);
|
||||
|
||||
match matcher.captures(&args) {
|
||||
Some(captures) => {
|
||||
let mut args: Vec<Value> = vec![];
|
||||
|
||||
if let Some(group) = captures.name("time") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "time",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) = captures.name("content") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "content",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) = captures.name("interval") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "interval",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) = captures.name("expires") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "expires",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) = captures.name("mentions") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "channels",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
Some(RawCommandMacro {
|
||||
guild_id,
|
||||
name: alias_name,
|
||||
description: None,
|
||||
commands: json!([
|
||||
{
|
||||
"command_name": "remind",
|
||||
"options": args,
|
||||
}
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
None => None,
|
||||
}
|
||||
} else if command_word == "n" || command_word == "natural" {
|
||||
let matcher_primary = regex!(
|
||||
r#"(?P<time>.*?)(?:\s+)(?:send|say)(?:\s+)(?P<content>.*?)(?:(?:\s+)to(?:\s+)(?P<mentions>((?:<@\d+>)|(?:<@!\d+>)|(?:<#\d+>)|(?:\s+))+))?$"#s
|
||||
);
|
||||
let matcher_secondary = regex!(
|
||||
r#"(?P<msg>.*)(?:\s+)every(?:\s+)(?P<interval>.*?)(?:(?:\s+)(?:until|for)(?:\s+)(?P<expires>.*?))?$"#s
|
||||
);
|
||||
|
||||
match matcher_primary.captures(&args) {
|
||||
Some(captures) => {
|
||||
let captures_secondary = matcher_secondary.captures(&args);
|
||||
|
||||
let mut args: Vec<Value> = vec![];
|
||||
|
||||
if let Some(group) = captures.name("time") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "time",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) = captures.name("content") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "content",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) =
|
||||
captures_secondary.as_ref().and_then(|c: &Captures| c.name("interval"))
|
||||
{
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "interval",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) =
|
||||
captures_secondary.and_then(|c: Captures| c.name("expires"))
|
||||
{
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "expires",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(group) = captures.name("mentions") {
|
||||
let content = group.as_str();
|
||||
args.push(json!({
|
||||
"name": "channels",
|
||||
"value": content,
|
||||
"type": CommandOptionType::String,
|
||||
}));
|
||||
}
|
||||
|
||||
Some(RawCommandMacro {
|
||||
guild_id,
|
||||
name: alias_name,
|
||||
description: None,
|
||||
commands: json!([
|
||||
{
|
||||
"command_name": "remind",
|
||||
"options": args,
|
||||
}
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
None => None,
|
||||
}
|
||||
}
|
19
src/commands/command_macro/mod.rs
Normal file
@ -0,0 +1,19 @@
|
||||
use crate::{Context, Error};
|
||||
|
||||
pub mod delete;
|
||||
pub mod list;
|
||||
pub mod migrate;
|
||||
pub mod record;
|
||||
pub mod run;
|
||||
|
||||
/// Record and replay command sequences
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "macro",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "macro_base"
|
||||
)]
|
||||
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
151
src/commands/command_macro/record.rs
Normal file
@ -0,0 +1,151 @@
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
use crate::{consts::THEME_COLOR, models::command_macro::CommandMacro, Context, Error};
|
||||
|
||||
/// Start recording up to 5 commands to replay
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "record",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "record_macro"
|
||||
)]
|
||||
pub async fn record_macro(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Name for the new macro"] name: String,
|
||||
#[description = "Description for the new macro"] description: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
if name.len() > 100 {
|
||||
ctx.say("Name must be less than 100 characters").await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if description.as_ref().map_or(0, |d| d.len()) > 100 {
|
||||
ctx.say("Description must be less than 100 characters").await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let guild_id = ctx.guild_id().unwrap();
|
||||
|
||||
let row = sqlx::query!(
|
||||
"
|
||||
SELECT 1 as _e FROM macro WHERE guild_id = ? AND name = ?",
|
||||
guild_id.0,
|
||||
name
|
||||
)
|
||||
.fetch_one(&ctx.data().database)
|
||||
.await;
|
||||
|
||||
if row.is_ok() {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Unique Name Required")
|
||||
.description(
|
||||
"A macro already exists under this name.
|
||||
Please select a unique name for your macro.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
let okay = {
|
||||
let mut lock = ctx.data().recording_macros.write().await;
|
||||
|
||||
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
|
||||
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if okay {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Macro Recording Started")
|
||||
.description(
|
||||
"Run up to 5 commands, or type `/macro finish` to stop at any point.
|
||||
Any commands ran as part of recording will be inconsequential",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Macro Already Recording")
|
||||
.description(
|
||||
"You are already recording a macro in this server.
|
||||
Please use `/macro finish` to end this recording before starting another.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish current macro recording
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "finish",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "finish_macro"
|
||||
)]
|
||||
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let key = (ctx.guild_id().unwrap(), ctx.author().id);
|
||||
|
||||
{
|
||||
let lock = ctx.data().recording_macros.read().await;
|
||||
let contained = lock.get(&key);
|
||||
|
||||
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
|
||||
ctx.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title("No Macro Recorded")
|
||||
.description("Use `/macro record` to start recording a macro")
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
let command_macro = contained.unwrap();
|
||||
let json = serde_json::to_string(&command_macro.commands).unwrap();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO macro (guild_id, name, description, commands) VALUES (?, ?, ?, ?)",
|
||||
command_macro.guild_id.0,
|
||||
command_macro.name,
|
||||
command_macro.description,
|
||||
json
|
||||
)
|
||||
.execute(&ctx.data().database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ctx.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title("Macro Recorded")
|
||||
.description("Use `/macro run` to execute the macro")
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut lock = ctx.data().recording_macros.write().await;
|
||||
lock.remove(&key);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
46
src/commands/command_macro/run.rs
Normal file
@ -0,0 +1,46 @@
|
||||
use super::super::autocomplete::macro_name_autocomplete;
|
||||
use crate::{models::command_macro::guild_command_macro, Context, Data, Error};
|
||||
|
||||
/// Run a recorded macro
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "run",
|
||||
guild_only = true,
|
||||
default_member_permissions = "MANAGE_GUILD",
|
||||
identifying_name = "run_macro"
|
||||
)]
|
||||
pub async fn run_macro(
|
||||
ctx: poise::ApplicationContext<'_, Data, Error>,
|
||||
#[description = "Name of macro to run"]
|
||||
#[autocomplete = "macro_name_autocomplete"]
|
||||
name: String,
|
||||
) -> Result<(), Error> {
|
||||
match guild_command_macro(&Context::Application(ctx), &name).await {
|
||||
Some(command_macro) => {
|
||||
ctx.defer_response(false).await?;
|
||||
|
||||
for command in command_macro.commands {
|
||||
if let Some(action) = command.action {
|
||||
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
println!("{:?}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Context::Application(ctx)
|
||||
.say(format!("Command \"{}\" not found", command.command_name))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
use chrono::offset::Utc;
|
||||
use poise::serenity::builder::CreateEmbedFooter;
|
||||
use poise::{serenity_prelude as serenity, serenity_prelude::Mentionable};
|
||||
|
||||
use crate::{models::CtxData, Context, Error, THEME_COLOR};
|
||||
|
||||
fn footer(ctx: Context<'_>) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter {
|
||||
fn footer(
|
||||
ctx: Context<'_>,
|
||||
) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
|
||||
let shard_count = ctx.discord().cache.shard_count();
|
||||
let shard = ctx.discord().shard_id;
|
||||
|
||||
@ -22,13 +24,12 @@ fn footer(ctx: Context<'_>) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut Creat
|
||||
pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let footer = footer(ctx);
|
||||
|
||||
let _ = ctx
|
||||
.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title("Help")
|
||||
.color(*THEME_COLOR)
|
||||
.description(
|
||||
"__Info Commands__
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Help")
|
||||
.color(*THEME_COLOR)
|
||||
.description(
|
||||
"__Info Commands__
|
||||
`/help` `/info` `/donate` `/dashboard` `/clock`
|
||||
*run these commands with no options*
|
||||
|
||||
@ -48,15 +49,16 @@ __Todo Commands__
|
||||
|
||||
__Setup Commands__
|
||||
`/timezone` - Set your timezone (necessary for `/remind` to work properly)
|
||||
`/dm allow/block` - Change your DM settings for reminders.
|
||||
|
||||
__Advanced Commands__
|
||||
`/macro` - Record and replay command sequences
|
||||
",
|
||||
)
|
||||
.footer(footer)
|
||||
})
|
||||
)
|
||||
.footer(footer)
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -68,9 +70,9 @@ pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
|
||||
|
||||
let _ = ctx
|
||||
.send(|m| {
|
||||
m.embed(|e| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Info")
|
||||
.description(format!(
|
||||
.description(
|
||||
"Help: `/help`
|
||||
|
||||
**Welcome to Reminder Bot!**
|
||||
@ -80,7 +82,7 @@ Find me on https://discord.jellywx.com and on https://github.com/JellyWX :)
|
||||
|
||||
Invite the bot: https://invite.reminder-bot.com/
|
||||
Use our dashboard: https://reminder-bot.com/",
|
||||
))
|
||||
)
|
||||
.footer(footer)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
@ -95,9 +97,10 @@ Use our dashboard: https://reminder-bot.com/",
|
||||
pub async fn donate(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let footer = footer(ctx);
|
||||
|
||||
let _ = ctx.send(|m| m.embed(|e| {
|
||||
e.title("Donate")
|
||||
.description("Thinking of adding a monthly contribution? Click below for my Patreon and official bot server :)
|
||||
ctx.send(|m| m.embed(|e| {
|
||||
e.title("Donate")
|
||||
.description("Thinking of adding a monthly contribution?
|
||||
Click below for my Patreon and official bot server :)
|
||||
|
||||
**https://www.patreon.com/jellywx/**
|
||||
**https://discord.jellywx.com/**
|
||||
@ -112,11 +115,11 @@ With your new rank, you'll be able to:
|
||||
Just $2 USD/month!
|
||||
|
||||
*Please note, you must be in the JellyWX Discord server to receive Patreon features*")
|
||||
.footer(footer)
|
||||
.color(*THEME_COLOR)
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
.footer(footer)
|
||||
.color(*THEME_COLOR)
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -126,21 +129,20 @@ Just $2 USD/month!
|
||||
pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let footer = footer(ctx);
|
||||
|
||||
let _ = ctx
|
||||
.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title("Dashboard")
|
||||
.description("**https://reminder-bot.com/dashboard**")
|
||||
.footer(footer)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Dashboard")
|
||||
.description("**https://reminder-bot.com/dashboard**")
|
||||
.footer(footer)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// View the current time in a user's selected timezone
|
||||
/// View the current time in your selected timezone
|
||||
#[poise::command(slash_command)]
|
||||
pub async fn clock(ctx: Context<'_>) -> Result<(), Error> {
|
||||
ctx.defer_ephemeral().await?;
|
||||
@ -155,3 +157,25 @@ pub async fn clock(ctx: Context<'_>) -> Result<(), Error> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// View the current time in a user's selected timezone
|
||||
#[poise::command(context_menu_command = "View Local Time")]
|
||||
pub async fn clock_context_menu(ctx: Context<'_>, user: serenity::User) -> Result<(), Error> {
|
||||
ctx.defer_ephemeral().await?;
|
||||
|
||||
let user_data = ctx.user_data(user.id).await?;
|
||||
let tz = user_data.timezone();
|
||||
|
||||
let now = Utc::now().with_timezone(&tz);
|
||||
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).content(format!(
|
||||
"Time in {}'s timezone: `{}`",
|
||||
user.mention(),
|
||||
now.format("%H:%M")
|
||||
))
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
mod autocomplete;
|
||||
pub mod command_macro;
|
||||
pub mod info_cmds;
|
||||
pub mod moderation_cmds;
|
||||
// pub mod reminder_cmds;
|
||||
// pub mod todo_cmds;
|
||||
pub mod reminder_cmds;
|
||||
pub mod todo_cmds;
|
||||
|
@ -1,37 +1,14 @@
|
||||
use chrono::offset::Utc;
|
||||
use chrono_tz::{Tz, TZ_VARIANTS};
|
||||
use levenshtein::levenshtein;
|
||||
use poise::CreateReply;
|
||||
use log::warn;
|
||||
use poise::serenity_prelude::{ChannelId, Mentionable};
|
||||
|
||||
use crate::{
|
||||
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
|
||||
hooks::guild_only,
|
||||
models::{
|
||||
command_macro::{CommandMacro, CommandOptions},
|
||||
CtxData,
|
||||
},
|
||||
Context, Data, Error,
|
||||
};
|
||||
|
||||
async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
|
||||
if partial.is_empty() {
|
||||
ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
|
||||
} else {
|
||||
TZ_VARIANTS
|
||||
.iter()
|
||||
.filter(|tz| {
|
||||
partial.contains(&tz.to_string())
|
||||
|| tz.to_string().contains(&partial)
|
||||
|| levenshtein(&tz.to_string(), &partial) < 4
|
||||
})
|
||||
.take(25)
|
||||
.map(|t| t.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
}
|
||||
}
|
||||
use super::autocomplete::timezone_autocomplete;
|
||||
use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
|
||||
|
||||
/// Select your timezone
|
||||
#[poise::command(slash_command)]
|
||||
#[poise::command(slash_command, identifying_name = "timezone")]
|
||||
pub async fn timezone(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"]
|
||||
@ -56,7 +33,7 @@ pub async fn timezone(
|
||||
.description(format!(
|
||||
"Timezone has been set to **{}**. Your current time should be `{}`",
|
||||
timezone,
|
||||
now.format("%H:%M").to_string()
|
||||
now.format("%H:%M")
|
||||
))
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
@ -79,10 +56,7 @@ pub async fn timezone(
|
||||
let fields = filtered_tz.iter().map(|tz| {
|
||||
(
|
||||
tz.to_string(),
|
||||
format!(
|
||||
"🕗 `{}`",
|
||||
Utc::now().with_timezone(tz).format("%H:%M").to_string()
|
||||
),
|
||||
format!("🕗 `{}`", Utc::now().with_timezone(tz).format("%H:%M")),
|
||||
true,
|
||||
)
|
||||
});
|
||||
@ -102,11 +76,7 @@ pub async fn timezone(
|
||||
}
|
||||
} else {
|
||||
let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| {
|
||||
(
|
||||
t.to_string(),
|
||||
format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()),
|
||||
true,
|
||||
)
|
||||
(t.to_string(), format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M")), true)
|
||||
});
|
||||
|
||||
ctx.send(|m| {
|
||||
@ -133,394 +103,122 @@ You may want to use one of the popular timezones below, otherwise click [here](h
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT name
|
||||
FROM macro
|
||||
WHERE
|
||||
guild_id = (SELECT id FROM guilds WHERE guild = ?)
|
||||
AND name LIKE CONCAT(?, '%')",
|
||||
ctx.guild_id().unwrap().0,
|
||||
partial,
|
||||
)
|
||||
.fetch_all(&ctx.data().database)
|
||||
.await
|
||||
.unwrap_or(vec![])
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Record and replay command sequences
|
||||
#[poise::command(slash_command, rename = "macro", check = "guild_only")]
|
||||
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
/// Configure whether other users can set reminders to your direct messages
|
||||
#[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")]
|
||||
pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start recording up to 5 commands to replay
|
||||
#[poise::command(slash_command, rename = "record", check = "guild_only")]
|
||||
pub async fn record_macro(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Name for the new macro"] name: String,
|
||||
#[description = "Description for the new macro"] description: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
let guild_id = ctx.guild_id().unwrap();
|
||||
/// Allow other users to set reminders in your direct messages
|
||||
#[poise::command(slash_command, rename = "allow", identifying_name = "allowed_dm")]
|
||||
pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let mut user_data = ctx.author_data().await?;
|
||||
user_data.allowed_dm = true;
|
||||
user_data.commit_changes(&ctx.data().database).await;
|
||||
|
||||
let row = sqlx::query!(
|
||||
"
|
||||
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
|
||||
guild_id.0,
|
||||
name
|
||||
)
|
||||
.fetch_one(&ctx.data().database)
|
||||
.await;
|
||||
|
||||
if row.is_ok() {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Unique Name Required")
|
||||
.description(
|
||||
"A macro already exists under this name.
|
||||
Please select a unique name for your macro.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
ctx.send(|r| {
|
||||
r.ephemeral(true).embed(|e| {
|
||||
e.title("DMs permitted")
|
||||
.description("You will receive a message if a user sets a DM reminder for you.")
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
let okay = {
|
||||
let mut lock = ctx.data().recording_macros.write().await;
|
||||
|
||||
if lock.contains_key(&(guild_id, ctx.author().id)) {
|
||||
false
|
||||
} else {
|
||||
lock.insert(
|
||||
(guild_id, ctx.author().id),
|
||||
CommandMacro { guild_id, name, description, commands: vec![] },
|
||||
);
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
if okay {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Macro Recording Started")
|
||||
.description(
|
||||
"Run up to 5 commands, or type `/macro finish` to stop at any point.
|
||||
Any commands ran as part of recording will be inconsequential",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Macro Already Recording")
|
||||
.description(
|
||||
"You are already recording a macro in this server.
|
||||
Please use `/macro finish` to end this recording before starting another.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish current macro recording
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "finish",
|
||||
check = "guild_only",
|
||||
identifying_name = "macro_finish"
|
||||
)]
|
||||
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let key = (ctx.guild_id().unwrap(), ctx.author().id);
|
||||
|
||||
{
|
||||
let lock = ctx.data().recording_macros.read().await;
|
||||
let contained = lock.get(&key);
|
||||
|
||||
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
|
||||
ctx.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title("No Macro Recorded")
|
||||
.description("Use `/macro record` to start recording a macro")
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
let command_macro = contained.unwrap();
|
||||
let json = serde_json::to_string(&command_macro.commands).unwrap();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
|
||||
command_macro.guild_id.0,
|
||||
command_macro.name,
|
||||
command_macro.description,
|
||||
json
|
||||
)
|
||||
.execute(&ctx.data().database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ctx.send(|m| {
|
||||
m.embed(|e| {
|
||||
e.title("Macro Recorded")
|
||||
.description("Use `/macro run` to execute the macro")
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut lock = ctx.data().recording_macros.write().await;
|
||||
lock.remove(&key);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List recorded macros
|
||||
#[poise::command(slash_command, rename = "list", check = "guild_only")]
|
||||
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let macros = CommandMacro::from_guild(&ctx.data().database, ctx.guild_id().unwrap()).await;
|
||||
|
||||
let resp = show_macro_page(¯os, 0);
|
||||
|
||||
ctx.send(|m| {
|
||||
*m = resp;
|
||||
m
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_command<'a>(
|
||||
commands: &'a [poise::Command<Data, Error>],
|
||||
searching_name: &str,
|
||||
command_options: &CommandOptions,
|
||||
) -> Option<&'a poise::Command<Data, Error>> {
|
||||
commands.iter().find_map(|cmd| {
|
||||
if searching_name != cmd.name {
|
||||
None
|
||||
} else {
|
||||
if let Some(subgroup) = &command_options.subcommand_group {
|
||||
find_command(&cmd.subcommands, &subgroup, &command_options)
|
||||
} else if let Some(subcommand) = &command_options.subcommand {
|
||||
find_command(&cmd.subcommands, &subcommand, &command_options)
|
||||
} else {
|
||||
Some(cmd)
|
||||
}
|
||||
}
|
||||
/// Block other users from setting reminders in your direct messages
|
||||
#[poise::command(slash_command, rename = "block", identifying_name = "allowed_dm")]
|
||||
pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let mut user_data = ctx.author_data().await?;
|
||||
user_data.allowed_dm = false;
|
||||
user_data.commit_changes(&ctx.data().database).await;
|
||||
|
||||
ctx.send(|r| {
|
||||
r.ephemeral(true).embed(|e| {
|
||||
e.title("DMs blocked")
|
||||
.description(
|
||||
"You can still set DM reminders for yourself or for users with DMs enabled.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a recorded macro
|
||||
#[poise::command(slash_command, rename = "run", check = "guild_only")]
|
||||
pub async fn run_macro(
|
||||
/// 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 = "Name of macro to run"]
|
||||
#[autocomplete = "macro_name_autocomplete"]
|
||||
name: String,
|
||||
#[description = "Channel to send reminders to by default"] channel: Option<ChannelId>,
|
||||
) -> Result<(), Error> {
|
||||
match sqlx::query!(
|
||||
"
|
||||
SELECT commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
|
||||
ctx.guild_id().unwrap().0,
|
||||
name
|
||||
)
|
||||
.fetch_one(&ctx.data().database)
|
||||
.await
|
||||
{
|
||||
Ok(row) => {
|
||||
ctx.defer().await?;
|
||||
if let Some(mut guild_data) = ctx.guild_data().await {
|
||||
guild_data.default_channel = channel.map(|c| c.0);
|
||||
|
||||
let commands: Vec<CommandOptions> = serde_json::from_str(&row.commands)?;
|
||||
guild_data.commit_changes(&ctx.data().database).await?;
|
||||
|
||||
for command in commands {
|
||||
let cmd =
|
||||
find_command(&ctx.framework().options().commands, &command.command, &command);
|
||||
|
||||
if let Some(cmd) = cmd {
|
||||
let mut executing_ctx = ctx.clone();
|
||||
|
||||
executing_ctx.command = cmd;
|
||||
} else {
|
||||
ctx.send(|m| {
|
||||
m.ephemeral(true)
|
||||
.content(format!("Command `{}` not found", command.command))
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(sqlx::Error::RowNotFound) => {
|
||||
ctx.say(format!("Macro \"{}\" not found", name)).await?;
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
panic!("{}", e);
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Delete a recorded macro
|
||||
#[poise::command(slash_command, rename = "delete", check = "guild_only")]
|
||||
pub async fn delete_macro(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Name of macro to delete"]
|
||||
#[autocomplete = "macro_name_autocomplete"]
|
||||
name: String,
|
||||
) -> Result<(), Error> {
|
||||
match sqlx::query!(
|
||||
"
|
||||
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
|
||||
ctx.guild_id().unwrap().0,
|
||||
name
|
||||
)
|
||||
.fetch_one(&ctx.data().database)
|
||||
.await
|
||||
{
|
||||
Ok(row) => {
|
||||
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
|
||||
.execute(&ctx.data().database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
|
||||
/// View the webhook being used to send reminders to this channel
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
identifying_name = "webhook_url",
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
default_member_permissions = "ADMINISTRATOR"
|
||||
)]
|
||||
pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
|
||||
match ctx.channel_data().await {
|
||||
Ok(data) => {
|
||||
if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
|
||||
ctx.send(|b| {
|
||||
b.ephemeral(true).content(format!(
|
||||
"**Warning!**
|
||||
This link can be used by users to anonymously send messages, with or without permissions.
|
||||
Do not share it!
|
||||
|| https://discord.com/api/webhooks/{}/{} ||",
|
||||
id, token,
|
||||
))
|
||||
})
|
||||
.await?;
|
||||
} else {
|
||||
ctx.say("No webhook configured on this channel.").await?;
|
||||
}
|
||||
}
|
||||
|
||||
Err(sqlx::Error::RowNotFound) => {
|
||||
ctx.say(format!("Macro \"{}\" not found", name)).await?;
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
panic!("{}", e);
|
||||
warn!("Error fetching channel data: {:?}", e);
|
||||
|
||||
ctx.say("No webhook configured on this channel.").await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn max_macro_page(macros: &[CommandMacro]) -> 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(macros: &[CommandMacro], page: usize) -> CreateReply {
|
||||
let mut reply = CreateReply::default();
|
||||
|
||||
reply.embed(|e| {
|
||||
e.title("Macros")
|
||||
.description("No Macros Set Up. Use `/macro record` to get started.")
|
||||
.color(*THEME_COLOR)
|
||||
});
|
||||
|
||||
reply
|
||||
|
||||
/*
|
||||
let pager = MacroPager::new(page);
|
||||
|
||||
if macros.is_empty() {
|
||||
let mut reply = CreateReply::default();
|
||||
|
||||
reply.embed(|e| {
|
||||
e.title("Macros")
|
||||
.description("No Macros Set Up. Use `/macro record` to get started.")
|
||||
.color(*THEME_COLOR)
|
||||
});
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
let pages = max_macro_page(macros);
|
||||
|
||||
let mut page = page;
|
||||
if page >= pages {
|
||||
page = pages - 1;
|
||||
}
|
||||
|
||||
let mut char_count = 0;
|
||||
let mut skipped_char_count = 0;
|
||||
|
||||
let mut skipped_pages = 0;
|
||||
|
||||
let display_vec: Vec<String> = macros
|
||||
.iter()
|
||||
.map(|m| {
|
||||
if let Some(description) = &m.description {
|
||||
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
|
||||
} else {
|
||||
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
|
||||
}
|
||||
})
|
||||
.skip_while(|p| {
|
||||
skipped_char_count += p.len();
|
||||
|
||||
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
|
||||
skipped_char_count = p.len();
|
||||
skipped_pages += 1;
|
||||
}
|
||||
|
||||
skipped_pages < page
|
||||
})
|
||||
.take_while(|p| {
|
||||
char_count += p.len();
|
||||
|
||||
char_count < EMBED_DESCRIPTION_MAX_LENGTH
|
||||
})
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let display = display_vec.join("\n");
|
||||
|
||||
let mut reply = CreateReply::default();
|
||||
|
||||
reply
|
||||
.embed(|e| {
|
||||
e.title("Macros")
|
||||
.description(display)
|
||||
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
.components(|comp| {
|
||||
pager.create_button_row(pages, comp);
|
||||
|
||||
comp
|
||||
});
|
||||
|
||||
reply
|
||||
*/
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
use regex_command_attr::command;
|
||||
use serenity::client::Context;
|
||||
use poise::CreateReply;
|
||||
|
||||
use crate::{
|
||||
component_models::{
|
||||
@ -7,134 +6,220 @@ use crate::{
|
||||
ComponentDataModel, TodoSelector,
|
||||
},
|
||||
consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR},
|
||||
framework::{CommandInvoke, CommandOptions, CreateGenericResponse},
|
||||
hooks::CHECK_GUILD_PERMISSIONS_HOOK,
|
||||
SQLPool,
|
||||
models::CtxData,
|
||||
Context, Error,
|
||||
};
|
||||
|
||||
#[command]
|
||||
#[description("Manage todo lists")]
|
||||
#[subcommandgroup("server")]
|
||||
#[description("Manage the server todo list")]
|
||||
#[subcommand("add")]
|
||||
#[description("Add an item to the server todo list")]
|
||||
#[arg(
|
||||
name = "task",
|
||||
description = "The task to add to the todo list",
|
||||
kind = "String",
|
||||
required = true
|
||||
/// Manage todo lists
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "todo",
|
||||
identifying_name = "todo_base",
|
||||
default_member_permissions = "MANAGE_GUILD"
|
||||
)]
|
||||
#[subcommand("view")]
|
||||
#[description("View and remove from the server todo list")]
|
||||
#[subcommandgroup("channel")]
|
||||
#[description("Manage the channel todo list")]
|
||||
#[subcommand("add")]
|
||||
#[description("Add to the channel todo list")]
|
||||
#[arg(
|
||||
name = "task",
|
||||
description = "The task to add to the todo list",
|
||||
kind = "String",
|
||||
required = true
|
||||
pub async fn todo_base(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Manage the server todo list
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "server",
|
||||
guild_only = true,
|
||||
identifying_name = "todo_guild_base",
|
||||
default_member_permissions = "MANAGE_GUILD"
|
||||
)]
|
||||
#[subcommand("view")]
|
||||
#[description("View and remove from the channel todo list")]
|
||||
#[subcommandgroup("user")]
|
||||
#[description("Manage your personal todo list")]
|
||||
#[subcommand("add")]
|
||||
#[description("Add to your personal todo list")]
|
||||
#[arg(
|
||||
name = "task",
|
||||
description = "The task to add to the todo list",
|
||||
kind = "String",
|
||||
required = true
|
||||
pub async fn todo_guild_base(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an item to the server todo list
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "add",
|
||||
guild_only = true,
|
||||
identifying_name = "todo_guild_add",
|
||||
default_member_permissions = "MANAGE_GUILD"
|
||||
)]
|
||||
#[subcommand("view")]
|
||||
#[description("View and remove from your personal todo list")]
|
||||
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
|
||||
async fn todo(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
|
||||
if invoke.guild_id().is_none() && args.subcommand_group != Some("user".to_string()) {
|
||||
let _ = invoke
|
||||
.respond(
|
||||
&ctx,
|
||||
CreateGenericResponse::new().content("Please use `/todo user` in direct messages"),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
||||
pub async fn todo_guild_add(
|
||||
ctx: Context<'_>,
|
||||
#[description = "The task to add to the todo list"] task: String,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO todos (guild_id, value)
|
||||
VALUES (?, ?)",
|
||||
ctx.guild_id().unwrap().0,
|
||||
task
|
||||
)
|
||||
.execute(&ctx.data().database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let keys = match args.subcommand_group.as_ref().unwrap().as_str() {
|
||||
"server" => (None, None, invoke.guild_id().map(|g| g.0)),
|
||||
"channel" => (None, Some(invoke.channel_id().0), invoke.guild_id().map(|g| g.0)),
|
||||
_ => (Some(invoke.author_id().0), None, None),
|
||||
};
|
||||
ctx.say("Item added to todo list").await?;
|
||||
|
||||
match args.get("task") {
|
||||
Some(task) => {
|
||||
let task = task.to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO todos (user_id, channel_id, guild_id, value) VALUES ((SELECT id FROM users WHERE user = ?), (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?), ?)",
|
||||
keys.0,
|
||||
keys.1,
|
||||
keys.2,
|
||||
task
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
/// View and remove from the server todo list
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "view",
|
||||
guild_only = true,
|
||||
identifying_name = "todo_guild_view",
|
||||
default_member_permissions = "MANAGE_GUILD"
|
||||
)]
|
||||
pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let values = sqlx::query!(
|
||||
"SELECT todos.id, value FROM todos WHERE guild_id = ?",
|
||||
ctx.guild_id().unwrap().0,
|
||||
)
|
||||
.fetch_all(&ctx.data().database)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| (row.id as usize, row.value.clone()))
|
||||
.collect::<Vec<(usize, String)>>();
|
||||
|
||||
let _ = invoke
|
||||
.respond(&ctx, CreateGenericResponse::new().content("Item added to todo list"))
|
||||
.await;
|
||||
}
|
||||
None => {
|
||||
let values = if let Some(uid) = keys.0 {
|
||||
sqlx::query!(
|
||||
"SELECT todos.id, value FROM todos
|
||||
INNER JOIN users ON todos.user_id = users.id
|
||||
WHERE users.user = ?",
|
||||
uid,
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| (row.id as usize, row.value.clone()))
|
||||
.collect::<Vec<(usize, String)>>()
|
||||
} else if let Some(cid) = keys.1 {
|
||||
sqlx::query!(
|
||||
"SELECT todos.id, value FROM todos
|
||||
let resp = show_todo_page(&values, 0, None, None, ctx.guild_id().map(|g| g.0));
|
||||
|
||||
ctx.send(|r| {
|
||||
*r = resp;
|
||||
r
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Manage the channel todo list
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "channel",
|
||||
guild_only = true,
|
||||
identifying_name = "todo_channel_base",
|
||||
default_member_permissions = "MANAGE_GUILD"
|
||||
)]
|
||||
pub async fn todo_channel_base(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an item to the channel todo list
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "add",
|
||||
guild_only = true,
|
||||
identifying_name = "todo_channel_add",
|
||||
default_member_permissions = "MANAGE_GUILD"
|
||||
)]
|
||||
pub async fn todo_channel_add(
|
||||
ctx: Context<'_>,
|
||||
#[description = "The task to add to the todo list"] task: String,
|
||||
) -> Result<(), Error> {
|
||||
// ensure channel is cached
|
||||
let _ = ctx.channel_data().await;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO todos (guild_id, channel_id, value)
|
||||
VALUES (?, (SELECT id FROM channels WHERE channel = ?), ?)",
|
||||
ctx.guild_id().unwrap().0,
|
||||
ctx.channel_id().0,
|
||||
task
|
||||
)
|
||||
.execute(&ctx.data().database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ctx.say("Item added to todo list").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// View and remove from the channel todo list
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
rename = "view",
|
||||
guild_only = true,
|
||||
identifying_name = "todo_channel_view",
|
||||
default_member_permissions = "MANAGE_GUILD"
|
||||
)]
|
||||
pub async fn todo_channel_view(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let values = sqlx::query!(
|
||||
"SELECT todos.id, value FROM todos
|
||||
INNER JOIN channels ON todos.channel_id = channels.id
|
||||
WHERE channels.channel = ?",
|
||||
cid,
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| (row.id as usize, row.value.clone()))
|
||||
.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 = ?",
|
||||
keys.2,
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| (row.id as usize, row.value.clone()))
|
||||
.collect::<Vec<(usize, String)>>()
|
||||
};
|
||||
ctx.channel_id().0,
|
||||
)
|
||||
.fetch_all(&ctx.data().database)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| (row.id as usize, row.value.clone()))
|
||||
.collect::<Vec<(usize, String)>>();
|
||||
|
||||
let resp = show_todo_page(&values, 0, keys.0, keys.1, keys.2);
|
||||
let resp =
|
||||
show_todo_page(&values, 0, None, Some(ctx.channel_id().0), ctx.guild_id().map(|g| g.0));
|
||||
|
||||
invoke.respond(&ctx, resp).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.send(|r| {
|
||||
*r = resp;
|
||||
r
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Manage your personal todo list
|
||||
#[poise::command(slash_command, rename = "user", identifying_name = "todo_user_base")]
|
||||
pub async fn todo_user_base(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an item to your personal todo list
|
||||
#[poise::command(slash_command, rename = "add", identifying_name = "todo_user_add")]
|
||||
pub async fn todo_user_add(
|
||||
ctx: Context<'_>,
|
||||
#[description = "The task to add to the todo list"] task: String,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO todos (user_id, value)
|
||||
VALUES ((SELECT id FROM users WHERE user = ?), ?)",
|
||||
ctx.author().id.0,
|
||||
task
|
||||
)
|
||||
.execute(&ctx.data().database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
ctx.say("Item added to todo list").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// View and remove from your personal todo list
|
||||
#[poise::command(slash_command, rename = "view", identifying_name = "todo_user_view")]
|
||||
pub async fn todo_user_view(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let values = sqlx::query!(
|
||||
"SELECT todos.id, value FROM todos
|
||||
INNER JOIN users ON todos.user_id = users.id
|
||||
WHERE users.user = ?",
|
||||
ctx.author().id.0,
|
||||
)
|
||||
.fetch_all(&ctx.data().database)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| (row.id as usize, row.value.clone()))
|
||||
.collect::<Vec<(usize, String)>>();
|
||||
|
||||
let resp = show_todo_page(&values, 0, Some(ctx.author().id.0), None, None);
|
||||
|
||||
ctx.send(|r| {
|
||||
*r = resp;
|
||||
r
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn max_todo_page(todo_values: &[(usize, String)]) -> usize {
|
||||
@ -164,7 +249,7 @@ pub fn show_todo_page(
|
||||
user_id: Option<u64>,
|
||||
channel_id: Option<u64>,
|
||||
guild_id: Option<u64>,
|
||||
) -> CreateGenericResponse {
|
||||
) -> CreateReply {
|
||||
let pager = TodoPager::new(page, user_id, channel_id, guild_id);
|
||||
|
||||
let pages = max_todo_page(todo_values);
|
||||
@ -219,17 +304,23 @@ pub fn show_todo_page(
|
||||
};
|
||||
|
||||
if todo_ids.is_empty() {
|
||||
CreateGenericResponse::new().embed(|e| {
|
||||
let mut reply = CreateReply::default();
|
||||
|
||||
reply.embed(|e| {
|
||||
e.title(format!("{} Todo List", title))
|
||||
.description("Todo List Empty!")
|
||||
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
});
|
||||
|
||||
reply
|
||||
} else {
|
||||
let todo_selector =
|
||||
ComponentDataModel::TodoSelector(TodoSelector { page, user_id, channel_id, guild_id });
|
||||
|
||||
CreateGenericResponse::new()
|
||||
let mut reply = CreateReply::default();
|
||||
|
||||
reply
|
||||
.embed(|e| {
|
||||
e.title(format!("{} Todo List", title))
|
||||
.description(display)
|
||||
@ -247,7 +338,7 @@ pub fn show_todo_page(
|
||||
opt.create_option(|o| {
|
||||
o.label(format!("Mark {} complete", count + first_num))
|
||||
.value(id)
|
||||
.description(disp.split_once(" ").unwrap_or(("", "")).1)
|
||||
.description(disp.split_once(' ').unwrap_or(("", "")).1)
|
||||
});
|
||||
}
|
||||
|
||||
@ -255,6 +346,8 @@ pub fn show_todo_page(
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
reply
|
||||
}
|
||||
}
|
||||
|
@ -3,23 +3,35 @@ pub(crate) mod pager;
|
||||
use std::io::Cursor;
|
||||
|
||||
use chrono_tz::Tz;
|
||||
use poise::serenity::{
|
||||
builder::CreateEmbed,
|
||||
client::Context,
|
||||
model::{
|
||||
channel::Channel,
|
||||
interactions::{message_component::MessageComponentInteraction, InteractionResponseType},
|
||||
prelude::InteractionApplicationCommandCallbackDataFlags,
|
||||
use log::warn;
|
||||
use poise::{
|
||||
serenity_prelude as serenity,
|
||||
serenity_prelude::{
|
||||
builder::CreateEmbed,
|
||||
model::{
|
||||
application::interaction::{
|
||||
message_component::MessageComponentInteraction, InteractionResponseType,
|
||||
MessageFlags,
|
||||
},
|
||||
channel::Channel,
|
||||
},
|
||||
Context,
|
||||
},
|
||||
};
|
||||
use rmp_serde::Serializer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
self,
|
||||
commands::{
|
||||
command_macro::list::{max_macro_page, show_macro_page},
|
||||
reminder_cmds::{max_delete_page, show_delete_page},
|
||||
todo_cmds::{max_todo_page, show_todo_page},
|
||||
},
|
||||
component_models::pager::{DelPager, LookPager, MacroPager, Pager, TodoPager},
|
||||
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
|
||||
models::{command_macro::CommandMacro, reminder::Reminder},
|
||||
models::reminder::Reminder,
|
||||
utils::send_as_initial_response,
|
||||
Data,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
@ -32,6 +44,7 @@ pub enum ComponentDataModel {
|
||||
DelSelector(DelSelector),
|
||||
TodoSelector(TodoSelector),
|
||||
MacroPager(MacroPager),
|
||||
UndoReminder(UndoReminder),
|
||||
}
|
||||
|
||||
impl ComponentDataModel {
|
||||
@ -49,7 +62,7 @@ impl ComponentDataModel {
|
||||
rmp_serde::from_read(cur).unwrap()
|
||||
}
|
||||
|
||||
pub async fn act(&self, ctx: &Context, component: MessageComponentInteraction) {
|
||||
pub async fn act(&self, ctx: &Context, data: &Data, component: &MessageComponentInteraction) {
|
||||
match self {
|
||||
ComponentDataModel::LookPager(pager) => {
|
||||
let flags = pager.flags;
|
||||
@ -66,7 +79,7 @@ impl ComponentDataModel {
|
||||
component.channel_id
|
||||
};
|
||||
|
||||
let reminders = Reminder::from_channel(ctx, channel_id, &flags).await;
|
||||
let reminders = Reminder::from_channel(&data.database, channel_id, &flags).await;
|
||||
|
||||
let pages = reminders
|
||||
.iter()
|
||||
@ -116,7 +129,7 @@ impl ComponentDataModel {
|
||||
.create_interaction_response(&ctx, |r| {
|
||||
r.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|
||||
|response| {
|
||||
response.embeds(vec![embed]).components(|comp| {
|
||||
response.set_embeds(vec![embed]).components(|comp| {
|
||||
pager.create_button_row(pages, comp);
|
||||
|
||||
comp
|
||||
@ -127,45 +140,68 @@ impl ComponentDataModel {
|
||||
.await;
|
||||
}
|
||||
ComponentDataModel::DelPager(pager) => {
|
||||
let reminders =
|
||||
Reminder::from_guild(ctx, component.guild_id, component.user.id).await;
|
||||
let reminders = Reminder::from_guild(
|
||||
&ctx,
|
||||
&data.database,
|
||||
component.guild_id,
|
||||
component.user.id,
|
||||
)
|
||||
.await;
|
||||
|
||||
let max_pages = max_delete_page(&reminders, &pager.timezone);
|
||||
|
||||
let resp = show_delete_page(&reminders, pager.next_page(max_pages), pager.timezone);
|
||||
|
||||
let mut invoke = CommandInvoke::component(component);
|
||||
let _ = invoke.respond(&ctx, resp).await;
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|
||||
|d| {
|
||||
send_as_initial_response(resp, d);
|
||||
d
|
||||
},
|
||||
)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
ComponentDataModel::DelSelector(selector) => {
|
||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
||||
let selected_id = component.data.values.join(",");
|
||||
|
||||
sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id)
|
||||
.execute(&pool)
|
||||
.execute(&data.database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let reminders =
|
||||
Reminder::from_guild(ctx, component.guild_id, component.user.id).await;
|
||||
let reminders = Reminder::from_guild(
|
||||
&ctx,
|
||||
&data.database,
|
||||
component.guild_id,
|
||||
component.user.id,
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = show_delete_page(&reminders, selector.page, selector.timezone);
|
||||
|
||||
let mut invoke = CommandInvoke::component(component);
|
||||
let _ = invoke.respond(&ctx, resp).await;
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|
||||
|d| {
|
||||
send_as_initial_response(resp, d);
|
||||
d
|
||||
},
|
||||
)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
ComponentDataModel::TodoPager(pager) => {
|
||||
if Some(component.user.id.0) == pager.user_id || pager.user_id.is_none() {
|
||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
||||
|
||||
let values = if let Some(uid) = pager.user_id {
|
||||
sqlx::query!(
|
||||
"SELECT todos.id, value FROM todos
|
||||
INNER JOIN users ON todos.user_id = users.id
|
||||
WHERE users.user = ?",
|
||||
INNER JOIN users ON todos.user_id = users.id
|
||||
WHERE users.user = ?",
|
||||
uid,
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.fetch_all(&data.database)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
@ -174,11 +210,11 @@ impl ComponentDataModel {
|
||||
} else if let Some(cid) = pager.channel_id {
|
||||
sqlx::query!(
|
||||
"SELECT todos.id, value FROM todos
|
||||
INNER JOIN channels ON todos.channel_id = channels.id
|
||||
WHERE channels.channel = ?",
|
||||
INNER JOIN channels ON todos.channel_id = channels.id
|
||||
WHERE channels.channel = ?",
|
||||
cid,
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.fetch_all(&data.database)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
@ -186,12 +222,10 @@ impl ComponentDataModel {
|
||||
.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(&pool)
|
||||
.fetch_all(&data.database)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
@ -209,15 +243,22 @@ impl ComponentDataModel {
|
||||
pager.guild_id,
|
||||
);
|
||||
|
||||
let mut invoke = CommandInvoke::component(component);
|
||||
let _ = invoke.respond(&ctx, resp).await;
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::UpdateMessage)
|
||||
.interaction_response_data(|d| {
|
||||
send_as_initial_response(resp, d);
|
||||
d
|
||||
})
|
||||
})
|
||||
.await;
|
||||
} else {
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |r| {
|
||||
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|d| {
|
||||
d.flags(
|
||||
InteractionApplicationCommandCallbackDataFlags::EPHEMERAL,
|
||||
MessageFlags::EPHEMERAL,
|
||||
)
|
||||
.content("Only the user who performed the command can use these components")
|
||||
})
|
||||
@ -227,11 +268,10 @@ impl ComponentDataModel {
|
||||
}
|
||||
ComponentDataModel::TodoSelector(selector) => {
|
||||
if Some(component.user.id.0) == selector.user_id || selector.user_id.is_none() {
|
||||
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
|
||||
let selected_id = component.data.values.join(",");
|
||||
|
||||
sqlx::query!("DELETE FROM todos WHERE FIND_IN_SET(id, ?)", selected_id)
|
||||
.execute(&pool)
|
||||
.execute(&data.database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@ -242,7 +282,7 @@ impl ComponentDataModel {
|
||||
selector.channel_id,
|
||||
selector.guild_id,
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.fetch_all(&data.database)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
@ -257,15 +297,22 @@ impl ComponentDataModel {
|
||||
selector.guild_id,
|
||||
);
|
||||
|
||||
let mut invoke = CommandInvoke::component(component);
|
||||
let _ = invoke.respond(&ctx, resp).await;
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::UpdateMessage)
|
||||
.interaction_response_data(|d| {
|
||||
send_as_initial_response(resp, d);
|
||||
d
|
||||
})
|
||||
})
|
||||
.await;
|
||||
} else {
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |r| {
|
||||
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|d| {
|
||||
d.flags(
|
||||
InteractionApplicationCommandCallbackDataFlags::EPHEMERAL,
|
||||
MessageFlags::EPHEMERAL,
|
||||
)
|
||||
.content("Only the user who performed the command can use these components")
|
||||
})
|
||||
@ -274,15 +321,87 @@ impl ComponentDataModel {
|
||||
}
|
||||
}
|
||||
ComponentDataModel::MacroPager(pager) => {
|
||||
let mut invoke = CommandInvoke::component(component);
|
||||
|
||||
let macros = CommandMacro::from_guild(ctx, invoke.guild_id().unwrap()).await;
|
||||
let macros = data.command_macros(component.guild_id.unwrap()).await.unwrap();
|
||||
|
||||
let max_page = max_macro_page(¯os);
|
||||
let page = pager.next_page(max_page);
|
||||
|
||||
let resp = show_macro_page(¯os, page);
|
||||
let _ = invoke.respond(&ctx, resp).await;
|
||||
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|
||||
|d| {
|
||||
send_as_initial_response(resp, d);
|
||||
d
|
||||
},
|
||||
)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
ComponentDataModel::UndoReminder(undo_reminder) => {
|
||||
if component.user.id == undo_reminder.user_id {
|
||||
let reminder =
|
||||
Reminder::from_id(&data.database, undo_reminder.reminder_id).await;
|
||||
|
||||
if let Some(reminder) = reminder {
|
||||
match reminder.delete(&data.database).await {
|
||||
Ok(()) => {
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::UpdateMessage)
|
||||
.interaction_response_data(|d| {
|
||||
d.embed(|e| {
|
||||
e.title("Reminder Canceled")
|
||||
.description(
|
||||
"This reminder has been canceled.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
.components(|c| c)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error canceling reminder: {:?}", e);
|
||||
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|d| {
|
||||
d.content(
|
||||
"The reminder could not be canceled: it may have already been deleted. Check `/del`!")
|
||||
.ephemeral(true)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|d| {
|
||||
d.content(
|
||||
"The reminder could not be canceled: it may have already been deleted. Check `/del`!")
|
||||
.ephemeral(true)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|d| {
|
||||
d.content(
|
||||
"Only the user who performed the command can use this button.")
|
||||
.ephemeral(true)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -301,3 +420,9 @@ pub struct TodoSelector {
|
||||
pub channel_id: Option<u64>,
|
||||
pub guild_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UndoReminder {
|
||||
pub user_id: serenity::UserId,
|
||||
pub reminder_id: u32,
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
// todo split pager out into a single struct
|
||||
use chrono_tz::Tz;
|
||||
use poise::serenity::{
|
||||
builder::CreateComponents, model::interactions::message_component::ButtonStyle,
|
||||
use poise::serenity_prelude::{
|
||||
builder::CreateComponents, model::application::component::ButtonStyle,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_repr::*;
|
||||
|
@ -1,18 +1,18 @@
|
||||
pub const DAY: u64 = 86_400;
|
||||
pub const HOUR: u64 = 3_600;
|
||||
pub const MINUTE: u64 = 60;
|
||||
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000;
|
||||
pub const SELECT_MAX_ENTRIES: usize = 25;
|
||||
|
||||
pub const MACRO_MAX_COMMANDS: usize = 5;
|
||||
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4096;
|
||||
pub const SELECT_MAX_ENTRIES: usize = 25;
|
||||
|
||||
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
|
||||
|
||||
const THEME_COLOR_FALLBACK: u32 = 0x8fb677;
|
||||
pub const MACRO_MAX_COMMANDS: usize = 5;
|
||||
|
||||
use std::{collections::HashSet, env, iter::FromIterator};
|
||||
|
||||
use poise::serenity::model::prelude::AttachmentType;
|
||||
use poise::serenity_prelude::model::prelude::AttachmentType;
|
||||
use regex::Regex;
|
||||
|
||||
lazy_static! {
|
||||
@ -36,15 +36,11 @@ lazy_static! {
|
||||
);
|
||||
pub static ref CNC_GUILD: Option<u64> =
|
||||
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
|
||||
pub static ref MIN_INTERVAL: i64 = env::var("MIN_INTERVAL")
|
||||
.ok()
|
||||
.map(|inner| inner.parse::<i64>().ok())
|
||||
.flatten()
|
||||
.unwrap_or(600);
|
||||
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")
|
||||
.ok()
|
||||
.map(|inner| inner.parse::<i64>().ok())
|
||||
.flatten()
|
||||
.and_then(|inner| inner.parse::<i64>().ok())
|
||||
.unwrap_or(60 * 60 * 24 * 365 * 50);
|
||||
pub static ref LOCAL_TIMEZONE: String =
|
||||
env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string());
|
||||
|
@ -1,83 +1,128 @@
|
||||
use std::{collections::HashMap, env};
|
||||
|
||||
use poise::serenity::{client::Context, model::interactions::Interaction, utils::shard_id};
|
||||
use log::error;
|
||||
use poise::{
|
||||
serenity_prelude as serenity,
|
||||
serenity_prelude::{model::application::interaction::Interaction, utils::shard_id},
|
||||
};
|
||||
|
||||
use crate::{Data, Error};
|
||||
use crate::{
|
||||
component_models::ComponentDataModel, models::guild_data::GuildData, Data, Error, THEME_COLOR,
|
||||
};
|
||||
|
||||
pub async fn listener(ctx: &Context, event: &poise::Event<'_>, data: &Data) -> Result<(), Error> {
|
||||
pub async fn listener(
|
||||
ctx: &serenity::Context,
|
||||
event: &poise::Event<'_>,
|
||||
data: &Data,
|
||||
) -> Result<(), Error> {
|
||||
match event {
|
||||
poise::Event::Ready { .. } => {
|
||||
ctx.set_activity(serenity::Activity::watching("for /remind")).await;
|
||||
}
|
||||
poise::Event::ChannelDelete { channel } => {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM channels WHERE channel = ?
|
||||
",
|
||||
channel.id.as_u64()
|
||||
)
|
||||
.execute(&data.database)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64())
|
||||
.execute(&data.database)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
poise::Event::GuildCreate { guild, is_new } => {
|
||||
if *is_new {
|
||||
let guild_id = guild.id.as_u64().to_owned();
|
||||
|
||||
sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id)
|
||||
sqlx::query!("INSERT IGNORE INTO guilds (id) VALUES (?)", guild_id)
|
||||
.execute(&data.database)
|
||||
.await
|
||||
.unwrap();
|
||||
.await?;
|
||||
|
||||
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
|
||||
let shard_count = ctx.cache.shard_count();
|
||||
let current_shard_id = shard_id(guild_id, shard_count);
|
||||
if let Err(e) = post_guild_count(ctx, &data.http, guild_id).await {
|
||||
error!("DiscordBotList: {:?}", e);
|
||||
}
|
||||
|
||||
let guild_count = ctx
|
||||
.cache
|
||||
.guilds()
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id
|
||||
let default_channel = guild.default_channel_guaranteed();
|
||||
|
||||
if let Some(default_channel) = default_channel {
|
||||
default_channel
|
||||
.send_message(&ctx, |m| {
|
||||
m.embed(|e| {
|
||||
e.title("Thank you for adding Reminder Bot!").description(
|
||||
"To get started:
|
||||
• Set your timezone with `/timezone`
|
||||
• Set up permissions in Server Settings 🠚 Integrations 🠚 Reminder Bot (desktop only)
|
||||
• Create your first reminder with `/remind`
|
||||
|
||||
__Support__
|
||||
If you need any support, please come and ask us! Join our [Discord](https://discord.jellywx.com).
|
||||
|
||||
__Updates__
|
||||
To stay up to date on the latest features and fixes, join our [Discord](https://discord.jellywx.com).
|
||||
",
|
||||
).color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.count() as u64;
|
||||
|
||||
let mut hm = HashMap::new();
|
||||
hm.insert("server_count", guild_count);
|
||||
hm.insert("shard_id", current_shard_id);
|
||||
hm.insert("shard_count", shard_count);
|
||||
|
||||
let response = data
|
||||
.http
|
||||
.post(
|
||||
format!(
|
||||
"https://top.gg/api/bots/{}/stats",
|
||||
ctx.cache.current_user_id().as_u64()
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
.header("Authorization", token)
|
||||
.json(&hm)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Err(res) = response {
|
||||
println!("DiscordBots Response: {:?}", res);
|
||||
}
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
poise::Event::GuildDelete { incomplete, full } => {
|
||||
let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0)
|
||||
poise::Event::GuildDelete { incomplete, .. } => {
|
||||
let _ = sqlx::query!("DELETE FROM guilds WHERE id = ?", incomplete.id.0)
|
||||
.execute(&data.database)
|
||||
.await;
|
||||
}
|
||||
poise::Event::InteractionCreate { interaction } => match interaction {
|
||||
Interaction::MessageComponent(component) => {
|
||||
//let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
|
||||
//component_model.act(&ctx, component).await;
|
||||
poise::Event::InteractionCreate { interaction } => {
|
||||
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?;
|
||||
}
|
||||
}
|
||||
|
||||
Interaction::MessageComponent(component) => {
|
||||
let component_model =
|
||||
ComponentDataModel::from_custom_id(&component.data.custom_id);
|
||||
|
||||
component_model.act(ctx, data, component).await;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn post_guild_count(
|
||||
ctx: &serenity::Context,
|
||||
http: &reqwest::Client,
|
||||
guild_id: u64,
|
||||
) -> Result<(), reqwest::Error> {
|
||||
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
|
||||
let shard_count = ctx.cache.shard_count();
|
||||
let current_shard_id = shard_id(guild_id, shard_count);
|
||||
|
||||
let guild_count = ctx
|
||||
.cache
|
||||
.guilds()
|
||||
.iter()
|
||||
.filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id)
|
||||
.count() as u64;
|
||||
|
||||
let mut hm = HashMap::new();
|
||||
hm.insert("server_count", guild_count);
|
||||
hm.insert("shard_id", current_shard_id);
|
||||
hm.insert("shard_count", shard_count);
|
||||
|
||||
http.post(
|
||||
format!("https://top.gg/api/bots/{}/stats", ctx.cache.current_user_id().as_u64())
|
||||
.as_str(),
|
||||
)
|
||||
.header("Authorization", token)
|
||||
.json(&hm)
|
||||
.send()
|
||||
.await
|
||||
.map(|_| ())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
56
src/hooks.rs
@ -1,61 +1,48 @@
|
||||
use poise::{serenity::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction};
|
||||
use poise::{
|
||||
serenity_prelude::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction,
|
||||
};
|
||||
|
||||
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::CommandOptions, Context, Error};
|
||||
|
||||
pub async fn guild_only(ctx: Context<'_>) -> Result<bool, Error> {
|
||||
if ctx.guild_id().is_some() {
|
||||
Ok(true)
|
||||
} else {
|
||||
let _ = ctx.say("This command can only be used in servers").await;
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
|
||||
|
||||
async fn macro_check(ctx: Context<'_>) -> bool {
|
||||
if let Context::Application(app_ctx) = ctx {
|
||||
if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(interaction) =
|
||||
if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) =
|
||||
app_ctx.interaction
|
||||
{
|
||||
if let Some(guild_id) = ctx.guild_id() {
|
||||
if ctx.command().identifying_name != "macro_finish" {
|
||||
if ctx.command().identifying_name != "finish_macro" {
|
||||
let mut lock = ctx.data().recording_macros.write().await;
|
||||
|
||||
if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) {
|
||||
if command_macro.commands.len() >= MACRO_MAX_COMMANDS {
|
||||
let _ = ctx.send(|m| {
|
||||
m.ephemeral(true).content(
|
||||
"5 commands already recorded. Please use `/macro finish` to end recording.",
|
||||
)
|
||||
})
|
||||
m.ephemeral(true).content(
|
||||
format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
} else {
|
||||
let mut command_options = CommandOptions::new(&ctx.command().name);
|
||||
command_options.populate(&interaction);
|
||||
let recorded = RecordedCommand {
|
||||
action: None,
|
||||
command_name: ctx.command().identifying_name.clone(),
|
||||
options: Vec::from(app_ctx.args),
|
||||
};
|
||||
|
||||
command_macro.commands.push(command_options);
|
||||
command_macro.commands.push(recorded);
|
||||
|
||||
let _ = ctx
|
||||
.send(|m| m.ephemeral(true).content("Command recorded to macro"))
|
||||
.await;
|
||||
}
|
||||
|
||||
false
|
||||
} else {
|
||||
true
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
||||
@ -69,16 +56,15 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
||||
let (view_channel, send_messages, embed_links) = ctx
|
||||
.channel_id()
|
||||
.to_channel_cached(&ctx.discord())
|
||||
.map(|c| {
|
||||
.and_then(|c| {
|
||||
if let Channel::Guild(channel) = c {
|
||||
channel.permissions_for_user(&ctx.discord(), user_id).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.map_or((false, false, false), |p| {
|
||||
(p.read_messages(), p.send_messages(), p.embed_links())
|
||||
(p.view_channel(), p.send_messages(), p.embed_links())
|
||||
});
|
||||
|
||||
if manage_webhooks && send_messages && embed_links {
|
||||
|
251
src/interval_parser.rs
Normal file
@ -0,0 +1,251 @@
|
||||
/*
|
||||
With modifications, 2022 Jude Southworth
|
||||
|
||||
Original copyright notice:
|
||||
|
||||
Copyright 2021 Paul Colomiets
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
use std::{error::Error as StdError, fmt, str::Chars};
|
||||
|
||||
/// Error parsing human-friendly duration
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub enum Error {
|
||||
/// Invalid character during parsing
|
||||
///
|
||||
/// More specifically anything that is not alphanumeric is prohibited
|
||||
///
|
||||
/// The field is an byte offset of the character in the string.
|
||||
InvalidCharacter(usize),
|
||||
/// Non-numeric value where number is expected
|
||||
///
|
||||
/// This usually means that either time unit is broken into words,
|
||||
/// e.g. `m sec` instead of `msec`, or just number is omitted,
|
||||
/// for example `2 hours min` instead of `2 hours 1 min`
|
||||
///
|
||||
/// The field is an byte offset of the errorneous character
|
||||
/// in the string.
|
||||
NumberExpected(usize),
|
||||
/// Unit in the number is not one of allowed units
|
||||
///
|
||||
/// See documentation of `parse_duration` for the list of supported
|
||||
/// time units.
|
||||
///
|
||||
/// The two fields are start and end (exclusive) of the slice from
|
||||
/// the original string, containing errorneous value
|
||||
UnknownUnit {
|
||||
/// Start of the invalid unit inside the original string
|
||||
start: usize,
|
||||
/// End of the invalid unit inside the original string
|
||||
end: usize,
|
||||
/// The unit verbatim
|
||||
unit: String,
|
||||
/// A number associated with the unit
|
||||
value: u64,
|
||||
},
|
||||
/// The numeric value is too large
|
||||
///
|
||||
/// Usually this means value is too large to be useful. If user writes
|
||||
/// data in subsecond units, then the maximum is about 3k years. When
|
||||
/// using seconds, or larger units, the limit is even larger.
|
||||
NumberOverflow,
|
||||
/// The value was an empty string (or consists only whitespace)
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl StdError for Error {}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::InvalidCharacter(offset) => write!(f, "invalid character at {}", offset),
|
||||
Error::NumberExpected(offset) => write!(f, "expected number at {}", offset),
|
||||
Error::UnknownUnit { unit, value, .. } if unit.is_empty() => {
|
||||
write!(f, "time unit needed, for example {0}sec or {0}ms", value,)
|
||||
}
|
||||
Error::UnknownUnit { unit, .. } => {
|
||||
write!(
|
||||
f,
|
||||
"unknown time unit {:?}, \
|
||||
supported units: ns, us, ms, sec, min, hours, days, \
|
||||
weeks, months, years (and few variations)",
|
||||
unit
|
||||
)
|
||||
}
|
||||
Error::NumberOverflow => write!(f, "number is too large"),
|
||||
Error::Empty => write!(f, "value was empty"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait OverflowOp: Sized {
|
||||
fn mul(self, other: Self) -> Result<Self, Error>;
|
||||
fn add(self, other: Self) -> Result<Self, Error>;
|
||||
}
|
||||
|
||||
impl OverflowOp for u64 {
|
||||
fn mul(self, other: Self) -> Result<Self, Error> {
|
||||
self.checked_mul(other).ok_or(Error::NumberOverflow)
|
||||
}
|
||||
fn add(self, other: Self) -> Result<Self, Error> {
|
||||
self.checked_add(other).ok_or(Error::NumberOverflow)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Interval {
|
||||
pub month: u64,
|
||||
pub sec: u64,
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
iter: Chars<'a>,
|
||||
src: &'a str,
|
||||
current: (u64, u64, u64),
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn off(&self) -> usize {
|
||||
self.src.len() - self.iter.as_str().len()
|
||||
}
|
||||
|
||||
fn parse_first_char(&mut self) -> Result<Option<u64>, Error> {
|
||||
let off = self.off();
|
||||
for c in self.iter.by_ref() {
|
||||
match c {
|
||||
'0'..='9' => {
|
||||
return Ok(Some(c as u64 - '0' as u64));
|
||||
}
|
||||
c if c.is_whitespace() => continue,
|
||||
_ => {
|
||||
return Err(Error::NumberExpected(off));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
fn parse_unit(&mut self, n: u64, start: usize, end: usize) -> Result<(), Error> {
|
||||
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,
|
||||
end,
|
||||
unit: self.src[start..end].to_string(),
|
||||
value: n,
|
||||
});
|
||||
}
|
||||
};
|
||||
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.1;
|
||||
month += self.current.0;
|
||||
|
||||
self.current = (month, sec, nsec);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse(mut self) -> Result<Interval, Error> {
|
||||
let mut n = self.parse_first_char()?.ok_or(Error::Empty)?;
|
||||
'outer: loop {
|
||||
let mut off = self.off();
|
||||
while let Some(c) = self.iter.next() {
|
||||
match c {
|
||||
'0'..='9' => {
|
||||
n = n
|
||||
.checked_mul(10)
|
||||
.and_then(|x| x.checked_add(c as u64 - '0' as u64))
|
||||
.ok_or(Error::NumberOverflow)?;
|
||||
}
|
||||
c if c.is_whitespace() => {}
|
||||
'a'..='z' | 'A'..='Z' => {
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::InvalidCharacter(off));
|
||||
}
|
||||
}
|
||||
off = self.off();
|
||||
}
|
||||
let start = off;
|
||||
let mut off = self.off();
|
||||
while let Some(c) = self.iter.next() {
|
||||
match c {
|
||||
'0'..='9' => {
|
||||
self.parse_unit(n, start, off)?;
|
||||
n = c as u64 - '0' as u64;
|
||||
continue 'outer;
|
||||
}
|
||||
c if c.is_whitespace() => break,
|
||||
'a'..='z' | 'A'..='Z' => {}
|
||||
_ => {
|
||||
return Err(Error::InvalidCharacter(off));
|
||||
}
|
||||
}
|
||||
off = self.off();
|
||||
}
|
||||
self.parse_unit(n, start, off)?;
|
||||
n = match self.parse_first_char()? {
|
||||
Some(n) => n,
|
||||
None => return Ok(Interval { month: self.current.0, sec: self.current.1 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse duration object `1hour 12min 5s`
|
||||
///
|
||||
/// The duration object is a concatenation of time spans. Where each time
|
||||
/// span is an integer number and a suffix. Supported suffixes:
|
||||
///
|
||||
/// * `nsec`, `ns` -- nanoseconds
|
||||
/// * `usec`, `us` -- microseconds
|
||||
/// * `msec`, `ms` -- milliseconds
|
||||
/// * `seconds`, `second`, `sec`, `s`
|
||||
/// * `minutes`, `minute`, `min`, `m`
|
||||
/// * `hours`, `hour`, `hr`, `h`
|
||||
/// * `days`, `day`, `d`
|
||||
/// * `weeks`, `week`, `w`
|
||||
/// * `months`, `month`, `M` -- defined as 30.44 days
|
||||
/// * `years`, `year`, `y` -- defined as 365.25 days
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use std::time::Duration;
|
||||
/// use humantime::parse_duration;
|
||||
///
|
||||
/// assert_eq!(parse_duration("2h 37min"), Ok(Duration::new(9420, 0)));
|
||||
/// 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) }.parse()
|
||||
}
|
179
src/main.rs
@ -1,29 +1,37 @@
|
||||
#![feature(int_roundings)]
|
||||
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
mod commands;
|
||||
// mod component_models;
|
||||
mod component_models;
|
||||
mod consts;
|
||||
mod event_handlers;
|
||||
mod hooks;
|
||||
mod interval_parser;
|
||||
mod models;
|
||||
mod time_parser;
|
||||
mod utils;
|
||||
|
||||
use std::{collections::HashMap, env};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
env,
|
||||
error::Error as StdError,
|
||||
fmt::{Debug, Display, Formatter},
|
||||
};
|
||||
|
||||
use chrono_tz::Tz;
|
||||
use dotenv::dotenv;
|
||||
use poise::serenity::model::{
|
||||
gateway::{Activity, GatewayIntents},
|
||||
use log::{error, warn};
|
||||
use poise::serenity_prelude::model::{
|
||||
gateway::GatewayIntents,
|
||||
id::{GuildId, UserId},
|
||||
};
|
||||
use sqlx::{MySql, Pool};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::{broadcast, broadcast::Sender, RwLock};
|
||||
|
||||
use crate::{
|
||||
commands::{info_cmds, moderation_cmds},
|
||||
commands::{command_macro, info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
|
||||
consts::THEME_COLOR,
|
||||
event_handlers::listener,
|
||||
hooks::all_checks,
|
||||
@ -33,18 +41,51 @@ use crate::{
|
||||
|
||||
type Database = MySql;
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
type Context<'a> = poise::Context<'a, Data, Error>;
|
||||
type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>;
|
||||
|
||||
pub struct Data {
|
||||
database: Pool<Database>,
|
||||
http: reqwest::Client,
|
||||
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro>>,
|
||||
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>,
|
||||
popular_timezones: Vec<Tz>,
|
||||
_broadcast: Sender<()>,
|
||||
}
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
type Context<'a> = poise::Context<'a, Data, Error>;
|
||||
impl Debug for Data {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Data {{ .. }}")
|
||||
}
|
||||
}
|
||||
|
||||
struct Ended;
|
||||
|
||||
impl Debug for Ended {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Process ended.")
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Ended {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Process ended.")
|
||||
}
|
||||
}
|
||||
|
||||
impl StdError for Ended {}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||
let (tx, mut rx) = broadcast::channel(16);
|
||||
|
||||
tokio::select! {
|
||||
output = _main(tx) => output,
|
||||
_ = rx.recv() => Err(Box::new(Ended) as Box<dyn StdError + Send + Sync>)
|
||||
}
|
||||
}
|
||||
|
||||
async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||
env_logger::init();
|
||||
|
||||
dotenv()?;
|
||||
@ -57,17 +98,68 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
info_cmds::info(),
|
||||
info_cmds::donate(),
|
||||
info_cmds::clock(),
|
||||
info_cmds::clock_context_menu(),
|
||||
info_cmds::dashboard(),
|
||||
moderation_cmds::timezone(),
|
||||
poise::Command {
|
||||
subcommands: vec![
|
||||
moderation_cmds::delete_macro(),
|
||||
moderation_cmds::finish_macro(),
|
||||
moderation_cmds::list_macro(),
|
||||
moderation_cmds::record_macro(),
|
||||
moderation_cmds::run_macro(),
|
||||
moderation_cmds::set_allowed_dm(),
|
||||
moderation_cmds::unset_allowed_dm(),
|
||||
],
|
||||
..moderation_cmds::macro_base()
|
||||
..moderation_cmds::allowed_dm()
|
||||
},
|
||||
moderation_cmds::webhook(),
|
||||
poise::Command {
|
||||
subcommands: vec![
|
||||
command_macro::delete::delete_macro(),
|
||||
command_macro::record::finish_macro(),
|
||||
command_macro::list::list_macro(),
|
||||
command_macro::record::record_macro(),
|
||||
command_macro::run::run_macro(),
|
||||
command_macro::migrate::migrate_macro(),
|
||||
],
|
||||
..command_macro::macro_base()
|
||||
},
|
||||
poise::Command {
|
||||
subcommands: vec![moderation_cmds::default_channel()],
|
||||
..moderation_cmds::default()
|
||||
},
|
||||
reminder_cmds::pause(),
|
||||
reminder_cmds::offset(),
|
||||
reminder_cmds::nudge(),
|
||||
reminder_cmds::look(),
|
||||
reminder_cmds::delete(),
|
||||
poise::Command {
|
||||
subcommands: vec![
|
||||
reminder_cmds::list_timer(),
|
||||
reminder_cmds::start_timer(),
|
||||
reminder_cmds::delete_timer(),
|
||||
],
|
||||
..reminder_cmds::timer_base()
|
||||
},
|
||||
reminder_cmds::remind(),
|
||||
poise::Command {
|
||||
subcommands: vec![
|
||||
poise::Command {
|
||||
subcommands: vec![
|
||||
todo_cmds::todo_guild_add(),
|
||||
todo_cmds::todo_guild_view(),
|
||||
],
|
||||
..todo_cmds::todo_guild_base()
|
||||
},
|
||||
poise::Command {
|
||||
subcommands: vec![
|
||||
todo_cmds::todo_channel_add(),
|
||||
todo_cmds::todo_channel_view(),
|
||||
],
|
||||
..todo_cmds::todo_channel_base()
|
||||
},
|
||||
poise::Command {
|
||||
subcommands: vec![todo_cmds::todo_user_add(), todo_cmds::todo_user_view()],
|
||||
..todo_cmds::todo_user_base()
|
||||
},
|
||||
],
|
||||
..todo_cmds::todo_base()
|
||||
},
|
||||
],
|
||||
allowed_mentions: None,
|
||||
@ -80,8 +172,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
|
||||
|
||||
let popular_timezones = sqlx::query!(
|
||||
"
|
||||
SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21"
|
||||
"SELECT IFNULL(timezone, 'UTC') AS timezone
|
||||
FROM users
|
||||
WHERE timezone IS NOT NULL
|
||||
GROUP BY timezone
|
||||
ORDER BY COUNT(timezone) DESC
|
||||
LIMIT 21"
|
||||
)
|
||||
.fetch_all(&database)
|
||||
.await
|
||||
@ -90,32 +186,55 @@ SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT
|
||||
.map(|t| t.timezone.parse::<Tz>().unwrap())
|
||||
.collect::<Vec<Tz>>();
|
||||
|
||||
poise::Framework::build()
|
||||
poise::Framework::builder()
|
||||
.token(discord_token)
|
||||
.user_data_setup(move |ctx, _bot, framework| {
|
||||
Box::pin(async move {
|
||||
ctx.set_activity(Activity::watching("for /remind")).await;
|
||||
register_application_commands(ctx, framework, None).await.unwrap();
|
||||
|
||||
register_application_commands(
|
||||
ctx,
|
||||
framework,
|
||||
env::var("DEBUG_GUILD")
|
||||
.map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid")))
|
||||
.ok(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let kill_tx = tx.clone();
|
||||
let kill_recv = tx.subscribe();
|
||||
|
||||
let ctx1 = ctx.clone();
|
||||
let ctx2 = ctx.clone();
|
||||
|
||||
let pool1 = database.clone();
|
||||
let pool2 = database.clone();
|
||||
|
||||
let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string());
|
||||
|
||||
if !run_settings.contains("postman") {
|
||||
tokio::spawn(async move {
|
||||
match postman::initialize(kill_recv, ctx1, &pool1).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("postman exiting: {}", e);
|
||||
}
|
||||
};
|
||||
});
|
||||
} else {
|
||||
warn!("Not running postman");
|
||||
}
|
||||
|
||||
if !run_settings.contains("web") {
|
||||
tokio::spawn(async move {
|
||||
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
|
||||
});
|
||||
} else {
|
||||
warn!("Not running web");
|
||||
}
|
||||
|
||||
Ok(Data {
|
||||
http: reqwest::Client::new(),
|
||||
database,
|
||||
popular_timezones,
|
||||
recording_macros: Default::default(),
|
||||
_broadcast: tx,
|
||||
})
|
||||
})
|
||||
})
|
||||
.options(options)
|
||||
.client_settings(move |client_builder| client_builder.intents(GatewayIntents::GUILDS))
|
||||
.intents(GatewayIntents::GUILDS)
|
||||
.run_autosharded()
|
||||
.await?;
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use poise::serenity::model::channel::Channel;
|
||||
use poise::serenity_prelude::model::channel::Channel;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
pub struct ChannelData {
|
||||
@ -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,
|
||||
|
@ -1,267 +1,77 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use poise::{
|
||||
serenity::{
|
||||
json::Value,
|
||||
model::{
|
||||
id::{ChannelId, GuildId, RoleId, UserId},
|
||||
interactions::application_command::{
|
||||
ApplicationCommandInteraction, ApplicationCommandInteractionData,
|
||||
ApplicationCommandInteractionDataOption, ApplicationCommandOptionType,
|
||||
ApplicationCommandType,
|
||||
},
|
||||
},
|
||||
},
|
||||
ApplicationCommandOrAutocompleteInteraction,
|
||||
use poise::serenity_prelude::model::{
|
||||
application::interaction::application_command::CommandDataOption, id::GuildId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Number;
|
||||
use sqlx::Executor;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::Database;
|
||||
use crate::{Context, Data, Error};
|
||||
|
||||
pub struct CommandMacro {
|
||||
type Func<U, E> = for<'a> fn(
|
||||
poise::ApplicationContext<'a, U, E>,
|
||||
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>;
|
||||
|
||||
fn default_none<U, E>() -> Option<Func<U, E>> {
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RecordedCommand<U, E> {
|
||||
#[serde(skip)]
|
||||
#[serde(default = "default_none::<U, E>")]
|
||||
pub action: Option<Func<U, E>>,
|
||||
pub command_name: String,
|
||||
pub options: Vec<CommandDataOption>,
|
||||
}
|
||||
|
||||
pub struct CommandMacro<U, E> {
|
||||
pub guild_id: GuildId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub commands: Vec<CommandOptions>,
|
||||
pub commands: Vec<RecordedCommand<U, E>>,
|
||||
}
|
||||
|
||||
impl CommandMacro {
|
||||
pub async fn from_guild(
|
||||
db_pool: impl Executor<'_, Database = Database>,
|
||||
guild_id: impl Into<GuildId>,
|
||||
) -> Vec<Self> {
|
||||
let guild_id = guild_id.into();
|
||||
|
||||
sqlx::query!(
|
||||
"SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||
guild_id.0
|
||||
)
|
||||
.fetch_all(db_pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| Self {
|
||||
guild_id,
|
||||
name: row.name.clone(),
|
||||
description: row.description.clone(),
|
||||
commands: serde_json::from_str(&row.commands).unwrap(),
|
||||
})
|
||||
.collect::<Vec<Self>>()
|
||||
}
|
||||
pub struct RawCommandMacro {
|
||||
pub guild_id: GuildId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub commands: Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub enum OptionValue {
|
||||
String(String),
|
||||
Integer(i64),
|
||||
Boolean(bool),
|
||||
User(UserId),
|
||||
Channel(ChannelId),
|
||||
Role(RoleId),
|
||||
Mentionable(u64),
|
||||
Number(f64),
|
||||
}
|
||||
|
||||
impl OptionValue {
|
||||
pub fn as_i64(&self) -> Option<i64> {
|
||||
match self {
|
||||
OptionValue::Integer(i) => Some(*i),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_bool(&self) -> Option<bool> {
|
||||
match self {
|
||||
OptionValue::Boolean(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_channel_id(&self) -> Option<ChannelId> {
|
||||
match self {
|
||||
OptionValue::Channel(c) => Some(*c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
OptionValue::String(s) => s.to_string(),
|
||||
OptionValue::Integer(i) => i.to_string(),
|
||||
OptionValue::Boolean(b) => b.to_string(),
|
||||
OptionValue::User(u) => u.to_string(),
|
||||
OptionValue::Channel(c) => c.to_string(),
|
||||
OptionValue::Role(r) => r.to_string(),
|
||||
OptionValue::Mentionable(m) => m.to_string(),
|
||||
OptionValue::Number(n) => n.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_value(&self) -> Value {
|
||||
match self {
|
||||
OptionValue::String(s) => Value::String(s.to_string()),
|
||||
OptionValue::Integer(i) => Value::Number(i.to_owned().into()),
|
||||
OptionValue::Boolean(b) => Value::Bool(b.to_owned()),
|
||||
OptionValue::User(u) => Value::String(u.to_string()),
|
||||
OptionValue::Channel(c) => Value::String(c.to_string()),
|
||||
OptionValue::Role(r) => Value::String(r.to_string()),
|
||||
OptionValue::Mentionable(m) => Value::String(m.to_string()),
|
||||
OptionValue::Number(n) => Value::Number(Number::from_f64(n.to_owned()).unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind(&self) -> ApplicationCommandOptionType {
|
||||
match self {
|
||||
OptionValue::String(_) => ApplicationCommandOptionType::String,
|
||||
OptionValue::Integer(_) => ApplicationCommandOptionType::Integer,
|
||||
OptionValue::Boolean(_) => ApplicationCommandOptionType::Boolean,
|
||||
OptionValue::User(_) => ApplicationCommandOptionType::User,
|
||||
OptionValue::Channel(_) => ApplicationCommandOptionType::Channel,
|
||||
OptionValue::Role(_) => ApplicationCommandOptionType::Role,
|
||||
OptionValue::Mentionable(_) => ApplicationCommandOptionType::Mentionable,
|
||||
OptionValue::Number(_) => ApplicationCommandOptionType::Number,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct CommandOptions {
|
||||
pub command: String,
|
||||
pub subcommand: Option<String>,
|
||||
pub subcommand_group: Option<String>,
|
||||
pub options: HashMap<String, OptionValue>,
|
||||
}
|
||||
|
||||
impl Into<ApplicationCommandInteractionData> for CommandOptions {
|
||||
fn into(self) -> ApplicationCommandInteractionData {
|
||||
ApplicationCommandInteractionData {
|
||||
name: self.command,
|
||||
kind: ApplicationCommandType::ChatInput,
|
||||
options: self
|
||||
.options
|
||||
.iter()
|
||||
.map(|(name, value)| ApplicationCommandInteractionDataOption {
|
||||
name: name.to_string(),
|
||||
value: Some(value.as_value()),
|
||||
kind: value.kind(),
|
||||
options: vec![],
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandOptions {
|
||||
pub fn new(command: impl ToString) -> Self {
|
||||
Self {
|
||||
command: command.to_string(),
|
||||
subcommand: None,
|
||||
subcommand_group: None,
|
||||
options: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn populate(&mut self, interaction: &ApplicationCommandInteraction) {
|
||||
fn match_option(
|
||||
option: ApplicationCommandInteractionDataOption,
|
||||
cmd_opts: &mut CommandOptions,
|
||||
) {
|
||||
match option.kind {
|
||||
ApplicationCommandOptionType::SubCommand => {
|
||||
cmd_opts.subcommand = Some(option.name);
|
||||
|
||||
for opt in option.options {
|
||||
match_option(opt, cmd_opts);
|
||||
}
|
||||
}
|
||||
ApplicationCommandOptionType::SubCommandGroup => {
|
||||
cmd_opts.subcommand_group = Some(option.name);
|
||||
|
||||
for opt in option.options {
|
||||
match_option(opt, cmd_opts);
|
||||
}
|
||||
}
|
||||
ApplicationCommandOptionType::String => {
|
||||
cmd_opts.options.insert(
|
||||
option.name,
|
||||
OptionValue::String(option.value.unwrap().as_str().unwrap().to_string()),
|
||||
);
|
||||
}
|
||||
ApplicationCommandOptionType::Integer => {
|
||||
cmd_opts.options.insert(
|
||||
option.name,
|
||||
OptionValue::Integer(option.value.map(|m| m.as_i64()).flatten().unwrap()),
|
||||
);
|
||||
}
|
||||
ApplicationCommandOptionType::Boolean => {
|
||||
cmd_opts.options.insert(
|
||||
option.name,
|
||||
OptionValue::Boolean(option.value.map(|m| m.as_bool()).flatten().unwrap()),
|
||||
);
|
||||
}
|
||||
ApplicationCommandOptionType::User => {
|
||||
cmd_opts.options.insert(
|
||||
option.name,
|
||||
OptionValue::User(UserId(
|
||||
option
|
||||
.value
|
||||
.map(|m| m.as_str().map(|s| s.parse::<u64>().ok()))
|
||||
.flatten()
|
||||
.flatten()
|
||||
.unwrap(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
ApplicationCommandOptionType::Channel => {
|
||||
cmd_opts.options.insert(
|
||||
option.name,
|
||||
OptionValue::Channel(ChannelId(
|
||||
option
|
||||
.value
|
||||
.map(|m| m.as_str().map(|s| s.parse::<u64>().ok()))
|
||||
.flatten()
|
||||
.flatten()
|
||||
.unwrap(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
ApplicationCommandOptionType::Role => {
|
||||
cmd_opts.options.insert(
|
||||
option.name,
|
||||
OptionValue::Role(RoleId(
|
||||
option
|
||||
.value
|
||||
.map(|m| m.as_str().map(|s| s.parse::<u64>().ok()))
|
||||
.flatten()
|
||||
.flatten()
|
||||
.unwrap(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
ApplicationCommandOptionType::Mentionable => {
|
||||
cmd_opts.options.insert(
|
||||
option.name,
|
||||
OptionValue::Mentionable(
|
||||
option.value.map(|m| m.as_u64()).flatten().unwrap(),
|
||||
),
|
||||
);
|
||||
}
|
||||
ApplicationCommandOptionType::Number => {
|
||||
cmd_opts.options.insert(
|
||||
option.name,
|
||||
OptionValue::Number(option.value.map(|m| m.as_f64()).flatten().unwrap()),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
for option in &interaction.data.options {
|
||||
match_option(option.clone(), self)
|
||||
}
|
||||
}
|
||||
pub async fn guild_command_macro(
|
||||
ctx: &Context<'_>,
|
||||
name: &str,
|
||||
) -> Option<CommandMacro<Data, Error>> {
|
||||
let row = sqlx::query!(
|
||||
"
|
||||
SELECT * FROM macro WHERE guild_id = ? AND name = ?
|
||||
",
|
||||
ctx.guild_id().unwrap().0,
|
||||
name
|
||||
)
|
||||
.fetch_one(&ctx.data().database)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
let mut commands: Vec<RecordedCommand<Data, Error>> =
|
||||
serde_json::from_str(&row.commands).unwrap();
|
||||
|
||||
for recorded_command in &mut commands {
|
||||
let command = &ctx
|
||||
.framework()
|
||||
.options()
|
||||
.commands
|
||||
.iter()
|
||||
.find(|c| c.identifying_name == recorded_command.command_name);
|
||||
|
||||
recorded_command.action = command.map(|c| c.slash_action).flatten();
|
||||
}
|
||||
|
||||
let command_macro = CommandMacro {
|
||||
guild_id: ctx.guild_id().unwrap(),
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
commands,
|
||||
};
|
||||
|
||||
Some(command_macro)
|
||||
}
|
||||
|
52
src/models/guild_data.rs
Normal 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(())
|
||||
}
|
||||
}
|
@ -1,29 +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::{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},
|
||||
Context,
|
||||
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, Box<dyn std::error::Error + Sync + Send>>;
|
||||
|
||||
async fn author_data(&self) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>>;
|
||||
|
||||
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, Box<dyn std::error::Error + Sync + Send>>;
|
||||
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]
|
||||
@ -48,4 +47,60 @@ impl CtxData for Context<'_> {
|
||||
|
||||
ChannelData::from_channel(&channel, &self.data().database).await
|
||||
}
|
||||
|
||||
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 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 = ?",
|
||||
guild_id.0
|
||||
)
|
||||
.fetch_all(&self.database)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|row| CommandMacro {
|
||||
guild_id,
|
||||
name: row.name.clone(),
|
||||
description: row.description.clone(),
|
||||
commands: serde_json::from_str(&row.commands).unwrap(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ use std::{collections::HashSet, fmt::Display};
|
||||
|
||||
use chrono::{Duration, NaiveDateTime, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use poise::serenity::{
|
||||
use poise::serenity_prelude::{
|
||||
http::CacheHttp,
|
||||
model::{
|
||||
channel::GuildChannel,
|
||||
@ -14,7 +14,8 @@ use poise::serenity::{
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::{
|
||||
consts::{DEFAULT_AVATAR, MAX_TIME, MIN_INTERVAL},
|
||||
consts::{DAY, DEFAULT_AVATAR, MAX_TIME, MIN_INTERVAL},
|
||||
interval_parser::Interval,
|
||||
models::{
|
||||
channel_data::ChannelData,
|
||||
reminder::{content::Content, errors::ReminderError, helper::generate_uid, Reminder},
|
||||
@ -52,7 +53,8 @@ pub struct ReminderBuilder {
|
||||
channel: u32,
|
||||
utc_time: NaiveDateTime,
|
||||
timezone: String,
|
||||
interval: Option<i64>,
|
||||
interval_secs: Option<i64>,
|
||||
interval_months: Option<i64>,
|
||||
expires: Option<NaiveDateTime>,
|
||||
content: String,
|
||||
tts: bool,
|
||||
@ -84,7 +86,8 @@ INSERT INTO reminders (
|
||||
`channel_id`,
|
||||
`utc_time`,
|
||||
`timezone`,
|
||||
`interval`,
|
||||
`interval_seconds`,
|
||||
`interval_months`,
|
||||
`expires`,
|
||||
`content`,
|
||||
`tts`,
|
||||
@ -102,6 +105,7 @@ INSERT INTO reminders (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?
|
||||
)
|
||||
",
|
||||
@ -109,7 +113,8 @@ INSERT INTO reminders (
|
||||
self.channel,
|
||||
utc_time,
|
||||
self.timezone,
|
||||
self.interval,
|
||||
self.interval_secs,
|
||||
self.interval_months,
|
||||
self.expires,
|
||||
self.content,
|
||||
self.tts,
|
||||
@ -121,7 +126,7 @@ INSERT INTO reminders (
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Ok(Reminder::from_uid(&self.pool, self.uid).await.unwrap())
|
||||
Ok(Reminder::from_uid(&self.pool, &self.uid).await.unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,7 +139,7 @@ pub struct MultiReminderBuilder<'a> {
|
||||
scopes: Vec<ReminderScope>,
|
||||
utc_time: NaiveDateTime,
|
||||
timezone: Tz,
|
||||
interval: Option<i64>,
|
||||
interval: Option<Interval>,
|
||||
expires: Option<NaiveDateTime>,
|
||||
content: Content,
|
||||
set_by: Option<u32>,
|
||||
@ -143,7 +148,7 @@ pub struct MultiReminderBuilder<'a> {
|
||||
}
|
||||
|
||||
impl<'a> MultiReminderBuilder<'a> {
|
||||
pub fn new(ctx: &'a Context<'a>, guild_id: Option<GuildId>) -> Self {
|
||||
pub fn new(ctx: &'a Context, guild_id: Option<GuildId>) -> Self {
|
||||
MultiReminderBuilder {
|
||||
scopes: vec![],
|
||||
utc_time: Utc::now().naive_utc(),
|
||||
@ -157,6 +162,12 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn timezone(mut self, timezone: Tz) -> Self {
|
||||
self.timezone = timezone;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn content(mut self, content: Content) -> Self {
|
||||
self.content = content;
|
||||
|
||||
@ -186,7 +197,7 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn interval(mut self, interval: Option<i64>) -> Self {
|
||||
pub fn interval(mut self, interval: Option<Interval>) -> Self {
|
||||
self.interval = interval;
|
||||
|
||||
self
|
||||
@ -196,29 +207,36 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
self.scopes = scopes;
|
||||
}
|
||||
|
||||
pub async fn build(self) -> (HashSet<ReminderError>, HashSet<ReminderScope>) {
|
||||
let pool = self.ctx.data().database.clone();
|
||||
|
||||
pub async fn build(self) -> (HashSet<ReminderError>, HashSet<(Reminder, ReminderScope)>) {
|
||||
let mut errors = HashSet::new();
|
||||
|
||||
let mut ok_locs = HashSet::new();
|
||||
|
||||
if self.interval.map_or(false, |i| (i 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 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 {
|
||||
for scope in self.scopes {
|
||||
let db_channel_id = match scope {
|
||||
ReminderScope::User(user_id) => {
|
||||
if let Ok(user) = UserId(user_id).to_user(&self.ctx.discord()).await {
|
||||
let user_data = UserData::from_user(&user, &self.ctx.discord(), &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let user_data = UserData::from_user(
|
||||
&user,
|
||||
&self.ctx.discord(),
|
||||
&self.ctx.data().database,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if let Some(guild_id) = self.guild_id {
|
||||
if guild_id.member(&self.ctx.discord(), user).await.is_err() {
|
||||
Err(ReminderError::InvalidTag)
|
||||
} else if self.set_by.map_or(true, |i| i != user_data.id)
|
||||
&& !user_data.allowed_dm
|
||||
{
|
||||
Err(ReminderError::UserBlockedDm)
|
||||
} else {
|
||||
Ok(user_data.dm_channel)
|
||||
}
|
||||
@ -238,7 +256,9 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
Err(ReminderError::InvalidTag)
|
||||
} else {
|
||||
let mut channel_data =
|
||||
ChannelData::from_channel(&channel, &pool).await.unwrap();
|
||||
ChannelData::from_channel(&channel, &self.ctx.data().database)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if channel_data.webhook_id.is_none()
|
||||
|| channel_data.webhook_token.is_none()
|
||||
@ -255,7 +275,9 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
Some(webhook.id.as_u64().to_owned());
|
||||
channel_data.webhook_token = webhook.token;
|
||||
|
||||
channel_data.commit_changes(&pool).await;
|
||||
channel_data
|
||||
.commit_changes(&self.ctx.data().database)
|
||||
.await;
|
||||
|
||||
Ok(channel_data.id)
|
||||
}
|
||||
@ -275,12 +297,13 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
match db_channel_id {
|
||||
Ok(c) => {
|
||||
let builder = ReminderBuilder {
|
||||
pool: pool.clone(),
|
||||
pool: self.ctx.data().database.clone(),
|
||||
uid: generate_uid(),
|
||||
channel: c,
|
||||
utc_time: self.utc_time,
|
||||
timezone: self.timezone.to_string(),
|
||||
interval: self.interval,
|
||||
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(),
|
||||
tts: self.content.tts,
|
||||
@ -290,8 +313,8 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
};
|
||||
|
||||
match builder.build().await {
|
||||
Ok(_) => {
|
||||
ok_locs.insert(scope);
|
||||
Ok(r) => {
|
||||
ok_locs.insert((r, scope));
|
||||
}
|
||||
Err(e) => {
|
||||
errors.insert(e);
|
||||
|
@ -7,6 +7,7 @@ pub enum ReminderError {
|
||||
PastTime,
|
||||
ShortInterval,
|
||||
InvalidTag,
|
||||
UserBlockedDm,
|
||||
DiscordError(String),
|
||||
}
|
||||
|
||||
@ -30,6 +31,9 @@ impl ToString for ReminderError {
|
||||
ReminderError::InvalidTag => {
|
||||
"Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string()
|
||||
}
|
||||
ReminderError::UserBlockedDm => {
|
||||
"User has DM reminders disabled".to_string()
|
||||
}
|
||||
ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s),
|
||||
}
|
||||
}
|
||||
|
@ -1,25 +1,6 @@
|
||||
use num_integer::Integer;
|
||||
use rand::{rngs::OsRng, seq::IteratorRandom};
|
||||
|
||||
use crate::consts::{CHARACTERS, DAY, HOUR, MINUTE};
|
||||
|
||||
pub fn longhand_displacement(seconds: u64) -> String {
|
||||
let (days, seconds) = seconds.div_rem(&DAY);
|
||||
let (hours, seconds) = seconds.div_rem(&HOUR);
|
||||
let (minutes, seconds) = seconds.div_rem(&MINUTE);
|
||||
|
||||
let mut sections = vec![];
|
||||
|
||||
for (var, name) in
|
||||
[days, hours, minutes, seconds].iter().zip(["days", "hours", "minutes", "seconds"].iter())
|
||||
{
|
||||
if *var > 0 {
|
||||
sections.push(format!("{} {}", var, name));
|
||||
}
|
||||
}
|
||||
|
||||
sections.join(", ")
|
||||
}
|
||||
use crate::consts::CHARACTERS;
|
||||
|
||||
pub fn generate_uid() -> String {
|
||||
let mut generator: OsRng = Default::default();
|
||||
|
@ -1,4 +1,4 @@
|
||||
use poise::serenity::model::id::ChannelId;
|
||||
use poise::serenity_prelude::model::id::ChannelId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_repr::*;
|
||||
|
||||
|
@ -4,17 +4,19 @@ pub mod errors;
|
||||
mod helper;
|
||||
pub mod look_flags;
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use chrono::{NaiveDateTime, TimeZone};
|
||||
use chrono_tz::Tz;
|
||||
use poise::serenity::model::id::{ChannelId, GuildId, UserId};
|
||||
use sqlx::{Executor, MySqlPool};
|
||||
use poise::serenity_prelude::{
|
||||
model::id::{ChannelId, GuildId, UserId},
|
||||
Cache,
|
||||
};
|
||||
use sqlx::Executor;
|
||||
|
||||
use crate::{
|
||||
models::reminder::{
|
||||
helper::longhand_displacement,
|
||||
look_flags::{LookFlags, TimeDisplayType},
|
||||
},
|
||||
Context, Database,
|
||||
models::reminder::look_flags::{LookFlags, TimeDisplayType},
|
||||
Database,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@ -23,7 +25,8 @@ pub struct Reminder {
|
||||
pub uid: String,
|
||||
pub channel: u64,
|
||||
pub utc_time: NaiveDateTime,
|
||||
pub interval: Option<u32>,
|
||||
pub interval_seconds: Option<u32>,
|
||||
pub interval_months: Option<u32>,
|
||||
pub expires: Option<NaiveDateTime>,
|
||||
pub enabled: bool,
|
||||
pub content: String,
|
||||
@ -31,8 +34,22 @@ pub struct Reminder {
|
||||
pub set_by: Option<u64>,
|
||||
}
|
||||
|
||||
impl Hash for Reminder {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.uid.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<Self> for Reminder {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.uid == other.uid
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Reminder {}
|
||||
|
||||
impl Reminder {
|
||||
pub async fn from_uid(pool: &MySqlPool, uid: String) -> Option<Self> {
|
||||
pub async fn from_uid(pool: impl Executor<'_, Database = Database>, uid: &str) -> Option<Self> {
|
||||
sqlx::query_as_unchecked!(
|
||||
Self,
|
||||
"
|
||||
@ -41,7 +58,8 @@ SELECT
|
||||
reminders.uid,
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.content,
|
||||
@ -67,8 +85,44 @@ WHERE
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub async fn from_id(pool: impl Executor<'_, Database = Database>, id: u32) -> Option<Self> {
|
||||
sqlx::query_as_unchecked!(
|
||||
Self,
|
||||
"
|
||||
SELECT
|
||||
reminders.id,
|
||||
reminders.uid,
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.content,
|
||||
reminders.embed_description,
|
||||
users.user AS set_by
|
||||
FROM
|
||||
reminders
|
||||
INNER JOIN
|
||||
channels
|
||||
ON
|
||||
reminders.channel_id = channels.id
|
||||
LEFT JOIN
|
||||
users
|
||||
ON
|
||||
reminders.set_by = users.id
|
||||
WHERE
|
||||
reminders.id = ?
|
||||
",
|
||||
id
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub async fn from_channel<C: Into<ChannelId>>(
|
||||
db_pool: impl Executor<'_, Database = Database>,
|
||||
pool: impl Executor<'_, Database = Database>,
|
||||
channel_id: C,
|
||||
flags: &LookFlags,
|
||||
) -> Vec<Self> {
|
||||
@ -83,7 +137,8 @@ SELECT
|
||||
reminders.uid,
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.content,
|
||||
@ -108,21 +163,19 @@ ORDER BY
|
||||
channel_id.as_u64(),
|
||||
enabled,
|
||||
)
|
||||
.fetch_all(db_pool)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn from_guild(
|
||||
ctx: &Context<'_>,
|
||||
cache: impl AsRef<Cache>,
|
||||
pool: impl Executor<'_, Database = Database>,
|
||||
guild_id: Option<GuildId>,
|
||||
user: UserId,
|
||||
) -> Vec<Self> {
|
||||
// todo: see if this can be moved to just extract from the context
|
||||
let pool = ctx.data().database.clone();
|
||||
|
||||
if let Some(guild_id) = guild_id {
|
||||
let guild_opt = guild_id.to_guild_cached(&ctx.discord());
|
||||
let guild_opt = guild_id.to_guild_cached(cache);
|
||||
|
||||
if let Some(guild) = guild_opt {
|
||||
let channels = guild
|
||||
@ -141,7 +194,8 @@ SELECT
|
||||
reminders.uid,
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.content,
|
||||
@ -162,7 +216,7 @@ WHERE
|
||||
",
|
||||
channels
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as_unchecked!(
|
||||
@ -173,7 +227,8 @@ SELECT
|
||||
reminders.uid,
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.content,
|
||||
@ -190,11 +245,11 @@ 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()
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
@ -206,7 +261,8 @@ SELECT
|
||||
reminders.uid,
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.content,
|
||||
@ -227,12 +283,19 @@ WHERE
|
||||
",
|
||||
user.as_u64()
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
&self,
|
||||
db: impl Executor<'_, Database = Database>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!("DELETE FROM reminders WHERE uid = ?", self.uid).execute(db).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub fn display_content(&self) -> &str {
|
||||
if self.content.is_empty() {
|
||||
&self.embed_description
|
||||
@ -247,10 +310,7 @@ WHERE
|
||||
count + 1,
|
||||
self.display_content(),
|
||||
self.channel,
|
||||
timezone
|
||||
.timestamp(self.utc_time.timestamp(), 0)
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string()
|
||||
timezone.timestamp(self.utc_time.timestamp(), 0).format("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
}
|
||||
|
||||
@ -264,12 +324,11 @@ WHERE
|
||||
TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()),
|
||||
};
|
||||
|
||||
if let Some(interval) = self.interval {
|
||||
if self.interval_seconds.is_some() || self.interval_months.is_some() {
|
||||
format!(
|
||||
"'{}' *occurs next at* **{}**, repeating every **{}** (set by {})",
|
||||
"'{}' *occurs next at* **{}**, repeating (set by {})",
|
||||
self.display_content(),
|
||||
time_display,
|
||||
longhand_displacement(interval as u64),
|
||||
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
|
||||
)
|
||||
} else {
|
||||
|
@ -1,6 +1,6 @@
|
||||
use chrono_tz::Tz;
|
||||
use log::error;
|
||||
use poise::serenity::{http::CacheHttp, model::id::UserId};
|
||||
use poise::serenity_prelude::{http::CacheHttp, model::id::UserId};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::consts::LOCAL_TIMEZONE;
|
||||
@ -10,6 +10,7 @@ pub struct UserData {
|
||||
pub user: u64,
|
||||
pub dm_channel: u32,
|
||||
pub timezone: String,
|
||||
pub allowed_dm: bool,
|
||||
}
|
||||
|
||||
impl UserData {
|
||||
@ -21,7 +22,7 @@ impl UserData {
|
||||
|
||||
match sqlx::query!(
|
||||
"
|
||||
SELECT timezone FROM users WHERE user = ?
|
||||
SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?
|
||||
",
|
||||
user_id
|
||||
)
|
||||
@ -46,7 +47,7 @@ SELECT timezone FROM users WHERE user = ?
|
||||
match sqlx::query_as_unchecked!(
|
||||
Self,
|
||||
"
|
||||
SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ?
|
||||
SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone, allowed_dm FROM users WHERE user = ?
|
||||
",
|
||||
*LOCAL_TIMEZONE,
|
||||
user_id.0
|
||||
@ -71,7 +72,7 @@ INSERT IGNORE INTO channels (channel) VALUES (?)
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO users (user, dm_channel, timezone) VALUES (?, (SELECT id FROM channels WHERE channel = ?), ?)
|
||||
INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id FROM channels WHERE channel = ?), ?)
|
||||
",
|
||||
user_id.0,
|
||||
dm_channel.id.0,
|
||||
@ -83,7 +84,7 @@ INSERT INTO users (user, dm_channel, timezone) VALUES (?, (SELECT id FROM channe
|
||||
Ok(sqlx::query_as_unchecked!(
|
||||
Self,
|
||||
"
|
||||
SELECT id, user, dm_channel, timezone FROM users WHERE user = ?
|
||||
SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ?
|
||||
",
|
||||
user_id.0
|
||||
)
|
||||
@ -102,9 +103,10 @@ SELECT id, user, dm_channel, timezone FROM users WHERE user = ?
|
||||
pub async fn commit_changes(&self, pool: &MySqlPool) {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users SET timezone = ? WHERE id = ?
|
||||
UPDATE users SET timezone = ?, allowed_dm = ? WHERE id = ?
|
||||
",
|
||||
self.timezone,
|
||||
self.allowed_dm,
|
||||
self.id
|
||||
)
|
||||
.execute(pool)
|
||||
|
@ -211,14 +211,12 @@ pub async fn natural_parser(time: &str, timezone: &str) -> Option<i64> {
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|inner| {
|
||||
.and_then(|inner| {
|
||||
if inner.status.success() {
|
||||
Some(from_utf8(&*inner.stdout).unwrap().parse::<i64>().unwrap())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.map(|inner| if inner < 0 { None } else { Some(inner) })
|
||||
.flatten()
|
||||
.and_then(|inner| if inner < 0 { None } else { Some(inner) })
|
||||
}
|
||||
|
55
src/utils.rs
@ -1,7 +1,11 @@
|
||||
use poise::serenity::{
|
||||
builder::CreateApplicationCommands,
|
||||
http::CacheHttp,
|
||||
model::id::{GuildId, UserId},
|
||||
use poise::{
|
||||
serenity_prelude as serenity,
|
||||
serenity_prelude::{
|
||||
builder::CreateApplicationCommands,
|
||||
http::CacheHttp,
|
||||
interaction::MessageFlags,
|
||||
model::id::{GuildId, UserId},
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@ -10,10 +14,10 @@ use crate::{
|
||||
};
|
||||
|
||||
pub async fn register_application_commands(
|
||||
ctx: &poise::serenity::client::Context,
|
||||
ctx: &serenity::Context,
|
||||
framework: &poise::Framework<Data, Error>,
|
||||
guild_id: Option<GuildId>,
|
||||
) -> Result<(), poise::serenity::Error> {
|
||||
) -> Result<(), serenity::Error> {
|
||||
let mut commands_builder = CreateApplicationCommands::default();
|
||||
let commands = &framework.options().commands;
|
||||
for command in commands {
|
||||
@ -24,7 +28,7 @@ pub async fn register_application_commands(
|
||||
commands_builder.add_application_command(context_menu_command);
|
||||
}
|
||||
}
|
||||
let commands_builder = poise::serenity::json::Value::Array(commands_builder.0);
|
||||
let commands_builder = poise::serenity_prelude::json::Value::Array(commands_builder.0);
|
||||
|
||||
if let Some(guild_id) = guild_id {
|
||||
ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?;
|
||||
@ -65,3 +69,40 @@ pub async fn check_guild_subscription(
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the message, specified via [`crate::CreateReply`], to the interaction initial response
|
||||
/// endpoint
|
||||
pub fn send_as_initial_response(
|
||||
data: poise::CreateReply<'_>,
|
||||
f: &mut serenity::CreateInteractionResponseData,
|
||||
) {
|
||||
let poise::CreateReply {
|
||||
content,
|
||||
embeds,
|
||||
attachments: _, // serenity doesn't support attachments in initial response yet
|
||||
components,
|
||||
ephemeral,
|
||||
allowed_mentions,
|
||||
reference_message: _, // can't reply to a message in interactions
|
||||
} = data;
|
||||
|
||||
if let Some(content) = content {
|
||||
f.content(content);
|
||||
}
|
||||
f.set_embeds(embeds);
|
||||
if let Some(allowed_mentions) = allowed_mentions {
|
||||
f.allowed_mentions(|f| {
|
||||
*f = allowed_mentions.clone();
|
||||
f
|
||||
});
|
||||
}
|
||||
if let Some(components) = components {
|
||||
f.components(|f| {
|
||||
f.0 = components.0;
|
||||
f
|
||||
});
|
||||
}
|
||||
if ephemeral {
|
||||
f.flags(MessageFlags::EPHEMERAL);
|
||||
}
|
||||
}
|
||||
|
21
web/Cargo.toml
Normal file
@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "reminder_web"
|
||||
version = "0.1.0"
|
||||
authors = ["jellywx <judesouthworth@pm.me>"]
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
rocket = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tls", "secrets", "json"] }
|
||||
rocket_dyn_templates = { git = "https://github.com/SergioBenitez/Rocket", branch = "master", features = ["tera"] }
|
||||
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
||||
oauth2 = "4"
|
||||
log = "0.4"
|
||||
reqwest = "0.11"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
|
||||
chrono = "0.4"
|
||||
chrono-tz = "0.5"
|
||||
lazy_static = "1.4.0"
|
||||
rand = "0.7"
|
||||
base64 = "0.13"
|
||||
csv = "1.1"
|
32
web/private/ca_cert.pem
Normal file
@ -0,0 +1,32 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFbzCCA1egAwIBAgIUUY2fYP5h41dtqcuNLVUS99N7+jAwDQYJKoZIhvcNAQEL
|
||||
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
|
||||
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
|
||||
MDUxNTE5MDIyNFowRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQK
|
||||
DAlSb2NrZXQgQ0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMIICIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAg8AMIICCgKCAgEA3MmVOUxbZ0rtBBbXjWkkM2n2IoPuqfiENdrM
|
||||
NH9IcIbEgFb7AQ/c2vGBLWWw2RFtXxe2ubbgcHyAySQ+ENGEf+W2yox5JpcOOBP+
|
||||
/KrUQ4ROqxLBWOD6+fA/q5m9/CXXJosWXly3DFE9nUEUlNXcaSxJm/bxIqnxwEMQ
|
||||
NESWdG1qlsGVhHLi5sflgwAcYH+nZzdtINLyvQ5AGIs8s6LpmvUVbC3I+5oNNSwW
|
||||
rRsIfCFaYLuUx7jZMaOk8Dfz8MbFL68+7OCGwmhduSHoGW8jVzDXwXv/crAmFtau
|
||||
zmu43/vPDwKa4w/kQJLNr1QmiGz9FBt8DXVJ0s5bokOIlodEgAvjsL3xwnrKak0F
|
||||
8ak74IBetfDdVvuC9WWmJ9/dj3z91KvCWLdZGm8ijlYZ2OuaiiJ/oC6iebaPFHEY
|
||||
IQmF5wzT/nZLIbQsti9wFnOpptgkhDAcKIAk3+pncuMwZ9WRvWO2IFt+ZCqKCCDU
|
||||
JliDZmm3ow/zob8DD6Txex5OkJGUF6iu8o7fjI9BxsjSc76bLrxIhvR43UXjzoHl
|
||||
t3lvDH8SzSN/kxzQMXVl48h3xydJ3+ZyDOLle1bqMga8Kcin9GcEUqmL1JcyFafe
|
||||
CUE/Nk1pHyrlnhpMCYIDVI3pmNLB0/m/IEWLckSr9+hXyUPJQyWiyrixwC86SPxZ
|
||||
AonU2aUCAwEAAaNTMFEwHQYDVR0OBBYEFJ5uQa9kD5fioNeS/Ff/mxDcMAzhMB8G
|
||||
A1UdIwQYMBaAFJ5uQa9kD5fioNeS/Ff/mxDcMAzhMA8GA1UdEwEB/wQFMAMBAf8w
|
||||
DQYJKoZIhvcNAQELBQADggIBABf7adF1J40/8EjfSJ5XdG7OBUrZTas3+nuULh8B
|
||||
6h1igAq0BgX9v3awmPCaxqDLqwJCsFZCk0s9Vgd62McKVKasyW1TQchWbdvlqeAB
|
||||
QQfZpJtAZK12dcMl6iznC/JBTPvYl9q1FKS8kYtg19in/xlhxiiEJaL5z+slpTgT
|
||||
cN12650W2FqjT9sD9jshZI/a8o53d2yUBXBBecCZ49djceBGLbxbteEi/sb9qM4f
|
||||
IUxeSrvK6owxKMZ5okUqZWaFseFCkdMKpMg9eecyfSTweac8yLlZmeqX5D/H85Hr
|
||||
hnWHjI5NeVZAoDkdmNeaDbAIv4f3prqC8uFP3ub8D0rG/WiPdOAd79KNgqbUUFyp
|
||||
NbjSiAesx4Rm1WIgjHljFQKXJdtnxt+v1VkKV9entXJX2tye7+Z1+zurNYyUyM1J
|
||||
COdXeV4jpq2mP8cLpAqX7ip1tNx9YMy5ctbAE1WsUw7lPyO91+VN2Q6MN5OyemY3
|
||||
4nBzRJU8X1nsDgeNQWjzb/ZC7eoS916Gywk8Hu6GoIwvYMm3zea25n6mAAOX17vE
|
||||
1YdvSzUlnVbwnbgd4BgFRGUfBVjO7qvFC7ZWLngWhBzIIYIGUJfO2rippgkxAoHH
|
||||
dgWYk1MNnk2yM8WSo0VKoe4yuF9NwPlfPqeCE683JHdwGEJKZWMocszzHrGZ2KX2
|
||||
I4/u
|
||||
-----END CERTIFICATE-----
|
51
web/private/ca_key.pem
Normal file
@ -0,0 +1,51 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIJKAIBAAKCAgEA3MmVOUxbZ0rtBBbXjWkkM2n2IoPuqfiENdrMNH9IcIbEgFb7
|
||||
AQ/c2vGBLWWw2RFtXxe2ubbgcHyAySQ+ENGEf+W2yox5JpcOOBP+/KrUQ4ROqxLB
|
||||
WOD6+fA/q5m9/CXXJosWXly3DFE9nUEUlNXcaSxJm/bxIqnxwEMQNESWdG1qlsGV
|
||||
hHLi5sflgwAcYH+nZzdtINLyvQ5AGIs8s6LpmvUVbC3I+5oNNSwWrRsIfCFaYLuU
|
||||
x7jZMaOk8Dfz8MbFL68+7OCGwmhduSHoGW8jVzDXwXv/crAmFtauzmu43/vPDwKa
|
||||
4w/kQJLNr1QmiGz9FBt8DXVJ0s5bokOIlodEgAvjsL3xwnrKak0F8ak74IBetfDd
|
||||
VvuC9WWmJ9/dj3z91KvCWLdZGm8ijlYZ2OuaiiJ/oC6iebaPFHEYIQmF5wzT/nZL
|
||||
IbQsti9wFnOpptgkhDAcKIAk3+pncuMwZ9WRvWO2IFt+ZCqKCCDUJliDZmm3ow/z
|
||||
ob8DD6Txex5OkJGUF6iu8o7fjI9BxsjSc76bLrxIhvR43UXjzoHlt3lvDH8SzSN/
|
||||
kxzQMXVl48h3xydJ3+ZyDOLle1bqMga8Kcin9GcEUqmL1JcyFafeCUE/Nk1pHyrl
|
||||
nhpMCYIDVI3pmNLB0/m/IEWLckSr9+hXyUPJQyWiyrixwC86SPxZAonU2aUCAwEA
|
||||
AQKCAgBVSXlfXOOiDwtnnPs/IPJe+fuecaBsABfyRcbEMLbm4OhfOzpSurHx0YC4
|
||||
7KNX9qdtKFfpfX9NdIq7KEjhbk3kqfPmYkUaZxeTCgZhzAua2S0aYHBXyPCqQ+gU
|
||||
fZsqH+Pwe6H0aZQ8KdXHPTCaHdK6veThXo7feQ5t2noT9rq31txpx/Xd6BNGWsmJ
|
||||
xS0xCZ68/GgnWdVyumKAGKkmKzRaK3pPA5CzwFqBw7ouvFaWvLuQymU6kWk1B6Xb
|
||||
NYIB7IaXWPbRwhnMV0x9C2ABEzFvqOpvT1rqDqloAR4dlvcfbsIZZkQ2mhjt6MeT
|
||||
hsorwQ4yCjvtZvVRfW1gTP4iR7ZpmHgHIYQDJy7raZqRVNe9WgMxKTS/IYsHvEvH
|
||||
MUBOfQEclAQ8FTUOgTg9+Hf9VXJOtjZRxY08gb+OgKxoAQKxRZmLDUZli9SNWzGe
|
||||
R3UECh0gImm/CzWnV3fercLX+qHLTcyJFcb2gclAfG8v8cOT2qu8RtsJAQM2ZSn7
|
||||
L8FaWXewjAwkZwCl8Zl5ky1RbBXUkcuLIkAeLAl0+ffM9xiwLhiIj8gRJoq0/jSr
|
||||
K1pA8CQ/rZC+9RsI8hP4x2Fn1CU8bOyEBPeNFEIDJHBiCrs/Rl6EAzDMJHlhL/HT
|
||||
f7bqssuRjnSbRCKaAdtfGTxQUEjefvqJ11xE3BfHGnKPqoasMQKCAQEA8nY+3MAB
|
||||
eBPlE/H4JeVpj7/9/q+JWLFFH9E3Re25LbObzRzIQOd5bTCJkOPQXYRbRIZ1pMt9
|
||||
+nZzeLKvWn5nuUFgG2S4n+BEJkBDV78LOkUuGIAPdztF2mwVumygEGJFFfZHbYrh
|
||||
XtaGUKdNxn1R3Z4B8W5jWpNNkU+dvoq4Zt0LrL28HB4Sa2yrtHeXbhqSb0BA15+N
|
||||
vO9mlfX2I6Yr8F+L/OBfxB0gaNLISJJsj5NSm302je/QrfoMAwbePoNPjEX1/qd2
|
||||
rgj9JfM+NE2FsVnFhrV+q4DywRUdX4VBFP4+x7fPm8LxvtVUmLVA3/NtGwPdnC6U
|
||||
mQh/+kKVhU2AQwKCAQEA6R2FrxZIUdmk45b/tSjlq7r1ycBYeSztjzXw6hx+w/K3
|
||||
Lf+5OGyoGoKr5bZHMhHFxqoWzP6kiZ2Uyvu+pLAGLfenJPec89ZncN4FvW9yU8yL
|
||||
nE2Sc8Vf2GnOw2KGJcJR11Ew4dDspHfnM3rof2G4gPC/EMZeGojXoc5GHmT4iasD
|
||||
Xwje4qqx5WKvRu1Rw2y+XM5h4gDn3QLLojDOvBpt22yaqSTAupY7OliGFO9h2WPL
|
||||
r9TL6va87nXNepCdtzuSlxqTVtgMu2wVu3CnQyw9RiD2M31YnUtBDLNy/UNFj/5Z
|
||||
6uXYuakh8vSFFvjgCUyQwR1xCTJur/6LdpAnt9Bz9wKCAQAUqoN9KVh2tatm4c72
|
||||
2/D9ca3ikW+xgZqUta5yZWrNPGvhNbzT22b8KZDwKprN/cQRuSw52aZpPMNm3EQa
|
||||
AIAyyCG69ADQj7r/T6btybjZRKBDMlcfIIw5q9DGTQ/vlZCx6IX6DkZbYQmdwkTc
|
||||
0D20GA2uWGxbggawhgq5/PTuv5SJKrrn4qBLS73u6eqcVeN5XA6q0kywd+9UhNxv
|
||||
+W/xUxOJgE5pVto2VREBLonWSwZVfnyx6GjvC0sOzv0Ocv7KxAPNqtRwzQ9Wtr7s
|
||||
klb84Nv3OW0MjTcjwfr481Cyy2DqgP5PFnSogWJuibR34jXAgbnX4BiGWrUdzaMU
|
||||
86AlAoIBABnw9Bh41VFuc9/zxL7nLy++HW33HqFVc5Y1PXr/8sdhcisHQxhZVxek
|
||||
JPbqIuAahDTIZsMnLy41QAKaoyt2fymMXqhJecjUuiwgOOlMxp82qu6Y30xM0Y6m
|
||||
r6CkjSMUjcD1QwhOFJd01GCxM8BBIqQOpmR6fqxbQAu8hacKO3IuerCPryXwMt3A
|
||||
7ppo/GlP55sySEg7K5I3pmuFHOxn0IPTgR6DfYMGBs9GXJ1lyjDD3z3Q42RhUsMC
|
||||
jvwtra9fTL/N8EmAv2H39C8oqSRbfvIX5u3x6/ONFU8RhSFT5CDTADSYoVZ/0MxV
|
||||
k53r0hqWz6D94r9QQmsJW4G1JwZYhx8CggEBANUybXsJRgDC5ZOlwbaq0FeTOpZ4
|
||||
pSKx1RkVV+G93hK5XdXIVu365pSt3T68MgF+4wWlbC4MuKuzJltFnJBapzz5ygcU
|
||||
jw+Q+l/jq+mJj8uvUfo5TAy9thuggj1a7SCs/dm5DBwYA7bfmamLbbBpA0qywMsF
|
||||
/vL1bpePIFhbGMhBK7uiEzOAOZVuceZWqcUByjUYy925K/an0ANsQAch5PVeAsEv
|
||||
wXC3soaCzfzUyv+eAYBy7LOcz+hpdRj1l4c9Zx6hZeBxMc5LSRoTs+HfE6GgTuI2
|
||||
cCfCZNFw7DhDy1TBd3XjQ0AFykeaesImZVR6gp0NYH1kionsMtR5rl4C2tw=
|
||||
-----END RSA PRIVATE KEY-----
|
21
web/private/ecdsa_nistp256_sha256_cert.pem
Normal file
@ -0,0 +1,21 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDYTCCAUmgAwIBAgIUA/EaCcXKgOxBiuaMNTackeF9DgcwDQYJKoZIhvcNAQEL
|
||||
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
|
||||
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
|
||||
MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK
|
||||
DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDBZMBMGByqGSM49AgEGCCqGSM49
|
||||
AwEHA0IABOEVdmVd218y3Yy+ocjtt5siaFsNR7tknvS21pzKzxsFc05UrF74YqRx
|
||||
Ic6/AQq56C48x6gDhUCdf+nMzD0NWTijGDAWMBQGA1UdEQQNMAuCCWxvY2FsaG9z
|
||||
dDANBgkqhkiG9w0BAQsFAAOCAgEAW32kadHWEIsN5WXacjtgE9jbFii/rWJjrAO/
|
||||
GWuARwLvjWeEFM5xSTny1cTDEz/7Bmfd9GRRpfgxKCFBGaU7zTmOV/AokrjUay+s
|
||||
KPj0EV9ZE/84KVfr+MOuhwXhSSv+NvxduFw1sdhTUHs+Wopwjte+bnOUBXmnQH97
|
||||
ITSQuUvEamPUS4tJD/kQ0qSGJKNv3CShhbe3XkjUd0UKK7lZzn6hY2fy3CrkkJDT
|
||||
GyzFCRf3ZDfjW5/fGXN/TNoEK65pPQVr5taK/bovDesEO7rR4Y4YgtnGlJ7YToWh
|
||||
E6I77CbsJCJdmgpjPNwRlwQ8upoWAfxOHqwg32s6uWq20v4TXZTOnEKOPEJG74bh
|
||||
JHtwqsy2fIdGz4kZksKGerzJffyQPhX4f3qxxrijT9Hj2EoFIQ4u/jTRpK31IW3R
|
||||
gEeNB8O4iyg3crx0oHRwc6zX63O6wBJCZ+0uxLoyjwtjKCxVYbJpccjoQh6zO3UO
|
||||
pqAO+NKxQ469QDBV3uRyyQ7DRPu6p67/8gn1E/o8kyU1P7eLFIhBp4T4wCaMQBG6
|
||||
IpL5W7eCyuOcqfdm471N07Z4KAqiV8eBJcKaAE+WzNIvrFvCZ/zq7Gj8p07nkjK8
|
||||
+ZGDUieiY0mQEpiXLQZt1xNtT/MmlAzFYrK7t6gSygykQKjO1o4GG4g+eXu3h1YK
|
||||
avsOwtc=
|
||||
-----END CERTIFICATE-----
|
5
web/private/ecdsa_nistp256_sha256_key_pkcs8.pem
Normal file
@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgeo/7wBIYVax9bj4m
|
||||
1UEe2H+H4YLsbApDCZMslZbubRuhRANCAAThFXZlXdtfMt2MvqHI7bebImhbDUe7
|
||||
ZJ70ttacys8bBXNOVKxe+GKkcSHOvwEKueguPMeoA4VAnX/pzMw9DVk4
|
||||
-----END PRIVATE KEY-----
|
21
web/private/ecdsa_nistp384_sha384_cert.pem
Normal file
@ -0,0 +1,21 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDfjCCAWagAwIBAgIUfmkM99JpQUsQsh8TVILPQDBGOhowDQYJKoZIhvcNAQEM
|
||||
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
|
||||
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
|
||||
MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK
|
||||
DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDB2MBAGByqGSM49AgEGBSuBBAAi
|
||||
A2IABM5rQQNZEGWeDyrg2dVQS15c+JsX/ginN9/ArHpTgGO6xaLfkbekIj7gewUR
|
||||
VQcQrYulwu02HTFDzGVvf1DdCZzIJLJUpl+jagdI05yMPFg2s5lThzGB6HENyw1I
|
||||
hU1dMaMYMBYwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEBDAUAA4IC
|
||||
AQAzpXFEeCMvTBG+kzmHN/+esjCK8MO/Grrw3Qoa1stX0yjNWzfhlGHfWk9Mgwnp
|
||||
DnIEFT9lB+nT9uTkKL81Opu2bkWXuL0MyjyYmcCfEgJeDblUbD4HYzPO/s2P7QZu
|
||||
Oa1pNKBiSWe1xq+F/gkn0+nDfICq3QQOOQMzaAmlFmszN3v7pryz+x21MqTFsfuW
|
||||
ymycRcwZ3VyYeceZuBxjOhR2l8I5yZbR+KPj8VS7A3vgqvkS8ixTK0LrxcLwrTIz
|
||||
W9Z1skzVpux4K7iwn3qlWIJ7EbERi9Ydz0MzpjD+CHxGlgHTzT552DGZhPO8D7BE
|
||||
+4IoS0YeXEGLeC2a9Hmiy3FXbM4nt3aIWMiWyhjslXIbvWOJL1Vi5GNpdQCG+rB7
|
||||
lvA0IISp/tAi9duufdONjgRceRDsqf7o6TOBQdB3QxHRt9gCB2QquRh5llMUY8YH
|
||||
PxFJAEzF4X5b0q0k4b50HRVNfDY0SC/XIR7S2xnLDGuoUqrOpUligBzfXV4JHrPv
|
||||
YKHsWSOiVxU9eY1SYKuKqnOsGvXSSQlYqSK1ol94eOKDjNy8wekaVYAQNsdNL7o5
|
||||
QSWZUcbZLkaNMFdD9twWGduAs0eTichVgyvRgPdevjTGDPBHKNSUURZRTYeW4aIJ
|
||||
QzSQUQXwOXsQOmfCkaaTISsDwZAJ0wFqqST1AOhlCWD1OQ==
|
||||
-----END CERTIFICATE-----
|
6
web/private/ecdsa_nistp384_sha384_key_pkcs8.pem
Normal file
@ -0,0 +1,6 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCRBt7MeJXyJRq7grGQ
|
||||
jEdBHE31hG5LuKQ5iL8yTVGk75Kc8ISll2LewbvQ3NhR6E+hZANiAATOa0EDWRBl
|
||||
ng8q4NnVUEteXPibF/4IpzffwKx6U4BjusWi35G3pCI+4HsFEVUHEK2LpcLtNh0x
|
||||
Q8xlb39Q3QmcyCSyVKZfo2oHSNOcjDxYNrOZU4cxgehxDcsNSIVNXTE=
|
||||
-----END PRIVATE KEY-----
|
20
web/private/ed25519_cert.pem
Normal file
@ -0,0 +1,20 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDMjCCARqgAwIBAgIUSu1CGfaWlNPjjLG7SaEEgxI+AsAwDQYJKoZIhvcNAQEL
|
||||
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
|
||||
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
|
||||
MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK
|
||||
DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUGAytlcAMhAKNv1fkv5rxY
|
||||
xGT84C98g2xPpain+jsliHvYrqNkS1zhoxgwFjAUBgNVHREEDTALgglsb2NhbGhv
|
||||
c3QwDQYJKoZIhvcNAQELBQADggIBAMgWZhr3whYbFPNYm5RZxjgEonMY7cH/Rza1
|
||||
UrBkyoMRL9v00YKJTEvjl5Xl4ku7jqxY2qjValacDroD8hYTLhhkkbHxH6u+kJSC
|
||||
cKRQ8QVBZMmoor8qD2Y2BuXZYGWEtgeoXh+DLHWOim7gXDD6GyFPnYFGHnRhNmkE
|
||||
6fPcfw1FZmBrMM9rk31EeK9nLVRabL+vuLDEddX1LH4LwK4OuV51wQMZGYiC3M2b
|
||||
JpmuPAUAUyiyN53Utuw/ubeih3cEglOwCyDPFt6XISYHO0UaQdw25uhpjxs8KTZB
|
||||
qOPJAI4cvGaN3GARXvz2P/QHUiTzsf2XOXXtrO0g+F9P7t6x2xL35c0A8yxKkfsa
|
||||
RKdCveRuVlsLXVLVBikqLE/OgPC6SIhIFvTYzXTaZyNfge6dRy2Wg6qWPcGwseeA
|
||||
QpFKgWiY3cuy7nJm6uybR6zOEMj5GgNWIbICGguQ5161MLnv0bEmKh2d9/jQ/XN5
|
||||
M+nixtGla/G4hL3FQIy9rhHVZzPWwSxl+/eE4qgnInEhmlQ2KrcpgneCt38t0vjJ
|
||||
dwHZSzVv8yX7fE0OSq/DWQXJraWUcT7Ds0g7T7jWkWfS0qStVd9VJ+rYQ8dBbE9Y
|
||||
gcmkm1DMUd+y9xDRDjxby7gMDWFiN2Au9FQgaIpIctTNCj0+agFBPnfXPVSIH6gX
|
||||
10kA2ZVX
|
||||
-----END CERTIFICATE-----
|
3
web/private/ed25519_key.pem
Normal file
@ -0,0 +1,3 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIGtLkaYE4D+P5HLkoc3a/tNhPP+gnhPLF3jEtdYcAtpd
|
||||
-----END PRIVATE KEY-----
|
114
web/private/gen_certs.sh
Normal file
@ -0,0 +1,114 @@
|
||||
#! /bin/bash
|
||||
|
||||
# Usage:
|
||||
# ./gen_certs.sh [cert-kind]
|
||||
#
|
||||
# [cert-kind]:
|
||||
# ed25519
|
||||
# rsa_sha256
|
||||
# ecdsa_nistp256_sha256
|
||||
# ecdsa_nistp384_sha384
|
||||
#
|
||||
# Generate a certificate of the [cert-kind] key type, or if no cert-kind is
|
||||
# specified, all of the certificates.
|
||||
#
|
||||
# Examples:
|
||||
# ./gen_certs.sh ed25519
|
||||
# ./gen_certs.sh rsa_sha256
|
||||
|
||||
# TODO: `rustls` (really, `webpki`) doesn't currently use the CN in the subject
|
||||
# to check if a certificate is valid for a server name sent via SNI. It's not
|
||||
# clear if this is intended, since certificates _should_ have a `subjectAltName`
|
||||
# with a DNS name, or if it simply hasn't been implemented yet. See
|
||||
# https://bugzilla.mozilla.org/show_bug.cgi?id=552346 for a bit more info.
|
||||
|
||||
CA_SUBJECT="/C=US/ST=CA/O=Rocket CA/CN=Rocket Root CA"
|
||||
SUBJECT="/C=US/ST=CA/O=Rocket/CN=localhost"
|
||||
ALT="DNS:localhost"
|
||||
|
||||
function gen_ca() {
|
||||
openssl genrsa -out ca_key.pem 4096
|
||||
openssl req -new -x509 -days 3650 -key ca_key.pem \
|
||||
-subj "${CA_SUBJECT}" -out ca_cert.pem
|
||||
}
|
||||
|
||||
function gen_ca_if_non_existent() {
|
||||
if ! [ -f ./ca_cert.pem ]; then gen_ca; fi
|
||||
}
|
||||
|
||||
function gen_rsa_sha256() {
|
||||
gen_ca_if_non_existent
|
||||
|
||||
openssl req -newkey rsa:4096 -nodes -sha256 -keyout rsa_sha256_key.pem \
|
||||
-subj "${SUBJECT}" -out server.csr
|
||||
|
||||
openssl x509 -req -sha256 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \
|
||||
-CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \
|
||||
-in server.csr -out rsa_sha256_cert.pem
|
||||
|
||||
rm ca_cert.srl server.csr
|
||||
}
|
||||
|
||||
function gen_ed25519() {
|
||||
gen_ca_if_non_existent
|
||||
|
||||
openssl genpkey -algorithm ED25519 > ed25519_key.pem
|
||||
|
||||
openssl req -new -key ed25519_key.pem -subj "${SUBJECT}" -out server.csr
|
||||
openssl x509 -req -extfile <(printf "subjectAltName=${ALT}") -days 3650 \
|
||||
-CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \
|
||||
-in server.csr -out ed25519_cert.pem
|
||||
|
||||
rm ca_cert.srl server.csr
|
||||
}
|
||||
|
||||
function gen_ecdsa_nistp256_sha256() {
|
||||
gen_ca_if_non_existent
|
||||
|
||||
openssl ecparam -out ecdsa_nistp256_sha256_key.pem -name prime256v1 -genkey
|
||||
|
||||
# Convert to pkcs8 format supported by rustls
|
||||
openssl pkcs8 -topk8 -nocrypt -in ecdsa_nistp256_sha256_key.pem \
|
||||
-out ecdsa_nistp256_sha256_key_pkcs8.pem
|
||||
|
||||
openssl req -new -nodes -sha256 -key ecdsa_nistp256_sha256_key_pkcs8.pem \
|
||||
-subj "${SUBJECT}" -out server.csr
|
||||
|
||||
openssl x509 -req -sha256 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \
|
||||
-CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \
|
||||
-in server.csr -out ecdsa_nistp256_sha256_cert.pem
|
||||
|
||||
rm ca_cert.srl server.csr ecdsa_nistp256_sha256_key.pem
|
||||
}
|
||||
|
||||
function gen_ecdsa_nistp384_sha384() {
|
||||
gen_ca_if_non_existent
|
||||
|
||||
openssl ecparam -out ecdsa_nistp384_sha384_key.pem -name secp384r1 -genkey
|
||||
|
||||
# Convert to pkcs8 format supported by rustls
|
||||
openssl pkcs8 -topk8 -nocrypt -in ecdsa_nistp384_sha384_key.pem \
|
||||
-out ecdsa_nistp384_sha384_key_pkcs8.pem
|
||||
|
||||
openssl req -new -nodes -sha384 -key ecdsa_nistp384_sha384_key_pkcs8.pem \
|
||||
-subj "${SUBJECT}" -out server.csr
|
||||
|
||||
openssl x509 -req -sha384 -extfile <(printf "subjectAltName=${ALT}") -days 3650 \
|
||||
-CA ca_cert.pem -CAkey ca_key.pem -CAcreateserial \
|
||||
-in server.csr -out ecdsa_nistp384_sha384_cert.pem
|
||||
|
||||
rm ca_cert.srl server.csr ecdsa_nistp384_sha384_key.pem
|
||||
}
|
||||
|
||||
case $1 in
|
||||
ed25519) gen_ed25519 ;;
|
||||
rsa_sha256) gen_rsa_sha256 ;;
|
||||
ecdsa_nistp256_sha256) gen_ecdsa_nistp256_sha256 ;;
|
||||
ecdsa_nistp384_sha384) gen_ecdsa_nistp384_sha384 ;;
|
||||
*)
|
||||
gen_ed25519
|
||||
gen_rsa_sha256
|
||||
gen_ecdsa_nistp256_sha256
|
||||
gen_ecdsa_nistp384_sha384
|
||||
;;
|
||||
esac
|
30
web/private/rsa_sha256_cert.pem
Normal file
@ -0,0 +1,30 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFLDCCAxSgAwIBAgIUZ461KXDgTT25LP1S6PK6i9ZKtOYwDQYJKoZIhvcNAQEL
|
||||
BQAwRzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRIwEAYDVQQKDAlSb2NrZXQg
|
||||
Q0ExFzAVBgNVBAMMDlJvY2tldCBSb290IENBMB4XDTIxMDUxNzE5MDIyNFoXDTMx
|
||||
MDUxNTE5MDIyNFowPzELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQK
|
||||
DAZSb2NrZXQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQAD
|
||||
ggIPADCCAgoCggIBAKl8Re1dneFB2obYPOuZf3d6BR2xGcMhr2vmHpnVgkeg/xGI
|
||||
cwHhhB04mEg4TqbZ0Q1wdKN4ruNigdrQAXDqnm4y2IB1q/uTdoXVWNsvAZmDq4N4
|
||||
rPUDWagpkilV4HOHDQOI27Lo/+30wJX6H8i3J6Nag1y9spuySkL1F1SgQng9uzvP
|
||||
3SwbsO9R/jS7qTZNEtirnJv3qJlxmT4BCvBuWNALShFlSZYnZk/2csYXEaVkWMUE
|
||||
rnWGmTmzMZEzDOdQdPC3sJDCPQbbgD4SnQANqhOVjPSdJ6Y6joN6XYtB2EP+6LJ8
|
||||
UBTICETnUROWeKqMm215Gsen32cyWkaCwEWtTFn7rJHbPvUY4vPYQZAB2/h07lYq
|
||||
v67W3r5lTq1KuoStD8M/7nCIYIuNhmJLMzzWrSfJrQpYexoMII6kjOxv2kiUgb1y
|
||||
bYKnFlVf4up3pTpi2/0We4SHRgc7xTcEPNvU5z5hc5JwHZIFjmi5dx50ZoAULrXl
|
||||
OUhfdUPut7H38UQg3riJiNAzedUiTubek26po8qH1iS/EMgAi5RboaBovjWhgjwq
|
||||
P/RP0vzpln6OdQIRaRlY9vZiik9HbFybafuA2zEkyc+zc4HqJk3vqX/0hgd9qWeL
|
||||
zgsHr6A86tNUXsUaoInQbzznjpbFE85klxd6HeqasOyVDCeDUs4lWoDNp0DbAgMB
|
||||
AAGjGDAWMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAgEA
|
||||
sS2TUKKYtNYC68TEQb5KAa8/zi6Lhz9lyn9rpBXAcAVeBdxCqrBU0nRN8EjXEff1
|
||||
oBeau9Wi9PwdK6J+4xFuer9YrZZWuWLKUBXJL9ZXEfI+USo5WVz7v/puoKZSUSu2
|
||||
+Ee4+v1eoAsCtaA8IR0Sh1KTc9kg1QZW1nIdBV0zgAERWTzm1iy2Ff+euowM/lUR
|
||||
FczMAJsNq83O2kaH541fRx3gC2iMN/QLwRalBR2y8hTI5wQa0Yr8moC4IsTfRrKQ
|
||||
/SZDjFT1XoA9TnrByIvGQNlxK1Rm2hvK1OQn4OL6sdYK6F+MxfUn/atQNyBQ6CF+
|
||||
oKuvOnE2jces8GGZIU1jYJ2271SQkzp0CW3N1ZKjClSQFAYED+5ERTGyeEL4o3Vr
|
||||
V/BUd0KC7HhbWBlFc2EDmPOoOTp/ID901Kf499M68pe2Fr/63/nCAON6WFk1NPYA
|
||||
+sWMfS25hikU5rOHGQgXxXAz90pwpvpoLQvaq0/azsbHvq9B4ubdcLE0xcoK+DGq
|
||||
+/aOeWlYkalWp93akPYpWN3UJXzDXzzagRID/1LEGS+Ssbh1WVhAhLKVoxzBvkgm
|
||||
ozVAhR3zHXI2QpQ2FEbOmkcRtpxv2CiaTsgHg2MK8cdmPx3G+ufCZjRbQx/VXVdN
|
||||
vaFRdmzr/5EQ7DT5Uy8w32h1to4Fm2sAtbAFYaFLXaM=
|
||||
-----END CERTIFICATE-----
|
52
web/private/rsa_sha256_key.pem
Normal file
@ -0,0 +1,52 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCpfEXtXZ3hQdqG
|
||||
2DzrmX93egUdsRnDIa9r5h6Z1YJHoP8RiHMB4YQdOJhIOE6m2dENcHSjeK7jYoHa
|
||||
0AFw6p5uMtiAdav7k3aF1VjbLwGZg6uDeKz1A1moKZIpVeBzhw0DiNuy6P/t9MCV
|
||||
+h/ItyejWoNcvbKbskpC9RdUoEJ4Pbs7z90sG7DvUf40u6k2TRLYq5yb96iZcZk+
|
||||
AQrwbljQC0oRZUmWJ2ZP9nLGFxGlZFjFBK51hpk5szGRMwznUHTwt7CQwj0G24A+
|
||||
Ep0ADaoTlYz0nSemOo6Del2LQdhD/uiyfFAUyAhE51ETlniqjJtteRrHp99nMlpG
|
||||
gsBFrUxZ+6yR2z71GOLz2EGQAdv4dO5WKr+u1t6+ZU6tSrqErQ/DP+5wiGCLjYZi
|
||||
SzM81q0nya0KWHsaDCCOpIzsb9pIlIG9cm2CpxZVX+Lqd6U6Ytv9FnuEh0YHO8U3
|
||||
BDzb1Oc+YXOScB2SBY5ouXcedGaAFC615TlIX3VD7rex9/FEIN64iYjQM3nVIk7m
|
||||
3pNuqaPKh9YkvxDIAIuUW6GgaL41oYI8Kj/0T9L86ZZ+jnUCEWkZWPb2YopPR2xc
|
||||
m2n7gNsxJMnPs3OB6iZN76l/9IYHfalni84LB6+gPOrTVF7FGqCJ0G88546WxRPO
|
||||
ZJcXeh3qmrDslQwng1LOJVqAzadA2wIDAQABAoICABT4gHp/Q+K0UEKxDNCl/ISe
|
||||
/3UODb78MwVpws2MAoO0YvsbZAeOjNdEwmrlNK4mc1xzVqtHanROIv0dEaCUFyhR
|
||||
eEJkzPPi6h5jKIxuQ4doKFerHdNvJ6/L/P7KVmxVAII4c96uP8SErTOhcD9Ykjn/
|
||||
IBPgkPH83H1ucAWTksXn9XvQG3CyuHDUN1z0/1ntrXBLw6P0v9LEoI5weJcJQEn1
|
||||
q6N9Yd6HX3xzZP4nqpJJWUZ/bsqx7dGa3340z9rrNJz4TYuLzRtFG5gSm4R/LFUi
|
||||
Av/dViOWST3xbROnAQhgyRAUm6AGpCdKa9i9nI6VuUGRY4PivJy7OTpSQVIdwD2K
|
||||
VFbFauCmsTY7B+Z+DXe9JJV+Tlazvlwop1DApxCgLMNr2pVB/1yPzMrNYDB4Sg1c
|
||||
T3stu4A8PtljTykla2C2LPFrdIijvYv9n6PSPWV4vf1ix9uYs99FhZPQJMgm+CDr
|
||||
n3cFfuofIWIRJpCuu3hn4woGgW2bXor4pyMNg1yCp1T2c8g6eJUYAAIRpse0HDbT
|
||||
ZUifxlNBx819bf9aqv+lhwMU4UFlmWMv3NETcCBqgUn+z4scNJ3kZwO9ZodLRblK
|
||||
SpalZ6SNejTnVukg3zbwJG8w5vIrJvfjBkikAL1O5X6Kn3YqfrXAl38bIvA50ZBe
|
||||
eOFcgDPClLPGlNKxQXiZAoIBAQDh0aArTjWi8Kxt2Ga3Hu4jQ7IXklL9osGAlFCB
|
||||
wZs831/ktLY0c7vY+mbwrY4AtqK21A00MrNSTsp/McHwAUi410DdcTqzkGnm9BBZ
|
||||
FKVO1H9KGg2t344tOXcPsFTl6SR5a0aPqagyvgdH+YWC4ccfYZWMcLELn3sOGoEp
|
||||
a7VBxjLZZ+/b+/oIzhwxEaBi8N0U3IZ3SsC9Lmojnb9+LMwGs14TFfahD3uM1hnU
|
||||
vww1elwPwXafWc+7Ej1bXijWBXbyzkCITzHafmDsAatEZnemHL8zKKtTn2aSTUSj
|
||||
Fl/y94WR7/V4nVEQ0R3fjGC0rKh08BtKzxPjYBqjT5aI7+yfAoIBAQDAIzROr00o
|
||||
65auD5TB2k/pSVKQpB/U6ESGafDxg4a+R9Ng2ESmDN5hUUZDefm0Uxf9AZqS6eno
|
||||
GSAqxNDixWPy2WFzbZ0mUCoyQn+Eh09WBvt0IqDZ2+ngBEXiKA/8dsvXinBHLuHV
|
||||
u+rJdLMzPfhWVo1/iXq7SOjBvDuthKROku5BEt6Q3Cs0nk6uneRIqKtaqa7KIInF
|
||||
BPtUifZtCP09h6iFFGNv2g2OYRYDg8TXvjt3scKy0DUC3kTeTCPvNvHaOObGbMRU
|
||||
Q85HkXburxWfsMto5m/lRsbY3COxddB47WIQ6QNewESfCab/R2lOdlXQeaH8fuxT
|
||||
wWC5DMz4F0ZFAoIBAFm+EjZDnaNEnHIHB0MNIryXAabGev7beKUdzCTVCVmWuChO
|
||||
/P45ZFTlppVNk9qKun2IJjsxTvyN3YHRB27XQ8xZlyiqABcudDfZlMmiH9QFNRUA
|
||||
56DK8Fjetodgn0zDa8BpNqCPXw3TYVdkPX/3NEgvYtxuSJ4C4keHlv8cE+uw1bJ6
|
||||
0OMO754iMyf5BlFrwaCxxyqPZauJT5sZ7Ok66lZbYC6bkukNGx+sUpWu2y5Bk2ab
|
||||
jwXjDmAc7o9qCzaK82upNhI1zu0zPldsjmDfi/tS/1VYe0X/WicYWAesM7N+VPHb
|
||||
eCVX98iEIqgdxKzo1QWsClyfkRrSraNrVLrVBqcCggEAaLIGK6YMNnMBPUGSPnt2
|
||||
NdlVWymDiuExjcimmQOhZYf/33KZHZ4/gunljpklfqQUmzHHh6xcX7NpOsTaSedj
|
||||
Sg43ss0U566g/5gKoi2VBnxxglvoKC5T51SMu+o2o8wb0QxHmBIszulBy5qClzZ6
|
||||
Xpl1Kvy/2tOkuQSXxDpVydb4ao8cpfTCuj5VA4NXxFvcW1/AtbU7PRc02GEA3XMb
|
||||
gu6r3jA46tb3shCnDS09Eo4/Gz7Kp+MaL8Dr5/G3Vv8qlE2TOqZD6OK1wXu7Qd43
|
||||
uzd772I5sMZ7TenOrUFUYsB/QlWmF3hPLBX3YH0KHc4PfrT4lnyWzCDAUrVt7vXH
|
||||
vQKCAQEAz1Q+jW+NG7CAqkOJ19n+KmGn+zddvy8x4txKx8kV0+3XIVNkseS6IT65
|
||||
uemD85Gn7MYwHxcKUHghHSKyJsvCb1vSmB3JtCnrsodmQNMb6Lsq89VlM8Ylc/s3
|
||||
F4AraiOlylqcEwLkanD3XSfPdQZhExZHEtpAW/Rr6zYT9J94VO1nK3+RkFI4a8kl
|
||||
pEbY+tqJj3LGTuaQkshB9rDtcBT5/JsaxlMk783qkKziCPZF47BWqrJb+t7tm3qg
|
||||
5gf+QUInEjdW3k3uZBL4316RP/TJlLvo29PkEwoC8X4R2EgDeHQhhRC2Fi2Wpy4O
|
||||
ce4G+zZOOYXwvWGJLwNhgsve8C3oqg==
|
||||
-----END PRIVATE KEY-----
|
48
web/src/consts.rs
Normal file
@ -0,0 +1,48 @@
|
||||
pub const DISCORD_OAUTH_TOKEN: &'static str = "https://discord.com/api/oauth2/token";
|
||||
pub const DISCORD_OAUTH_AUTHORIZE: &'static str = "https://discord.com/api/oauth2/authorize";
|
||||
pub const DISCORD_API: &'static str = "https://discord.com/api";
|
||||
|
||||
pub const MAX_CONTENT_LENGTH: usize = 2000;
|
||||
pub const MAX_EMBED_DESCRIPTION_LENGTH: usize = 4096;
|
||||
pub const MAX_EMBED_TITLE_LENGTH: usize = 256;
|
||||
pub const MAX_EMBED_AUTHOR_LENGTH: usize = 256;
|
||||
pub const MAX_EMBED_FOOTER_LENGTH: usize = 2048;
|
||||
pub const MAX_URL_LENGTH: usize = 512;
|
||||
pub const MAX_USERNAME_LENGTH: usize = 100;
|
||||
pub const MAX_EMBED_FIELDS: usize = 25;
|
||||
pub const MAX_EMBED_FIELD_TITLE_LENGTH: usize = 256;
|
||||
pub const MAX_EMBED_FIELD_VALUE_LENGTH: usize = 1024;
|
||||
|
||||
pub const MINUTE: usize = 60;
|
||||
pub const HOUR: usize = 60 * MINUTE;
|
||||
pub const DAY: usize = 24 * HOUR;
|
||||
|
||||
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
|
||||
|
||||
use std::{collections::HashSet, env, iter::FromIterator};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use serenity::model::prelude::AttachmentType;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
|
||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/webhook.jpg")) as &[u8],
|
||||
"webhook.jpg",
|
||||
)
|
||||
.into();
|
||||
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
||||
env::var("SUBSCRIPTION_ROLES")
|
||||
.map(|var| var
|
||||
.split(',')
|
||||
.filter_map(|item| { item.parse::<u64>().ok() })
|
||||
.collect::<Vec<u64>>())
|
||||
.unwrap_or_else(|_| Vec::new())
|
||||
);
|
||||
pub static ref CNC_GUILD: Option<u64> =
|
||||
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())
|
||||
.flatten()
|
||||
.unwrap_or(600);
|
||||
}
|
204
web/src/lib.rs
Normal file
@ -0,0 +1,204 @@
|
||||
#[macro_use]
|
||||
extern crate rocket;
|
||||
|
||||
mod consts;
|
||||
#[macro_use]
|
||||
mod macros;
|
||||
mod routes;
|
||||
|
||||
use std::{collections::HashMap, env};
|
||||
|
||||
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
|
||||
use rocket::{
|
||||
fs::FileServer,
|
||||
serde::json::{json, Value as JsonValue},
|
||||
tokio::sync::broadcast::Sender,
|
||||
};
|
||||
use rocket_dyn_templates::Template;
|
||||
use serenity::{
|
||||
client::Context,
|
||||
http::CacheHttp,
|
||||
model::id::{GuildId, UserId},
|
||||
};
|
||||
use sqlx::{MySql, Pool};
|
||||
|
||||
use crate::consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES};
|
||||
|
||||
type Database = MySql;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Error {
|
||||
SQLx(sqlx::Error),
|
||||
Serenity(serenity::Error),
|
||||
}
|
||||
|
||||
#[catch(401)]
|
||||
async fn not_authorized() -> Template {
|
||||
let map: HashMap<String, String> = HashMap::new();
|
||||
Template::render("errors/401", &map)
|
||||
}
|
||||
|
||||
#[catch(403)]
|
||||
async fn forbidden() -> Template {
|
||||
let map: HashMap<String, String> = HashMap::new();
|
||||
Template::render("errors/403", &map)
|
||||
}
|
||||
|
||||
#[catch(404)]
|
||||
async fn not_found() -> Template {
|
||||
let map: HashMap<String, String> = HashMap::new();
|
||||
Template::render("errors/404", &map)
|
||||
}
|
||||
|
||||
#[catch(413)]
|
||||
async fn payload_too_large() -> JsonValue {
|
||||
json!({"error": "Data too large.", "errors": ["Data too large."]})
|
||||
}
|
||||
|
||||
#[catch(422)]
|
||||
async fn unprocessable_entity() -> JsonValue {
|
||||
json!({"error": "Invalid request.", "errors": ["Invalid request."]})
|
||||
}
|
||||
|
||||
#[catch(500)]
|
||||
async fn internal_server_error() -> Template {
|
||||
let map: HashMap<String, String> = HashMap::new();
|
||||
Template::render("errors/500", &map)
|
||||
}
|
||||
|
||||
pub async fn initialize(
|
||||
kill_channel: Sender<()>,
|
||||
serenity_context: Context,
|
||||
db_pool: Pool<Database>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("Checking environment variables...");
|
||||
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' not supplied");
|
||||
info!("Done!");
|
||||
|
||||
let oauth2_client = BasicClient::new(
|
||||
ClientId::new(env::var("OAUTH2_CLIENT_ID")?),
|
||||
Some(ClientSecret::new(env::var("OAUTH2_CLIENT_SECRET")?)),
|
||||
AuthUrl::new(DISCORD_OAUTH_AUTHORIZE.to_string())?,
|
||||
Some(TokenUrl::new(DISCORD_OAUTH_TOKEN.to_string())?),
|
||||
)
|
||||
.set_redirect_uri(RedirectUrl::new(env::var("OAUTH2_DISCORD_CALLBACK")?)?);
|
||||
|
||||
let reqwest_client = reqwest::Client::new();
|
||||
|
||||
rocket::build()
|
||||
.attach(Template::fairing())
|
||||
.register(
|
||||
"/",
|
||||
catchers![
|
||||
not_authorized,
|
||||
forbidden,
|
||||
not_found,
|
||||
internal_server_error,
|
||||
unprocessable_entity,
|
||||
payload_too_large,
|
||||
],
|
||||
)
|
||||
.manage(oauth2_client)
|
||||
.manage(reqwest_client)
|
||||
.manage(serenity_context)
|
||||
.manage(db_pool)
|
||||
.mount("/static", FileServer::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static")))
|
||||
.mount(
|
||||
"/",
|
||||
routes![
|
||||
routes::index,
|
||||
routes::cookies,
|
||||
routes::privacy,
|
||||
routes::terms,
|
||||
routes::return_to_same_site
|
||||
],
|
||||
)
|
||||
.mount(
|
||||
"/help",
|
||||
routes![
|
||||
routes::help,
|
||||
routes::help_timezone,
|
||||
routes::help_create_reminder,
|
||||
routes::help_delete_reminder,
|
||||
routes::help_timers,
|
||||
routes::help_todo_lists,
|
||||
routes::help_macros,
|
||||
routes::help_intervals,
|
||||
routes::help_dashboard,
|
||||
routes::help_iemanager,
|
||||
],
|
||||
)
|
||||
.mount("/login", routes![routes::login::discord_login, routes::login::discord_callback])
|
||||
.mount(
|
||||
"/dashboard",
|
||||
routes![
|
||||
routes::dashboard::dashboard,
|
||||
routes::dashboard::dashboard_home,
|
||||
routes::dashboard::user::get_user_info,
|
||||
routes::dashboard::user::update_user_info,
|
||||
routes::dashboard::user::get_user_guilds,
|
||||
routes::dashboard::guild::get_guild_patreon,
|
||||
routes::dashboard::guild::get_guild_channels,
|
||||
routes::dashboard::guild::get_guild_roles,
|
||||
routes::dashboard::guild::get_reminder_templates,
|
||||
routes::dashboard::guild::create_reminder_template,
|
||||
routes::dashboard::guild::delete_reminder_template,
|
||||
routes::dashboard::guild::create_guild_reminder,
|
||||
routes::dashboard::guild::get_reminders,
|
||||
routes::dashboard::guild::edit_reminder,
|
||||
routes::dashboard::guild::delete_reminder,
|
||||
routes::dashboard::export::export_reminders,
|
||||
routes::dashboard::export::export_reminder_templates,
|
||||
routes::dashboard::export::export_todos,
|
||||
routes::dashboard::export::import_reminders,
|
||||
routes::dashboard::export::import_todos,
|
||||
],
|
||||
)
|
||||
.launch()
|
||||
.await?;
|
||||
|
||||
warn!("Exiting rocket runtime");
|
||||
// distribute kill signal
|
||||
match kill_channel.send(()) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("Failed to issue kill signal: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> bool {
|
||||
if let Some(subscription_guild) = *CNC_GUILD {
|
||||
let guild_member = GuildId(subscription_guild).member(cache_http, user_id).await;
|
||||
|
||||
if let Ok(member) = guild_member {
|
||||
for role in member.roles {
|
||||
if SUBSCRIPTION_ROLES.contains(role.as_u64()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_guild_subscription(
|
||||
cache_http: impl CacheHttp,
|
||||
guild_id: impl Into<GuildId>,
|
||||
) -> bool {
|
||||
if let Some(guild) = cache_http.cache().unwrap().guild(guild_id) {
|
||||
let owner = guild.owner_id;
|
||||
|
||||
check_subscription(&cache_http, owner).await
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
125
web/src/macros.rs
Normal file
@ -0,0 +1,125 @@
|
||||
macro_rules! check_length {
|
||||
($max:ident, $field:expr) => {
|
||||
if $field.len() > $max {
|
||||
return Err(json!({ "error": format!("{} exceeded", stringify!($max)) }));
|
||||
}
|
||||
};
|
||||
($max:ident, $field:expr, $($fields:expr),+) => {
|
||||
check_length!($max, $field);
|
||||
check_length!($max, $($fields),+);
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! check_length_opt {
|
||||
($max:ident, $field:expr) => {
|
||||
if let Some(field) = &$field {
|
||||
check_length!($max, field);
|
||||
}
|
||||
};
|
||||
($max:ident, $field:expr, $($fields:expr),+) => {
|
||||
check_length_opt!($max, $field);
|
||||
check_length_opt!($max, $($fields),+);
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! check_url {
|
||||
($field:expr) => {
|
||||
if !($field.starts_with("http://") || $field.starts_with("https://")) {
|
||||
return Err(json!({ "error": "URL invalid" }));
|
||||
}
|
||||
};
|
||||
($field:expr, $($fields:expr),+) => {
|
||||
check_url!($max, $field);
|
||||
check_url!($max, $($fields),+);
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! check_url_opt {
|
||||
($field:expr) => {
|
||||
if let Some(field) = &$field {
|
||||
check_url!(field);
|
||||
}
|
||||
};
|
||||
($field:expr, $($fields:expr),+) => {
|
||||
check_url_opt!($field);
|
||||
check_url_opt!($($fields),+);
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! check_authorization {
|
||||
($cookies:expr, $ctx:expr, $guild:expr) => {
|
||||
use serenity::model::id::UserId;
|
||||
|
||||
let user_id = $cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten();
|
||||
|
||||
match user_id {
|
||||
Some(user_id) => {
|
||||
match GuildId($guild).to_guild_cached($ctx) {
|
||||
Some(guild) => {
|
||||
let member = guild.member($ctx, UserId(user_id)).await;
|
||||
|
||||
match member {
|
||||
Err(_) => {
|
||||
return Err(json!({"error": "User not in guild"}));
|
||||
}
|
||||
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
return Err(json!({"error": "Bot not in guild"}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
return Err(json!({"error": "User not authorized"}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! update_field {
|
||||
($pool:expr, $error:ident, $reminder:ident.[$field:ident]) => {
|
||||
if let Some(value) = &$reminder.$field {
|
||||
match sqlx::query(concat!(
|
||||
"UPDATE reminders SET `",
|
||||
stringify!($field),
|
||||
"` = ? WHERE uid = ?"
|
||||
))
|
||||
.bind(value)
|
||||
.bind(&$reminder.uid)
|
||||
.execute($pool)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
concat!(
|
||||
"Error in `update_field!(",
|
||||
stringify!($pool),
|
||||
stringify!($reminder),
|
||||
stringify!($field),
|
||||
")': {:?}"
|
||||
),
|
||||
e
|
||||
);
|
||||
|
||||
$error.push(format!("Error setting field {}", stringify!($field)));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
($pool:expr, $error:ident, $reminder:ident.[$field:ident, $($fields:ident),+]) => {
|
||||
update_field!($pool, $error, $reminder.[$field]);
|
||||
update_field!($pool, $error, $reminder.[$($fields),+]);
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! json_err {
|
||||
($message:expr) => {
|
||||
Err(json!({ "error": $message }))
|
||||
};
|
||||
}
|
430
web/src/routes/dashboard/export.rs
Normal file
@ -0,0 +1,430 @@
|
||||
use csv::{QuoteStyle, WriterBuilder};
|
||||
use rocket::{
|
||||
http::CookieJar,
|
||||
serde::json::{json, serde_json, Json},
|
||||
State,
|
||||
};
|
||||
use serenity::{
|
||||
client::Context,
|
||||
model::id::{ChannelId, GuildId},
|
||||
};
|
||||
use sqlx::{MySql, Pool};
|
||||
|
||||
use crate::routes::dashboard::{
|
||||
create_reminder, generate_uid, ImportBody, JsonResult, Reminder, ReminderCsv,
|
||||
ReminderTemplateCsv, TodoCsv,
|
||||
};
|
||||
|
||||
#[get("/api/guild/<id>/export/reminders")]
|
||||
pub async fn export_reminders(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
||||
|
||||
let channels_res = GuildId(id).channels(&ctx.inner()).await;
|
||||
|
||||
match channels_res {
|
||||
Ok(channels) => {
|
||||
let channels = channels
|
||||
.keys()
|
||||
.into_iter()
|
||||
.map(|k| k.as_u64().to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(",");
|
||||
|
||||
let result = sqlx::query_as_unchecked!(
|
||||
ReminderCsv,
|
||||
"SELECT
|
||||
reminders.attachment,
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
CONCAT('#', channels.channel) AS channel,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
reminders.embed_color,
|
||||
reminders.embed_description,
|
||||
reminders.embed_footer,
|
||||
reminders.embed_footer_url,
|
||||
reminders.embed_image_url,
|
||||
reminders.embed_thumbnail_url,
|
||||
reminders.embed_title,
|
||||
reminders.embed_fields,
|
||||
reminders.enabled,
|
||||
reminders.expires,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.name,
|
||||
reminders.restartable,
|
||||
reminders.tts,
|
||||
reminders.username,
|
||||
reminders.utc_time
|
||||
FROM reminders
|
||||
LEFT JOIN channels ON channels.id = reminders.channel_id
|
||||
WHERE FIND_IN_SET(channels.channel, ?)",
|
||||
channels
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(reminders) => {
|
||||
reminders.iter().for_each(|reminder| {
|
||||
csv_writer.serialize(reminder).unwrap();
|
||||
});
|
||||
|
||||
match csv_writer.into_inner() {
|
||||
Ok(inner) => match String::from_utf8(inner) {
|
||||
Ok(encoded) => Ok(json!({ "body": encoded })),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to write UTF-8: {:?}", e);
|
||||
|
||||
Err(json!({"error": "Failed to write UTF-8"}))
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to extract CSV: {:?}", e);
|
||||
|
||||
Err(json!({"error": "Failed to extract CSV"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to complete SQL query: {:?}", e);
|
||||
|
||||
Err(json!({"error": "Failed to query reminders"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Could not fetch channels from {}: {:?}", id, e);
|
||||
|
||||
Err(json!({"error": "Failed to get guild channels"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[put("/api/guild/<id>/export/reminders", data = "<body>")]
|
||||
pub async fn import_reminders(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
body: Json<ImportBody>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
let user_id =
|
||||
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
||||
|
||||
match base64::decode(&body.body) {
|
||||
Ok(body) => {
|
||||
let mut reader = csv::Reader::from_reader(body.as_slice());
|
||||
|
||||
for result in reader.deserialize::<ReminderCsv>() {
|
||||
match result {
|
||||
Ok(record) => {
|
||||
let channel_id = record.channel.split_at(1).1;
|
||||
|
||||
match channel_id.parse::<u64>() {
|
||||
Ok(channel_id) => {
|
||||
let reminder = Reminder {
|
||||
attachment: record.attachment,
|
||||
attachment_name: record.attachment_name,
|
||||
avatar: record.avatar,
|
||||
channel: channel_id,
|
||||
content: record.content,
|
||||
embed_author: record.embed_author,
|
||||
embed_author_url: record.embed_author_url,
|
||||
embed_color: record.embed_color,
|
||||
embed_description: record.embed_description,
|
||||
embed_footer: record.embed_footer,
|
||||
embed_footer_url: record.embed_footer_url,
|
||||
embed_image_url: record.embed_image_url,
|
||||
embed_thumbnail_url: record.embed_thumbnail_url,
|
||||
embed_title: record.embed_title,
|
||||
embed_fields: record
|
||||
.embed_fields
|
||||
.map(|s| serde_json::from_str(&s).ok())
|
||||
.flatten(),
|
||||
enabled: record.enabled,
|
||||
expires: record.expires,
|
||||
interval_seconds: record.interval_seconds,
|
||||
interval_months: record.interval_months,
|
||||
name: record.name,
|
||||
restartable: record.restartable,
|
||||
tts: record.tts,
|
||||
uid: generate_uid(),
|
||||
username: record.username,
|
||||
utc_time: record.utc_time,
|
||||
};
|
||||
|
||||
create_reminder(
|
||||
ctx.inner(),
|
||||
pool.inner(),
|
||||
GuildId(id),
|
||||
UserId(user_id),
|
||||
reminder,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
return json_err!(format!(
|
||||
"Failed to parse channel {}",
|
||||
channel_id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Couldn't deserialize CSV row: {:?}", e);
|
||||
|
||||
return json_err!("Deserialize error. Aborted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
json_err!("Malformed base64")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/api/guild/<id>/export/todos")]
|
||||
pub async fn export_todos(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
||||
|
||||
match sqlx::query_as_unchecked!(
|
||||
TodoCsv,
|
||||
"SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos
|
||||
LEFT JOIN channels ON todos.channel_id = channels.id
|
||||
INNER JOIN guilds ON todos.guild_id = guilds.id
|
||||
WHERE guilds.guild = ?",
|
||||
id
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(todos) => {
|
||||
todos.iter().for_each(|todo| {
|
||||
csv_writer.serialize(todo).unwrap();
|
||||
});
|
||||
|
||||
match csv_writer.into_inner() {
|
||||
Ok(inner) => match String::from_utf8(inner) {
|
||||
Ok(encoded) => Ok(json!({ "body": encoded })),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to write UTF-8: {:?}", e);
|
||||
|
||||
json_err!("Failed to write UTF-8")
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to extract CSV: {:?}", e);
|
||||
|
||||
json_err!("Failed to extract CSV")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Could not fetch templates from {}: {:?}", id, e);
|
||||
|
||||
json_err!("Failed to query templates")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[put("/api/guild/<id>/export/todos", data = "<body>")]
|
||||
pub async fn import_todos(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
body: Json<ImportBody>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
let channels_res = GuildId(id).channels(&ctx.inner()).await;
|
||||
|
||||
match channels_res {
|
||||
Ok(channels) => match base64::decode(&body.body) {
|
||||
Ok(body) => {
|
||||
let mut reader = csv::Reader::from_reader(body.as_slice());
|
||||
|
||||
let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?))";
|
||||
let mut query_params = vec![];
|
||||
|
||||
for result in reader.deserialize::<TodoCsv>() {
|
||||
match result {
|
||||
Ok(record) => match record.channel_id {
|
||||
Some(channel_id) => {
|
||||
let channel_id = channel_id.split_at(1).1;
|
||||
|
||||
match channel_id.parse::<u64>() {
|
||||
Ok(channel_id) => {
|
||||
if channels.contains_key(&ChannelId(channel_id)) {
|
||||
query_params.push((record.value, Some(channel_id), id));
|
||||
} else {
|
||||
return json_err!(format!(
|
||||
"Invalid channel ID {}",
|
||||
channel_id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
return json_err!(format!(
|
||||
"Invalid channel ID {}",
|
||||
channel_id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
query_params.push((record.value, None, id));
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Couldn't deserialize CSV row: {:?}", e);
|
||||
|
||||
return json_err!("Deserialize error. Aborted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(",")
|
||||
);
|
||||
let mut query = sqlx::query(&query_str);
|
||||
|
||||
for param in query_params {
|
||||
query = query.bind(param.0).bind(param.1).bind(param.2);
|
||||
}
|
||||
|
||||
let res = query.execute(pool.inner()).await;
|
||||
|
||||
match res {
|
||||
Ok(_) => Ok(json!({})),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Couldn't execute todo query: {:?}", e);
|
||||
|
||||
json_err!("An unexpected error occured.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
json_err!("Malformed base64")
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Couldn't fetch channels for guild {}: {:?}", id, e);
|
||||
|
||||
json_err!("Couldn't fetch channels.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/api/guild/<id>/export/reminder_templates")]
|
||||
pub async fn export_reminder_templates(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
||||
|
||||
match sqlx::query_as_unchecked!(
|
||||
ReminderTemplateCsv,
|
||||
"SELECT
|
||||
name,
|
||||
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,
|
||||
tts,
|
||||
username
|
||||
FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||
id
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(templates) => {
|
||||
templates.iter().for_each(|template| {
|
||||
csv_writer.serialize(template).unwrap();
|
||||
});
|
||||
|
||||
match csv_writer.into_inner() {
|
||||
Ok(inner) => match String::from_utf8(inner) {
|
||||
Ok(encoded) => Ok(json!({ "body": encoded })),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to write UTF-8: {:?}", e);
|
||||
|
||||
json_err!("Failed to write UTF-8")
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to extract CSV: {:?}", e);
|
||||
|
||||
json_err!("Failed to extract CSV")
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Could not fetch templates from {}: {:?}", id, e);
|
||||
|
||||
json_err!("Failed to query templates")
|
||||
}
|
||||
}
|
||||
}
|
528
web/src/routes/dashboard/guild.rs
Normal file
@ -0,0 +1,528 @@
|
||||
use std::env;
|
||||
|
||||
use rocket::{
|
||||
http::CookieJar,
|
||||
serde::json::{json, Json},
|
||||
State,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serenity::{
|
||||
client::Context,
|
||||
model::{
|
||||
channel::GuildChannel,
|
||||
id::{ChannelId, GuildId, RoleId},
|
||||
},
|
||||
};
|
||||
use sqlx::{MySql, Pool};
|
||||
|
||||
use crate::{
|
||||
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,
|
||||
},
|
||||
routes::dashboard::{
|
||||
create_database_channel, create_reminder, template_name_default, DeleteReminder,
|
||||
DeleteReminderTemplate, JsonResult, PatchReminder, Reminder, ReminderTemplate,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChannelInfo {
|
||||
id: String,
|
||||
name: String,
|
||||
webhook_avatar: Option<String>,
|
||||
webhook_name: Option<String>,
|
||||
}
|
||||
|
||||
#[get("/api/guild/<id>/patreon")]
|
||||
pub async fn get_guild_patreon(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
match GuildId(id).to_guild_cached(ctx.inner()) {
|
||||
Some(guild) => {
|
||||
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
|
||||
.member(&ctx.inner(), guild.owner_id)
|
||||
.await;
|
||||
|
||||
let patreon = member_res.map_or(false, |member| {
|
||||
member
|
||||
.roles
|
||||
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
|
||||
});
|
||||
|
||||
Ok(json!({ "patreon": patreon }))
|
||||
}
|
||||
|
||||
None => json_err!("Bot not in guild"),
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/api/guild/<id>/channels")]
|
||||
pub async fn get_guild_channels(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
match GuildId(id).to_guild_cached(ctx.inner()) {
|
||||
Some(guild) => {
|
||||
let mut channels = guild
|
||||
.channels
|
||||
.iter()
|
||||
.filter_map(|(id, channel)| channel.to_owned().guild().map(|c| (id.to_owned(), c)))
|
||||
.filter(|(_, channel)| channel.is_text_based())
|
||||
.collect::<Vec<(ChannelId, GuildChannel)>>();
|
||||
|
||||
channels.sort_by(|(_, c1), (_, c2)| c1.position.cmp(&c2.position));
|
||||
|
||||
let channel_info = channels
|
||||
.iter()
|
||||
.map(|(channel_id, channel)| ChannelInfo {
|
||||
name: channel.name.to_string(),
|
||||
id: channel_id.to_string(),
|
||||
webhook_avatar: None,
|
||||
webhook_name: None,
|
||||
})
|
||||
.collect::<Vec<ChannelInfo>>();
|
||||
|
||||
Ok(json!(channel_info))
|
||||
}
|
||||
|
||||
None => json_err!("Bot not in guild"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RoleInfo {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[get("/api/guild/<id>/roles")]
|
||||
pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
let roles_res = ctx.cache.guild_roles(id);
|
||||
|
||||
match roles_res {
|
||||
Some(roles) => {
|
||||
let roles = roles
|
||||
.iter()
|
||||
.map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
|
||||
.collect::<Vec<RoleInfo>>();
|
||||
|
||||
Ok(json!(roles))
|
||||
}
|
||||
None => {
|
||||
warn!("Could not fetch roles from {}", id);
|
||||
|
||||
json_err!("Could not get roles")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/api/guild/<id>/templates")]
|
||||
pub async fn get_reminder_templates(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
match sqlx::query_as_unchecked!(
|
||||
ReminderTemplate,
|
||||
"SELECT * FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||
id
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(templates) => Ok(json!(templates)),
|
||||
Err(e) => {
|
||||
warn!("Could not fetch templates from {}: {:?}", id, e);
|
||||
|
||||
json_err!("Could not get templates")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/api/guild/<id>/templates", data = "<reminder_template>")]
|
||||
pub async fn create_reminder_template(
|
||||
id: u64,
|
||||
reminder_template: Json<ReminderTemplate>,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
// validate lengths
|
||||
check_length!(MAX_CONTENT_LENGTH, reminder_template.content);
|
||||
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder_template.embed_description);
|
||||
check_length!(MAX_EMBED_TITLE_LENGTH, reminder_template.embed_title);
|
||||
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder_template.embed_author);
|
||||
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder_template.embed_footer);
|
||||
check_length_opt!(MAX_EMBED_FIELDS, reminder_template.embed_fields);
|
||||
if let Some(fields) = &reminder_template.embed_fields {
|
||||
for field in &fields.0 {
|
||||
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
|
||||
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
|
||||
}
|
||||
}
|
||||
check_length_opt!(MAX_USERNAME_LENGTH, reminder_template.username);
|
||||
check_length_opt!(
|
||||
MAX_URL_LENGTH,
|
||||
reminder_template.embed_footer_url,
|
||||
reminder_template.embed_thumbnail_url,
|
||||
reminder_template.embed_author_url,
|
||||
reminder_template.embed_image_url,
|
||||
reminder_template.avatar
|
||||
);
|
||||
|
||||
// validate urls
|
||||
check_url_opt!(
|
||||
reminder_template.embed_footer_url,
|
||||
reminder_template.embed_thumbnail_url,
|
||||
reminder_template.embed_author_url,
|
||||
reminder_template.embed_image_url,
|
||||
reminder_template.avatar
|
||||
);
|
||||
|
||||
let name = if reminder_template.name.is_empty() {
|
||||
template_name_default()
|
||||
} else {
|
||||
reminder_template.name.clone()
|
||||
};
|
||||
|
||||
match sqlx::query!(
|
||||
"INSERT INTO reminder_template
|
||||
(guild_id,
|
||||
name,
|
||||
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,
|
||||
tts,
|
||||
username
|
||||
) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
id, name,
|
||||
reminder_template.attachment,
|
||||
reminder_template.attachment_name,
|
||||
reminder_template.avatar,
|
||||
reminder_template.content,
|
||||
reminder_template.embed_author,
|
||||
reminder_template.embed_author_url,
|
||||
reminder_template.embed_color,
|
||||
reminder_template.embed_description,
|
||||
reminder_template.embed_footer,
|
||||
reminder_template.embed_footer_url,
|
||||
reminder_template.embed_image_url,
|
||||
reminder_template.embed_thumbnail_url,
|
||||
reminder_template.embed_title,
|
||||
reminder_template.embed_fields,
|
||||
reminder_template.tts,
|
||||
reminder_template.username,
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
Ok(json!({}))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Could not fetch templates from {}: {:?}", id, e);
|
||||
|
||||
json_err!("Could not get templates")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("/api/guild/<id>/templates", data = "<delete_reminder_template>")]
|
||||
pub async fn delete_reminder_template(
|
||||
id: u64,
|
||||
delete_reminder_template: Json<DeleteReminderTemplate>,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
match sqlx::query!(
|
||||
"DELETE FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND id = ?",
|
||||
id, delete_reminder_template.id
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
Ok(json!({}))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Could not delete template from {}: {:?}", id, e);
|
||||
|
||||
json_err!("Could not delete template")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
|
||||
pub async fn create_guild_reminder(
|
||||
id: u64,
|
||||
reminder: Json<Reminder>,
|
||||
cookies: &CookieJar<'_>,
|
||||
serenity_context: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, serenity_context.inner(), id);
|
||||
|
||||
let user_id =
|
||||
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
||||
|
||||
create_reminder(
|
||||
serenity_context.inner(),
|
||||
pool.inner(),
|
||||
GuildId(id),
|
||||
UserId(user_id),
|
||||
reminder.into_inner(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[get("/api/guild/<id>/reminders")]
|
||||
pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySql>>) -> JsonResult {
|
||||
let channels_res = GuildId(id).channels(&ctx.inner()).await;
|
||||
|
||||
match channels_res {
|
||||
Ok(channels) => {
|
||||
let channels = channels
|
||||
.keys()
|
||||
.into_iter()
|
||||
.map(|k| k.as_u64().to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(",");
|
||||
|
||||
sqlx::query_as_unchecked!(
|
||||
Reminder,
|
||||
"SELECT
|
||||
reminders.attachment,
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
channels.channel,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
reminders.embed_color,
|
||||
reminders.embed_description,
|
||||
reminders.embed_footer,
|
||||
reminders.embed_footer_url,
|
||||
reminders.embed_image_url,
|
||||
reminders.embed_thumbnail_url,
|
||||
reminders.embed_title,
|
||||
reminders.embed_fields,
|
||||
reminders.enabled,
|
||||
reminders.expires,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.name,
|
||||
reminders.restartable,
|
||||
reminders.tts,
|
||||
reminders.uid,
|
||||
reminders.username,
|
||||
reminders.utc_time
|
||||
FROM reminders
|
||||
LEFT JOIN channels ON channels.id = reminders.channel_id
|
||||
WHERE FIND_IN_SET(channels.channel, ?)",
|
||||
channels
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
.map(|r| Ok(json!(r)))
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to complete SQL query: {:?}", e);
|
||||
|
||||
json_err!("Could not load reminders")
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Could not fetch channels from {}: {:?}", id, e);
|
||||
|
||||
Ok(json!([]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[patch("/api/guild/<id>/reminders", data = "<reminder>")]
|
||||
pub async fn edit_reminder(
|
||||
id: u64,
|
||||
reminder: Json<PatchReminder>,
|
||||
serenity_context: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
let mut error = vec![];
|
||||
|
||||
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.channel > 0 {
|
||||
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
|
||||
match channel {
|
||||
Some(channel) => {
|
||||
let channel_matches_guild = channel.guild().map_or(false, |c| c.guild_id.0 == id);
|
||||
|
||||
if !channel_matches_guild {
|
||||
warn!(
|
||||
"Error in `edit_reminder`: channel {:?} not found for guild {}",
|
||||
reminder.channel, id
|
||||
);
|
||||
|
||||
return Err(json!({"error": "Channel not found"}));
|
||||
}
|
||||
|
||||
let channel = create_database_channel(
|
||||
serenity_context.inner(),
|
||||
ChannelId(reminder.channel),
|
||||
pool.inner(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = channel {
|
||||
warn!("`create_database_channel` returned an error code: {:?}", e);
|
||||
|
||||
return Err(
|
||||
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
|
||||
);
|
||||
}
|
||||
|
||||
let channel = channel.unwrap();
|
||||
|
||||
match sqlx::query!(
|
||||
"UPDATE reminders SET channel_id = ? WHERE uid = ?",
|
||||
channel,
|
||||
reminder.uid
|
||||
)
|
||||
.execute(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
warn!("Error setting channel: {:?}", e);
|
||||
|
||||
error.push("Couldn't set channel".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
warn!(
|
||||
"Error in `edit_reminder`: channel {:?} not found for guild {}",
|
||||
reminder.channel, id
|
||||
);
|
||||
|
||||
return Err(json!({"error": "Channel not found"}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match sqlx::query_as_unchecked!(
|
||||
Reminder,
|
||||
"SELECT reminders.attachment,
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
channels.channel,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
reminders.embed_color,
|
||||
reminders.embed_description,
|
||||
reminders.embed_footer,
|
||||
reminders.embed_footer_url,
|
||||
reminders.embed_image_url,
|
||||
reminders.embed_thumbnail_url,
|
||||
reminders.embed_title,
|
||||
reminders.embed_fields,
|
||||
reminders.enabled,
|
||||
reminders.expires,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.name,
|
||||
reminders.restartable,
|
||||
reminders.tts,
|
||||
reminders.uid,
|
||||
reminders.username,
|
||||
reminders.utc_time
|
||||
FROM reminders
|
||||
LEFT JOIN channels ON channels.id = reminders.channel_id
|
||||
WHERE uid = ?",
|
||||
reminder.uid
|
||||
)
|
||||
.fetch_one(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error exiting `edit_reminder': {:?}", e);
|
||||
|
||||
Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("/api/guild/<_>/reminders", data = "<reminder>")]
|
||||
pub async fn delete_reminder(
|
||||
reminder: Json<DeleteReminder>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid)
|
||||
.execute(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(json!({})),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error in `delete_reminder`: {:?}", e);
|
||||
|
||||
Err(json!({"error": "Could not delete reminder"}))
|
||||
}
|
||||
}
|
||||
}
|
591
web/src/routes/dashboard/mod.rs
Normal file
@ -0,0 +1,591 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{naive::NaiveDateTime, Utc};
|
||||
use rand::{rngs::OsRng, seq::IteratorRandom};
|
||||
use rocket::{
|
||||
http::CookieJar,
|
||||
response::Redirect,
|
||||
serde::json::{json, Value as JsonValue},
|
||||
};
|
||||
use rocket_dyn_templates::Template;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serenity::{
|
||||
client::Context,
|
||||
http::Http,
|
||||
model::id::{ChannelId, GuildId, UserId},
|
||||
};
|
||||
use sqlx::{types::Json, Executor, MySql, Pool};
|
||||
|
||||
use crate::{
|
||||
check_guild_subscription, check_subscription,
|
||||
consts::{
|
||||
CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH,
|
||||
MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH,
|
||||
MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH,
|
||||
MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL,
|
||||
},
|
||||
Database, Error,
|
||||
};
|
||||
|
||||
pub mod export;
|
||||
pub mod guild;
|
||||
pub mod user;
|
||||
|
||||
pub type JsonResult = Result<JsonValue, JsonValue>;
|
||||
type Unset<T> = Option<T>;
|
||||
|
||||
fn name_default() -> String {
|
||||
"Reminder".to_string()
|
||||
}
|
||||
|
||||
fn template_name_default() -> String {
|
||||
"Template".to_string()
|
||||
}
|
||||
|
||||
fn channel_default() -> u64 {
|
||||
0
|
||||
}
|
||||
|
||||
fn id_default() -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ReminderTemplate {
|
||||
#[serde(default = "id_default")]
|
||||
id: u32,
|
||||
#[serde(default = "id_default")]
|
||||
guild_id: u32,
|
||||
#[serde(default = "template_name_default")]
|
||||
name: String,
|
||||
attachment: Option<Vec<u8>>,
|
||||
attachment_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
content: String,
|
||||
embed_author: String,
|
||||
embed_author_url: Option<String>,
|
||||
embed_color: u32,
|
||||
embed_description: String,
|
||||
embed_footer: String,
|
||||
embed_footer_url: Option<String>,
|
||||
embed_image_url: Option<String>,
|
||||
embed_thumbnail_url: Option<String>,
|
||||
embed_title: String,
|
||||
embed_fields: Option<Json<Vec<EmbedField>>>,
|
||||
tts: bool,
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ReminderTemplateCsv {
|
||||
#[serde(default = "template_name_default")]
|
||||
name: String,
|
||||
attachment: Option<Vec<u8>>,
|
||||
attachment_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
content: String,
|
||||
embed_author: String,
|
||||
embed_author_url: Option<String>,
|
||||
embed_color: u32,
|
||||
embed_description: String,
|
||||
embed_footer: String,
|
||||
embed_footer_url: Option<String>,
|
||||
embed_image_url: Option<String>,
|
||||
embed_thumbnail_url: Option<String>,
|
||||
embed_title: String,
|
||||
embed_fields: Option<String>,
|
||||
tts: bool,
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteReminderTemplate {
|
||||
id: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct EmbedField {
|
||||
title: String,
|
||||
value: String,
|
||||
inline: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Reminder {
|
||||
#[serde(with = "base64s")]
|
||||
attachment: Option<Vec<u8>>,
|
||||
attachment_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
#[serde(with = "string")]
|
||||
channel: u64,
|
||||
content: String,
|
||||
embed_author: String,
|
||||
embed_author_url: Option<String>,
|
||||
embed_color: u32,
|
||||
embed_description: String,
|
||||
embed_footer: String,
|
||||
embed_footer_url: Option<String>,
|
||||
embed_image_url: Option<String>,
|
||||
embed_thumbnail_url: Option<String>,
|
||||
embed_title: String,
|
||||
embed_fields: Option<Json<Vec<EmbedField>>>,
|
||||
enabled: bool,
|
||||
expires: Option<NaiveDateTime>,
|
||||
interval_seconds: Option<u32>,
|
||||
interval_months: Option<u32>,
|
||||
#[serde(default = "name_default")]
|
||||
name: String,
|
||||
restartable: bool,
|
||||
tts: bool,
|
||||
#[serde(default)]
|
||||
uid: String,
|
||||
username: Option<String>,
|
||||
utc_time: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ReminderCsv {
|
||||
#[serde(with = "base64s")]
|
||||
attachment: Option<Vec<u8>>,
|
||||
attachment_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
channel: String,
|
||||
content: String,
|
||||
embed_author: String,
|
||||
embed_author_url: Option<String>,
|
||||
embed_color: u32,
|
||||
embed_description: String,
|
||||
embed_footer: String,
|
||||
embed_footer_url: Option<String>,
|
||||
embed_image_url: Option<String>,
|
||||
embed_thumbnail_url: Option<String>,
|
||||
embed_title: String,
|
||||
embed_fields: Option<String>,
|
||||
enabled: bool,
|
||||
expires: Option<NaiveDateTime>,
|
||||
interval_seconds: Option<u32>,
|
||||
interval_months: Option<u32>,
|
||||
#[serde(default = "name_default")]
|
||||
name: String,
|
||||
restartable: bool,
|
||||
tts: bool,
|
||||
username: Option<String>,
|
||||
utc_time: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PatchReminder {
|
||||
uid: String,
|
||||
#[serde(default)]
|
||||
attachment: Unset<Option<String>>,
|
||||
#[serde(default)]
|
||||
attachment_name: Unset<Option<String>>,
|
||||
#[serde(default)]
|
||||
avatar: Unset<Option<String>>,
|
||||
#[serde(default = "channel_default")]
|
||||
#[serde(with = "string")]
|
||||
channel: u64,
|
||||
#[serde(default)]
|
||||
content: Unset<String>,
|
||||
#[serde(default)]
|
||||
embed_author: Unset<String>,
|
||||
#[serde(default)]
|
||||
embed_author_url: Unset<Option<String>>,
|
||||
#[serde(default)]
|
||||
embed_color: Unset<u32>,
|
||||
#[serde(default)]
|
||||
embed_description: Unset<String>,
|
||||
#[serde(default)]
|
||||
embed_footer: Unset<String>,
|
||||
#[serde(default)]
|
||||
embed_footer_url: Unset<Option<String>>,
|
||||
#[serde(default)]
|
||||
embed_image_url: Unset<Option<String>>,
|
||||
#[serde(default)]
|
||||
embed_thumbnail_url: Unset<Option<String>>,
|
||||
#[serde(default)]
|
||||
embed_title: Unset<String>,
|
||||
#[serde(default)]
|
||||
embed_fields: Unset<Json<Vec<EmbedField>>>,
|
||||
#[serde(default)]
|
||||
enabled: Unset<bool>,
|
||||
#[serde(default)]
|
||||
expires: Unset<Option<NaiveDateTime>>,
|
||||
#[serde(default)]
|
||||
interval_seconds: Unset<Option<u32>>,
|
||||
#[serde(default)]
|
||||
interval_months: Unset<Option<u32>>,
|
||||
#[serde(default)]
|
||||
name: Unset<String>,
|
||||
#[serde(default)]
|
||||
restartable: Unset<bool>,
|
||||
#[serde(default)]
|
||||
tts: Unset<bool>,
|
||||
#[serde(default)]
|
||||
username: Unset<Option<String>>,
|
||||
#[serde(default)]
|
||||
utc_time: Unset<NaiveDateTime>,
|
||||
}
|
||||
|
||||
pub fn generate_uid() -> String {
|
||||
let mut generator: OsRng = Default::default();
|
||||
|
||||
(0..64)
|
||||
.map(|_| CHARACTERS.chars().choose(&mut generator).unwrap().to_owned().to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
// https://github.com/serde-rs/json/issues/329#issuecomment-305608405
|
||||
mod string {
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
|
||||
use serde::{de, Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
T: Display,
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.collect_str(value)
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
|
||||
where
|
||||
T: FromStr,
|
||||
T::Err: Display,
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
mod base64s {
|
||||
use serde::{de, Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S>(value: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
if let Some(opt) = value {
|
||||
serializer.collect_str(&base64::encode(opt))
|
||||
} else {
|
||||
serializer.serialize_none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let string = Option::<String>::deserialize(deserializer)?;
|
||||
Some(string.map(|b| base64::decode(b).map_err(de::Error::custom))).flatten().transpose()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteReminder {
|
||||
uid: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ImportBody {
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TodoCsv {
|
||||
value: String,
|
||||
channel_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_reminder(
|
||||
ctx: &Context,
|
||||
pool: &Pool<MySql>,
|
||||
guild_id: GuildId,
|
||||
user_id: UserId,
|
||||
reminder: Reminder,
|
||||
) -> JsonResult {
|
||||
// validate channel
|
||||
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx);
|
||||
let channel_exists = channel.is_some();
|
||||
|
||||
let channel_matches_guild =
|
||||
channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id == guild_id));
|
||||
|
||||
if !channel_matches_guild || !channel_exists {
|
||||
warn!(
|
||||
"Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})",
|
||||
reminder.channel, guild_id, channel_exists
|
||||
);
|
||||
|
||||
return Err(json!({"error": "Channel not found"}));
|
||||
}
|
||||
|
||||
let channel = create_database_channel(&ctx, ChannelId(reminder.channel), pool).await;
|
||||
|
||||
if let Err(e) = channel {
|
||||
warn!("`create_database_channel` returned an error code: {:?}", e);
|
||||
|
||||
return Err(
|
||||
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
|
||||
);
|
||||
}
|
||||
|
||||
let channel = channel.unwrap();
|
||||
|
||||
// validate lengths
|
||||
check_length!(MAX_CONTENT_LENGTH, reminder.content);
|
||||
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
|
||||
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
|
||||
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
|
||||
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
|
||||
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
|
||||
if let Some(fields) = &reminder.embed_fields {
|
||||
for field in &fields.0 {
|
||||
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
|
||||
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
|
||||
}
|
||||
}
|
||||
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
|
||||
check_length_opt!(
|
||||
MAX_URL_LENGTH,
|
||||
reminder.embed_footer_url,
|
||||
reminder.embed_thumbnail_url,
|
||||
reminder.embed_author_url,
|
||||
reminder.embed_image_url,
|
||||
reminder.avatar
|
||||
);
|
||||
|
||||
// validate urls
|
||||
check_url_opt!(
|
||||
reminder.embed_footer_url,
|
||||
reminder.embed_thumbnail_url,
|
||||
reminder.embed_author_url,
|
||||
reminder.embed_image_url,
|
||||
reminder.avatar
|
||||
);
|
||||
|
||||
// validate time and interval
|
||||
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_months.is_some() {
|
||||
if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32
|
||||
+ reminder.interval_seconds.unwrap_or(0)
|
||||
< *MIN_INTERVAL
|
||||
{
|
||||
return Err(json!({"error": "Interval too short"}));
|
||||
}
|
||||
}
|
||||
|
||||
// check patreon if necessary
|
||||
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
|
||||
{
|
||||
return Err(json!({"error": "Patreon is required to set intervals"}));
|
||||
}
|
||||
}
|
||||
|
||||
// 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 new_uid = generate_uid();
|
||||
|
||||
// write to db
|
||||
match sqlx::query!(
|
||||
"INSERT INTO reminders (
|
||||
uid,
|
||||
attachment,
|
||||
attachment_name,
|
||||
channel_id,
|
||||
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`
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
new_uid,
|
||||
attachment_data,
|
||||
reminder.attachment_name,
|
||||
channel,
|
||||
reminder.avatar,
|
||||
reminder.content,
|
||||
reminder.embed_author,
|
||||
reminder.embed_author_url,
|
||||
reminder.embed_color,
|
||||
reminder.embed_description,
|
||||
reminder.embed_footer,
|
||||
reminder.embed_footer_url,
|
||||
reminder.embed_image_url,
|
||||
reminder.embed_thumbnail_url,
|
||||
reminder.embed_title,
|
||||
reminder.embed_fields,
|
||||
reminder.enabled,
|
||||
reminder.expires,
|
||||
reminder.interval_seconds,
|
||||
reminder.interval_months,
|
||||
name,
|
||||
reminder.restartable,
|
||||
reminder.tts,
|
||||
reminder.username,
|
||||
reminder.utc_time,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
Ok(_) => sqlx::query_as_unchecked!(
|
||||
Reminder,
|
||||
"SELECT
|
||||
reminders.attachment,
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
channels.channel,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
reminders.embed_color,
|
||||
reminders.embed_description,
|
||||
reminders.embed_footer,
|
||||
reminders.embed_footer_url,
|
||||
reminders.embed_image_url,
|
||||
reminders.embed_thumbnail_url,
|
||||
reminders.embed_title,
|
||||
reminders.embed_fields,
|
||||
reminders.enabled,
|
||||
reminders.expires,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.name,
|
||||
reminders.restartable,
|
||||
reminders.tts,
|
||||
reminders.uid,
|
||||
reminders.username,
|
||||
reminders.utc_time
|
||||
FROM reminders
|
||||
LEFT JOIN channels ON channels.id = reminders.channel_id
|
||||
WHERE uid = ?",
|
||||
new_uid
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map(|r| Ok(json!(r)))
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to complete SQL query: {:?}", e);
|
||||
|
||||
Err(json!({"error": "Could not load reminder"}))
|
||||
}),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error in `create_reminder`: Could not execute query: {:?}", e);
|
||||
|
||||
Err(json!({"error": "Unknown error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_database_channel(
|
||||
ctx: impl AsRef<Http>,
|
||||
channel: ChannelId,
|
||||
pool: impl Executor<'_, Database = Database> + Copy,
|
||||
) -> Result<u32, crate::Error> {
|
||||
let row =
|
||||
sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0)
|
||||
.fetch_one(pool)
|
||||
.await;
|
||||
|
||||
match row {
|
||||
Ok(row) => {
|
||||
if row.webhook_token.is_none() || row.webhook_id.is_none() {
|
||||
let webhook = channel
|
||||
.create_webhook_with_avatar(&ctx, "Reminder", DEFAULT_AVATAR.clone())
|
||||
.await
|
||||
.map_err(|e| Error::Serenity(e))?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE channels SET webhook_id = ?, webhook_token = ? WHERE channel = ?",
|
||||
webhook.id.0,
|
||||
webhook.token,
|
||||
channel.0
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| Error::SQLx(e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Err(sqlx::Error::RowNotFound) => {
|
||||
// create webhook
|
||||
let webhook = channel
|
||||
.create_webhook_with_avatar(&ctx, "Reminder", DEFAULT_AVATAR.clone())
|
||||
.await
|
||||
.map_err(|e| Error::Serenity(e))?;
|
||||
|
||||
// create database entry
|
||||
sqlx::query!(
|
||||
"INSERT INTO channels (
|
||||
webhook_id,
|
||||
webhook_token,
|
||||
channel
|
||||
) VALUES (?, ?, ?)",
|
||||
webhook.id.0,
|
||||
webhook.token,
|
||||
channel.0
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| Error::SQLx(e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Err(e) => Err(Error::SQLx(e)),
|
||||
}?;
|
||||
|
||||
let row = sqlx::query!("SELECT id FROM channels WHERE channel = ?", channel.0)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| Error::SQLx(e))?;
|
||||
|
||||
Ok(row.id)
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
pub async fn dashboard_home(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
|
||||
if cookies.get_private("userid").is_some() {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Ok(Template::render("dashboard", &map))
|
||||
} else {
|
||||
Err(Redirect::to("/login/discord"))
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/<_>")]
|
||||
pub async fn dashboard(cookies: &CookieJar<'_>) -> Result<Template, Redirect> {
|
||||
if cookies.get_private("userid").is_some() {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Ok(Template::render("dashboard", &map))
|
||||
} else {
|
||||
Err(Redirect::to("/login/discord"))
|
||||
}
|
||||
}
|
168
web/src/routes/dashboard/user.rs
Normal file
@ -0,0 +1,168 @@
|
||||
use std::env;
|
||||
|
||||
use chrono_tz::Tz;
|
||||
use reqwest::Client;
|
||||
use rocket::{
|
||||
http::CookieJar,
|
||||
serde::json::{json, Json, Value as JsonValue},
|
||||
State,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serenity::{
|
||||
client::Context,
|
||||
model::{
|
||||
id::{GuildId, RoleId},
|
||||
permissions::Permissions,
|
||||
},
|
||||
};
|
||||
use sqlx::{MySql, Pool};
|
||||
|
||||
use crate::consts::DISCORD_API;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UserInfo {
|
||||
name: String,
|
||||
patreon: bool,
|
||||
timezone: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUser {
|
||||
timezone: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GuildInfo {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PartialGuild {
|
||||
pub id: GuildId,
|
||||
pub icon: Option<String>,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub owner: bool,
|
||||
#[serde(rename = "permissions_new")]
|
||||
pub permissions: Option<String>,
|
||||
}
|
||||
|
||||
#[get("/api/user")]
|
||||
pub async fn get_user_info(
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonValue {
|
||||
if let Some(user_id) =
|
||||
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
|
||||
{
|
||||
let member_res = GuildId(env::var("PATREON_GUILD_ID").unwrap().parse().unwrap())
|
||||
.member(&ctx.inner(), user_id)
|
||||
.await;
|
||||
|
||||
let timezone = sqlx::query!(
|
||||
"SELECT IFNULL(timezone, 'UTC') AS timezone FROM users WHERE user = ?",
|
||||
user_id
|
||||
)
|
||||
.fetch_one(pool.inner())
|
||||
.await
|
||||
.map_or(None, |q| Some(q.timezone));
|
||||
|
||||
let user_info = UserInfo {
|
||||
name: cookies
|
||||
.get_private("username")
|
||||
.map_or("DiscordUser#0000".to_string(), |c| c.value().to_string()),
|
||||
patreon: member_res.map_or(false, |member| {
|
||||
member
|
||||
.roles
|
||||
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
|
||||
}),
|
||||
timezone,
|
||||
};
|
||||
|
||||
json!(user_info)
|
||||
} else {
|
||||
json!({"error": "Not authorized"})
|
||||
}
|
||||
}
|
||||
|
||||
#[patch("/api/user", data = "<user>")]
|
||||
pub async fn update_user_info(
|
||||
cookies: &CookieJar<'_>,
|
||||
user: Json<UpdateUser>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonValue {
|
||||
if let Some(user_id) =
|
||||
cookies.get_private("userid").map(|u| u.value().parse::<u64>().ok()).flatten()
|
||||
{
|
||||
if user.timezone.parse::<Tz>().is_ok() {
|
||||
let _ = sqlx::query!(
|
||||
"UPDATE users SET timezone = ? WHERE user = ?",
|
||||
user.timezone,
|
||||
user_id,
|
||||
)
|
||||
.execute(pool.inner())
|
||||
.await;
|
||||
|
||||
json!({})
|
||||
} else {
|
||||
json!({"error": "Timezone not recognized"})
|
||||
}
|
||||
} else {
|
||||
json!({"error": "Not authorized"})
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/api/user/guilds")]
|
||||
pub async fn get_user_guilds(cookies: &CookieJar<'_>, reqwest_client: &State<Client>) -> JsonValue {
|
||||
if let Some(access_token) = cookies.get_private("access_token") {
|
||||
let request_res = reqwest_client
|
||||
.get(format!("{}/users/@me/guilds", DISCORD_API))
|
||||
.bearer_auth(access_token.value())
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match request_res {
|
||||
Ok(response) => {
|
||||
let guilds_res = response.json::<Vec<PartialGuild>>().await;
|
||||
|
||||
match guilds_res {
|
||||
Ok(guilds) => {
|
||||
let reduced_guilds = guilds
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
g.owner
|
||||
|| g.permissions.as_ref().map_or(false, |p| {
|
||||
let permissions =
|
||||
Permissions::from_bits_truncate(p.parse().unwrap());
|
||||
|
||||
permissions.manage_messages()
|
||||
|| permissions.manage_guild()
|
||||
|| permissions.administrator()
|
||||
})
|
||||
})
|
||||
.map(|g| GuildInfo { id: g.id.to_string(), name: g.name.to_string() })
|
||||
.collect::<Vec<GuildInfo>>();
|
||||
|
||||
json!(reduced_guilds)
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error constructing user from request: {:?}", e);
|
||||
|
||||
json!({"error": "Could not get user details"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error getting user guilds: {:?}", e);
|
||||
|
||||
json!({"error": "Could not reach Discord"})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
json!({"error": "Not authorized"})
|
||||
}
|
||||
}
|
148
web/src/routes/login.rs
Normal file
@ -0,0 +1,148 @@
|
||||
use log::warn;
|
||||
use oauth2::{
|
||||
basic::BasicClient, reqwest::async_http_client, AuthorizationCode, CsrfToken,
|
||||
PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use rocket::{
|
||||
http::{private::cookie::Expiration, Cookie, CookieJar, SameSite},
|
||||
response::{Flash, Redirect},
|
||||
uri, State,
|
||||
};
|
||||
use serenity::model::user::User;
|
||||
|
||||
use crate::consts::DISCORD_API;
|
||||
|
||||
#[get("/discord")]
|
||||
pub async fn discord_login(
|
||||
oauth2_client: &State<BasicClient>,
|
||||
cookies: &CookieJar<'_>,
|
||||
) -> Redirect {
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
|
||||
let (auth_url, csrf_token) = oauth2_client
|
||||
.authorize_url(CsrfToken::new_random)
|
||||
// Set the desired scopes.
|
||||
.add_scope(Scope::new("identify".to_string()))
|
||||
.add_scope(Scope::new("guilds".to_string()))
|
||||
// Set the PKCE code challenge.
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.url();
|
||||
|
||||
// store the pkce secret to verify the authorization later
|
||||
cookies.add_private(
|
||||
Cookie::build("verify", pkce_verifier.secret().to_string())
|
||||
.http_only(true)
|
||||
.path("/login")
|
||||
.same_site(SameSite::Lax)
|
||||
.expires(Expiration::Session)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// store the csrf token to verify no interference
|
||||
cookies.add_private(
|
||||
Cookie::build("csrf", csrf_token.secret().to_string())
|
||||
.http_only(true)
|
||||
.path("/login")
|
||||
.same_site(SameSite::Lax)
|
||||
.expires(Expiration::Session)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
Redirect::to(auth_url.to_string())
|
||||
}
|
||||
|
||||
#[get("/discord/authorized?<code>&<state>")]
|
||||
pub async fn discord_callback(
|
||||
code: &str,
|
||||
state: &str,
|
||||
cookies: &CookieJar<'_>,
|
||||
oauth2_client: &State<BasicClient>,
|
||||
reqwest_client: &State<Client>,
|
||||
) -> Result<Redirect, Flash<Redirect>> {
|
||||
if let (Some(pkce_secret), Some(csrf_token)) =
|
||||
(cookies.get_private("verify"), cookies.get_private("csrf"))
|
||||
{
|
||||
if state == csrf_token.value() {
|
||||
let token_result = oauth2_client
|
||||
.exchange_code(AuthorizationCode::new(code.to_string()))
|
||||
// Set the PKCE code verifier.
|
||||
.set_pkce_verifier(PkceCodeVerifier::new(pkce_secret.value().to_string()))
|
||||
.request_async(async_http_client)
|
||||
.await;
|
||||
|
||||
cookies.remove_private(Cookie::named("verify"));
|
||||
cookies.remove_private(Cookie::named("csrf"));
|
||||
|
||||
match token_result {
|
||||
Ok(token) => {
|
||||
cookies.add_private(
|
||||
Cookie::build("access_token", token.access_token().secret().to_string())
|
||||
.secure(true)
|
||||
.http_only(true)
|
||||
.path("/dashboard")
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let request_res = reqwest_client
|
||||
.get(format!("{}/users/@me", DISCORD_API))
|
||||
.bearer_auth(token.access_token().secret())
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match request_res {
|
||||
Ok(response) => {
|
||||
let user_res = response.json::<User>().await;
|
||||
|
||||
match user_res {
|
||||
Ok(user) => {
|
||||
let user_name = format!("{}#{}", user.name, user.discriminator);
|
||||
let user_id = user.id.as_u64().to_string();
|
||||
|
||||
cookies.add_private(Cookie::new("username", user_name));
|
||||
cookies.add_private(Cookie::new("userid", user_id));
|
||||
|
||||
Ok(Redirect::to(uri!(super::return_to_same_site("dashboard"))))
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error constructing user from request: {:?}", e);
|
||||
|
||||
Err(Flash::new(
|
||||
Redirect::to(uri!(super::return_to_same_site(""))),
|
||||
"danger",
|
||||
"Failed to contact Discord",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error getting user info: {:?}", e);
|
||||
|
||||
Err(Flash::new(
|
||||
Redirect::to(uri!(super::return_to_same_site(""))),
|
||||
"danger",
|
||||
"Failed to contact Discord",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error in discord callback: {:?}", e);
|
||||
|
||||
Err(Flash::new(
|
||||
Redirect::to(uri!(super::return_to_same_site(""))),
|
||||
"warning",
|
||||
"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 (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 (error: CSRF Validation Tokens Missing)"))
|
||||
}
|
||||
}
|
106
web/src/routes/mod.rs
Normal file
@ -0,0 +1,106 @@
|
||||
pub mod dashboard;
|
||||
pub mod login;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rocket::request::FlashMessage;
|
||||
use rocket_dyn_templates::Template;
|
||||
|
||||
#[get("/")]
|
||||
pub async fn index(flash: Option<FlashMessage<'_>>) -> Template {
|
||||
let mut map: HashMap<&str, String> = HashMap::new();
|
||||
|
||||
if let Some(message) = flash {
|
||||
map.insert("flashed_message", message.message().to_string());
|
||||
map.insert("flashed_grade", message.kind().to_string());
|
||||
}
|
||||
|
||||
Template::render("index", &map)
|
||||
}
|
||||
|
||||
#[get("/ret?<to>")]
|
||||
pub async fn return_to_same_site(to: &str) -> Template {
|
||||
let mut map: HashMap<&str, String> = HashMap::new();
|
||||
|
||||
map.insert("to", to.to_string());
|
||||
|
||||
Template::render("return", &map)
|
||||
}
|
||||
|
||||
#[get("/cookies")]
|
||||
pub async fn cookies() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("cookies", &map)
|
||||
}
|
||||
|
||||
#[get("/privacy")]
|
||||
pub async fn privacy() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("privacy", &map)
|
||||
}
|
||||
|
||||
#[get("/terms")]
|
||||
pub async fn terms() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("terms", &map)
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
pub async fn help() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("help", &map)
|
||||
}
|
||||
|
||||
#[get("/timezone")]
|
||||
pub async fn help_timezone() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/timezone", &map)
|
||||
}
|
||||
|
||||
#[get("/create_reminder")]
|
||||
pub async fn help_create_reminder() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/create_reminder", &map)
|
||||
}
|
||||
|
||||
#[get("/delete_reminder")]
|
||||
pub async fn help_delete_reminder() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/delete_reminder", &map)
|
||||
}
|
||||
|
||||
#[get("/timers")]
|
||||
pub async fn help_timers() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/timers", &map)
|
||||
}
|
||||
|
||||
#[get("/todo_lists")]
|
||||
pub async fn help_todo_lists() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/todo_lists", &map)
|
||||
}
|
||||
|
||||
#[get("/macros")]
|
||||
pub async fn help_macros() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/macros", &map)
|
||||
}
|
||||
|
||||
#[get("/intervals")]
|
||||
pub async fn help_intervals() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/intervals", &map)
|
||||
}
|
||||
|
||||
#[get("/dashboard")]
|
||||
pub async fn help_dashboard() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/dashboard", &map)
|
||||
}
|
||||
|
||||
#[get("/iemanager")]
|
||||
pub async fn help_iemanager() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/iemanager", &map)
|
||||
}
|
1
web/static/css/bulma.min.css
vendored
Normal file
91
web/static/css/dtsel.css
Normal file
@ -0,0 +1,91 @@
|
||||
.date-selector-wrapper {
|
||||
width: 200px;
|
||||
padding: 3px;
|
||||
background-color: #fff;
|
||||
box-shadow: 1px 1px 10px 1px #5c5c5c;
|
||||
position: absolute;
|
||||
font-size: 12px;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-o-user-select: none;
|
||||
/* user-select: none; */
|
||||
}
|
||||
.cal-header, .cal-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.cal-cell, .cal-nav {
|
||||
cursor: pointer;
|
||||
}
|
||||
.cal-day-names {
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
}
|
||||
.cal-day-names .cal-cell {
|
||||
cursor: default;
|
||||
font-weight: bold;
|
||||
}
|
||||
.cal-cell-prev, .cal-cell-next {
|
||||
color: #777;
|
||||
}
|
||||
.cal-months .cal-row, .cal-years .cal-row {
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
}
|
||||
.cal-nav-prev, .cal-nav-next {
|
||||
flex: 0.15;
|
||||
}
|
||||
.cal-nav-current {
|
||||
flex: 0.75;
|
||||
font-weight: bold;
|
||||
}
|
||||
.cal-months .cal-cell, .cal-years .cal-cell {
|
||||
flex: 0.25;
|
||||
}
|
||||
.cal-days .cal-cell {
|
||||
flex: 0.143;
|
||||
}
|
||||
.cal-value {
|
||||
color: #fff;
|
||||
background-color: #286090;
|
||||
}
|
||||
.cal-cell:hover, .cal-nav:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
.cal-value:hover {
|
||||
background-color: #204d74;
|
||||
}
|
||||
|
||||
/* time footer */
|
||||
.cal-time {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
height: 27px;
|
||||
line-height: 27px;
|
||||
}
|
||||
.cal-time-label, .cal-time-value {
|
||||
flex: 0.12;
|
||||
text-align: center;
|
||||
}
|
||||
.cal-time-slider {
|
||||
flex: 0.77;
|
||||
background-image: linear-gradient(to right, #d1d8dd, #d1d8dd);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 1px;
|
||||
background-position: left 50%;
|
||||
height: 100%;
|
||||
}
|
||||
.cal-time-slider input {
|
||||
width: 100%;
|
||||
-webkit-appearance: none;
|
||||
background: 0 0;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
outline: 0;
|
||||
user-select: auto;
|
||||
}
|
12749
web/static/css/fa.css
Normal file
63
web/static/css/font.css
Normal file
@ -0,0 +1,63 @@
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkids18E.ttf) format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7nsDc.ttf) format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCds18E.ttf) format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwlxdr.ttf) format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK3dSBYKcSV-LCoeQqfX1RYOo3qOK7g.ttf) format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwlxdr.ttf) format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlxdr.ttf) format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCs6KVjbNBYlgo6eA.ttf) format('truetype');
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Ubuntu';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCv6KVjbNBYlgoCxCvTtw.ttf) format('truetype');
|
||||
font-display: swap;
|
||||
}
|
582
web/static/css/style.css
Normal file
@ -0,0 +1,582 @@
|
||||
* {
|
||||
font-family: "Ubuntu Bold", "Ubuntu", sans-serif;
|
||||
}
|
||||
|
||||
button {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* override styles for when the div is collapsed */
|
||||
div.reminderContent.is-collapsed .column.discord-frame {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.reminderContent.is-collapsed .collapses {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.reminderContent.is-collapsed .invert-collapses {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
div.reminderContent .invert-collapses {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.reminderContent.is-collapsed .settings {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
div.reminderContent.is-collapsed .channel-field {
|
||||
display: inline-flex;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
div.reminderContent.is-collapsed .reminder-topbar {
|
||||
display: inline-flex;
|
||||
margin-bottom: 0px;
|
||||
flex-grow: 1;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
div.reminderContent.is-collapsed input[name="name"] {
|
||||
display: inline-flex;
|
||||
flex-grow: 1;
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
background: none;
|
||||
}
|
||||
|
||||
div.reminderContent.is-collapsed button.hide-box {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
div.reminderContent.is-collapsed button.hide-box i {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
/* END */
|
||||
|
||||
/* dashboard styles */
|
||||
button.inline-btn {
|
||||
height: 100%;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
button.change-color {
|
||||
position: absolute;
|
||||
left: calc(-1rem - 40px);
|
||||
}
|
||||
|
||||
button.disable-enable[data-action="enable"]:after {
|
||||
content: "Enable";
|
||||
}
|
||||
|
||||
button.disable-enable[data-action="disable"]:after {
|
||||
content: "Disable";
|
||||
}
|
||||
|
||||
.media-content {
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
div.discord-embed {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div.reminderContent {
|
||||
padding: 2px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
div.interval-group > button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Interval inputs */
|
||||
div.interval-group > .interval-group-left input {
|
||||
-webkit-appearance: none;
|
||||
border-style: none;
|
||||
background-color: #eee;
|
||||
font-size: 1rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
div.interval-group > .interval-group-left input.w2 {
|
||||
width: 3ch;
|
||||
}
|
||||
|
||||
div.interval-group > .interval-group-left input.w3 {
|
||||
width: 6ch;
|
||||
}
|
||||
|
||||
div.interval-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
/* !Interval inputs */
|
||||
|
||||
.left-pad {
|
||||
padding-left: 1rem;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.notification {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
div.inset-content {
|
||||
margin-left: 10%;
|
||||
margin-right: 10%;
|
||||
}
|
||||
|
||||
div.flash-message {
|
||||
position: fixed;
|
||||
width: calc(100% - 32px);
|
||||
margin: 16px !important;
|
||||
z-index: 99;
|
||||
bottom: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.flash-message.is-active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
span.spacer {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
nav .dashboard-button {
|
||||
background: white ;
|
||||
}
|
||||
|
||||
span.patreon-color {
|
||||
color: #f96854;
|
||||
}
|
||||
|
||||
p.pageTitle {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
#welcome > div {
|
||||
height: 100%;
|
||||
padding-top: 30vh;
|
||||
}
|
||||
|
||||
div#pageNavbar {
|
||||
background-color: #363636;
|
||||
}
|
||||
|
||||
div#pageNavbar a {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div#pageNavbar a:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
img.rounded-corners {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
div.brand {
|
||||
text-align: center;
|
||||
height: 52px;
|
||||
background-color: #8fb677;
|
||||
}
|
||||
|
||||
img.dashboard-brand {
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
div.dashboard-sidebar {
|
||||
background-color: #363636;
|
||||
width: 230px !important;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
div.dashboard-sidebar:not(.mobile-sidebar) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 226px;
|
||||
}
|
||||
|
||||
div.mobile-sidebar {
|
||||
z-index: 100;
|
||||
min-height: 100vh;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#expandAll {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
div.mobile-sidebar .aside-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
div.mobile-sidebar.is-active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
aside.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
div.dashboard-frame {
|
||||
min-height: 100vh;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.embed-field-box[data-inlined="0"] .inline-btn > i {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.embed-field-box[data-inlined="0"] {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.embed-field-box[data-inlined="1"] {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.menu a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.menu .menu-label {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.menu {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.dashboard-navbar {
|
||||
background-color: #8fb677 !important;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea.autoresize {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
textarea, input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input.default-width {
|
||||
width: initial;
|
||||
}
|
||||
|
||||
.message-input:placeholder-shown {
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom-style: dashed;
|
||||
background-color: #40444b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
border: none;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.time-input {
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom-style: solid;
|
||||
background-color: #40444b;
|
||||
color: #fff;
|
||||
width: 120px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
||||
.message-input::placeholder {
|
||||
color: #72767b;
|
||||
}
|
||||
|
||||
.discord-title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
margin: 4px 0 4px 0;
|
||||
}
|
||||
|
||||
.discord-description {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.discord-username {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
width: initial;
|
||||
}
|
||||
|
||||
.discord-message-header {
|
||||
white-space: nowrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.discord-content {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.customizable img {
|
||||
background-color: #72767b;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.customizable.is-20x20 img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.customizable.is-24x24 img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.customizable.is-400x300 img {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.customizable.is-32x32 img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.customizable.thumbnail img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.customizable input.imageInput {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 36px;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.customizable.thumbnail input.imageInput {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -400px;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.customizable input.is-active {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.discord-frame {
|
||||
color: #fff;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background-color: #36393f;
|
||||
}
|
||||
|
||||
.discord-embed {
|
||||
padding: 8px 16px 16px 12px;
|
||||
margin: 0 20px 4px 0;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #fff;
|
||||
background-color: #2f3136;
|
||||
}
|
||||
|
||||
.embed-author-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.embed-author-box > .a {
|
||||
flex: initial;
|
||||
}
|
||||
|
||||
.embed-author-box > .b {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.embed-footer-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.embed-author-box .image {
|
||||
margin: 0 8px 0 0 !important;
|
||||
}
|
||||
|
||||
.embed-footer-box .image {
|
||||
margin: 0 8px 0 0 !important;
|
||||
}
|
||||
|
||||
.discord-embed-author {
|
||||
display: inline-block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.discord-embed-footer {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.embed-body {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.embed-body > .a {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.embed-body input, .embed-body textarea {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.embed-body > .b {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.discord-field-title, .discord-field-value {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.discord-field-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.embed-field-box {
|
||||
margin: 12px 8px 0 0;
|
||||
max-width: 120px;
|
||||
flex: initial;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
font-size: 0.875rem;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.embed-multifield-box {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.channel-select {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 4px;
|
||||
margin-left: 48px;
|
||||
display: inline-flex;
|
||||
font-weight: bold;
|
||||
color: #6e89da;
|
||||
width: auto;
|
||||
border-radius: 2px;
|
||||
border-bottom: 1px solid #fff;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.customizable.thumbnail img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.customizable.is-24x24 img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* loader */
|
||||
#loader {
|
||||
position: fixed;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
width: 100vw;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
#loader .title {
|
||||
font-size: 6rem;
|
||||
}
|
||||
|
||||
/* END */
|
||||
|
||||
/* other stuff */
|
||||
|
||||
.half-rem {
|
||||
width: 0.5rem;
|
||||
}
|
||||
|
||||
.pad-left {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
#dead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.colorpicker-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.create-reminder {
|
||||
margin: 0 12px 12px 12px;
|
||||
}
|
||||
|
||||
.button.is-success:not(.is-outlined) {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.is-outlined.is-success {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.is-locked {
|
||||
pointer-events: none;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.is-locked .foreground {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.is-locked .field:last-of-type {
|
||||
display: none;
|
||||
}
|
BIN
web/static/favicon/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 7.2 KiB |
BIN
web/static/favicon/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
web/static/favicon/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
9
web/static/favicon/browserconfig.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
BIN
web/static/favicon/favicon-16x16.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
web/static/favicon/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
web/static/favicon/favicon.ico
Normal file
After Width: | Height: | Size: 7.2 KiB |
BIN
web/static/favicon/mstile-150x150.png
Normal file
After Width: | Height: | Size: 4.9 KiB |
19
web/static/favicon/site.webmanifest
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
BIN
web/static/img/bg.webp
Normal file
After Width: | Height: | Size: 762 B |
BIN
web/static/img/icon.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
web/static/img/logo_flat.jpg
Normal file
After Width: | Height: | Size: 323 KiB |
BIN
web/static/img/logo_flat.webp
Normal file
After Width: | Height: | Size: 61 KiB |
BIN
web/static/img/slash-commands.png
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
web/static/img/support/delete_reminder/1.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
web/static/img/support/delete_reminder/2.png
Normal file
After Width: | Height: | Size: 55 KiB |
BIN
web/static/img/support/delete_reminder/3.png
Normal file
After Width: | Height: | Size: 3.6 KiB |
BIN
web/static/img/support/delete_reminder/cancel-1.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
web/static/img/support/delete_reminder/cancel-2.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
web/static/img/support/delete_reminder/cmd-1.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
web/static/img/support/delete_reminder/cmd-2.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
web/static/img/support/iemanager/edit_spreadsheet.png
Normal file
After Width: | Height: | Size: 44 KiB |
BIN
web/static/img/support/iemanager/format_text.png
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
web/static/img/support/iemanager/import.png
Normal file
After Width: | Height: | Size: 44 KiB |
BIN
web/static/img/support/iemanager/select_export.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
web/static/img/support/iemanager/sheets_settings.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
web/static/img/tournament-demo.png
Normal file
After Width: | Height: | Size: 65 KiB |
931
web/static/js/dtsel.js
Normal file
@ -0,0 +1,931 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var BODYTYPES = ["DAYS", "MONTHS", "YEARS"];
|
||||
var MONTHS = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"
|
||||
];
|
||||
var WEEKDAYS = [
|
||||
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
|
||||
];
|
||||
|
||||
/** @typedef {Object.<string, Function[]>} Handlers */
|
||||
/** @typedef {function(String, Function): null} AddHandler */
|
||||
/** @typedef {("DAYS"|"MONTHS"|"YEARS")} BodyType */
|
||||
/** @typedef {string|number} StringNum */
|
||||
/** @typedef {Object.<string, StringNum>} StringNumObj */
|
||||
|
||||
/**
|
||||
* The local state
|
||||
* @typedef {Object} InstanceState
|
||||
* @property {Date} value
|
||||
* @property {Number} year
|
||||
* @property {Number} month
|
||||
* @property {Number} day
|
||||
* @property {Number} time
|
||||
* @property {Number} hours
|
||||
* @property {Number} minutes
|
||||
* @property {Number} seconds
|
||||
* @property {BodyType} bodyType
|
||||
* @property {Boolean} visible
|
||||
* @property {Number} cancelBlur
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Config
|
||||
* @property {String} dateFormat
|
||||
* @property {String} timeFormat
|
||||
* @property {Boolean} showDate
|
||||
* @property {Boolean} showTime
|
||||
* @property {Number} paddingX
|
||||
* @property {Number} paddingY
|
||||
* @property {BodyType} defaultView
|
||||
* @property {"TOP"|"BOTTOM"} direction
|
||||
*/
|
||||
|
||||
/**
|
||||
* @class
|
||||
* @param {HTMLElement} elem
|
||||
* @param {Config} config
|
||||
*/
|
||||
function DTS(elem, config) {
|
||||
var config = config || {};
|
||||
|
||||
/** @type {Config} */
|
||||
var defaultConfig = {
|
||||
defaultView: BODYTYPES[0],
|
||||
dateFormat: "yyyy-mm-dd",
|
||||
timeFormat: "HH:MM:SS",
|
||||
showDate: true,
|
||||
showTime: false,
|
||||
paddingX: 5,
|
||||
paddingY: 5,
|
||||
direction: 'TOP'
|
||||
}
|
||||
|
||||
if (!elem) {
|
||||
throw TypeError("input element or selector required for contructor");
|
||||
}
|
||||
if (Object.getPrototypeOf(elem) === String.prototype) {
|
||||
var _elem = document.querySelectorAll(elem);
|
||||
if (!_elem[0]){
|
||||
throw Error('"' + elem + '" not found.');
|
||||
}
|
||||
elem = _elem[0];
|
||||
}
|
||||
this.config = setDefaults(config, defaultConfig);
|
||||
this.dateFormat = this.config.dateFormat;
|
||||
this.timeFormat = this.config.timeFormat;
|
||||
this.dateFormatRegEx = new RegExp("yyyy|yy|mm|dd", "gi");
|
||||
this.timeFormatRegEx = new RegExp("hh|mm|ss|a", "gi");
|
||||
this.inputElem = elem;
|
||||
this.dtbox = null;
|
||||
this.setup();
|
||||
}
|
||||
DTS.prototype.setup = function () {
|
||||
var handler = this.inputElemHandler.bind(this);
|
||||
this.inputElem.addEventListener("focus", handler, false)
|
||||
this.inputElem.addEventListener("blur", handler, false);
|
||||
}
|
||||
DTS.prototype.inputElemHandler = function (e) {
|
||||
if (e.type == "focus") {
|
||||
if (!this.dtbox) {
|
||||
this.dtbox = new DTBox(e.target, this);
|
||||
}
|
||||
this.dtbox.visible = true;
|
||||
} else if (e.type == "blur" && this.dtbox && this.dtbox.visible) {
|
||||
var self = this;
|
||||
setTimeout(function () {
|
||||
if (self.dtbox.cancelBlur > 0) {
|
||||
self.dtbox.cancelBlur -= 1;
|
||||
} else {
|
||||
self.dtbox.visible = false;
|
||||
self.inputElem.blur();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @class
|
||||
* @param {HTMLElement} elem
|
||||
* @param {DTS} settings
|
||||
*/
|
||||
function DTBox(elem, settings) {
|
||||
/** @type {DTBox} */
|
||||
var self = this;
|
||||
|
||||
/** @type {Handlers} */
|
||||
var handlers = {};
|
||||
|
||||
/** @type {InstanceState} */
|
||||
var localState = {};
|
||||
|
||||
/**
|
||||
* @param {String} key
|
||||
* @param {*} default_val
|
||||
*/
|
||||
function getterSetter(key, default_val) {
|
||||
return {
|
||||
get: function () {
|
||||
var val = localState[key];
|
||||
return val === undefined ? default_val : val;
|
||||
},
|
||||
set: function (val) {
|
||||
var prevState = self.state;
|
||||
var _handlers = handlers[key] || [];
|
||||
localState[key] = val;
|
||||
for (var i = 0; i < _handlers.length; i++) {
|
||||
_handlers[i].bind(self)(localState, prevState);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** @type {AddHandler} */
|
||||
function addHandler(key, handlerFn) {
|
||||
if (!key || !handlerFn) {
|
||||
return false;
|
||||
}
|
||||
if (!handlers[key]) {
|
||||
handlers[key] = [];
|
||||
}
|
||||
handlers[key].push(handlerFn);
|
||||
}
|
||||
|
||||
Object.defineProperties(this, {
|
||||
visible: getterSetter("visible", false),
|
||||
bodyType: getterSetter("bodyType", settings.config.defaultView),
|
||||
value: getterSetter("value"),
|
||||
year: getterSetter("year", 0),
|
||||
month: getterSetter("month", 0),
|
||||
day: getterSetter("day", 0),
|
||||
hours: getterSetter("hours", 0),
|
||||
minutes: getterSetter("minutes", 0),
|
||||
seconds: getterSetter("seconds", 0),
|
||||
cancelBlur: getterSetter("cancelBlur", 0),
|
||||
addHandler: {value: addHandler},
|
||||
month_long: {
|
||||
get: function () {
|
||||
return MONTHS[self.month];
|
||||
},
|
||||
},
|
||||
month_short: {
|
||||
get: function () {
|
||||
return self.month_long.slice(0, 3);
|
||||
},
|
||||
},
|
||||
state: {
|
||||
get: function () {
|
||||
return Object.assign({}, localState);
|
||||
},
|
||||
},
|
||||
time: {
|
||||
get: function() {
|
||||
var hours = self.hours * 60 * 60 * 1000;
|
||||
var minutes = self.minutes * 60 * 1000;
|
||||
var seconds = self.seconds * 1000;
|
||||
return hours + minutes + seconds;
|
||||
}
|
||||
},
|
||||
});
|
||||
this.el = {};
|
||||
this.settings = settings;
|
||||
this.elem = elem;
|
||||
this.setup();
|
||||
}
|
||||
DTBox.prototype.setup = function () {
|
||||
Object.defineProperties(this.el, {
|
||||
wrapper: { value: null, configurable: true },
|
||||
header: { value: null, configurable: true },
|
||||
body: { value: null, configurable: true },
|
||||
footer: { value: null, configurable: true }
|
||||
});
|
||||
this.setupWrapper();
|
||||
if (this.settings.config.showDate) {
|
||||
this.setupHeader();
|
||||
this.setupBody();
|
||||
}
|
||||
if (this.settings.config.showTime) {
|
||||
this.setupFooter();
|
||||
}
|
||||
|
||||
var self = this;
|
||||
this.addHandler("visible", function (state, prevState) {
|
||||
if (state.visible && !prevState.visible){
|
||||
document.body.appendChild(this.el.wrapper);
|
||||
|
||||
var parts = self.elem.value.split(/\s*,\s*/);
|
||||
var startDate = undefined;
|
||||
var startTime = 0;
|
||||
if (self.settings.config.showDate) {
|
||||
startDate = parseDate(parts[0], self.settings);
|
||||
}
|
||||
if (self.settings.config.showTime) {
|
||||
startTime = parseTime(parts[parts.length-1], self.settings);
|
||||
startTime = startTime || 0;
|
||||
}
|
||||
if (!(startDate && startDate.getTime())) {
|
||||
startDate = new Date();
|
||||
startDate = new Date(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate()
|
||||
);
|
||||
}
|
||||
var value = new Date(startDate.getTime() + startTime);
|
||||
self.value = value;
|
||||
self.year = value.getFullYear();
|
||||
self.month = value.getMonth();
|
||||
self.day = value.getDate();
|
||||
self.hours = value.getHours();
|
||||
self.minutes = value.getMinutes();
|
||||
self.seconds = value.getSeconds();
|
||||
|
||||
if (self.settings.config.showDate) {
|
||||
self.setHeaderContent();
|
||||
self.setBodyContent();
|
||||
}
|
||||
if (self.settings.config.showTime) {
|
||||
self.setFooterContent();
|
||||
}
|
||||
} else if (!state.visible && prevState.visible) {
|
||||
document.body.removeChild(this.el.wrapper);
|
||||
}
|
||||
});
|
||||
}
|
||||
DTBox.prototype.setupWrapper = function () {
|
||||
if (!this.el.wrapper) {
|
||||
var el = document.createElement("div");
|
||||
el.classList.add("date-selector-wrapper");
|
||||
Object.defineProperty(this.el, "wrapper", { value: el });
|
||||
}
|
||||
var self = this;
|
||||
var htmlRoot = document.getElementsByTagName('html')[0];
|
||||
function setPosition(e){
|
||||
var minTopSpace = 300;
|
||||
var box = getOffset(self.elem);
|
||||
var config = self.settings.config;
|
||||
var paddingY = config.paddingY || 5;
|
||||
var paddingX = config.paddingX || 5;
|
||||
var top = box.top + self.elem.offsetHeight + paddingY;
|
||||
var left = box.left + paddingX;
|
||||
var bottom = htmlRoot.clientHeight - box.top + paddingY;
|
||||
|
||||
self.el.wrapper.style.left = `${left}px`;
|
||||
if (box.top > minTopSpace && config.direction != 'BOTTOM') {
|
||||
self.el.wrapper.style.bottom = `${bottom}px`;
|
||||
self.el.wrapper.style.top = '';
|
||||
} else {
|
||||
self.el.wrapper.style.top = `${top}px`;
|
||||
self.el.wrapper.style.bottom = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handler(e) {
|
||||
self.cancelBlur += 1;
|
||||
setTimeout(function(){
|
||||
self.elem.focus();
|
||||
}, 50);
|
||||
}
|
||||
setPosition();
|
||||
this.setPosition = setPosition;
|
||||
this.el.wrapper.addEventListener("mousedown", handler, false);
|
||||
this.el.wrapper.addEventListener("touchstart", handler, false);
|
||||
window.addEventListener('resize', this.setPosition);
|
||||
}
|
||||
DTBox.prototype.setupHeader = function () {
|
||||
if (!this.el.header) {
|
||||
var row = document.createElement("div");
|
||||
var classes = ["cal-nav-prev", "cal-nav-current", "cal-nav-next"];
|
||||
row.classList.add("cal-header");
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var cell = document.createElement("div");
|
||||
cell.classList.add("cal-nav", classes[i]);
|
||||
cell.onclick = this.onHeaderChange.bind(this);
|
||||
row.appendChild(cell);
|
||||
}
|
||||
row.children[0].innerHTML = "<";
|
||||
row.children[2].innerHTML = ">";
|
||||
Object.defineProperty(this.el, "header", { value: row });
|
||||
tryAppendChild(row, this.el.wrapper);
|
||||
}
|
||||
this.setHeaderContent();
|
||||
}
|
||||
DTBox.prototype.setHeaderContent = function () {
|
||||
var content = this.year;
|
||||
if ("DAYS" == this.bodyType) {
|
||||
content = this.month_long + " " + content;
|
||||
} else if ("YEARS" == this.bodyType) {
|
||||
var start = this.year + 10 - (this.year % 10);
|
||||
content = start - 10 + "-" + (start - 1);
|
||||
}
|
||||
this.el.header.children[1].innerText = content;
|
||||
}
|
||||
DTBox.prototype.setupBody = function () {
|
||||
if (!this.el.body) {
|
||||
var el = document.createElement("div");
|
||||
el.classList.add("cal-body");
|
||||
Object.defineProperty(this.el, "body", { value: el });
|
||||
tryAppendChild(el, this.el.wrapper);
|
||||
}
|
||||
var toAppend = null;
|
||||
function makeGrid(rows, cols, className, firstRowClass, clickHandler) {
|
||||
var grid = document.createElement("div");
|
||||
grid.classList.add(className);
|
||||
for (var i = 1; i < rows + 1; i++) {
|
||||
var row = document.createElement("div");
|
||||
row.classList.add("cal-row", "cal-row-" + i);
|
||||
if (i == 1 && firstRowClass) {
|
||||
row.classList.add(firstRowClass);
|
||||
}
|
||||
for (var j = 1; j < cols + 1; j++) {
|
||||
var col = document.createElement("div");
|
||||
col.classList.add("cal-cell", "cal-col-" + j);
|
||||
col.onclick = clickHandler;
|
||||
row.appendChild(col);
|
||||
}
|
||||
grid.appendChild(row);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
if ("DAYS" == this.bodyType) {
|
||||
toAppend = this.el.body.calDays;
|
||||
if (!toAppend) {
|
||||
toAppend = makeGrid(7, 7, "cal-days", "cal-day-names", this.onDateSelected.bind(this));
|
||||
for (var i = 0; i < 7; i++) {
|
||||
var cell = toAppend.children[0].children[i];
|
||||
cell.innerText = WEEKDAYS[i].slice(0, 2);
|
||||
cell.onclick = null;
|
||||
}
|
||||
this.el.body.calDays = toAppend;
|
||||
}
|
||||
} else if ("MONTHS" == this.bodyType) {
|
||||
toAppend = this.el.body.calMonths;
|
||||
if (!toAppend) {
|
||||
toAppend = makeGrid(3, 4, "cal-months", null, this.onMonthSelected.bind(this));
|
||||
for (var i = 0; i < 3; i++) {
|
||||
for (var j = 0; j < 4; j++) {
|
||||
var monthShort = MONTHS[4 * i + j].slice(0, 3);
|
||||
toAppend.children[i].children[j].innerText = monthShort;
|
||||
}
|
||||
}
|
||||
this.el.body.calMonths = toAppend;
|
||||
}
|
||||
} else if ("YEARS" == this.bodyType) {
|
||||
toAppend = this.el.body.calYears;
|
||||
if (!toAppend) {
|
||||
toAppend = makeGrid(3, 4, "cal-years", null, this.onYearSelected.bind(this));
|
||||
this.el.body.calYears = toAppend;
|
||||
}
|
||||
}
|
||||
empty(this.el.body);
|
||||
tryAppendChild(toAppend, this.el.body);
|
||||
this.setBodyContent();
|
||||
}
|
||||
DTBox.prototype.setBodyContent = function () {
|
||||
var grid = this.el.body.children[0];
|
||||
var classes = ["cal-cell-prev", "cal-cell-next", "cal-value"];
|
||||
if ("DAYS" == this.bodyType) {
|
||||
var oneDayMilliSecs = 24 * 60 * 60 * 1000;
|
||||
var start = new Date(this.year, this.month, 1);
|
||||
var adjusted = new Date(start.getTime() - oneDayMilliSecs * start.getDay());
|
||||
|
||||
grid.children[6].style.display = "";
|
||||
for (var i = 1; i < 7; i++) {
|
||||
for (var j = 0; j < 7; j++) {
|
||||
var cell = grid.children[i].children[j];
|
||||
var month = adjusted.getMonth();
|
||||
var date = adjusted.getDate();
|
||||
|
||||
cell.innerText = date;
|
||||
cell.classList.remove(classes[0], classes[1], classes[2]);
|
||||
if (month != this.month) {
|
||||
if (i == 6 && j == 0) {
|
||||
grid.children[6].style.display = "none";
|
||||
break;
|
||||
}
|
||||
cell.classList.add(month < this.month ? classes[0] : classes[1]);
|
||||
} else if (isEqualDate(adjusted, this.value)){
|
||||
cell.classList.add(classes[2]);
|
||||
}
|
||||
adjusted = new Date(adjusted.getTime() + oneDayMilliSecs);
|
||||
}
|
||||
}
|
||||
} else if ("YEARS" == this.bodyType) {
|
||||
var year = this.year - (this.year % 10) - 1;
|
||||
for (i = 0; i < 3; i++) {
|
||||
for (j = 0; j < 4; j++) {
|
||||
grid.children[i].children[j].innerText = year;
|
||||
year += 1;
|
||||
}
|
||||
}
|
||||
grid.children[0].children[0].classList.add(classes[0]);
|
||||
grid.children[2].children[3].classList.add(classes[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Event} e */
|
||||
DTBox.prototype.onTimeChange = function(e) {
|
||||
e.stopPropagation();
|
||||
if (e.type == 'mousedown') {
|
||||
this.cancelBlur += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
var el = e.target;
|
||||
this[el.name] = parseInt(el.value) || 0;
|
||||
this.setupFooter();
|
||||
if (e.type == 'change') {
|
||||
var self = this;
|
||||
setTimeout(function(){
|
||||
self.elem.focus();
|
||||
}, 50);
|
||||
}
|
||||
this.setInputValue();
|
||||
}
|
||||
|
||||
DTBox.prototype.setupFooter = function() {
|
||||
if (!this.el.footer) {
|
||||
var footer = document.createElement("div");
|
||||
var handler = this.onTimeChange.bind(this);
|
||||
var self = this;
|
||||
|
||||
function makeRow(label, name, range, changeHandler) {
|
||||
var row = document.createElement("div");
|
||||
row.classList.add('cal-time');
|
||||
|
||||
var labelCol = row.appendChild(document.createElement("div"));
|
||||
labelCol.classList.add('cal-time-label');
|
||||
labelCol.innerText = label;
|
||||
|
||||
var valueCol = row.appendChild(document.createElement("div"));
|
||||
valueCol.classList.add('cal-time-value');
|
||||
valueCol.innerText = '00';
|
||||
|
||||
var inputCol = row.appendChild(document.createElement("div"));
|
||||
var slider = inputCol.appendChild(document.createElement("input"));
|
||||
Object.assign(slider, {step:1, min:0, max:range, name:name, type:'range'});
|
||||
Object.defineProperty(footer, name, {value: slider});
|
||||
inputCol.classList.add('cal-time-slider');
|
||||
slider.onchange = changeHandler;
|
||||
slider.oninput = changeHandler;
|
||||
slider.onmousedown = changeHandler;
|
||||
self[name] = self[name] || parseInt(slider.value) || 0;
|
||||
footer.appendChild(row)
|
||||
}
|
||||
makeRow('HH:', 'hours', 23, handler);
|
||||
makeRow('MM:', 'minutes', 59, handler);
|
||||
makeRow('SS:', 'seconds', 59, handler);
|
||||
|
||||
footer.classList.add("cal-footer");
|
||||
Object.defineProperty(this.el, "footer", { value: footer });
|
||||
tryAppendChild(footer, this.el.wrapper);
|
||||
}
|
||||
this.setFooterContent();
|
||||
}
|
||||
|
||||
DTBox.prototype.setFooterContent = function() {
|
||||
if (this.el.footer) {
|
||||
var footer = this.el.footer;
|
||||
footer.hours.value = this.hours;
|
||||
footer.children[0].children[1].innerText = padded(this.hours, 2);
|
||||
footer.minutes.value = this.minutes;
|
||||
footer.children[1].children[1].innerText = padded(this.minutes, 2);
|
||||
footer.seconds.value = this.seconds;
|
||||
footer.children[2].children[1].innerText = padded(this.seconds, 2);
|
||||
}
|
||||
}
|
||||
|
||||
DTBox.prototype.setInputValue = function() {
|
||||
var date = new Date(this.year, this.month, this.day);
|
||||
var strings = [];
|
||||
if (this.settings.config.showDate) {
|
||||
strings.push(renderDate(date, this.settings));
|
||||
}
|
||||
if (this.settings.config.showTime) {
|
||||
var joined = new Date(date.getTime() + this.time);
|
||||
strings.push(renderTime(joined, this.settings));
|
||||
}
|
||||
this.elem.value = strings.join(', ');
|
||||
}
|
||||
|
||||
DTBox.prototype.onDateSelected = function (e) {
|
||||
var row = e.target.parentNode;
|
||||
var date = parseInt(e.target.innerText);
|
||||
if (!(row.nextSibling && row.nextSibling.nextSibling) && date < 8) {
|
||||
this.month += 1;
|
||||
} else if (!(row.previousSibling && row.previousSibling.previousSibling) && date > 7) {
|
||||
this.month -= 1;
|
||||
}
|
||||
this.day = parseInt(e.target.innerText);
|
||||
this.value = new Date(this.year, this.month, this.day);
|
||||
this.setInputValue();
|
||||
this.setHeaderContent();
|
||||
this.setBodyContent();
|
||||
}
|
||||
|
||||
/** @param {Event} e */
|
||||
DTBox.prototype.onMonthSelected = function (e) {
|
||||
var col = 0;
|
||||
var row = 2;
|
||||
var cell = e.target;
|
||||
if (cell.parentNode.nextSibling){
|
||||
row = cell.parentNode.previousSibling ? 1: 0;
|
||||
}
|
||||
if (cell.previousSibling) {
|
||||
col = 3;
|
||||
if (cell.nextSibling) {
|
||||
col = cell.previousSibling.previousSibling ? 2 : 1;
|
||||
}
|
||||
}
|
||||
this.month = 4 * row + col;
|
||||
this.bodyType = "DAYS";
|
||||
this.setHeaderContent();
|
||||
this.setupBody();
|
||||
}
|
||||
|
||||
/** @param {Event} e */
|
||||
DTBox.prototype.onYearSelected = function (e) {
|
||||
this.year = parseInt(e.target.innerText);
|
||||
this.bodyType = "MONTHS";
|
||||
this.setHeaderContent();
|
||||
this.setupBody();
|
||||
}
|
||||
|
||||
/** @param {Event} e */
|
||||
DTBox.prototype.onHeaderChange = function (e) {
|
||||
var cell = e.target;
|
||||
if (cell.previousSibling && cell.nextSibling) {
|
||||
var idx = BODYTYPES.indexOf(this.bodyType);
|
||||
if (idx < 0 || !BODYTYPES[idx + 1]) {
|
||||
return;
|
||||
}
|
||||
this.bodyType = BODYTYPES[idx + 1];
|
||||
this.setupBody();
|
||||
} else {
|
||||
var sign = cell.previousSibling ? 1 : -1;
|
||||
switch (this.bodyType) {
|
||||
case "DAYS":
|
||||
this.month += sign * 1;
|
||||
break;
|
||||
case "MONTHS":
|
||||
this.year += sign * 1;
|
||||
break;
|
||||
case "YEARS":
|
||||
this.year += sign * 10;
|
||||
}
|
||||
if (this.month > 11 || this.month < 0) {
|
||||
this.year += Math.floor(this.month / 11);
|
||||
this.month = this.month > 11 ? 0 : 11;
|
||||
}
|
||||
}
|
||||
this.setHeaderContent();
|
||||
this.setBodyContent();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} elem
|
||||
* @returns {{left:number, top:number}}
|
||||
*/
|
||||
function getOffset(elem) {
|
||||
var box = elem.getBoundingClientRect();
|
||||
var left = window.pageXOffset !== undefined ? window.pageXOffset :
|
||||
(document.documentElement || document.body.parentNode || document.body).scrollLeft;
|
||||
var top = window.pageYOffset !== undefined ? window.pageYOffset :
|
||||
(document.documentElement || document.body.parentNode || document.body).scrollTop;
|
||||
return { left: box.left + left, top: box.top + top };
|
||||
}
|
||||
function empty(e) {
|
||||
for (; e.children.length; ) e.removeChild(e.children[0]);
|
||||
}
|
||||
function tryAppendChild(newChild, refNode) {
|
||||
try {
|
||||
refNode.appendChild(newChild);
|
||||
return newChild;
|
||||
} catch (e) {
|
||||
console.trace(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** @class */
|
||||
function hookFuncs() {
|
||||
/** @type {Handlers} */
|
||||
this._funcs = {};
|
||||
}
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {Function} func
|
||||
*/
|
||||
hookFuncs.prototype.add = function(key, func){
|
||||
if (!this._funcs[key]){
|
||||
this._funcs[key] = [];
|
||||
}
|
||||
this._funcs[key].push(func)
|
||||
}
|
||||
/**
|
||||
* @param {String} key
|
||||
* @returns {Function[]} handlers
|
||||
*/
|
||||
hookFuncs.prototype.get = function(key){
|
||||
return this._funcs[key] ? this._funcs[key] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array.<string>} arr
|
||||
* @param {String} string
|
||||
* @returns {Array.<string>} sorted string
|
||||
*/
|
||||
function sortByStringIndex(arr, string) {
|
||||
return arr.sort(function(a, b){
|
||||
var h = string.indexOf(a);
|
||||
var l = string.indexOf(b);
|
||||
var rank = 0;
|
||||
if (h < l) {
|
||||
rank = -1;
|
||||
} else if (l < h) {
|
||||
rank = 1;
|
||||
} else if (a.length > b.length) {
|
||||
rank = -1;
|
||||
} else if (b.length > a.length) {
|
||||
rank = 1;
|
||||
}
|
||||
return rank;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove keys from array that are not in format
|
||||
* @param {string[]} keys
|
||||
* @param {string} format
|
||||
* @returns {string[]} new filtered array
|
||||
*/
|
||||
function filterFormatKeys(keys, format) {
|
||||
var out = [];
|
||||
var formatIdx = 0;
|
||||
for (var i = 0; i<keys.length; i++) {
|
||||
var key = keys[i];
|
||||
if (format.slice(formatIdx).indexOf(key) > -1) {
|
||||
formatIdx += key.length;
|
||||
out.push(key);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {StringNumObj} FormatObj
|
||||
* @param {string} value
|
||||
* @param {string} format
|
||||
* @param {FormatObj} formatObj
|
||||
* @param {function(Object.<string, hookFuncs>): null} setHooks
|
||||
* @returns {FormatObj} formatObj
|
||||
*/
|
||||
function parseData(value, format, formatObj, setHooks) {
|
||||
var hooks = {
|
||||
canSkip: new hookFuncs(),
|
||||
updateValue: new hookFuncs(),
|
||||
}
|
||||
var keys = sortByStringIndex(Object.keys(formatObj), format);
|
||||
var filterdKeys = filterFormatKeys(keys, format);
|
||||
var vstart = 0; // value start
|
||||
if (setHooks) {
|
||||
setHooks(hooks);
|
||||
}
|
||||
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
var fstart = format.indexOf(key);
|
||||
var _vstart = vstart; // next value start
|
||||
var val = null;
|
||||
var canSkip = false;
|
||||
var funcs = hooks.canSkip.get(key);
|
||||
|
||||
vstart = vstart || fstart;
|
||||
|
||||
for (var j = 0; j < funcs.length; j++) {
|
||||
if (funcs[j](formatObj)){
|
||||
canSkip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fstart > -1 && !canSkip) {
|
||||
var sep = null;
|
||||
var stop = vstart + key.length;
|
||||
var fnext = -1;
|
||||
var nextKeyIdx = i + 1;
|
||||
_vstart += key.length; // set next value start if current key is found
|
||||
|
||||
// get next format token used to determine separator
|
||||
while (fnext == -1 && nextKeyIdx < keys.length){
|
||||
var nextKey = keys[nextKeyIdx];
|
||||
nextKeyIdx += 1;
|
||||
if (filterdKeys.indexOf(nextKey) === -1) {
|
||||
continue;
|
||||
}
|
||||
fnext = nextKey ? format.indexOf(nextKey) : -1; // next format start
|
||||
}
|
||||
if (fnext > -1){
|
||||
sep = format.slice(stop, fnext);
|
||||
if (sep) {
|
||||
var _stop = value.slice(vstart).indexOf(sep);
|
||||
if (_stop && _stop > -1){
|
||||
stop = _stop + vstart;
|
||||
_vstart = stop + sep.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
val = parseInt(value.slice(vstart, stop));
|
||||
|
||||
var funcs = hooks.updateValue.get(key);
|
||||
for (var k = 0; k < funcs.length; k++) {
|
||||
val = funcs[k](val, formatObj, vstart, stop);
|
||||
}
|
||||
}
|
||||
formatObj[key] = { index: vstart, value: val };
|
||||
vstart = _vstart; // set next value start
|
||||
}
|
||||
return formatObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} value
|
||||
* @param {DTS} settings
|
||||
* @returns {Date} date object
|
||||
*/
|
||||
function parseDate(value, settings) {
|
||||
/** @type {{yyyy:number=, yy:number=, mm:number=, dd:number=}} */
|
||||
var formatObj = {yyyy:null, yy:null, mm:null, dd:null};
|
||||
var format = ((settings.dateFormat) || '').toLowerCase();
|
||||
if (!format) {
|
||||
throw new TypeError('dateFormat not found (' + settings.dateFormat + ')');
|
||||
}
|
||||
var formatObj = parseData(value, format, formatObj, function(hooks){
|
||||
hooks.canSkip.add("yy", function(data){
|
||||
return data["yyyy"].value;
|
||||
});
|
||||
hooks.updateValue.add("yy", function(val){
|
||||
return 100 * Math.floor(new Date().getFullYear() / 100) + val;
|
||||
});
|
||||
});
|
||||
var year = formatObj["yyyy"].value || formatObj["yy"].value;
|
||||
var month = formatObj["mm"].value - 1;
|
||||
var date = formatObj["dd"].value;
|
||||
var result = new Date(year, month, date);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} value
|
||||
* @param {DTS} settings
|
||||
* @returns {Number} time in milliseconds <= (24 * 60 * 60 * 1000) - 1
|
||||
*/
|
||||
function parseTime(value, settings) {
|
||||
var format = ((settings.timeFormat) || '').toLowerCase();
|
||||
if (!format) {
|
||||
throw new TypeError('timeFormat not found (' + settings.timeFormat + ')');
|
||||
}
|
||||
|
||||
/** @type {{hh:number=, mm:number=, ss:number=, a:string=}} */
|
||||
var formatObj = {hh:null, mm:null, ss:null, a:null};
|
||||
var formatObj = parseData(value, format, formatObj, function(hooks){
|
||||
hooks.updateValue.add("a", function(val, data, start, stop){
|
||||
return value.slice(start, start + 2);
|
||||
});
|
||||
});
|
||||
var hours = formatObj["hh"].value;
|
||||
var minutes = formatObj["mm"].value;
|
||||
var seconds = formatObj["ss"].value;
|
||||
var am_pm = formatObj["a"].value;
|
||||
var am_pm_lower = am_pm ? am_pm.toLowerCase() : am_pm;
|
||||
if (am_pm && ["am", "pm"].indexOf(am_pm_lower) > -1){
|
||||
if (am_pm_lower == 'am' && hours == 12){
|
||||
hours = 0;
|
||||
} else if (am_pm_lower == 'pm') {
|
||||
hours += 12;
|
||||
}
|
||||
}
|
||||
var time = hours * 60 * 60 * 1000 + minutes * 60 * 1000 + seconds * 1000;
|
||||
return time;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} value
|
||||
* @param {DTS} settings
|
||||
* @returns {String} date string
|
||||
*/
|
||||
function renderDate(value, settings) {
|
||||
var format = settings.dateFormat.toLowerCase();
|
||||
var date = value.getDate();
|
||||
var month = value.getMonth() + 1;
|
||||
var year = value.getFullYear();
|
||||
var yearShort = year % 100;
|
||||
var formatObj = {
|
||||
dd: date < 10 ? "0" + date : date,
|
||||
mm: month < 10 ? "0" + month : month,
|
||||
yyyy: year,
|
||||
yy: yearShort < 10 ? "0" + yearShort : yearShort
|
||||
};
|
||||
var str = format.replace(settings.dateFormatRegEx, function (found) {
|
||||
return formatObj[found];
|
||||
});
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} value
|
||||
* @param {DTS} settings
|
||||
* @returns {String} date string
|
||||
*/
|
||||
function renderTime(value, settings) {
|
||||
var Format = settings.timeFormat;
|
||||
var format = Format.toLowerCase();
|
||||
var hours = value.getHours();
|
||||
var minutes = value.getMinutes();
|
||||
var seconds = value.getSeconds();
|
||||
var am_pm = null;
|
||||
var hh_am_pm = null;
|
||||
if (format.indexOf('a') > -1) {
|
||||
am_pm = hours >= 12 ? 'pm' : 'am';
|
||||
am_pm = Format.indexOf('A') > -1 ? am_pm.toUpperCase() : am_pm;
|
||||
hh_am_pm = hours == 0 ? '12' : (hours > 12 ? hours%12 : hours);
|
||||
}
|
||||
var formatObj = {
|
||||
hh: am_pm ? hh_am_pm : (hours < 10 ? "0" + hours : hours),
|
||||
mm: minutes < 10 ? "0" + minutes : minutes,
|
||||
ss: seconds < 10 ? "0" + seconds : seconds,
|
||||
a: am_pm,
|
||||
};
|
||||
var str = format.replace(settings.timeFormatRegEx, function (found) {
|
||||
return formatObj[found];
|
||||
});
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* checks if two dates are equal
|
||||
* @param {Date} date1
|
||||
* @param {Date} date2
|
||||
* @returns {Boolean} true or false
|
||||
*/
|
||||
function isEqualDate(date1, date2) {
|
||||
if (!(date1 && date2)) return false;
|
||||
return (date1.getFullYear() == date2.getFullYear() &&
|
||||
date1.getMonth() == date2.getMonth() &&
|
||||
date1.getDate() == date2.getDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} val
|
||||
* @param {Number} pad
|
||||
* @param {*} default_val
|
||||
* @returns {String} padded string
|
||||
*/
|
||||
function padded(val, pad, default_val) {
|
||||
var default_val = default_val || 0;
|
||||
var valStr = '' + (parseInt(val) || default_val);
|
||||
var diff = Math.max(pad, valStr.length) - valStr.length;
|
||||
return ('' + default_val).repeat(diff) + valStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template X
|
||||
* @template Y
|
||||
* @param {X} obj
|
||||
* @param {Y} objDefaults
|
||||
* @returns {X|Y} merged object
|
||||
*/
|
||||
function setDefaults(obj, objDefaults) {
|
||||
var keys = Object.keys(objDefaults);
|
||||
for (var i=0; i<keys.length; i++) {
|
||||
var key = keys[i];
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
obj[key] = objDefaults[key];
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
||||
window.dtsel = Object.create({},{
|
||||
DTS: { value: DTS },
|
||||
DTObj: { value: DTBox },
|
||||
fn: {
|
||||
value: Object.defineProperties({}, {
|
||||
empty: { value: empty },
|
||||
appendAfter: {
|
||||
value: function (newElem, refNode) {
|
||||
refNode.parentNode.insertBefore(newElem, refNode.nextSibling);
|
||||
},
|
||||
},
|
||||
getOffset: { value: getOffset },
|
||||
parseDate: { value: parseDate },
|
||||
renderDate: { value: renderDate },
|
||||
parseTime: {value: parseTime},
|
||||
renderTime: {value: renderTime},
|
||||
setDefaults: {value: setDefaults},
|
||||
}),
|
||||
},
|
||||
});
|
||||
})();
|
23
web/static/js/expand.js
Normal file
@ -0,0 +1,23 @@
|
||||
function collapse_all() {
|
||||
document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => {
|
||||
el.classList.add("is-collapsed");
|
||||
});
|
||||
}
|
||||
|
||||
function expand_all() {
|
||||
document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => {
|
||||
el.classList.remove("is-collapsed");
|
||||
});
|
||||
}
|
||||
|
||||
const expandAll = document.querySelector("#expandAll");
|
||||
|
||||
expandAll.addEventListener("change", (ev) => {
|
||||
if (ev.target.value === "expand") {
|
||||
expand_all();
|
||||
} else if (ev.target.value === "collapse") {
|
||||
collapse_all();
|
||||
}
|
||||
|
||||
ev.target.value = "";
|
||||
});
|
88
web/static/js/interval.js
Normal file
@ -0,0 +1,88 @@
|
||||
function get_interval(element) {
|
||||
let months = element.querySelector('input[name="interval_months"]').value;
|
||||
let days = element.querySelector('input[name="interval_days"]').value;
|
||||
let hours = element.querySelector('input[name="interval_hours"]').value;
|
||||
let minutes = element.querySelector('input[name="interval_minutes"]').value;
|
||||
let seconds = element.querySelector('input[name="interval_seconds"]').value;
|
||||
|
||||
return {
|
||||
months: parseInt(months) || null,
|
||||
seconds:
|
||||
(parseInt(days) || 0) * 86400 +
|
||||
(parseInt(hours) || 0) * 3600 +
|
||||
(parseInt(minutes) || 0) * 60 +
|
||||
(parseInt(seconds) || 0) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function update_interval(element) {
|
||||
let months = element.querySelector('input[name="interval_months"]');
|
||||
let days = element.querySelector('input[name="interval_days"]');
|
||||
let hours = element.querySelector('input[name="interval_hours"]');
|
||||
let minutes = element.querySelector('input[name="interval_minutes"]');
|
||||
let seconds = element.querySelector('input[name="interval_seconds"]');
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
hours.value = String(remainder).padStart(2, "0");
|
||||
days.value = Number(days.value) + Number(quotient);
|
||||
}
|
||||
}
|
||||
|
||||
const $intervalGroup = document.querySelector(".interval-group");
|
||||
|
||||
document.querySelector(".interval-group").addEventListener(
|
||||
"blur",
|
||||
(ev) => {
|
||||
if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
$intervalGroup.querySelector("button.clear").addEventListener("click", () => {
|
||||
$intervalGroup.querySelectorAll("input").forEach((el) => {
|
||||
el.value = "";
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("remindersLoaded", (event) => {
|
||||
for (reminder of event.detail) {
|
||||
let $intervalGroup = reminder.node.querySelector(".interval-group");
|
||||
|
||||
$intervalGroup.addEventListener(
|
||||
"blur",
|
||||
(ev) => {
|
||||
if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
$intervalGroup.querySelector("button.clear").addEventListener("click", () => {
|
||||
$intervalGroup.querySelectorAll("input").forEach((el) => {
|
||||
el.value = "";
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|