2 Commits

Author SHA1 Message Date
32be8a4281 NON EXHAUSTIVE SHOULD BE A FUCKING WARNING 2022-02-01 15:30:35 +00:00
e436d9db80 moving stuff to poise 2022-02-01 01:07:12 +00:00
142 changed files with 1945 additions and 76039 deletions

View File

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

2246
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,14 @@
[package] [package]
name = "reminder_rs" name = "reminder_rs"
version = "1.6.3" version = "1.6.0-beta2"
authors = ["jellywx <judesouthworth@pm.me>"] authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018" edition = "2018"
[dependencies] [dependencies]
poise = "0.2" songbird = { git = "https://github.com/serenity-rs/songbird", branch = "next" }
poise = { git = "https://github.com/kangalioo/poise", branch = "master" }
dotenv = "0.15" dotenv = "0.15"
humantime = "2.1"
tokio = { version = "1", features = ["process", "full"] } tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11" reqwest = "0.11"
regex = "1.4" regex = "1.4"
@ -23,10 +25,4 @@ rmp-serde = "0.15"
rand = "0.7" rand = "0.7"
levenshtein = "1.0" levenshtein = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]} sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
base64 = "0.13" base64 = "0.13.0"
[dependencies.postman]
path = "postman"
[dependencies.reminder_web]
path = "web"

View File

@ -2,20 +2,13 @@
Reminder Bot for Discord. Reminder Bot for Discord.
## How do I use it? ## How do I use it?
I offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating We 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. 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) 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 ### Compiling
Install build requirements: 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.
`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 #### Compilation environment variables
These environment variables must be provided when compiling the bot These environment variables must be provided when compiling the bot
@ -37,10 +30,15 @@ __Other Variables__
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor * `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users * `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to * `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
* `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 * `PYTHON_LOCATION` - default `venv/bin/python3`. Can be changed if your Python executable is located somewhere else
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds * `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds
* `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 * `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages
### Todo List ### Todo List
* Convert aliases to macros * Convert aliases to macros
* Help command
* Test everything

View File

@ -1,28 +0,0 @@
[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"

View File

@ -157,9 +157,4 @@ CREATE TABLE events (
FOREIGN KEY (reminder_id) REFERENCES reminders_new(id) ON DELETE SET NULL 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; SET FOREIGN_KEY_CHECKS = 1;

View File

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

View File

@ -1,51 +0,0 @@
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
);

View File

@ -1,16 +0,0 @@
[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.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }

View File

@ -1,50 +0,0 @@
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;
}
}

View File

@ -1,606 +0,0 @@
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;
}
}
}

View File

@ -1,11 +1,9 @@
use chrono::offset::Utc; use chrono::offset::Utc;
use poise::{serenity_prelude as serenity, serenity_prelude::Mentionable}; use poise::serenity::builder::CreateEmbedFooter;
use crate::{models::CtxData, Context, Error, THEME_COLOR}; use crate::{models::CtxData, Context, Error, THEME_COLOR};
fn footer( fn footer(ctx: Context<'_>) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter {
ctx: Context<'_>,
) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
let shard_count = ctx.discord().cache.shard_count(); let shard_count = ctx.discord().cache.shard_count();
let shard = ctx.discord().shard_id; let shard = ctx.discord().shard_id;
@ -24,12 +22,13 @@ fn footer(
pub async fn help(ctx: Context<'_>) -> Result<(), Error> { pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx); let footer = footer(ctx);
ctx.send(|m| { let _ = ctx
m.ephemeral(true).embed(|e| { .send(|m| {
e.title("Help") m.embed(|e| {
.color(*THEME_COLOR) e.title("Help")
.description( .color(*THEME_COLOR)
"__Info Commands__ .description(
"__Info Commands__
`/help` `/info` `/donate` `/dashboard` `/clock` `/help` `/info` `/donate` `/dashboard` `/clock`
*run these commands with no options* *run these commands with no options*
@ -49,16 +48,15 @@ __Todo Commands__
__Setup Commands__ __Setup Commands__
`/timezone` - Set your timezone (necessary for `/remind` to work properly) `/timezone` - Set your timezone (necessary for `/remind` to work properly)
`/dm allow/block` - Change your DM settings for reminders.
__Advanced Commands__ __Advanced Commands__
`/macro` - Record and replay command sequences `/macro` - Record and replay command sequences
", ",
) )
.footer(footer) .footer(footer)
})
}) })
}) .await;
.await?;
Ok(()) Ok(())
} }
@ -70,9 +68,9 @@ pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
let _ = ctx let _ = ctx
.send(|m| { .send(|m| {
m.ephemeral(true).embed(|e| { m.embed(|e| {
e.title("Info") e.title("Info")
.description( .description(format!(
"Help: `/help` "Help: `/help`
**Welcome to Reminder Bot!** **Welcome to Reminder Bot!**
@ -82,7 +80,7 @@ Find me on https://discord.jellywx.com and on https://github.com/JellyWX :)
Invite the bot: https://invite.reminder-bot.com/ Invite the bot: https://invite.reminder-bot.com/
Use our dashboard: https://reminder-bot.com/", Use our dashboard: https://reminder-bot.com/",
) ))
.footer(footer) .footer(footer)
.color(*THEME_COLOR) .color(*THEME_COLOR)
}) })
@ -97,10 +95,9 @@ Use our dashboard: https://reminder-bot.com/",
pub async fn donate(ctx: Context<'_>) -> Result<(), Error> { pub async fn donate(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx); let footer = footer(ctx);
ctx.send(|m| m.embed(|e| { let _ = ctx.send(|m| m.embed(|e| {
e.title("Donate") e.title("Donate")
.description("Thinking of adding a monthly contribution? .description("Thinking of adding a monthly contribution? Click below for my Patreon and official bot server :)
Click below for my Patreon and official bot server :)
**https://www.patreon.com/jellywx/** **https://www.patreon.com/jellywx/**
**https://discord.jellywx.com/** **https://discord.jellywx.com/**
@ -115,11 +112,11 @@ With your new rank, you'll be able to:
Just $2 USD/month! Just $2 USD/month!
*Please note, you must be in the JellyWX Discord server to receive Patreon features*") *Please note, you must be in the JellyWX Discord server to receive Patreon features*")
.footer(footer) .footer(footer)
.color(*THEME_COLOR) .color(*THEME_COLOR)
}), }),
) )
.await?; .await;
Ok(()) Ok(())
} }
@ -129,20 +126,21 @@ Just $2 USD/month!
pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> { pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx); let footer = footer(ctx);
ctx.send(|m| { let _ = ctx
m.ephemeral(true).embed(|e| { .send(|m| {
e.title("Dashboard") m.embed(|e| {
.description("**https://reminder-bot.com/dashboard**") e.title("Dashboard")
.footer(footer) .description("**https://reminder-bot.com/dashboard**")
.color(*THEME_COLOR) .footer(footer)
.color(*THEME_COLOR)
})
}) })
}) .await;
.await?;
Ok(()) Ok(())
} }
/// View the current time in your selected timezone /// View the current time in a user's selected timezone
#[poise::command(slash_command)] #[poise::command(slash_command)]
pub async fn clock(ctx: Context<'_>) -> Result<(), Error> { pub async fn clock(ctx: Context<'_>) -> Result<(), Error> {
ctx.defer_ephemeral().await?; ctx.defer_ephemeral().await?;
@ -157,25 +155,3 @@ pub async fn clock(ctx: Context<'_>) -> Result<(), Error> {
Ok(()) 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(())
}

View File

@ -1,4 +1,4 @@
pub mod info_cmds; pub mod info_cmds;
pub mod moderation_cmds; pub mod moderation_cmds;
pub mod reminder_cmds; // pub mod reminder_cmds;
pub mod todo_cmds; // pub mod todo_cmds;

View File

@ -1,15 +1,13 @@
use std::collections::hash_map::Entry;
use chrono::offset::Utc; use chrono::offset::Utc;
use chrono_tz::{Tz, TZ_VARIANTS}; use chrono_tz::{Tz, TZ_VARIANTS};
use levenshtein::levenshtein; use levenshtein::levenshtein;
use poise::CreateReply; use poise::CreateReply;
use crate::{ use crate::{
component_models::pager::{MacroPager, Pager},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
hooks::guild_only,
models::{ models::{
command_macro::{guild_command_macro, CommandMacro}, command_macro::{CommandMacro, CommandOptions},
CtxData, CtxData,
}, },
Context, Data, Error, Context, Data, Error,
@ -21,7 +19,11 @@ async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String>
} else { } else {
TZ_VARIANTS TZ_VARIANTS
.iter() .iter()
.filter(|tz| tz.to_string().contains(&partial)) .filter(|tz| {
partial.contains(&tz.to_string())
|| tz.to_string().contains(&partial)
|| levenshtein(&tz.to_string(), &partial) < 4
})
.take(25) .take(25)
.map(|t| t.to_string()) .map(|t| t.to_string())
.collect::<Vec<String>>() .collect::<Vec<String>>()
@ -29,7 +31,7 @@ async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String>
} }
/// Select your timezone /// Select your timezone
#[poise::command(slash_command, identifying_name = "timezone")] #[poise::command(slash_command)]
pub async fn timezone( pub async fn timezone(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"] #[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"]
@ -54,7 +56,7 @@ pub async fn timezone(
.description(format!( .description(format!(
"Timezone has been set to **{}**. Your current time should be `{}`", "Timezone has been set to **{}**. Your current time should be `{}`",
timezone, timezone,
now.format("%H:%M") now.format("%H:%M").to_string()
)) ))
.color(*THEME_COLOR) .color(*THEME_COLOR)
}) })
@ -77,7 +79,10 @@ pub async fn timezone(
let fields = filtered_tz.iter().map(|tz| { let fields = filtered_tz.iter().map(|tz| {
( (
tz.to_string(), tz.to_string(),
format!("🕗 `{}`", Utc::now().with_timezone(tz).format("%H:%M")), format!(
"🕗 `{}`",
Utc::now().with_timezone(tz).format("%H:%M").to_string()
),
true, true,
) )
}); });
@ -97,7 +102,11 @@ pub async fn timezone(
} }
} else { } else {
let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| { let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| {
(t.to_string(), format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M")), true) (
t.to_string(),
format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()),
true,
)
}); });
ctx.send(|m| { ctx.send(|m| {
@ -124,85 +133,6 @@ You may want to use one of the popular timezones below, otherwise click [here](h
Ok(()) Ok(())
} }
/// 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(())
}
/// 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;
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?;
Ok(())
}
/// 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(())
}
/// View the webhook being used to send reminders to this channel
#[poise::command(
slash_command,
identifying_name = "webhook_url",
required_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) {
let _ = 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 {
let _ = ctx.say("No webhook configured on this channel.").await;
}
}
Err(_) => {
let _ = ctx.say("No webhook configured on this channel.").await;
}
}
Ok(())
}
async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> { async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
sqlx::query!( sqlx::query!(
" "
@ -216,32 +146,20 @@ WHERE
) )
.fetch_all(&ctx.data().database) .fetch_all(&ctx.data().database)
.await .await
.unwrap_or_default() .unwrap_or(vec![])
.iter() .iter()
.map(|s| s.name.clone()) .map(|s| s.name.clone())
.collect() .collect()
} }
/// Record and replay command sequences /// Record and replay command sequences
#[poise::command( #[poise::command(slash_command, rename = "macro", check = "guild_only")]
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> { pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
/// Start recording up to 5 commands to replay /// Start recording up to 5 commands to replay
#[poise::command( #[poise::command(slash_command, rename = "record", check = "guild_only")]
slash_command,
rename = "record",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "record_macro"
)]
pub async fn record_macro( pub async fn record_macro(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "Name for the new macro"] name: String, #[description = "Name for the new macro"] name: String,
@ -274,11 +192,14 @@ Please select a unique name for your macro.",
let okay = { let okay = {
let mut lock = ctx.data().recording_macros.write().await; let mut lock = ctx.data().recording_macros.write().await;
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) { if lock.contains_key(&(guild_id, ctx.author().id)) {
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
true
} else {
false false
} else {
lock.insert(
(guild_id, ctx.author().id),
CommandMacro { guild_id, name, description, commands: vec![] },
);
true
} }
}; };
@ -316,9 +237,8 @@ Please use `/macro finish` to end this recording before starting another.",
#[poise::command( #[poise::command(
slash_command, slash_command,
rename = "finish", rename = "finish",
guild_only = true, check = "guild_only",
default_member_permissions = "MANAGE_GUILD", identifying_name = "macro_finish"
identifying_name = "finish_macro"
)] )]
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> { pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
let key = (ctx.guild_id().unwrap(), ctx.author().id); let key = (ctx.guild_id().unwrap(), ctx.author().id);
@ -371,15 +291,9 @@ pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
} }
/// List recorded macros /// List recorded macros
#[poise::command( #[poise::command(slash_command, rename = "list", check = "guild_only")]
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> { pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
let macros = ctx.command_macros().await?; let macros = CommandMacro::from_guild(&ctx.data().database, ctx.guild_id().unwrap()).await;
let resp = show_macro_page(&macros, 0); let resp = show_macro_page(&macros, 0);
@ -392,44 +306,72 @@ pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
Ok(()) 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)
}
}
})
}
/// Run a recorded macro /// Run a recorded macro
#[poise::command( #[poise::command(slash_command, rename = "run", check = "guild_only")]
slash_command,
rename = "run",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "run_macro"
)]
pub async fn run_macro( pub async fn run_macro(
ctx: poise::ApplicationContext<'_, Data, Error>, ctx: Context<'_>,
#[description = "Name of macro to run"] #[description = "Name of macro to run"]
#[autocomplete = "macro_name_autocomplete"] #[autocomplete = "macro_name_autocomplete"]
name: String, name: String,
) -> Result<(), Error> { ) -> Result<(), Error> {
match guild_command_macro(&Context::Application(ctx), &name).await { match sqlx::query!(
Some(command_macro) => { "
ctx.defer_response(false).await?; 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?;
for command in command_macro.commands { let commands: Vec<CommandOptions> = serde_json::from_str(&row.commands)?;
if let Some(action) = command.action {
match (action)(poise::ApplicationContext { args: &command.options, ..ctx }) for command in commands {
.await let cmd =
{ find_command(&ctx.framework().options().commands, &command.command, &command);
Ok(()) => {}
Err(e) => { if let Some(cmd) = cmd {
println!("{:?}", e); let mut executing_ctx = ctx.clone();
}
} executing_ctx.command = cmd;
} else { } else {
Context::Application(ctx) ctx.send(|m| {
.say(format!("Command \"{}\" not found", command.command_name)) m.ephemeral(true)
.await?; .content(format!("Command `{}` not found", command.command))
})
.await?;
} }
} }
} }
None => { Err(sqlx::Error::RowNotFound) => {
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?; ctx.say(format!("Macro \"{}\" not found", name)).await?;
}
Err(e) => {
panic!("{}", e);
} }
} }
@ -437,13 +379,7 @@ pub async fn run_macro(
} }
/// Delete a recorded macro /// Delete a recorded macro
#[poise::command( #[poise::command(slash_command, rename = "delete", check = "guild_only")]
slash_command,
rename = "delete",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "delete_macro"
)]
pub async fn delete_macro( pub async fn delete_macro(
ctx: Context<'_>, ctx: Context<'_>,
#[description = "Name of macro to delete"] #[description = "Name of macro to delete"]
@ -480,7 +416,7 @@ SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AN
Ok(()) Ok(())
} }
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize { pub fn max_macro_page(macros: &[CommandMacro]) -> usize {
let mut skipped_char_count = 0; let mut skipped_char_count = 0;
macros macros
@ -504,7 +440,18 @@ pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
}) })
} }
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply { 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); let pager = MacroPager::new(page);
if macros.is_empty() { if macros.is_empty() {
@ -575,4 +522,5 @@ pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> Crea
}); });
reply reply
*/
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
use poise::CreateReply; use regex_command_attr::command;
use serenity::client::Context;
use crate::{ use crate::{
component_models::{ component_models::{
@ -6,222 +7,134 @@ use crate::{
ComponentDataModel, TodoSelector, ComponentDataModel, TodoSelector,
}, },
consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR}, consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR},
models::CtxData, framework::{CommandInvoke, CommandOptions, CreateGenericResponse},
Context, Error, hooks::CHECK_GUILD_PERMISSIONS_HOOK,
SQLPool,
}; };
/// Manage todo lists #[command]
#[poise::command( #[description("Manage todo lists")]
slash_command, #[subcommandgroup("server")]
rename = "todo", #[description("Manage the server todo list")]
identifying_name = "todo_base", #[subcommand("add")]
default_member_permissions = "MANAGE_GUILD" #[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
)] )]
pub async fn todo_base(_ctx: Context<'_>) -> Result<(), Error> { #[subcommand("view")]
Ok(()) #[description("View and remove from the server todo list")]
} #[subcommandgroup("channel")]
#[description("Manage the channel todo list")]
/// Manage the server todo list #[subcommand("add")]
#[poise::command( #[description("Add to the channel todo list")]
slash_command, #[arg(
rename = "server", name = "task",
guild_only = true, description = "The task to add to the todo list",
identifying_name = "todo_guild_base", kind = "String",
default_member_permissions = "MANAGE_GUILD" required = true
)] )]
pub async fn todo_guild_base(_ctx: Context<'_>) -> Result<(), Error> { #[subcommand("view")]
Ok(()) #[description("View and remove from the channel todo list")]
} #[subcommandgroup("user")]
#[description("Manage your personal todo list")]
/// Add an item to the server todo list #[subcommand("add")]
#[poise::command( #[description("Add to your personal todo list")]
slash_command, #[arg(
rename = "add", name = "task",
guild_only = true, description = "The task to add to the todo list",
identifying_name = "todo_guild_add", kind = "String",
default_member_permissions = "MANAGE_GUILD" required = true
)] )]
pub async fn todo_guild_add( #[subcommand("view")]
ctx: Context<'_>, #[description("View and remove from your personal todo list")]
#[description = "The task to add to the todo list"] task: String, #[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
) -> Result<(), Error> { async fn todo(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
sqlx::query!( if invoke.guild_id().is_none() && args.subcommand_group != Some("user".to_string()) {
"INSERT INTO todos (guild_id, value) let _ = invoke
VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)", .respond(
ctx.guild_id().unwrap().0, &ctx,
task CreateGenericResponse::new().content("Please use `/todo user` in direct messages"),
) )
.execute(&ctx.data().database) .await;
.await } else {
.unwrap(); let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
ctx.say("Item added to todo list").await?; 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),
};
Ok(()) match args.get("task") {
} Some(task) => {
let task = task.to_string();
/// View and remove from the server todo list sqlx::query!(
#[poise::command( "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 = ?), ?)",
slash_command, keys.0,
rename = "view", keys.1,
guild_only = true, keys.2,
identifying_name = "todo_guild_view", task
default_member_permissions = "MANAGE_GUILD" )
)] .execute(&pool)
pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> { .await
let values = sqlx::query!( .unwrap();
"SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
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 resp = show_todo_page(&values, 0, None, None, ctx.guild_id().map(|g| g.0)); let _ = invoke
.respond(&ctx, CreateGenericResponse::new().content("Item added to todo list"))
ctx.send(|r| { .await;
*r = resp; }
r None => {
}) let values = if let Some(uid) = keys.0 {
.await?; sqlx::query!(
"SELECT todos.id, value FROM todos
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 guilds WHERE guild = ?), (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 = ?",
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, None, Some(ctx.channel_id().0), ctx.guild_id().map(|g| g.0));
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 INNER JOIN users ON todos.user_id = users.id
WHERE users.user = ?", WHERE users.user = ?",
ctx.author().id.0, uid,
) )
.fetch_all(&ctx.data().database) .fetch_all(&pool)
.await .await
.unwrap() .unwrap()
.iter() .iter()
.map(|row| (row.id as usize, row.value.clone())) .map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>(); .collect::<Vec<(usize, String)>>()
} else if let Some(cid) = keys.1 {
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)>>()
};
let resp = show_todo_page(&values, 0, Some(ctx.author().id.0), None, None); let resp = show_todo_page(&values, 0, keys.0, keys.1, keys.2);
ctx.send(|r| { invoke.respond(&ctx, resp).await.unwrap();
*r = resp; }
r }
}) }
.await?;
Ok(())
} }
pub fn max_todo_page(todo_values: &[(usize, String)]) -> usize { pub fn max_todo_page(todo_values: &[(usize, String)]) -> usize {
@ -251,7 +164,7 @@ pub fn show_todo_page(
user_id: Option<u64>, user_id: Option<u64>,
channel_id: Option<u64>, channel_id: Option<u64>,
guild_id: Option<u64>, guild_id: Option<u64>,
) -> CreateReply { ) -> CreateGenericResponse {
let pager = TodoPager::new(page, user_id, channel_id, guild_id); let pager = TodoPager::new(page, user_id, channel_id, guild_id);
let pages = max_todo_page(todo_values); let pages = max_todo_page(todo_values);
@ -306,23 +219,17 @@ pub fn show_todo_page(
}; };
if todo_ids.is_empty() { if todo_ids.is_empty() {
let mut reply = CreateReply::default(); CreateGenericResponse::new().embed(|e| {
reply.embed(|e| {
e.title(format!("{} Todo List", title)) e.title(format!("{} Todo List", title))
.description("Todo List Empty!") .description("Todo List Empty!")
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages))) .footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
.color(*THEME_COLOR) .color(*THEME_COLOR)
}); })
reply
} else { } else {
let todo_selector = let todo_selector =
ComponentDataModel::TodoSelector(TodoSelector { page, user_id, channel_id, guild_id }); ComponentDataModel::TodoSelector(TodoSelector { page, user_id, channel_id, guild_id });
let mut reply = CreateReply::default(); CreateGenericResponse::new()
reply
.embed(|e| { .embed(|e| {
e.title(format!("{} Todo List", title)) e.title(format!("{} Todo List", title))
.description(display) .description(display)
@ -340,7 +247,7 @@ pub fn show_todo_page(
opt.create_option(|o| { opt.create_option(|o| {
o.label(format!("Mark {} complete", count + first_num)) o.label(format!("Mark {} complete", count + first_num))
.value(id) .value(id)
.description(disp.split_once(' ').unwrap_or(("", "")).1) .description(disp.split_once(" ").unwrap_or(("", "")).1)
}); });
} }
@ -348,8 +255,6 @@ pub fn show_todo_page(
}) })
}) })
}) })
}); })
reply
} }
} }

View File

@ -3,35 +3,23 @@ pub(crate) mod pager;
use std::io::Cursor; use std::io::Cursor;
use chrono_tz::Tz; use chrono_tz::Tz;
use log::warn; use poise::serenity::{
use poise::{ builder::CreateEmbed,
serenity::{ client::Context,
builder::CreateEmbed, model::{
client::Context, channel::Channel,
model::{ interactions::{message_component::MessageComponentInteraction, InteractionResponseType},
application::interaction::{ prelude::InteractionApplicationCommandCallbackDataFlags,
message_component::MessageComponentInteraction, InteractionResponseType,
MessageFlags,
},
channel::Channel,
},
}, },
serenity_prelude as serenity,
}; };
use rmp_serde::Serializer; use rmp_serde::Serializer;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
commands::{ self,
moderation_cmds::{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}, component_models::pager::{DelPager, LookPager, MacroPager, Pager, TodoPager},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR}, consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
models::reminder::Reminder, models::{command_macro::CommandMacro, reminder::Reminder},
utils::send_as_initial_response,
Data,
}; };
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
@ -44,7 +32,6 @@ pub enum ComponentDataModel {
DelSelector(DelSelector), DelSelector(DelSelector),
TodoSelector(TodoSelector), TodoSelector(TodoSelector),
MacroPager(MacroPager), MacroPager(MacroPager),
UndoReminder(UndoReminder),
} }
impl ComponentDataModel { impl ComponentDataModel {
@ -62,7 +49,7 @@ impl ComponentDataModel {
rmp_serde::from_read(cur).unwrap() rmp_serde::from_read(cur).unwrap()
} }
pub async fn act(&self, ctx: &Context, data: &Data, component: &MessageComponentInteraction) { pub async fn act(&self, ctx: &Context, component: MessageComponentInteraction) {
match self { match self {
ComponentDataModel::LookPager(pager) => { ComponentDataModel::LookPager(pager) => {
let flags = pager.flags; let flags = pager.flags;
@ -79,7 +66,7 @@ impl ComponentDataModel {
component.channel_id component.channel_id
}; };
let reminders = Reminder::from_channel(&data.database, channel_id, &flags).await; let reminders = Reminder::from_channel(ctx, channel_id, &flags).await;
let pages = reminders let pages = reminders
.iter() .iter()
@ -129,7 +116,7 @@ impl ComponentDataModel {
.create_interaction_response(&ctx, |r| { .create_interaction_response(&ctx, |r| {
r.kind(InteractionResponseType::UpdateMessage).interaction_response_data( r.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|response| { |response| {
response.set_embeds(vec![embed]).components(|comp| { response.embeds(vec![embed]).components(|comp| {
pager.create_button_row(pages, comp); pager.create_button_row(pages, comp);
comp comp
@ -140,68 +127,45 @@ impl ComponentDataModel {
.await; .await;
} }
ComponentDataModel::DelPager(pager) => { ComponentDataModel::DelPager(pager) => {
let reminders = Reminder::from_guild( let reminders =
&ctx, Reminder::from_guild(ctx, component.guild_id, component.user.id).await;
&data.database,
component.guild_id,
component.user.id,
)
.await;
let max_pages = max_delete_page(&reminders, &pager.timezone); let max_pages = max_delete_page(&reminders, &pager.timezone);
let resp = show_delete_page(&reminders, pager.next_page(max_pages), pager.timezone); let resp = show_delete_page(&reminders, pager.next_page(max_pages), pager.timezone);
let _ = component let mut invoke = CommandInvoke::component(component);
.create_interaction_response(&ctx, |f| { let _ = invoke.respond(&ctx, resp).await;
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|d| {
send_as_initial_response(resp, d);
d
},
)
})
.await;
} }
ComponentDataModel::DelSelector(selector) => { ComponentDataModel::DelSelector(selector) => {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let selected_id = component.data.values.join(","); let selected_id = component.data.values.join(",");
sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id) sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id)
.execute(&data.database) .execute(&pool)
.await .await
.unwrap(); .unwrap();
let reminders = Reminder::from_guild( let reminders =
&ctx, Reminder::from_guild(ctx, component.guild_id, component.user.id).await;
&data.database,
component.guild_id,
component.user.id,
)
.await;
let resp = show_delete_page(&reminders, selector.page, selector.timezone); let resp = show_delete_page(&reminders, selector.page, selector.timezone);
let _ = component let mut invoke = CommandInvoke::component(component);
.create_interaction_response(&ctx, |f| { let _ = invoke.respond(&ctx, resp).await;
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|d| {
send_as_initial_response(resp, d);
d
},
)
})
.await;
} }
ComponentDataModel::TodoPager(pager) => { ComponentDataModel::TodoPager(pager) => {
if Some(component.user.id.0) == pager.user_id || pager.user_id.is_none() { 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 { let values = if let Some(uid) = pager.user_id {
sqlx::query!( sqlx::query!(
"SELECT todos.id, value FROM todos "SELECT todos.id, value FROM todos
INNER JOIN users ON todos.user_id = users.id INNER JOIN users ON todos.user_id = users.id
WHERE users.user = ?", WHERE users.user = ?",
uid, uid,
) )
.fetch_all(&data.database) .fetch_all(&pool)
.await .await
.unwrap() .unwrap()
.iter() .iter()
@ -210,11 +174,11 @@ WHERE users.user = ?",
} else if let Some(cid) = pager.channel_id { } else if let Some(cid) = pager.channel_id {
sqlx::query!( sqlx::query!(
"SELECT todos.id, value FROM todos "SELECT todos.id, value FROM todos
INNER JOIN channels ON todos.channel_id = channels.id INNER JOIN channels ON todos.channel_id = channels.id
WHERE channels.channel = ?", WHERE channels.channel = ?",
cid, cid,
) )
.fetch_all(&data.database) .fetch_all(&pool)
.await .await
.unwrap() .unwrap()
.iter() .iter()
@ -223,11 +187,11 @@ WHERE channels.channel = ?",
} else { } else {
sqlx::query!( sqlx::query!(
"SELECT todos.id, value FROM todos "SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?", WHERE guilds.guild = ?",
pager.guild_id, pager.guild_id,
) )
.fetch_all(&data.database) .fetch_all(&pool)
.await .await
.unwrap() .unwrap()
.iter() .iter()
@ -245,22 +209,15 @@ WHERE guilds.guild = ?",
pager.guild_id, pager.guild_id,
); );
let _ = component let mut invoke = CommandInvoke::component(component);
.create_interaction_response(&ctx, |f| { let _ = invoke.respond(&ctx, resp).await;
f.kind(InteractionResponseType::UpdateMessage)
.interaction_response_data(|d| {
send_as_initial_response(resp, d);
d
})
})
.await;
} else { } else {
let _ = component let _ = component
.create_interaction_response(&ctx, |r| { .create_interaction_response(&ctx, |r| {
r.kind(InteractionResponseType::ChannelMessageWithSource) r.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|d| { .interaction_response_data(|d| {
d.flags( d.flags(
MessageFlags::EPHEMERAL, InteractionApplicationCommandCallbackDataFlags::EPHEMERAL,
) )
.content("Only the user who performed the command can use these components") .content("Only the user who performed the command can use these components")
}) })
@ -270,10 +227,11 @@ WHERE guilds.guild = ?",
} }
ComponentDataModel::TodoSelector(selector) => { ComponentDataModel::TodoSelector(selector) => {
if Some(component.user.id.0) == selector.user_id || selector.user_id.is_none() { 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(","); let selected_id = component.data.values.join(",");
sqlx::query!("DELETE FROM todos WHERE FIND_IN_SET(id, ?)", selected_id) sqlx::query!("DELETE FROM todos WHERE FIND_IN_SET(id, ?)", selected_id)
.execute(&data.database) .execute(&pool)
.await .await
.unwrap(); .unwrap();
@ -284,7 +242,7 @@ WHERE guilds.guild = ?",
selector.channel_id, selector.channel_id,
selector.guild_id, selector.guild_id,
) )
.fetch_all(&data.database) .fetch_all(&pool)
.await .await
.unwrap() .unwrap()
.iter() .iter()
@ -299,22 +257,15 @@ WHERE guilds.guild = ?",
selector.guild_id, selector.guild_id,
); );
let _ = component let mut invoke = CommandInvoke::component(component);
.create_interaction_response(&ctx, |f| { let _ = invoke.respond(&ctx, resp).await;
f.kind(InteractionResponseType::UpdateMessage)
.interaction_response_data(|d| {
send_as_initial_response(resp, d);
d
})
})
.await;
} else { } else {
let _ = component let _ = component
.create_interaction_response(&ctx, |r| { .create_interaction_response(&ctx, |r| {
r.kind(InteractionResponseType::ChannelMessageWithSource) r.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|d| { .interaction_response_data(|d| {
d.flags( d.flags(
MessageFlags::EPHEMERAL, InteractionApplicationCommandCallbackDataFlags::EPHEMERAL,
) )
.content("Only the user who performed the command can use these components") .content("Only the user who performed the command can use these components")
}) })
@ -323,87 +274,15 @@ WHERE guilds.guild = ?",
} }
} }
ComponentDataModel::MacroPager(pager) => { ComponentDataModel::MacroPager(pager) => {
let macros = data.command_macros(component.guild_id.unwrap()).await.unwrap(); let mut invoke = CommandInvoke::component(component);
let macros = CommandMacro::from_guild(ctx, invoke.guild_id().unwrap()).await;
let max_page = max_macro_page(&macros); let max_page = max_macro_page(&macros);
let page = pager.next_page(max_page); let page = pager.next_page(max_page);
let resp = show_macro_page(&macros, page); let resp = show_macro_page(&macros, 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;
}
} }
} }
} }
@ -422,9 +301,3 @@ pub struct TodoSelector {
pub channel_id: Option<u64>, pub channel_id: Option<u64>,
pub guild_id: Option<u64>, pub guild_id: Option<u64>,
} }
#[derive(Serialize, Deserialize)]
pub struct UndoReminder {
pub user_id: serenity::UserId,
pub reminder_id: u32,
}

View File

@ -1,6 +1,8 @@
// todo split pager out into a single struct // todo split pager out into a single struct
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::serenity::{builder::CreateComponents, model::application::component::ButtonStyle}; use poise::serenity::{
builder::CreateComponents, model::interactions::message_component::ButtonStyle,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_repr::*; use serde_repr::*;

View File

@ -1,14 +1,14 @@
pub const DAY: u64 = 86_400; pub const DAY: u64 = 86_400;
pub const HOUR: u64 = 3_600; pub const HOUR: u64 = 3_600;
pub const MINUTE: u64 = 60; pub const MINUTE: u64 = 60;
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000; pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000;
pub const SELECT_MAX_ENTRIES: usize = 25; pub const SELECT_MAX_ENTRIES: usize = 25;
pub const MACRO_MAX_COMMANDS: usize = 5;
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
const THEME_COLOR_FALLBACK: u32 = 0x8fb677; const THEME_COLOR_FALLBACK: u32 = 0x8fb677;
pub const MACRO_MAX_COMMANDS: usize = 5;
use std::{collections::HashSet, env, iter::FromIterator}; use std::{collections::HashSet, env, iter::FromIterator};
@ -36,11 +36,15 @@ lazy_static! {
); );
pub static ref CNC_GUILD: Option<u64> = pub static ref CNC_GUILD: Option<u64> =
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten(); env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
pub static ref MIN_INTERVAL: i64 = pub static ref MIN_INTERVAL: i64 = env::var("MIN_INTERVAL")
env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600); .ok()
.map(|inner| inner.parse::<i64>().ok())
.flatten()
.unwrap_or(600);
pub static ref MAX_TIME: i64 = env::var("MAX_TIME") pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
.ok() .ok()
.and_then(|inner| inner.parse::<i64>().ok()) .map(|inner| inner.parse::<i64>().ok())
.flatten()
.unwrap_or(60 * 60 * 24 * 365 * 50); .unwrap_or(60 * 60 * 24 * 365 * 50);
pub static ref LOCAL_TIMEZONE: String = pub static ref LOCAL_TIMEZONE: String =
env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string()); env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string());

View File

@ -1,66 +1,21 @@
use std::{collections::HashMap, env, sync::atomic::Ordering}; use std::{collections::HashMap, env};
use log::{error, info, warn}; use poise::serenity::{client::Context, model::interactions::Interaction, utils::shard_id};
use poise::{
serenity::{model::application::interaction::Interaction, utils::shard_id},
serenity_prelude as serenity,
};
use crate::{component_models::ComponentDataModel, Data, Error}; use crate::{Data, Error};
pub async fn listener( pub async fn listener(ctx: &Context, event: &poise::Event<'_>, data: &Data) -> Result<(), Error> {
ctx: &serenity::Context,
event: &poise::Event<'_>,
data: &Data,
) -> Result<(), Error> {
match event { match event {
poise::Event::Ready { .. } => {
ctx.set_activity(serenity::Activity::watching("for /remind")).await;
}
poise::Event::CacheReady { .. } => {
info!("Cache Ready! Preparing extra processes");
if !data.is_loop_running.load(Ordering::Relaxed) {
let kill_tx = data.broadcast.clone();
let kill_recv = data.broadcast.subscribe();
let ctx1 = ctx.clone();
let ctx2 = ctx.clone();
let pool1 = data.database.clone();
let pool2 = data.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");
}
data.is_loop_running.swap(true, Ordering::Relaxed);
}
}
poise::Event::ChannelDelete { channel } => { poise::Event::ChannelDelete { channel } => {
sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64()) sqlx::query!(
.execute(&data.database) "
.await DELETE FROM channels WHERE channel = ?
.unwrap(); ",
channel.id.as_u64()
)
.execute(&data.database)
.await
.unwrap();
} }
poise::Event::GuildCreate { guild, is_new } => { poise::Event::GuildCreate { guild, is_new } => {
if *is_new { if *is_new {
@ -109,18 +64,18 @@ pub async fn listener(
} }
} }
} }
poise::Event::GuildDelete { incomplete, .. } => { poise::Event::GuildDelete { incomplete, full } => {
let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0) let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0)
.execute(&data.database) .execute(&data.database)
.await; .await;
} }
poise::Event::InteractionCreate { interaction } => { poise::Event::InteractionCreate { interaction } => match interaction {
if let Interaction::MessageComponent(component) = interaction { Interaction::MessageComponent(component) => {
let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); //let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
//component_model.act(&ctx, component).await;
component_model.act(ctx, data, component).await;
} }
} _ => {}
},
_ => {} _ => {}
} }

View File

@ -1,42 +1,61 @@
use poise::serenity::model::channel::Channel; use poise::{serenity::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction};
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error}; 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)
}
}
async fn macro_check(ctx: Context<'_>) -> bool { async fn macro_check(ctx: Context<'_>) -> bool {
if let Context::Application(app_ctx) = ctx { if let Context::Application(app_ctx) = ctx {
if let Some(guild_id) = ctx.guild_id() { if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(interaction) =
if ctx.command().identifying_name != "finish_macro" { app_ctx.interaction
let mut lock = ctx.data().recording_macros.write().await; {
if let Some(guild_id) = ctx.guild_id() {
if ctx.command().identifying_name != "macro_finish" {
let mut lock = ctx.data().recording_macros.write().await;
if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) { if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) {
if command_macro.commands.len() >= MACRO_MAX_COMMANDS { if command_macro.commands.len() >= MACRO_MAX_COMMANDS {
let _ = ctx.send(|m| { let _ = ctx.send(|m| {
m.ephemeral(true).content( m.ephemeral(true).content(
format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS), "5 commands already recorded. Please use `/macro finish` to end recording.",
) )
}) })
.await; .await;
} else {
let mut command_options = CommandOptions::new(&ctx.command().name);
command_options.populate(&interaction);
command_macro.commands.push(command_options);
let _ = ctx
.send(|m| m.ephemeral(true).content("Command recorded to macro"))
.await;
}
false
} else { } else {
let recorded = RecordedCommand { true
action: None,
command_name: ctx.command().identifying_name.clone(),
options: Vec::from(app_ctx.args),
};
command_macro.commands.push(recorded);
let _ = ctx
.send(|m| m.ephemeral(true).content("Command recorded to macro"))
.await;
} }
} else {
return false; true
} }
} else {
true
} }
} else {
true
} }
} else {
true
} }
true
} }
async fn check_self_permissions(ctx: Context<'_>) -> bool { async fn check_self_permissions(ctx: Context<'_>) -> bool {
@ -50,15 +69,16 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
let (view_channel, send_messages, embed_links) = ctx let (view_channel, send_messages, embed_links) = ctx
.channel_id() .channel_id()
.to_channel_cached(&ctx.discord()) .to_channel_cached(&ctx.discord())
.and_then(|c| { .map(|c| {
if let Channel::Guild(channel) = c { if let Channel::Guild(channel) = c {
channel.permissions_for_user(&ctx.discord(), user_id).ok() channel.permissions_for_user(&ctx.discord(), user_id).ok()
} else { } else {
None None
} }
}) })
.flatten()
.map_or((false, false, false), |p| { .map_or((false, false, false), |p| {
(p.view_channel(), p.send_messages(), p.embed_links()) (p.read_messages(), p.send_messages(), p.embed_links())
}); });
if manage_webhooks && send_messages && embed_links { if manage_webhooks && send_messages && embed_links {

View File

@ -1,251 +0,0 @@
/*
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()
}

View File

@ -1,37 +1,29 @@
#![feature(int_roundings)] #![feature(int_roundings)]
#[macro_use] #[macro_use]
extern crate lazy_static; extern crate lazy_static;
mod commands; mod commands;
mod component_models; // mod component_models;
mod consts; mod consts;
mod event_handlers; mod event_handlers;
mod hooks; mod hooks;
mod interval_parser;
mod models; mod models;
mod time_parser; mod time_parser;
mod utils; mod utils;
use std::{ use std::{collections::HashMap, env};
collections::HashMap,
env,
error::Error as StdError,
fmt::{Debug, Display, Formatter},
sync::atomic::AtomicBool,
};
use chrono_tz::Tz; use chrono_tz::Tz;
use dotenv::dotenv; use dotenv::dotenv;
use poise::serenity::model::{ use poise::serenity::model::{
gateway::GatewayIntents, gateway::{Activity, GatewayIntents},
id::{GuildId, UserId}, id::{GuildId, UserId},
}; };
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use tokio::sync::{broadcast, broadcast::Sender, RwLock}; use tokio::sync::RwLock;
use crate::{ use crate::{
commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, commands::{info_cmds, moderation_cmds},
consts::THEME_COLOR, consts::THEME_COLOR,
event_handlers::listener, event_handlers::listener,
hooks::all_checks, hooks::all_checks,
@ -41,51 +33,18 @@ use crate::{
type Database = MySql; type Database = MySql;
type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>;
pub struct Data { pub struct Data {
database: Pool<Database>, database: Pool<Database>,
http: reqwest::Client, http: reqwest::Client,
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>, recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro>>,
popular_timezones: Vec<Tz>, popular_timezones: Vec<Tz>,
is_loop_running: AtomicBool,
broadcast: Sender<()>,
} }
impl Debug for Data { type Error = Box<dyn std::error::Error + Send + Sync>;
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { type Context<'a> = poise::Context<'a, Data, Error>;
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] #[tokio::main]
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> { async fn main() -> Result<(), Box<dyn std::error::Error + 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(); env_logger::init();
dotenv()?; dotenv()?;
@ -98,17 +57,8 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
info_cmds::info(), info_cmds::info(),
info_cmds::donate(), info_cmds::donate(),
info_cmds::clock(), info_cmds::clock(),
info_cmds::clock_context_menu(),
info_cmds::dashboard(), info_cmds::dashboard(),
moderation_cmds::timezone(), moderation_cmds::timezone(),
poise::Command {
subcommands: vec![
moderation_cmds::set_allowed_dm(),
moderation_cmds::unset_allowed_dm(),
],
..moderation_cmds::allowed_dm()
},
moderation_cmds::webhook(),
poise::Command { poise::Command {
subcommands: vec![ subcommands: vec![
moderation_cmds::delete_macro(), moderation_cmds::delete_macro(),
@ -119,43 +69,6 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
], ],
..moderation_cmds::macro_base() ..moderation_cmds::macro_base()
}, },
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, allowed_mentions: None,
command_check: Some(|ctx| Box::pin(all_checks(ctx))), command_check: Some(|ctx| Box::pin(all_checks(ctx))),
@ -167,7 +80,8 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap(); Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
let popular_timezones = sqlx::query!( let popular_timezones = sqlx::query!(
"SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21" "
SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21"
) )
.fetch_all(&database) .fetch_all(&database)
.await .await
@ -180,6 +94,8 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
.token(discord_token) .token(discord_token)
.user_data_setup(move |ctx, _bot, framework| { .user_data_setup(move |ctx, _bot, framework| {
Box::pin(async move { Box::pin(async move {
ctx.set_activity(Activity::watching("for /remind")).await;
register_application_commands( register_application_commands(
ctx, ctx,
framework, framework,
@ -195,13 +111,11 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
database, database,
popular_timezones, popular_timezones,
recording_macros: Default::default(), recording_macros: Default::default(),
is_loop_running: AtomicBool::new(false),
broadcast: tx,
}) })
}) })
}) })
.options(options) .options(options)
.intents(GatewayIntents::GUILDS) .client_settings(move |client_builder| client_builder.intents(GatewayIntents::GUILDS))
.run_autosharded() .run_autosharded()
.await?; .await?;

View File

@ -1,69 +1,267 @@
use poise::serenity::model::{ use std::collections::HashMap;
application::interaction::application_command::CommandDataOption, id::GuildId,
use poise::{
serenity::{
json::Value,
model::{
id::{ChannelId, GuildId, RoleId, UserId},
interactions::application_command::{
ApplicationCommandInteraction, ApplicationCommandInteractionData,
ApplicationCommandInteractionDataOption, ApplicationCommandOptionType,
ApplicationCommandType,
},
},
},
ApplicationCommandOrAutocompleteInteraction,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Number;
use sqlx::Executor;
use crate::{Context, Data, Error}; use crate::Database;
type Func<U, E> = for<'a> fn( pub struct CommandMacro {
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 guild_id: GuildId,
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
pub commands: Vec<RecordedCommand<U, E>>, pub commands: Vec<CommandOptions>,
} }
pub async fn guild_command_macro( impl CommandMacro {
ctx: &Context<'_>, pub async fn from_guild(
name: &str, db_pool: impl Executor<'_, Database = Database>,
) -> Option<CommandMacro<Data, Error>> { guild_id: impl Into<GuildId>,
let row = sqlx::query!( ) -> Vec<Self> {
" let guild_id = guild_id.into();
SELECT * 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()?;
let mut commands: Vec<RecordedCommand<Data, Error>> = sqlx::query!(
serde_json::from_str(&row.commands).unwrap(); "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>>()
}
}
for recorded_command in &mut commands { #[derive(Serialize, Deserialize, Clone)]
let command = &ctx pub enum OptionValue {
.framework() String(String),
.options() Integer(i64),
.commands Boolean(bool),
.iter() User(UserId),
.find(|c| c.identifying_name == recorded_command.command_name); Channel(ChannelId),
Role(RoleId),
Mentionable(u64),
Number(f64),
}
recorded_command.action = command.map(|c| c.slash_action).flatten(); impl OptionValue {
pub fn as_i64(&self) -> Option<i64> {
match self {
OptionValue::Integer(i) => Some(*i),
_ => None,
}
} }
let command_macro = CommandMacro { pub fn as_bool(&self) -> Option<bool> {
guild_id: ctx.guild_id().unwrap(), match self {
name: row.name, OptionValue::Boolean(b) => Some(*b),
description: row.description, _ => None,
commands, }
}; }
Some(command_macro) 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)
}
}
} }

View File

@ -9,20 +9,21 @@ use poise::serenity::{async_trait, model::id::UserId};
use crate::{ use crate::{
models::{channel_data::ChannelData, user_data::UserData}, models::{channel_data::ChannelData, user_data::UserData},
CommandMacro, Context, Data, Error, GuildId, Context,
}; };
#[async_trait] #[async_trait]
pub trait CtxData { pub trait CtxData {
async fn user_data<U: Into<UserId> + Send>(&self, user_id: U) -> Result<UserData, Error>; async fn user_data<U: Into<UserId> + Send>(
&self,
user_id: U,
) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>>;
async fn author_data(&self) -> Result<UserData, Error>; async fn author_data(&self) -> Result<UserData, Box<dyn std::error::Error + Sync + Send>>;
async fn timezone(&self) -> Tz; async fn timezone(&self) -> Tz;
async fn channel_data(&self) -> Result<ChannelData, Error>; async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>>;
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error>;
} }
#[async_trait] #[async_trait]
@ -47,29 +48,4 @@ impl CtxData for Context<'_> {
ChannelData::from_channel(&channel, &self.data().database).await 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
}
}
impl Data {
pub(crate) async fn command_macros(
&self,
guild_id: GuildId,
) -> Result<Vec<CommandMacro<Data, Error>>, Error> {
let rows = sqlx::query!(
"SELECT name, description, commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
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)
}
} }

View File

@ -14,8 +14,7 @@ use poise::serenity::{
use sqlx::MySqlPool; use sqlx::MySqlPool;
use crate::{ use crate::{
consts::{DAY, DEFAULT_AVATAR, MAX_TIME, MIN_INTERVAL}, consts::{DEFAULT_AVATAR, MAX_TIME, MIN_INTERVAL},
interval_parser::Interval,
models::{ models::{
channel_data::ChannelData, channel_data::ChannelData,
reminder::{content::Content, errors::ReminderError, helper::generate_uid, Reminder}, reminder::{content::Content, errors::ReminderError, helper::generate_uid, Reminder},
@ -53,8 +52,7 @@ pub struct ReminderBuilder {
channel: u32, channel: u32,
utc_time: NaiveDateTime, utc_time: NaiveDateTime,
timezone: String, timezone: String,
interval_secs: Option<i64>, interval: Option<i64>,
interval_months: Option<i64>,
expires: Option<NaiveDateTime>, expires: Option<NaiveDateTime>,
content: String, content: String,
tts: bool, tts: bool,
@ -86,8 +84,7 @@ INSERT INTO reminders (
`channel_id`, `channel_id`,
`utc_time`, `utc_time`,
`timezone`, `timezone`,
`interval_seconds`, `interval`,
`interval_months`,
`expires`, `expires`,
`content`, `content`,
`tts`, `tts`,
@ -105,7 +102,6 @@ INSERT INTO reminders (
?, ?,
?, ?,
?, ?,
?,
? ?
) )
", ",
@ -113,8 +109,7 @@ INSERT INTO reminders (
self.channel, self.channel,
utc_time, utc_time,
self.timezone, self.timezone,
self.interval_secs, self.interval,
self.interval_months,
self.expires, self.expires,
self.content, self.content,
self.tts, self.tts,
@ -126,7 +121,7 @@ INSERT INTO reminders (
.await .await
.unwrap(); .unwrap();
Ok(Reminder::from_uid(&self.pool, &self.uid).await.unwrap()) Ok(Reminder::from_uid(&self.pool, self.uid).await.unwrap())
} }
} }
@ -139,7 +134,7 @@ pub struct MultiReminderBuilder<'a> {
scopes: Vec<ReminderScope>, scopes: Vec<ReminderScope>,
utc_time: NaiveDateTime, utc_time: NaiveDateTime,
timezone: Tz, timezone: Tz,
interval: Option<Interval>, interval: Option<i64>,
expires: Option<NaiveDateTime>, expires: Option<NaiveDateTime>,
content: Content, content: Content,
set_by: Option<u32>, set_by: Option<u32>,
@ -148,7 +143,7 @@ pub struct MultiReminderBuilder<'a> {
} }
impl<'a> MultiReminderBuilder<'a> { impl<'a> MultiReminderBuilder<'a> {
pub fn new(ctx: &'a Context, guild_id: Option<GuildId>) -> Self { pub fn new(ctx: &'a Context<'a>, guild_id: Option<GuildId>) -> Self {
MultiReminderBuilder { MultiReminderBuilder {
scopes: vec![], scopes: vec![],
utc_time: Utc::now().naive_utc(), utc_time: Utc::now().naive_utc(),
@ -162,12 +157,6 @@ impl<'a> MultiReminderBuilder<'a> {
} }
} }
pub fn timezone(mut self, timezone: Tz) -> Self {
self.timezone = timezone;
self
}
pub fn content(mut self, content: Content) -> Self { pub fn content(mut self, content: Content) -> Self {
self.content = content; self.content = content;
@ -197,7 +186,7 @@ impl<'a> MultiReminderBuilder<'a> {
self self
} }
pub fn interval(mut self, interval: Option<Interval>) -> Self { pub fn interval(mut self, interval: Option<i64>) -> Self {
self.interval = interval; self.interval = interval;
self self
@ -207,36 +196,29 @@ impl<'a> MultiReminderBuilder<'a> {
self.scopes = scopes; self.scopes = scopes;
} }
pub async fn build(self) -> (HashSet<ReminderError>, HashSet<(Reminder, ReminderScope)>) { pub async fn build(self) -> (HashSet<ReminderError>, HashSet<ReminderScope>) {
let pool = self.ctx.data().database.clone();
let mut errors = HashSet::new(); let mut errors = HashSet::new();
let mut ok_locs = HashSet::new(); let mut ok_locs = HashSet::new();
if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) as i64) < *MIN_INTERVAL) { if self.interval.map_or(false, |i| (i as i64) < *MIN_INTERVAL) {
errors.insert(ReminderError::ShortInterval); errors.insert(ReminderError::ShortInterval);
} else if self.interval.map_or(false, |i| ((i.sec + i.month * 30 * DAY) as i64) > *MAX_TIME) } else if self.interval.map_or(false, |i| (i as i64) > *MAX_TIME) {
{
errors.insert(ReminderError::LongInterval); errors.insert(ReminderError::LongInterval);
} else { } else {
for scope in self.scopes { for scope in self.scopes {
let db_channel_id = match scope { let db_channel_id = match scope {
ReminderScope::User(user_id) => { ReminderScope::User(user_id) => {
if let Ok(user) = UserId(user_id).to_user(&self.ctx.discord()).await { if let Ok(user) = UserId(user_id).to_user(&self.ctx.discord()).await {
let user_data = UserData::from_user( let user_data = UserData::from_user(&user, &self.ctx.discord(), &pool)
&user, .await
&self.ctx.discord(), .unwrap();
&self.ctx.data().database,
)
.await
.unwrap();
if let Some(guild_id) = self.guild_id { if let Some(guild_id) = self.guild_id {
if guild_id.member(&self.ctx.discord(), user).await.is_err() { if guild_id.member(&self.ctx.discord(), user).await.is_err() {
Err(ReminderError::InvalidTag) Err(ReminderError::InvalidTag)
} else if self.set_by.map_or(true, |i| i != user_data.id)
&& !user_data.allowed_dm
{
Err(ReminderError::UserBlockedDm)
} else { } else {
Ok(user_data.dm_channel) Ok(user_data.dm_channel)
} }
@ -256,9 +238,7 @@ impl<'a> MultiReminderBuilder<'a> {
Err(ReminderError::InvalidTag) Err(ReminderError::InvalidTag)
} else { } else {
let mut channel_data = let mut channel_data =
ChannelData::from_channel(&channel, &self.ctx.data().database) ChannelData::from_channel(&channel, &pool).await.unwrap();
.await
.unwrap();
if channel_data.webhook_id.is_none() if channel_data.webhook_id.is_none()
|| channel_data.webhook_token.is_none() || channel_data.webhook_token.is_none()
@ -275,9 +255,7 @@ impl<'a> MultiReminderBuilder<'a> {
Some(webhook.id.as_u64().to_owned()); Some(webhook.id.as_u64().to_owned());
channel_data.webhook_token = webhook.token; channel_data.webhook_token = webhook.token;
channel_data channel_data.commit_changes(&pool).await;
.commit_changes(&self.ctx.data().database)
.await;
Ok(channel_data.id) Ok(channel_data.id)
} }
@ -297,13 +275,12 @@ impl<'a> MultiReminderBuilder<'a> {
match db_channel_id { match db_channel_id {
Ok(c) => { Ok(c) => {
let builder = ReminderBuilder { let builder = ReminderBuilder {
pool: self.ctx.data().database.clone(), pool: pool.clone(),
uid: generate_uid(), uid: generate_uid(),
channel: c, channel: c,
utc_time: self.utc_time, utc_time: self.utc_time,
timezone: self.timezone.to_string(), timezone: self.timezone.to_string(),
interval_secs: self.interval.map(|i| i.sec as i64), interval: self.interval,
interval_months: self.interval.map(|i| i.month as i64),
expires: self.expires, expires: self.expires,
content: self.content.content.clone(), content: self.content.content.clone(),
tts: self.content.tts, tts: self.content.tts,
@ -313,8 +290,8 @@ impl<'a> MultiReminderBuilder<'a> {
}; };
match builder.build().await { match builder.build().await {
Ok(r) => { Ok(_) => {
ok_locs.insert((r, scope)); ok_locs.insert(scope);
} }
Err(e) => { Err(e) => {
errors.insert(e); errors.insert(e);

View File

@ -7,7 +7,6 @@ pub enum ReminderError {
PastTime, PastTime,
ShortInterval, ShortInterval,
InvalidTag, InvalidTag,
UserBlockedDm,
DiscordError(String), DiscordError(String),
} }
@ -31,9 +30,6 @@ impl ToString for ReminderError {
ReminderError::InvalidTag => { 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() "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), ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s),
} }
} }

View File

@ -1,6 +1,25 @@
use num_integer::Integer;
use rand::{rngs::OsRng, seq::IteratorRandom}; use rand::{rngs::OsRng, seq::IteratorRandom};
use crate::consts::CHARACTERS; 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(", ")
}
pub fn generate_uid() -> String { pub fn generate_uid() -> String {
let mut generator: OsRng = Default::default(); let mut generator: OsRng = Default::default();

View File

@ -4,19 +4,17 @@ pub mod errors;
mod helper; mod helper;
pub mod look_flags; pub mod look_flags;
use std::hash::{Hash, Hasher};
use chrono::{NaiveDateTime, TimeZone}; use chrono::{NaiveDateTime, TimeZone};
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::{ use poise::serenity::model::id::{ChannelId, GuildId, UserId};
serenity::model::id::{ChannelId, GuildId, UserId}, use sqlx::{Executor, MySqlPool};
serenity_prelude::Cache,
};
use sqlx::Executor;
use crate::{ use crate::{
models::reminder::look_flags::{LookFlags, TimeDisplayType}, models::reminder::{
Database, helper::longhand_displacement,
look_flags::{LookFlags, TimeDisplayType},
},
Context, Database,
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -25,8 +23,7 @@ pub struct Reminder {
pub uid: String, pub uid: String,
pub channel: u64, pub channel: u64,
pub utc_time: NaiveDateTime, pub utc_time: NaiveDateTime,
pub interval_seconds: Option<u32>, pub interval: Option<u32>,
pub interval_months: Option<u32>,
pub expires: Option<NaiveDateTime>, pub expires: Option<NaiveDateTime>,
pub enabled: bool, pub enabled: bool,
pub content: String, pub content: String,
@ -34,22 +31,8 @@ pub struct Reminder {
pub set_by: Option<u64>, 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 { impl Reminder {
pub async fn from_uid(pool: impl Executor<'_, Database = Database>, uid: &str) -> Option<Self> { pub async fn from_uid(pool: &MySqlPool, uid: String) -> Option<Self> {
sqlx::query_as_unchecked!( sqlx::query_as_unchecked!(
Self, Self,
" "
@ -58,8 +41,7 @@ SELECT
reminders.uid, reminders.uid,
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval,
reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
reminders.content, reminders.content,
@ -85,44 +67,8 @@ WHERE
.ok() .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>>( pub async fn from_channel<C: Into<ChannelId>>(
pool: impl Executor<'_, Database = Database>, db_pool: impl Executor<'_, Database = Database>,
channel_id: C, channel_id: C,
flags: &LookFlags, flags: &LookFlags,
) -> Vec<Self> { ) -> Vec<Self> {
@ -137,8 +83,7 @@ SELECT
reminders.uid, reminders.uid,
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval,
reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
reminders.content, reminders.content,
@ -163,19 +108,21 @@ ORDER BY
channel_id.as_u64(), channel_id.as_u64(),
enabled, enabled,
) )
.fetch_all(pool) .fetch_all(db_pool)
.await .await
.unwrap() .unwrap()
} }
pub async fn from_guild( pub async fn from_guild(
cache: impl AsRef<Cache>, ctx: &Context<'_>,
pool: impl Executor<'_, Database = Database>,
guild_id: Option<GuildId>, guild_id: Option<GuildId>,
user: UserId, user: UserId,
) -> Vec<Self> { ) -> 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 { if let Some(guild_id) = guild_id {
let guild_opt = guild_id.to_guild_cached(cache); let guild_opt = guild_id.to_guild_cached(&ctx.discord());
if let Some(guild) = guild_opt { if let Some(guild) = guild_opt {
let channels = guild let channels = guild
@ -194,8 +141,7 @@ SELECT
reminders.uid, reminders.uid,
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval,
reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
reminders.content, reminders.content,
@ -216,7 +162,7 @@ WHERE
", ",
channels channels
) )
.fetch_all(pool) .fetch_all(&pool)
.await .await
} else { } else {
sqlx::query_as_unchecked!( sqlx::query_as_unchecked!(
@ -227,8 +173,7 @@ SELECT
reminders.uid, reminders.uid,
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval,
reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
reminders.content, reminders.content,
@ -249,7 +194,7 @@ WHERE
", ",
guild_id.as_u64() guild_id.as_u64()
) )
.fetch_all(pool) .fetch_all(&pool)
.await .await
} }
} else { } else {
@ -261,8 +206,7 @@ SELECT
reminders.uid, reminders.uid,
channels.channel, channels.channel,
reminders.utc_time, reminders.utc_time,
reminders.interval_seconds, reminders.interval,
reminders.interval_months,
reminders.expires, reminders.expires,
reminders.enabled, reminders.enabled,
reminders.content, reminders.content,
@ -283,19 +227,12 @@ WHERE
", ",
user.as_u64() user.as_u64()
) )
.fetch_all(pool) .fetch_all(&pool)
.await .await
} }
.unwrap() .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 { pub fn display_content(&self) -> &str {
if self.content.is_empty() { if self.content.is_empty() {
&self.embed_description &self.embed_description
@ -310,7 +247,10 @@ WHERE
count + 1, count + 1,
self.display_content(), self.display_content(),
self.channel, self.channel,
timezone.timestamp(self.utc_time.timestamp(), 0).format("%Y-%m-%d %H:%M:%S") timezone
.timestamp(self.utc_time.timestamp(), 0)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
) )
} }
@ -324,11 +264,12 @@ WHERE
TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()), TimeDisplayType::Relative => format!("<t:{}:R>", self.utc_time.timestamp()),
}; };
if self.interval_seconds.is_some() || self.interval_months.is_some() { if let Some(interval) = self.interval {
format!( format!(
"'{}' *occurs next at* **{}**, repeating (set by {})", "'{}' *occurs next at* **{}**, repeating every **{}** (set by {})",
self.display_content(), self.display_content(),
time_display, time_display,
longhand_displacement(interval as u64),
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string()) self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
) )
} else { } else {

View File

@ -10,7 +10,6 @@ pub struct UserData {
pub user: u64, pub user: u64,
pub dm_channel: u32, pub dm_channel: u32,
pub timezone: String, pub timezone: String,
pub allowed_dm: bool,
} }
impl UserData { impl UserData {
@ -47,7 +46,7 @@ SELECT timezone FROM users WHERE user = ?
match sqlx::query_as_unchecked!( match sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone, allowed_dm FROM users WHERE user = ? SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ?
", ",
*LOCAL_TIMEZONE, *LOCAL_TIMEZONE,
user_id.0 user_id.0
@ -72,7 +71,7 @@ INSERT IGNORE INTO channels (channel) VALUES (?)
sqlx::query!( sqlx::query!(
" "
INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id FROM channels WHERE channel = ?), ?) INSERT INTO users (user, dm_channel, timezone) VALUES (?, (SELECT id FROM channels WHERE channel = ?), ?)
", ",
user_id.0, user_id.0,
dm_channel.id.0, dm_channel.id.0,
@ -84,7 +83,7 @@ INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id F
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ? SELECT id, user, dm_channel, timezone FROM users WHERE user = ?
", ",
user_id.0 user_id.0
) )
@ -103,10 +102,9 @@ SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ?
pub async fn commit_changes(&self, pool: &MySqlPool) { pub async fn commit_changes(&self, pool: &MySqlPool) {
sqlx::query!( sqlx::query!(
" "
UPDATE users SET timezone = ?, allowed_dm = ? WHERE id = ? UPDATE users SET timezone = ? WHERE id = ?
", ",
self.timezone, self.timezone,
self.allowed_dm,
self.id self.id
) )
.execute(pool) .execute(pool)

View File

@ -211,12 +211,14 @@ pub async fn natural_parser(time: &str, timezone: &str) -> Option<i64> {
.output() .output()
.await .await
.ok() .ok()
.and_then(|inner| { .map(|inner| {
if inner.status.success() { if inner.status.success() {
Some(from_utf8(&*inner.stdout).unwrap().parse::<i64>().unwrap()) Some(from_utf8(&*inner.stdout).unwrap().parse::<i64>().unwrap())
} else { } else {
None None
} }
}) })
.and_then(|inner| if inner < 0 { None } else { Some(inner) }) .flatten()
.map(|inner| if inner < 0 { None } else { Some(inner) })
.flatten()
} }

View File

@ -1,11 +1,7 @@
use poise::{ use poise::serenity::{
serenity::{ builder::CreateApplicationCommands,
builder::CreateApplicationCommands, http::CacheHttp,
http::CacheHttp, model::id::{GuildId, UserId},
model::id::{GuildId, UserId},
},
serenity_prelude as serenity,
serenity_prelude::interaction::MessageFlags,
}; };
use crate::{ use crate::{
@ -69,40 +65,3 @@ pub async fn check_guild_subscription(
false 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);
}
}

View File

@ -1,21 +0,0 @@
[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.5", 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"

View File

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

View File

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

View File

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

View File

@ -1,5 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgeo/7wBIYVax9bj4m
1UEe2H+H4YLsbApDCZMslZbubRuhRANCAAThFXZlXdtfMt2MvqHI7bebImhbDUe7
ZJ70ttacys8bBXNOVKxe+GKkcSHOvwEKueguPMeoA4VAnX/pzMw9DVk4
-----END PRIVATE KEY-----

View File

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

View File

@ -1,6 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCRBt7MeJXyJRq7grGQ
jEdBHE31hG5LuKQ5iL8yTVGk75Kc8ISll2LewbvQ3NhR6E+hZANiAATOa0EDWRBl
ng8q4NnVUEteXPibF/4IpzffwKx6U4BjusWi35G3pCI+4HsFEVUHEK2LpcLtNh0x
Q8xlb39Q3QmcyCSyVKZfo2oHSNOcjDxYNrOZU4cxgehxDcsNSIVNXTE=
-----END PRIVATE KEY-----

View File

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

View File

@ -1,3 +0,0 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIGtLkaYE4D+P5HLkoc3a/tNhPP+gnhPLF3jEtdYcAtpd
-----END PRIVATE KEY-----

View File

@ -1,114 +0,0 @@
#! /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

View File

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

View File

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

View File

@ -1,48 +0,0 @@
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);
}

View File

@ -1,204 +0,0 @@
#[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
}
}

View File

@ -1,125 +0,0 @@
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 }))
};
}

View File

@ -1,430 +0,0 @@
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")
}
}
}

View File

@ -1,528 +0,0 @@
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"}))
}
}
}

View File

@ -1,591 +0,0 @@
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"))
}
}

View File

@ -1,165 +0,0 @@
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 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"})
}
}

View File

@ -1,148 +0,0 @@
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)"))
}
}

View File

@ -1,106 +0,0 @@
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)
}

File diff suppressed because one or more lines are too long

View File

@ -1,91 +0,0 @@
.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;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,63 +0,0 @@
@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;
}

View File

@ -1,582 +0,0 @@
* {
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;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -1,19 +0,0 @@
{
"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"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

View File

@ -1,931 +0,0 @@
(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 = "&lt;";
row.children[2].innerHTML = "&gt;";
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},
}),
},
});
})();

View File

@ -1,23 +0,0 @@
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 = "";
});

View File

@ -1,88 +0,0 @@
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 = "";
});
});
}
});

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
/*! js-cookie v3.0.0-rc.0 | MIT */
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self,function(){var r=e.Cookies,n=e.Cookies=t();n.noConflict=function(){return e.Cookies=r,n}}())}(this,function(){"use strict";function e(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)e[n]=r[n]}return e}var t={read:function(e){return e.replace(/%3B/g,";")},write:function(e){return e.replace(/;/g,"%3B")}};return function r(n,i){function o(r,o,u){if("undefined"!=typeof document){"number"==typeof(u=e({},i,u)).expires&&(u.expires=new Date(Date.now()+864e5*u.expires)),u.expires&&(u.expires=u.expires.toUTCString()),r=t.write(r).replace(/=/g,"%3D"),o=n.write(String(o),r);var c="";for(var f in u)u[f]&&(c+="; "+f,!0!==u[f]&&(c+="="+u[f].split(";")[0]));return document.cookie=r+"="+o+c}}return Object.create({set:o,get:function(e){if("undefined"!=typeof document&&(!arguments.length||e)){for(var r=document.cookie?document.cookie.split("; "):[],i={},o=0;o<r.length;o++){var u=r[o].split("="),c=u.slice(1).join("="),f=t.read(u[0]).replace(/%3D/g,"=");if(i[f]=n.read(c,f),e===f)break}return e?i[e]:i}},remove:function(t,r){o(t,"",e({},r,{expires:-1}))},withAttributes:function(t){return r(this.converter,e({},this.attributes,t))},withConverter:function(t){return r(e({},this.converter,t),this.attributes)}},{attributes:{value:Object.freeze(i)},converter:{value:Object.freeze(n)}})}(t,{path:"/"})});

File diff suppressed because one or more lines are too long

View File

@ -1,974 +0,0 @@
let colorPicker = new iro.ColorPicker("#colorpicker");
let $discordFrame;
const $loader = document.querySelector("#loader");
const $colorPickerModal = document.querySelector("div#pickColorModal");
const $colorPickerInput = $colorPickerModal.querySelector("input");
const $deleteReminderBtn = document.querySelector("#delete-reminder-confirm");
const $reminderTemplate = document.querySelector("template#guildReminder");
const $embedFieldTemplate = document.querySelector("template#embedFieldTemplate");
const $createReminder = document.querySelector("#reminderCreator");
const $createReminderBtn = $createReminder.querySelector("button#createReminder");
const $createTemplateBtn = $createReminder.querySelector("button#createTemplate");
const $loadTemplateBtn = document.querySelector("button#load-template");
const $deleteTemplateBtn = document.querySelector("button#delete-template");
const $templateSelect = document.querySelector("select#templateSelect");
const $exportBtn = document.querySelector("button#export-data");
const $importBtn = document.querySelector("button#import-data");
const $downloader = document.querySelector("a#downloader");
const $uploader = document.querySelector("input#uploader");
let channels = [];
let guildNames = {};
let roles = [];
let templates = {};
let mentions = new Tribute({
values: [],
allowSpaces: true,
selectTemplate: (item) => {
return `<@&${item.original.value}>`;
},
});
let globalPatreon = false;
let guildPatreon = false;
function guildId() {
return document.querySelector(".guildList a.is-active").dataset["guild"];
}
function colorToInt(r, g, b) {
return (r << 16) + (g << 8) + b;
}
function intToColor(i) {
return `#${i.toString(16).padStart(6, "0")}`;
}
function switch_pane(selector) {
document.querySelectorAll("aside a").forEach((el) => {
el.classList.remove("is-active");
});
document.querySelectorAll("div.is-main-content > section").forEach((el) => {
el.classList.add("is-hidden");
});
document.getElementById(selector).classList.remove("is-hidden");
}
function update_select(sel) {
if (sel.selectedOptions[0].dataset["webhookAvatar"]) {
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src =
sel.selectedOptions[0].dataset["webhookAvatar"];
} else {
sel.closest("div.reminderContent").querySelector("img.discord-avatar").src = "";
}
if (sel.selectedOptions[0].dataset["webhookName"]) {
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
sel.selectedOptions[0].dataset["webhookName"];
} else {
sel.closest("div.reminderContent").querySelector("input.discord-username").value =
"";
}
}
function reset_guild_pane() {
document
.querySelectorAll("select.channel-selector option")
.forEach((opt) => opt.remove());
}
async function fetch_patreon(guild_id) {
fetch(`/dashboard/api/guild/${guild_id}/patreon`)
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
return data.patreon;
}
});
}
function fetch_roles(guild_id) {
fetch(`/dashboard/api/guild/${guild_id}/roles`)
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
let values = Array.from(
data.map((role) => {
return {
key: role.name,
value: role.id,
};
})
);
mentions.collection[0].values = values;
}
});
}
function fetch_templates(guild_id) {
fetch(`/dashboard/api/guild/${guild_id}/templates`)
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
templates = {};
const select = document.querySelector("#templateSelect");
select.innerHTML = "";
for (let template of data) {
templates[template["id"]] = template;
let option = document.createElement("option");
option.value = template["id"];
option.textContent = template["name"];
select.appendChild(option);
}
}
});
}
async function fetch_channels(guild_id) {
const event = new Event("channelsLoading");
document.dispatchEvent(event);
await fetch(`/dashboard/api/guild/${guild_id}/channels`)
.then((response) => response.json())
.then((data) => {
if (data.error) {
if (data.error === "Bot not in guild") {
switch_pane("guild-error");
} else {
show_error(data.error);
}
} else {
channels = data;
}
})
.then(() => {
const event = new Event("channelsLoaded");
document.dispatchEvent(event);
});
}
async function fetch_reminders(guild_id) {
document.dispatchEvent(new Event("remindersLoading"));
const $reminderBox = document.querySelector("div#guildReminders");
// reset div contents
$reminderBox.innerHTML = "";
// fetch reminders
await fetch(`/dashboard/api/guild/${guild_id}/reminders`)
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
for (let reminder of data) {
let newFrame = $reminderTemplate.content.cloneNode(true);
newFrame.querySelector(".reminderContent").dataset["uid"] =
reminder["uid"];
mentions.attach(newFrame.querySelector("textarea"));
deserialize_reminder(reminder, newFrame, "load");
$reminderBox.appendChild(newFrame);
reminder.node = $reminderBox.lastElementChild;
}
const remindersLoadedEvent = new CustomEvent("remindersLoaded", {
detail: data,
});
document.dispatchEvent(remindersLoadedEvent);
}
});
}
async function serialize_reminder(node, mode) {
let interval, utc_time, expiration_time;
if (mode !== "template") {
interval = get_interval(node);
utc_time = luxon.DateTime.fromISO(
node.querySelector('input[name="time"]').value
).setZone("UTC");
if (utc_time.invalid) {
return { error: "Time provided invalid." };
} else {
utc_time = utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
}
expiration_time = luxon.DateTime.fromISO(
node.querySelector('input[name="time"]').value
).setZone("UTC");
if (expiration_time.invalid) {
return { error: "Expiration provided invalid." };
} else {
expiration_time = expiration_time.toFormat("yyyy-LL-dd'T'HH:mm:ss");
}
}
let rgb_color = window.getComputedStyle(
node.querySelector("div.discord-embed")
).borderLeftColor;
let rgb = rgb_color.match(/\d+/g);
let color = colorToInt(parseInt(rgb[0]), parseInt(rgb[1]), parseInt(rgb[2]));
let fields = [
...node.querySelectorAll("div.embed-multifield-box div.embed-field-box"),
]
.map((el) => {
return {
title: el.querySelector("textarea.discord-field-title").value,
value: el.querySelector("textarea.discord-field-value").value,
inline: el.dataset["inlined"] === "1",
};
})
.filter(({ title, value, inline }) => title.length + value.length > 0);
let attachment = null;
let attachment_name = null;
if (node.querySelector('input[name="attachment"]').files.length > 0) {
let file = node.querySelector('input[name="attachment"]').files[0];
if (file.size >= 8 * 1024 * 1024) {
return { error: "File too large." };
}
attachment = await new Promise((resolve) => {
let fileReader = new FileReader();
fileReader.onload = (e) => resolve(fileReader.result);
fileReader.readAsDataURL(file);
});
attachment = attachment.split(",")[1];
attachment_name = file.name;
}
let uid = "";
if (mode === "edit") {
uid = node.closest(".reminderContent").dataset["uid"];
}
let enabled = null;
if (mode === "create") {
enabled = true;
}
const content = node.querySelector('textarea[name="content"]').value;
const embed_author_url = has_source(node.querySelector("img.embed_author_url").src);
const embed_author = node.querySelector('textarea[name="embed_author"]').value;
const embed_description = node.querySelector(
'textarea[name="embed_description"]'
).value;
const embed_footer = node.querySelector('textarea[name="embed_footer"]').value;
const embed_footer_url = has_source(node.querySelector("img.embed_footer_url").src);
const embed_image_url = has_source(node.querySelector("img.embed_image_url").src);
const embed_thumbnail_url = has_source(
node.querySelector("img.embed_thumbnail_url").src
);
const embed_title = node.querySelector('textarea[name="embed_title"]').value;
if (
attachment === null &&
content.length == 0 &&
embed_author_url === null &&
embed_author.length == 0 &&
embed_description.length == 0 &&
embed_footer.length == 0 &&
embed_footer_url === null &&
embed_image_url === null &&
embed_thumbnail_url === null
) {
return { error: "Reminder needs content." };
}
return {
// if we're creating a reminder, ignore this field
uid: uid,
// if we're editing a reminder, ignore this field
enabled: enabled,
restartable: false,
attachment: attachment,
attachment_name: attachment_name,
avatar: has_source(node.querySelector("img.discord-avatar").src),
channel: node.querySelector("select.channel-selector").value,
content: content,
embed_author_url: embed_author_url,
embed_author: embed_author,
embed_color: color,
embed_description: embed_description,
embed_footer: embed_footer,
embed_footer_url: embed_footer_url,
embed_image_url: embed_image_url,
embed_thumbnail_url: embed_thumbnail_url,
embed_title: embed_title,
embed_fields: fields,
expires: expiration_time,
interval_seconds: mode !== "template" ? interval.seconds : null,
interval_months: mode !== "template" ? interval.months : null,
name: node.querySelector('input[name="name"]').value,
tts: node.querySelector('input[name="tts"]').checked,
username: node.querySelector('input[name="username"]').value,
utc_time: utc_time,
};
}
function deserialize_reminder(reminder, frame, mode) {
// populate channels
set_channels(frame.querySelector("select.channel-selector"));
// populate majority of items
for (let prop in reminder) {
if (reminder.hasOwnProperty(prop) && reminder[prop] !== null) {
if (prop === "attachment") {
} else if (prop === "attachment_name") {
frame.querySelector(".file-cta > .file-label").textContent =
reminder[prop];
} else {
let $input = frame.querySelector(`*[name="${prop}"]`);
let $image = frame.querySelector(`img.${prop}`);
if ($input !== null) {
$input.value = reminder[prop];
} else if ($image !== null) {
$image.src = reminder[prop];
}
}
}
}
const lastChild = frame.querySelector("div.embed-multifield-box .embed-field-box");
for (let field of reminder["embed_fields"]) {
let embed_field = $embedFieldTemplate.content.cloneNode(true);
embed_field.querySelector("textarea.discord-field-title").value = field["title"];
embed_field.querySelector("textarea.discord-field-value").value = field["value"];
embed_field.querySelector(".embed-field-box").dataset["inlined"] = field["inline"]
? "1"
: "0";
frame
.querySelector("div.embed-multifield-box")
.insertBefore(embed_field, lastChild);
}
if (mode !== "template") {
if (reminder["interval_seconds"]) update_interval(frame);
let $enableBtn = frame.querySelector(".disable-enable");
$enableBtn.dataset["action"] = reminder["enabled"] ? "disable" : "enable";
let timeInput = frame.querySelector('input[name="time"]');
let localTime = luxon.DateTime.fromISO(reminder["utc_time"], {
zone: "UTC",
}).setZone(timezone);
timeInput.value = localTime.toFormat("yyyy-LL-dd'T'HH:mm:ss");
if (reminder["expires"]) {
let expiresInput = frame.querySelector('input[name="time"]');
let expiresTime = luxon.DateTime.fromISO(reminder["expires"], {
zone: "UTC",
}).setZone(timezone);
expiresInput.value = expiresTime.toFormat("yyyy-LL-dd'T'HH:mm:ss");
}
}
}
document.addEventListener("guildSwitched", async (e) => {
$loader.classList.remove("is-hidden");
document
.querySelectorAll(".patreon-only")
.forEach((el) => el.classList.add("is-locked"));
let $anchor = document.querySelector(
`.switch-pane[data-guild="${e.detail.guild_id}"]`
);
switch_pane($anchor.dataset["pane"]);
reset_guild_pane();
$anchor.classList.add("is-active");
if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) {
document
.querySelectorAll(".patreon-only")
.forEach((el) => el.classList.remove("is-locked"));
}
fetch_roles(e.detail.guild_id);
fetch_templates(e.detail.guild_id);
await fetch_channels(e.detail.guild_id);
fetch_reminders(e.detail.guild_id);
document.querySelectorAll("p.pageTitle").forEach((el) => {
el.textContent = `${e.detail.guild_name} Reminders`;
});
document.querySelectorAll("select.channel-selector").forEach((el) => {
el.addEventListener("change", (e) => {
update_select(e.target);
});
});
$loader.classList.add("is-hidden");
});
document.addEventListener("channelsLoaded", () => {
document.querySelectorAll("select.channel-selector").forEach(set_channels);
});
document.addEventListener("remindersLoaded", (event) => {
const guild = guildId();
for (let reminder of event.detail) {
let node = reminder.node;
node.querySelector("button.hide-box").addEventListener("click", () => {
node.closest(".reminderContent").classList.toggle("is-collapsed");
});
node.querySelector("div.discord-embed").style.borderLeftColor = intToColor(
reminder.embed_color
);
const enableBtn = node.querySelector(".disable-enable");
enableBtn.addEventListener("click", () => {
let enable = enableBtn.dataset["action"] === "enable";
fetch(`/dashboard/api/guild/${guild}/reminders`, {
method: "PATCH",
body: JSON.stringify({
uid: reminder["uid"],
enabled: enable,
}),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
enableBtn.dataset["action"] = data["enabled"]
? "enable"
: "disable";
}
});
});
node.querySelector("button.delete-reminder").addEventListener("click", () => {
$deleteReminderBtn.dataset["uid"] = reminder["uid"];
$deleteReminderBtn.closest(".modal").classList.toggle("is-active");
});
const $saveBtn = node.querySelector("button.save-btn");
$saveBtn.addEventListener("click", async (event) => {
$saveBtn.querySelector("span.icon > i").classList = [
"fas fa-spinner fa-spin",
];
let reminder = await serialize_reminder(node, "edit");
if (reminder.error) {
show_error(reminder.error);
return;
}
let guild = guildId();
fetch(`/dashboard/api/guild/${guild}/reminders`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reminder),
})
.then((response) => response.json())
.then((data) => {
for (let error of data.errors) show_error(error);
});
$saveBtn.querySelector("span.icon > i").classList = ["fas fa-check"];
window.setTimeout(() => {
$saveBtn.querySelector("span.icon > i").classList = ["fas fa-save"];
}, 1500);
});
}
});
$deleteReminderBtn.addEventListener("click", () => {
let guild = guildId();
fetch(`/dashboard/api/guild/${guild}/reminders`, {
method: "DELETE",
body: JSON.stringify({
uid: $deleteReminderBtn.dataset["uid"],
}),
}).then(() => {
document.querySelector("#deleteReminderModal").classList.remove("is-active");
fetch_reminders(guild);
});
});
function show_error(error) {
document.getElementById("errors").querySelector("span.error-message").textContent =
error;
document.getElementById("errors").classList.add("is-active");
window.setTimeout(() => {
document.getElementById("errors").classList.remove("is-active");
}, 5000);
}
$colorPickerInput.value = colorPicker.color.hexString;
$colorPickerInput.addEventListener("input", () => {
if (/^#[0-9a-fA-F]{6}$/.test($colorPickerInput.value) === true) {
colorPicker.color.hexString = $colorPickerInput.value;
}
});
colorPicker.on("color:change", function (color) {
$colorPickerInput.value = color.hexString;
});
$colorPickerModal.querySelector("button.is-success").addEventListener("click", () => {
$discordFrame.style.borderLeftColor = colorPicker.color.rgbString;
$colorPickerModal.classList.remove("is-active");
});
document.querySelectorAll(".show-modal").forEach((element) => {
element.addEventListener("click", (e) => {
e.preventDefault();
document.getElementById(element.dataset["modal"]).classList.toggle("is-active");
});
});
document.addEventListener("DOMContentLoaded", () => {
$loader.classList.remove("is-hidden");
mentions.attach(document.querySelectorAll("textarea"));
document.querySelectorAll(".navbar-burger").forEach((el) => {
el.addEventListener("click", () => {
const target = el.dataset["target"];
const $target = document.getElementById(target);
el.classList.toggle("is-active");
$target.classList.toggle("is-active");
});
});
let hideBox = document.querySelector("#reminderCreator button.hide-box");
hideBox.addEventListener("click", () => {
hideBox.closest(".reminderContent").classList.toggle("is-collapsed");
});
fetch("/dashboard/api/user")
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
if (data.timezone !== null) botTimezone = data.timezone;
globalPatreon = data.patreon;
update_times();
}
});
fetch("/dashboard/api/user/guilds")
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
const $template = document.getElementById("guildListEntry");
for (let guild of data) {
guildNames[guild.id] = guild.name;
document.querySelectorAll(".guildList").forEach((element) => {
const $clone = $template.content.cloneNode(true);
const $anchor = $clone.querySelector("a");
let $span = $clone.querySelector("a > span.guild-name");
$span.textContent = $span.textContent.replace(
"%guildname%",
guild.name
);
$anchor.dataset["guild"] = guild.id;
$anchor.dataset["name"] = guild.name;
$anchor.href = `/dashboard/${guild.id}?name=${guild.name}`;
$anchor.addEventListener("click", async (e) => {
e.preventDefault();
window.history.pushState({}, "", `/dashboard/${guild.id}`);
const event = new CustomEvent("guildSwitched", {
detail: {
guild_name: guild.name,
guild_id: guild.id,
},
});
document.dispatchEvent(event);
});
element.append($clone);
});
}
const matches = window.location.href.match(/dashboard\/(\d+)/);
if (matches) {
let id = matches[1];
let name = guildNames[id];
const event = new CustomEvent("guildSwitched", {
detail: {
guild_name: name,
guild_id: id,
},
});
document.dispatchEvent(event);
}
}
});
$loader.classList.add("is-hidden");
});
function set_channels(element) {
for (let channel of channels) {
let newOption = document.createElement("option");
newOption.value = channel.id;
newOption.textContent = channel.name;
element.appendChild(newOption);
}
update_select(element);
}
function has_source(string) {
if (string.startsWith(`https://${window.location.hostname}`)) {
return null;
} else {
return string;
}
}
$uploader.addEventListener("change", (ev) => {
const urlTail = document.querySelector('input[name="exportSelect"]:checked').value;
new Promise((resolve) => {
let fileReader = new FileReader();
fileReader.onload = (e) => resolve(fileReader.result);
fileReader.readAsDataURL($uploader.files[0]);
}).then((dataUrl) => {
fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`, {
method: "PUT",
body: JSON.stringify({ body: dataUrl.split(",")[1] }),
}).then(() => {
delete $uploader.files[0];
});
});
});
$importBtn.addEventListener("click", () => {
$uploader.click();
});
$exportBtn.addEventListener("click", () => {
const urlTail = document.querySelector('input[name="exportSelect"]:checked').value;
fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`)
.then((response) => response.json())
.then((data) => {
$downloader.href =
"data:text/plain;charset=utf-8," + encodeURIComponent(data.body);
$downloader.click();
});
});
$createReminderBtn.addEventListener("click", async () => {
$createReminderBtn.querySelector("span.icon > i").classList = [
"fas fa-spinner fa-spin",
];
let reminder = await serialize_reminder($createReminder, "create");
if (reminder.error) {
show_error(reminder.error);
return;
}
let guild = guildId();
fetch(`/dashboard/api/guild/${guild}/reminders`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reminder),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
$createReminderBtn.querySelector("span.icon > i").classList = [
"fas fa-sparkles",
];
} else {
const $reminderBox = document.querySelector("div#guildReminders");
let newFrame = $reminderTemplate.content.cloneNode(true);
newFrame.querySelector(".reminderContent").dataset["uid"] = data["uid"];
deserialize_reminder(data, newFrame, "load");
$reminderBox.appendChild(newFrame);
data.node = $reminderBox.lastElementChild;
document.dispatchEvent(
new CustomEvent("remindersLoaded", {
detail: [data],
})
);
$createReminderBtn.querySelector("span.icon > i").classList = [
"fas fa-check",
];
window.setTimeout(() => {
$createReminderBtn.querySelector("span.icon > i").classList = [
"fas fa-sparkles",
];
}, 1500);
}
});
});
$createTemplateBtn.addEventListener("click", async () => {
$createTemplateBtn.querySelector("span.icon > i").classList = [
"fas fa-spinner fa-spin",
];
let reminder = await serialize_reminder($createReminder, "template");
let guild = guildId();
fetch(`/dashboard/api/guild/${guild}/templates`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reminder),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
$createTemplateBtn.querySelector("span.icon > i").classList = [
"fas fa-file-spreadsheet",
];
} else {
fetch_templates(guildId());
$createTemplateBtn.querySelector("span.icon > i").classList = [
"fas fa-check",
];
window.setTimeout(() => {
$createTemplateBtn.querySelector("span.icon > i").classList = [
"fas fa-file-spreadsheet",
];
}, 1500);
}
});
});
$loadTemplateBtn.addEventListener("click", (ev) => {
deserialize_reminder(
templates[parseInt($templateSelect.value)],
$createReminder,
"template"
);
});
$deleteTemplateBtn.addEventListener("click", (ev) => {
fetch(`/dashboard/api/guild/${guildId()}/templates`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: parseInt($templateSelect.value) }),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
$templateSelect
.querySelector(`option[value="${$templateSelect.value}"]`)
.remove();
}
});
});
document.querySelectorAll("textarea.autoresize").forEach((element) => {
element.addEventListener("input", () => {
element.style.height = "";
element.style.height = element.scrollHeight + 3 + "px";
});
});
let $img;
const $urlModal = document.querySelector("div#addImageModal");
const $urlInput = $urlModal.querySelector("input");
$urlModal.querySelector("button#setImgUrl").addEventListener("click", () => {
$img.src = $urlInput.value;
$urlInput.value = "";
$urlModal.classList.remove("is-active");
});
document.querySelectorAll("button.close-modal").forEach((element) => {
element.addEventListener("click", () => {
let $modal = element.closest("div.modal");
$urlInput.value = "";
$modal.classList.remove("is-active");
});
});
document.addEventListener("remindersLoaded", () => {
document.querySelectorAll(".customizable").forEach((element) => {
element.querySelector("a").addEventListener("click", (e) => {
e.preventDefault();
$img = element.querySelector("img");
$urlModal.classList.toggle("is-active");
});
});
const fileInput = document.querySelectorAll("input.file-input[type=file]");
fileInput.forEach((element) => {
element.addEventListener("change", () => {
if (element.files.length > 0) {
const fileName = element.parentElement.querySelector(".file-label");
fileName.textContent = element.files[0].name;
}
});
});
document.querySelectorAll(".change-color").forEach((element) => {
element.addEventListener("click", (e) => {
e.preventDefault();
$discordFrame = element
.closest("div.reminderContent")
.querySelector("div.discord-embed");
$colorPickerModal.classList.toggle("is-active");
colorPicker.color.rgbString =
window.getComputedStyle($discordFrame).borderLeftColor;
});
});
});
function check_embed_fields() {
document.querySelectorAll(".embed-field-box").forEach((element) => {
const $titleInput = element.querySelector(".discord-field-title");
const $valueInput = element.querySelector(".discord-field-value");
// when the user clicks out of the field title and if the field title/value are empty, remove the field
$titleInput.addEventListener("blur", () => {
if (
$titleInput.value === "" &&
$valueInput.value === "" &&
element.nextElementSibling !== null
) {
element.remove();
}
});
$valueInput.addEventListener("blur", () => {
if (
$titleInput.value === "" &&
$valueInput.value === "" &&
element.nextElementSibling !== null
) {
element.remove();
}
});
// when the user inputs into the end field, create a new field after it
$titleInput.addEventListener("input", () => {
if (
$titleInput.value !== "" &&
$valueInput.value !== "" &&
element.nextElementSibling === null
) {
const $clone = $embedFieldTemplate.content.cloneNode(true);
element.parentElement.append($clone);
}
});
$valueInput.addEventListener("input", () => {
if (
$titleInput.value !== "" &&
$valueInput.value !== "" &&
element.nextElementSibling === null
) {
const $clone = $embedFieldTemplate.content.cloneNode(true);
element.parentElement.append($clone);
}
});
});
}
document.addEventListener("DOMNodeInserted", () => {
document.querySelectorAll("div.mobile-sidebar a").forEach((element) => {
element.addEventListener("click", (e) => {
document.getElementById("mobileSidebar").classList.remove("is-active");
document.querySelectorAll(".navbar-burger").forEach((el) => {
el.classList.remove("is-active");
});
});
});
document.querySelectorAll('input[type="datetime-local"]').forEach((el) => {
let now = luxon.DateTime.now().setZone(timezone);
el.min = now.toFormat("yyyy-LL-dd'T'HH:mm:ss");
});
check_embed_fields();
});
document.addEventListener("click", (ev) => {
if (ev.target.closest("button.inline-btn") !== null) {
let inlined = ev.target.closest(".embed-field-box").dataset["inlined"];
ev.target.closest(".embed-field-box").dataset["inlined"] =
inlined == "1" ? "0" : "1";
}
});

View File

@ -1,70 +0,0 @@
let guildReminders = document.querySelector("#guildReminders");
function sort_by(cond) {
if (cond === "channel") {
[...guildReminders.children]
.sort((a, b) => {
let channel1 = a.querySelector("select.channel-selector").value;
let channel2 = b.querySelector("select.channel-selector").value;
return channel1 > channel2 ? 1 : -1;
})
.forEach((node) => guildReminders.appendChild(node));
// go through and add channel categories
let currentChannelGroup = null;
for (let child of guildReminders.querySelectorAll("div.reminderContent")) {
let thisChannelGroup = child.querySelector("select.channel-selector").value;
if (currentChannelGroup !== thisChannelGroup) {
let newNode = document.createElement("div");
newNode.textContent =
"#" + channels.find((a) => a.id === thisChannelGroup).name;
newNode.classList.add("channel-tag");
guildReminders.insertBefore(newNode, child);
currentChannelGroup = thisChannelGroup;
}
}
} else {
// remove any channel tags if previous ordering was by channel
guildReminders.querySelectorAll("div.channel-tag").forEach((el) => {
el.remove();
});
if (cond === "time") {
[...guildReminders.children]
.sort((a, b) => {
let time1 = luxon.DateTime.fromISO(
a.querySelector('input[name="time"]').value
);
let time2 = luxon.DateTime.fromISO(
b.querySelector('input[name="time"]').value
);
return time1 > time2 ? 1 : -1;
})
.forEach((node) => guildReminders.appendChild(node));
} else {
[...guildReminders.children]
.sort((a, b) => {
let name1 = a.querySelector('input[name="name"]').value;
let name2 = b.querySelector('input[name="name"]').value;
return name1 > name2 ? 1 : -1;
})
.forEach((node) => guildReminders.appendChild(node));
}
}
}
const selector = document.querySelector("#orderBy");
selector.addEventListener("change", () => {
sort_by(selector.value);
});
document.addEventListener("remindersLoaded", () => {
sort_by(selector.value);
});

View File

@ -1,57 +0,0 @@
let timezone = luxon.DateTime.now().zone.name;
const browserTimezone = luxon.DateTime.now().zone.name;
let botTimezone = "UTC";
function update_times() {
document.querySelectorAll("span.set-timezone").forEach((element) => {
element.textContent = timezone;
});
document.querySelectorAll("span.set-time").forEach((element) => {
element.textContent = luxon.DateTime.now().setZone(timezone).toFormat("HH:mm");
});
document.querySelectorAll("span.browser-timezone").forEach((element) => {
element.textContent = browserTimezone;
});
document.querySelectorAll("span.browser-time").forEach((element) => {
element.textContent = luxon.DateTime.now().toFormat("HH:mm");
});
document.querySelectorAll("span.bot-timezone").forEach((element) => {
element.textContent = botTimezone;
});
document.querySelectorAll("span.bot-time").forEach((element) => {
element.textContent = luxon.DateTime.now().setZone(botTimezone).toFormat("HH:mm");
});
}
window.setInterval(() => {
update_times();
}, 30000);
document.getElementById("set-bot-timezone").addEventListener("click", () => {
timezone = botTimezone;
update_times();
});
document.getElementById("set-browser-timezone").addEventListener("click", () => {
timezone = browserTimezone;
update_times();
});
document.getElementById("update-bot-timezone").addEventListener("click", () => {
timezone = browserTimezone;
fetch("/dashboard/api/user", {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ timezone: timezone }),
})
.then((response) => response.json())
.then((data) => {
if (data.error) {
show_error(data.error);
} else {
botTimezone = browserTimezone;
update_times();
}
});
});

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 712 KiB

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