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
126 changed files with 1782 additions and 74711 deletions

View File

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

2117
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,14 @@
[package]
name = "reminder_rs"
version = "1.6.0-beta3"
version = "1.6.0-beta2"
authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018"
[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"
humantime = "2.1"
tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11"
regex = "1.4"
@ -24,9 +26,3 @@ rand = "0.7"
levenshtein = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
base64 = "0.13.0"
[dependencies.postman]
path = "postman"
[dependencies.reminder_web]
path = "web"

View File

@ -30,10 +30,15 @@ __Other Variables__
* `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
* `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to subscribed users
* `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the subscription roles belong to
* `IGNORE_BOTS` - default `1`, if `1`, Reminder Bot will ignore all other bots
* `PYTHON_LOCATION` - default `venv/bin/python3`. Can be changed if your Python executable is located somewhere else
* `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message embeds
* `SHARD_COUNT` - default `None`, accepts the number of shards that are being ran
* `SHARD_RANGE` - default `None`, if `SHARD_COUNT` is specified, specifies what range of shards to start on this process
* `DM_ENABLED` - default `1`, if `1`, Reminder Bot will respond to direct messages
### Todo List
* Convert aliases to macros
* Help command
* Test everything

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

@ -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,34 +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;

View File

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

View File

@ -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,559 +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, 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").unwrap().as_str();
let format = caps.name("format").unwrap().as_str();
if let Ok(final_time) = final_time.parse::<i64>() {
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").unwrap().as_str();
println!("{}", timezone);
if let Ok(tz) = timezone.parse::<Tz>() {
let format = caps.name("format").unwrap().as_str();
let now = Utc::now().with_timezone(&tz);
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> {
let mut embed = 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
.unwrap();
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
}
}
pub fn has_content(&self) -> bool {
if self.title.is_empty()
&& self.description.is_empty()
&& self.image_url.is_none()
&& self.thumbnail_url.is_none()
&& self.footer.is_empty()
&& self.footer_url.is_none()
&& self.author.is_empty()
&& self.author_url.is_none()
&& self.fields.0.is_empty()
{
false
} else {
true
}
}
}
impl Into<CreateEmbed> for Embed {
fn into(self) -> CreateEmbed {
let mut c = CreateEmbed::default();
c.title(&self.title)
.description(&self.description)
.color(self.color)
.author(|a| {
a.name(&self.author);
if let Some(author_icon) = &self.author_url {
a.icon_url(author_icon);
}
a
})
.footer(|f| {
f.text(&self.footer);
if let Some(footer_icon) = &self.footer_url {
f.icon_url(footer_icon);
}
f
});
for field in &self.fields.0 {
c.field(&field.title, &field.value, field.inline);
}
if let Some(image_url) = &self.image_url {
c.image(image_url);
}
if let Some(thumbnail_url) = &self.thumbnail_url {
c.thumbnail(thumbnail_url);
}
c
}
}
#[derive(Debug)]
pub struct Reminder {
id: u32,
channel_id: u64,
webhook_id: Option<u64>,
webhook_token: Option<String>,
channel_paused: bool,
channel_paused_until: Option<NaiveDateTime>,
enabled: bool,
tts: bool,
pin: bool,
content: String,
attachment: Option<Vec<u8>>,
attachment_name: Option<String>,
utc_time: NaiveDateTime,
timezone: String,
restartable: bool,
expires: Option<NaiveDateTime>,
interval_seconds: Option<u32>,
interval_months: Option<u32>,
avatar: Option<String>,
username: Option<String>,
}
impl Reminder {
pub async fn fetch_reminders(pool: impl Executor<'_, Database = Database> + Copy) -> Vec<Self> {
sqlx::query_as_unchecked!(
Reminder,
"
SELECT
reminders.`id` AS id,
channels.`channel` AS channel_id,
channels.`webhook_id` AS webhook_id,
channels.`webhook_token` AS webhook_token,
channels.`paused` AS channel_paused,
channels.`paused_until` AS channel_paused_until,
reminders.`enabled` AS enabled,
reminders.`tts` AS tts,
reminders.`pin` AS pin,
reminders.`content` AS content,
reminders.`attachment` AS attachment,
reminders.`attachment_name` AS attachment_name,
reminders.`utc_time` AS 'utc_time',
reminders.`timezone` AS timezone,
reminders.`restartable` AS restartable,
reminders.`expires` AS expires,
reminders.`interval_seconds` AS 'interval_seconds',
reminders.`interval_months` AS 'interval_months',
reminders.`avatar` AS avatar,
reminders.`username` AS username
FROM
reminders
INNER JOIN
channels
ON
reminders.channel_id = channels.id
WHERE
reminders.`utc_time` < NOW()
",
)
.fetch_all(pool)
.await
.unwrap()
.into_iter()
.map(|mut rem| {
rem.content = substitute(&rem.content);
rem
})
.collect::<Vec<Self>>()
}
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 {
let row = 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
.unwrap();
updated_reminder_time = row.new_time.unwrap();
}
if let Some(interval) = self.interval_seconds {
while updated_reminder_time < now {
updated_reminder_time += Duration::seconds(interval as i64);
}
}
if self.expires.map_or(false, |expires| {
NaiveDateTime::from_timestamp(updated_reminder_time.timestamp(), 0) > expires
}) {
self.force_delete(pool).await;
} else {
sqlx::query!(
"
UPDATE reminders SET `utc_time` = ? WHERE `id` = ?
",
updated_reminder_time,
self.id
)
.execute(pool)
.await
.expect(&format!("Could not update time on Reminder {}", self.id));
}
} else {
self.force_delete(pool).await;
}
}
async fn force_delete(&self, pool: impl Executor<'_, Database = Database> + Copy) {
sqlx::query!(
"
DELETE FROM reminders WHERE `id` = ?
",
self.id
)
.execute(pool)
.await
.expect(&format!("Could not delete Reminder {}", self.id));
}
async fn pin_message<M: Into<u64>>(&self, message_id: M, http: impl AsRef<Http>) {
let _ = http.as_ref().pin_message(self.channel_id, message_id.into(), None).await;
}
pub async fn send(
&self,
pool: impl Executor<'_, Database = Database> + Copy,
cache_http: impl CacheHttp,
) {
async fn send_to_channel(
cache_http: impl CacheHttp,
reminder: &Reminder,
embed: Option<CreateEmbed>,
) -> Result<()> {
let channel = ChannelId(reminder.channel_id).to_channel(&cache_http).await;
match channel {
Ok(Channel::Guild(channel)) => {
match channel
.send_message(&cache_http, |m| {
m.content(&reminder.content).tts(reminder.tts);
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
m.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
m.set_embed(embed);
}
m
})
.await
{
Ok(m) => {
if reminder.pin {
reminder.pin_message(m.id, cache_http.http()).await;
}
Ok(())
}
Err(e) => Err(e),
}
}
Ok(Channel::Private(channel)) => {
match channel
.send_message(&cache_http.http(), |m| {
m.content(&reminder.content).tts(reminder.tts);
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
m.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
m.set_embed(embed);
}
m
})
.await
{
Ok(m) => {
if reminder.pin {
reminder.pin_message(m.id, cache_http.http()).await;
}
Ok(())
}
Err(e) => Err(e),
}
}
Err(e) => Err(e),
_ => Err(Error::Other("Channel not of valid type")),
}
}
async fn send_to_webhook(
cache_http: impl CacheHttp,
reminder: &Reminder,
webhook: Webhook,
embed: Option<CreateEmbed>,
) -> Result<()> {
match webhook
.execute(&cache_http.http(), reminder.pin || reminder.restartable, |w| {
w.content(&reminder.content).tts(reminder.tts);
if let Some(username) = &reminder.username {
w.username(username);
}
if let Some(avatar) = &reminder.avatar {
w.avatar_url(avatar);
}
if let (Some(attachment), Some(name)) =
(&reminder.attachment, &reminder.attachment_name)
{
w.add_file((attachment as &[u8], name.as_str()));
}
if let Some(embed) = embed {
w.embeds(vec![SerenityEmbed::fake(|c| {
*c = embed;
c
})]);
}
w
})
.await
{
Ok(m) => {
if reminder.pin {
if let Some(message) = m {
reminder.pin_message(message.id, cache_http.http()).await;
}
}
Ok(())
}
Err(e) => Err(e),
}
}
if self.enabled
&& !(self.channel_paused
&& self
.channel_paused_until
.map_or(true, |inner| inner >= Utc::now().naive_local()))
{
let _ = sqlx::query!(
"
UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
",
self.channel_id
)
.execute(pool)
.await;
let embed = Embed::from_id(pool, self.id).await.map(|e| e.into());
let result = if let (Some(webhook_id), Some(webhook_token)) =
(self.webhook_id, &self.webhook_token)
{
let webhook_res =
cache_http.http().get_webhook_with_token(webhook_id, webhook_token).await;
if let Ok(webhook) = webhook_res {
send_to_webhook(cache_http, &self, webhook, embed).await
} else {
warn!("Webhook vanished: {:?}", webhook_res);
self.reset_webhook(pool).await;
send_to_channel(cache_http, &self, embed).await
}
} else {
send_to_channel(cache_http, &self, embed).await
};
if let Err(e) = result {
error!("Error sending {:?}: {:?}", self, e);
if let Error::Http(error) = e {
if error.status_code() == Some(StatusCode::from_u16(404).unwrap()) {
error!("Seeing channel is deleted. Removing reminder");
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 poise::{serenity_prelude as serenity, serenity_prelude::Mentionable};
use poise::serenity::builder::CreateEmbedFooter;
use crate::{models::CtxData, Context, Error, THEME_COLOR};
fn footer(
ctx: Context<'_>,
) -> impl FnOnce(&mut serenity::CreateEmbedFooter) -> &mut serenity::CreateEmbedFooter {
fn footer(ctx: Context<'_>) -> impl FnOnce(&mut CreateEmbedFooter) -> &mut CreateEmbedFooter {
let shard_count = ctx.discord().cache.shard_count();
let shard = ctx.discord().shard_id;
@ -24,12 +22,13 @@ fn footer(
pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx);
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Help")
.color(*THEME_COLOR)
.description(
"__Info Commands__
let _ = ctx
.send(|m| {
m.embed(|e| {
e.title("Help")
.color(*THEME_COLOR)
.description(
"__Info Commands__
`/help` `/info` `/donate` `/dashboard` `/clock`
*run these commands with no options*
@ -53,11 +52,11 @@ __Setup Commands__
__Advanced Commands__
`/macro` - Record and replay command sequences
",
)
.footer(footer)
)
.footer(footer)
})
})
})
.await?;
.await;
Ok(())
}
@ -69,7 +68,7 @@ pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
let _ = ctx
.send(|m| {
m.ephemeral(true).embed(|e| {
m.embed(|e| {
e.title("Info")
.description(format!(
"Help: `/help`
@ -96,10 +95,9 @@ Use our dashboard: https://reminder-bot.com/",
pub async fn donate(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx);
ctx.send(|m| m.embed(|e| {
e.title("Donate")
.description("Thinking of adding a monthly contribution?
Click below for my Patreon and official bot server :)
let _ = ctx.send(|m| m.embed(|e| {
e.title("Donate")
.description("Thinking of adding a monthly contribution? Click below for my Patreon and official bot server :)
**https://www.patreon.com/jellywx/**
**https://discord.jellywx.com/**
@ -114,11 +112,11 @@ With your new rank, you'll be able to:
Just $2 USD/month!
*Please note, you must be in the JellyWX Discord server to receive Patreon features*")
.footer(footer)
.color(*THEME_COLOR)
}),
)
.await?;
.footer(footer)
.color(*THEME_COLOR)
}),
)
.await;
Ok(())
}
@ -128,20 +126,21 @@ Just $2 USD/month!
pub async fn dashboard(ctx: Context<'_>) -> Result<(), Error> {
let footer = footer(ctx);
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Dashboard")
.description("**https://reminder-bot.com/dashboard**")
.footer(footer)
.color(*THEME_COLOR)
let _ = ctx
.send(|m| {
m.embed(|e| {
e.title("Dashboard")
.description("**https://reminder-bot.com/dashboard**")
.footer(footer)
.color(*THEME_COLOR)
})
})
})
.await?;
.await;
Ok(())
}
/// View the current time in your selected timezone
/// View the current time in a user's selected timezone
#[poise::command(slash_command)]
pub async fn clock(ctx: Context<'_>) -> Result<(), Error> {
ctx.defer_ephemeral().await?;
@ -156,25 +155,3 @@ pub async fn clock(ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// View the current time in a user's selected timezone
#[poise::command(context_menu_command = "View Local Time")]
pub async fn clock_context_menu(ctx: Context<'_>, user: serenity::User) -> Result<(), Error> {
ctx.defer_ephemeral().await?;
let user_data = ctx.user_data(user.id).await?;
let tz = user_data.timezone();
let now = Utc::now().with_timezone(&tz);
ctx.send(|m| {
m.ephemeral(true).content(format!(
"Time in {}'s timezone: `{}`",
user.mention(),
now.format("%H:%M")
))
})
.await?;
Ok(())
}

View File

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

View File

@ -4,10 +4,10 @@ use levenshtein::levenshtein;
use poise::CreateReply;
use crate::{
component_models::pager::{MacroPager, Pager},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
hooks::guild_only,
models::{
command_macro::{guild_command_macro, CommandMacro},
command_macro::{CommandMacro, CommandOptions},
CtxData,
},
Context, Data, Error,
@ -19,7 +19,11 @@ async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String>
} else {
TZ_VARIANTS
.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)
.map(|t| t.to_string())
.collect::<Vec<String>>()
@ -27,7 +31,7 @@ async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String>
}
/// Select your timezone
#[poise::command(slash_command, identifying_name = "timezone")]
#[poise::command(slash_command)]
pub async fn timezone(
ctx: Context<'_>,
#[description = "Timezone to use from this list: https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee"]
@ -149,25 +153,13 @@ WHERE
}
/// Record and replay command sequences
#[poise::command(
slash_command,
rename = "macro",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "macro_base"
)]
#[poise::command(slash_command, rename = "macro", check = "guild_only")]
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Start recording up to 5 commands to replay
#[poise::command(
slash_command,
rename = "record",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "record_macro"
)]
#[poise::command(slash_command, rename = "record", check = "guild_only")]
pub async fn record_macro(
ctx: Context<'_>,
#[description = "Name for the new macro"] name: String,
@ -245,9 +237,8 @@ Please use `/macro finish` to end this recording before starting another.",
#[poise::command(
slash_command,
rename = "finish",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "finish_macro"
check = "guild_only",
identifying_name = "macro_finish"
)]
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
let key = (ctx.guild_id().unwrap(), ctx.author().id);
@ -300,15 +291,9 @@ pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
}
/// List recorded macros
#[poise::command(
slash_command,
rename = "list",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "list_macro"
)]
#[poise::command(slash_command, rename = "list", check = "guild_only")]
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);
@ -321,44 +306,72 @@ pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
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
#[poise::command(
slash_command,
rename = "run",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "run_macro"
)]
#[poise::command(slash_command, rename = "run", check = "guild_only")]
pub async fn run_macro(
ctx: poise::ApplicationContext<'_, Data, Error>,
ctx: Context<'_>,
#[description = "Name of macro to run"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match guild_command_macro(&Context::Application(ctx), &name).await {
Some(command_macro) => {
ctx.defer_response(false).await?;
match sqlx::query!(
"
SELECT commands FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
ctx.guild_id().unwrap().0,
name
)
.fetch_one(&ctx.data().database)
.await
{
Ok(row) => {
ctx.defer().await?;
for command in command_macro.commands {
if let Some(action) = command.action {
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
.await
{
Ok(()) => {}
Err(e) => {
println!("{:?}", e);
}
}
let commands: Vec<CommandOptions> = serde_json::from_str(&row.commands)?;
for command in commands {
let cmd =
find_command(&ctx.framework().options().commands, &command.command, &command);
if let Some(cmd) = cmd {
let mut executing_ctx = ctx.clone();
executing_ctx.command = cmd;
} else {
Context::Application(ctx)
.say(format!("Command \"{}\" not found", command.command_name))
.await?;
ctx.send(|m| {
m.ephemeral(true)
.content(format!("Command `{}` not found", command.command))
})
.await?;
}
}
}
None => {
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
Err(sqlx::Error::RowNotFound) => {
ctx.say(format!("Macro \"{}\" not found", name)).await?;
}
Err(e) => {
panic!("{}", e);
}
}
@ -366,13 +379,7 @@ pub async fn run_macro(
}
/// Delete a recorded macro
#[poise::command(
slash_command,
rename = "delete",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "delete_macro"
)]
#[poise::command(slash_command, rename = "delete", check = "guild_only")]
pub async fn delete_macro(
ctx: Context<'_>,
#[description = "Name of macro to delete"]
@ -409,7 +416,7 @@ SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AN
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;
macros
@ -433,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);
if macros.is_empty() {
@ -504,4 +522,5 @@ pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> Crea
});
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::{
component_models::{
@ -6,218 +7,134 @@ use crate::{
ComponentDataModel, TodoSelector,
},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR},
Context, Error,
framework::{CommandInvoke, CommandOptions, CreateGenericResponse},
hooks::CHECK_GUILD_PERMISSIONS_HOOK,
SQLPool,
};
/// Manage todo lists
#[poise::command(
slash_command,
rename = "todo",
identifying_name = "todo_base",
default_member_permissions = "MANAGE_GUILD"
#[command]
#[description("Manage todo lists")]
#[subcommandgroup("server")]
#[description("Manage the server todo list")]
#[subcommand("add")]
#[description("Add an item to the server todo list")]
#[arg(
name = "task",
description = "The task to add to the todo list",
kind = "String",
required = true
)]
pub async fn todo_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Manage the server todo list
#[poise::command(
slash_command,
rename = "server",
guild_only = true,
identifying_name = "todo_guild_base",
default_member_permissions = "MANAGE_GUILD"
#[subcommand("view")]
#[description("View and remove from the server todo list")]
#[subcommandgroup("channel")]
#[description("Manage the channel todo list")]
#[subcommand("add")]
#[description("Add to the channel todo list")]
#[arg(
name = "task",
description = "The task to add to the todo list",
kind = "String",
required = true
)]
pub async fn todo_guild_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Add an item to the server todo list
#[poise::command(
slash_command,
rename = "add",
guild_only = true,
identifying_name = "todo_guild_add",
default_member_permissions = "MANAGE_GUILD"
#[subcommand("view")]
#[description("View and remove from the channel todo list")]
#[subcommandgroup("user")]
#[description("Manage your personal todo list")]
#[subcommand("add")]
#[description("Add to your personal todo list")]
#[arg(
name = "task",
description = "The task to add to the todo list",
kind = "String",
required = true
)]
pub async fn todo_guild_add(
ctx: Context<'_>,
#[description = "The task to add to the todo list"] task: String,
) -> Result<(), Error> {
sqlx::query!(
"INSERT INTO todos (guild_id, value)
VALUES ((SELECT id FROM guilds WHERE guild = ?), ?)",
ctx.guild_id().unwrap().0,
task
)
.execute(&ctx.data().database)
.await
.unwrap();
#[subcommand("view")]
#[description("View and remove from your personal todo list")]
#[hook(CHECK_GUILD_PERMISSIONS_HOOK)]
async fn todo(ctx: &Context, invoke: &mut CommandInvoke, args: CommandOptions) {
if invoke.guild_id().is_none() && args.subcommand_group != Some("user".to_string()) {
let _ = invoke
.respond(
&ctx,
CreateGenericResponse::new().content("Please use `/todo user` in direct messages"),
)
.await;
} else {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
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
#[poise::command(
slash_command,
rename = "view",
guild_only = true,
identifying_name = "todo_guild_view",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn todo_guild_view(ctx: Context<'_>) -> Result<(), Error> {
let values = sqlx::query!(
"SELECT todos.id, value FROM todos
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)>>();
sqlx::query!(
"INSERT INTO todos (user_id, channel_id, guild_id, value) VALUES ((SELECT id FROM users WHERE user = ?), (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?), ?)",
keys.0,
keys.1,
keys.2,
task
)
.execute(&pool)
.await
.unwrap();
let resp = show_todo_page(&values, 0, None, None, ctx.guild_id().map(|g| g.0));
ctx.send(|r| {
*r = resp;
r
})
.await?;
Ok(())
}
/// Manage the channel todo list
#[poise::command(
slash_command,
rename = "channel",
guild_only = true,
identifying_name = "todo_channel_base",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn todo_channel_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
/// Add an item to the channel todo list
#[poise::command(
slash_command,
rename = "add",
guild_only = true,
identifying_name = "todo_channel_add",
default_member_permissions = "MANAGE_GUILD"
)]
pub async fn todo_channel_add(
ctx: Context<'_>,
#[description = "The task to add to the todo list"] task: String,
) -> Result<(), Error> {
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
let _ = invoke
.respond(&ctx, CreateGenericResponse::new().content("Item added to todo list"))
.await;
}
None => {
let values = if let Some(uid) = keys.0 {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN users ON todos.user_id = users.id
WHERE users.user = ?",
ctx.author().id.0,
)
.fetch_all(&ctx.data().database)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>();
uid,
)
.fetch_all(&pool)
.await
.unwrap()
.iter()
.map(|row| (row.id as usize, row.value.clone()))
.collect::<Vec<(usize, String)>>()
} else if let Some(cid) = keys.1 {
sqlx::query!(
"SELECT todos.id, value FROM todos
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| {
*r = resp;
r
})
.await?;
Ok(())
invoke.respond(&ctx, resp).await.unwrap();
}
}
}
}
pub fn max_todo_page(todo_values: &[(usize, String)]) -> usize {
@ -247,7 +164,7 @@ pub fn show_todo_page(
user_id: Option<u64>,
channel_id: Option<u64>,
guild_id: Option<u64>,
) -> CreateReply {
) -> CreateGenericResponse {
let pager = TodoPager::new(page, user_id, channel_id, guild_id);
let pages = max_todo_page(todo_values);
@ -302,23 +219,17 @@ pub fn show_todo_page(
};
if todo_ids.is_empty() {
let mut reply = CreateReply::default();
reply.embed(|e| {
CreateGenericResponse::new().embed(|e| {
e.title(format!("{} Todo List", title))
.description("Todo List Empty!")
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
.color(*THEME_COLOR)
});
reply
})
} else {
let todo_selector =
ComponentDataModel::TodoSelector(TodoSelector { page, user_id, channel_id, guild_id });
let mut reply = CreateReply::default();
reply
CreateGenericResponse::new()
.embed(|e| {
e.title(format!("{} Todo List", title))
.description(display)
@ -344,8 +255,6 @@ pub fn show_todo_page(
})
})
})
});
reply
})
}
}

View File

@ -16,16 +16,10 @@ use rmp_serde::Serializer;
use serde::{Deserialize, Serialize};
use crate::{
commands::{
moderation_cmds::{max_macro_page, show_macro_page},
reminder_cmds::{max_delete_page, show_delete_page},
todo_cmds::{max_todo_page, show_todo_page},
},
self,
component_models::pager::{DelPager, LookPager, MacroPager, Pager, TodoPager},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
models::reminder::Reminder,
utils::send_as_initial_response,
Data,
models::{command_macro::CommandMacro, reminder::Reminder},
};
#[derive(Deserialize, Serialize)]
@ -55,7 +49,7 @@ impl ComponentDataModel {
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 {
ComponentDataModel::LookPager(pager) => {
let flags = pager.flags;
@ -72,7 +66,7 @@ impl ComponentDataModel {
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
.iter()
@ -122,7 +116,7 @@ impl ComponentDataModel {
.create_interaction_response(&ctx, |r| {
r.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|response| {
response.set_embeds(vec![embed]).components(|comp| {
response.embeds(vec![embed]).components(|comp| {
pager.create_button_row(pages, comp);
comp
@ -133,68 +127,45 @@ impl ComponentDataModel {
.await;
}
ComponentDataModel::DelPager(pager) => {
let reminders = Reminder::from_guild(
&ctx,
&data.database,
component.guild_id,
component.user.id,
)
.await;
let reminders =
Reminder::from_guild(ctx, component.guild_id, component.user.id).await;
let max_pages = max_delete_page(&reminders, &pager.timezone);
let resp = show_delete_page(&reminders, pager.next_page(max_pages), pager.timezone);
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|d| {
send_as_initial_response(resp, d);
d
},
)
})
.await;
let mut invoke = CommandInvoke::component(component);
let _ = invoke.respond(&ctx, resp).await;
}
ComponentDataModel::DelSelector(selector) => {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let selected_id = component.data.values.join(",");
sqlx::query!("DELETE FROM reminders WHERE FIND_IN_SET(id, ?)", selected_id)
.execute(&data.database)
.execute(&pool)
.await
.unwrap();
let reminders = Reminder::from_guild(
&ctx,
&data.database,
component.guild_id,
component.user.id,
)
.await;
let reminders =
Reminder::from_guild(ctx, component.guild_id, component.user.id).await;
let resp = show_delete_page(&reminders, selector.page, selector.timezone);
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|d| {
send_as_initial_response(resp, d);
d
},
)
})
.await;
let mut invoke = CommandInvoke::component(component);
let _ = invoke.respond(&ctx, resp).await;
}
ComponentDataModel::TodoPager(pager) => {
if Some(component.user.id.0) == pager.user_id || pager.user_id.is_none() {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let values = if let Some(uid) = pager.user_id {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN users ON todos.user_id = users.id
WHERE users.user = ?",
INNER JOIN users ON todos.user_id = users.id
WHERE users.user = ?",
uid,
)
.fetch_all(&data.database)
.fetch_all(&pool)
.await
.unwrap()
.iter()
@ -203,11 +174,11 @@ WHERE users.user = ?",
} else if let Some(cid) = pager.channel_id {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN channels ON todos.channel_id = channels.id
WHERE channels.channel = ?",
INNER JOIN channels ON todos.channel_id = channels.id
WHERE channels.channel = ?",
cid,
)
.fetch_all(&data.database)
.fetch_all(&pool)
.await
.unwrap()
.iter()
@ -216,11 +187,11 @@ WHERE channels.channel = ?",
} else {
sqlx::query!(
"SELECT todos.id, value FROM todos
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
INNER JOIN guilds ON todos.guild_id = guilds.id
WHERE guilds.guild = ?",
pager.guild_id,
)
.fetch_all(&data.database)
.fetch_all(&pool)
.await
.unwrap()
.iter()
@ -238,15 +209,8 @@ WHERE guilds.guild = ?",
pager.guild_id,
);
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::UpdateMessage)
.interaction_response_data(|d| {
send_as_initial_response(resp, d);
d
})
})
.await;
let mut invoke = CommandInvoke::component(component);
let _ = invoke.respond(&ctx, resp).await;
} else {
let _ = component
.create_interaction_response(&ctx, |r| {
@ -263,10 +227,11 @@ WHERE guilds.guild = ?",
}
ComponentDataModel::TodoSelector(selector) => {
if Some(component.user.id.0) == selector.user_id || selector.user_id.is_none() {
let pool = ctx.data.read().await.get::<SQLPool>().cloned().unwrap();
let selected_id = component.data.values.join(",");
sqlx::query!("DELETE FROM todos WHERE FIND_IN_SET(id, ?)", selected_id)
.execute(&data.database)
.execute(&pool)
.await
.unwrap();
@ -277,7 +242,7 @@ WHERE guilds.guild = ?",
selector.channel_id,
selector.guild_id,
)
.fetch_all(&data.database)
.fetch_all(&pool)
.await
.unwrap()
.iter()
@ -292,15 +257,8 @@ WHERE guilds.guild = ?",
selector.guild_id,
);
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::UpdateMessage)
.interaction_response_data(|d| {
send_as_initial_response(resp, d);
d
})
})
.await;
let mut invoke = CommandInvoke::component(component);
let _ = invoke.respond(&ctx, resp).await;
} else {
let _ = component
.create_interaction_response(&ctx, |r| {
@ -316,23 +274,15 @@ WHERE guilds.guild = ?",
}
}
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 page = pager.next_page(max_page);
let resp = show_macro_page(&macros, page);
let _ = component
.create_interaction_response(&ctx, |f| {
f.kind(InteractionResponseType::UpdateMessage).interaction_response_data(
|d| {
send_as_initial_response(resp, d);
d
},
)
})
.await;
let _ = invoke.respond(&ctx, resp).await;
}
}
}

View File

@ -1,14 +1,14 @@
pub const DAY: u64 = 86_400;
pub const HOUR: u64 = 3_600;
pub const MINUTE: u64 = 60;
pub const EMBED_DESCRIPTION_MAX_LENGTH: usize = 4000;
pub const SELECT_MAX_ENTRIES: usize = 25;
pub const MACRO_MAX_COMMANDS: usize = 5;
pub const CHARACTERS: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
const THEME_COLOR_FALLBACK: u32 = 0x8fb677;
pub const MACRO_MAX_COMMANDS: usize = 5;
use std::{collections::HashSet, env, iter::FromIterator};

View File

@ -1,63 +1,21 @@
use std::{collections::HashMap, env, sync::atomic::Ordering};
use std::{collections::HashMap, env};
use log::{error, info, warn};
use poise::{
serenity::{model::interactions::Interaction, utils::shard_id},
serenity_prelude as serenity,
};
use poise::serenity::{client::Context, model::interactions::Interaction, utils::shard_id};
use crate::{component_models::ComponentDataModel, Data, Error};
use crate::{Data, Error};
pub async fn listener(
ctx: &serenity::Context,
event: &poise::Event<'_>,
data: &Data,
) -> Result<(), Error> {
pub async fn listener(ctx: &Context, event: &poise::Event<'_>, data: &Data) -> Result<(), Error> {
match event {
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 } => {
sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64())
.execute(&data.database)
.await
.unwrap();
sqlx::query!(
"
DELETE FROM channels WHERE channel = ?
",
channel.id.as_u64()
)
.execute(&data.database)
.await
.unwrap();
}
poise::Event::GuildCreate { guild, is_new } => {
if *is_new {
@ -106,16 +64,15 @@ pub async fn listener(
}
}
}
poise::Event::GuildDelete { incomplete, .. } => {
poise::Event::GuildDelete { incomplete, full } => {
let _ = sqlx::query!("DELETE FROM guilds WHERE guild = ?", incomplete.id.0)
.execute(&data.database)
.await;
}
poise::Event::InteractionCreate { interaction } => match interaction {
Interaction::MessageComponent(component) => {
let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
component_model.act(ctx, data, component).await;
//let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
//component_model.act(&ctx, component).await;
}
_ => {}
},

View File

@ -1,36 +1,49 @@
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 {
if let Context::Application(app_ctx) = ctx {
if let Some(guild_id) = ctx.guild_id() {
if ctx.command().identifying_name != "finish_macro" {
let mut lock = ctx.data().recording_macros.write().await;
if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(interaction) =
app_ctx.interaction
{
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 command_macro.commands.len() >= MACRO_MAX_COMMANDS {
let _ = ctx.send(|m| {
if let Some(command_macro) = lock.get_mut(&(guild_id, ctx.author().id)) {
if command_macro.commands.len() >= MACRO_MAX_COMMANDS {
let _ = ctx.send(|m| {
m.ephemeral(true).content(
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;
} 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 {
let recorded = RecordedCommand {
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;
true
}
false
} else {
true
}
@ -65,7 +78,7 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
})
.flatten()
.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 {

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 == &"" => {
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 = sec + nsec / 1_000_000_000;
nsec %= 1_000_000_000;
}
sec = self.current.1 + sec;
month = self.current.0 + month;
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

@ -3,22 +3,15 @@
extern crate lazy_static;
mod commands;
mod component_models;
// mod component_models;
mod consts;
mod event_handlers;
mod hooks;
mod interval_parser;
mod models;
mod time_parser;
mod utils;
use std::{
collections::HashMap,
env,
error::Error as StdError,
fmt::{Debug, Display, Formatter},
sync::atomic::AtomicBool,
};
use std::{collections::HashMap, env};
use chrono_tz::Tz;
use dotenv::dotenv;
@ -27,10 +20,10 @@ use poise::serenity::model::{
id::{GuildId, UserId},
};
use sqlx::{MySql, Pool};
use tokio::sync::{broadcast, broadcast::Sender, RwLock};
use tokio::sync::RwLock;
use crate::{
commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
commands::{info_cmds, moderation_cmds},
consts::THEME_COLOR,
event_handlers::listener,
hooks::all_checks,
@ -40,51 +33,18 @@ use crate::{
type Database = MySql;
type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>;
pub struct Data {
database: Pool<Database>,
http: reqwest::Client,
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>,
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro>>,
popular_timezones: Vec<Tz>,
is_loop_running: AtomicBool,
broadcast: Sender<()>,
}
impl std::fmt::Debug for Data {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Data {{ .. }}")
}
}
struct Ended;
impl Debug for Ended {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str("Process ended.")
}
}
impl Display for Ended {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str("Process ended.")
}
}
impl StdError for Ended {}
type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>;
#[tokio::main]
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
let (tx, mut rx) = broadcast::channel(16);
tokio::select! {
output = _main(tx) => output,
_ = rx.recv() => Err(Box::new(Ended) as Box<dyn StdError + Send + Sync>)
}
}
async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
env_logger::init();
dotenv()?;
@ -97,7 +57,6 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
info_cmds::info(),
info_cmds::donate(),
info_cmds::clock(),
info_cmds::clock_context_menu(),
info_cmds::dashboard(),
moderation_cmds::timezone(),
poise::Command {
@ -110,43 +69,6 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
],
..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,
command_check: Some(|ctx| Box::pin(all_checks(ctx))),
@ -158,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();
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)
.await
@ -188,13 +111,11 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
database,
popular_timezones,
recording_macros: Default::default(),
is_loop_running: AtomicBool::new(false),
broadcast: tx,
})
})
})
.options(options)
.intents(GatewayIntents::GUILDS)
.client_settings(move |client_builder| client_builder.intents(GatewayIntents::GUILDS))
.run_autosharded()
.await?;

View File

@ -1,73 +1,267 @@
use poise::serenity::model::{
id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption,
use std::collections::HashMap;
use poise::{
serenity::{
json::Value,
model::{
id::{ChannelId, GuildId, RoleId, UserId},
interactions::application_command::{
ApplicationCommandInteraction, ApplicationCommandInteractionData,
ApplicationCommandInteractionDataOption, ApplicationCommandOptionType,
ApplicationCommandType,
},
},
},
ApplicationCommandOrAutocompleteInteraction,
};
use serde::{Deserialize, Serialize};
use serde_json::Number;
use sqlx::Executor;
use crate::{Context, Data, Error};
use crate::Database;
fn default_none<U, E>() -> Option<
for<'a> fn(
poise::ApplicationContext<'a, U, E>,
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>,
> {
None
}
#[derive(Serialize, Deserialize)]
pub struct RecordedCommand<U, E> {
#[serde(skip)]
#[serde(default = "default_none::<U, E>")]
pub action: Option<
for<'a> fn(
poise::ApplicationContext<'a, U, E>,
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>,
>,
pub command_name: String,
pub options: Vec<ApplicationCommandInteractionDataOption>,
}
pub struct CommandMacro<U, E> {
pub struct CommandMacro {
pub guild_id: GuildId,
pub name: String,
pub description: Option<String>,
pub commands: Vec<RecordedCommand<U, E>>,
pub commands: Vec<CommandOptions>,
}
pub async fn guild_command_macro(
ctx: &Context<'_>,
name: &str,
) -> Option<CommandMacro<Data, Error>> {
let row = sqlx::query!(
"
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()?;
impl CommandMacro {
pub async fn from_guild(
db_pool: impl Executor<'_, Database = Database>,
guild_id: impl Into<GuildId>,
) -> Vec<Self> {
let guild_id = guild_id.into();
let mut commands: Vec<RecordedCommand<Data, Error>> =
serde_json::from_str(&row.commands).unwrap();
sqlx::query!(
"SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
guild_id.0
)
.fetch_all(db_pool)
.await
.unwrap()
.iter()
.map(|row| Self {
guild_id,
name: row.name.clone(),
description: row.description.clone(),
commands: serde_json::from_str(&row.commands).unwrap(),
})
.collect::<Vec<Self>>()
}
}
for recorded_command in &mut commands {
let command = &ctx
.framework()
.options()
.commands
.iter()
.find(|c| c.identifying_name == recorded_command.command_name);
#[derive(Serialize, Deserialize, Clone)]
pub enum OptionValue {
String(String),
Integer(i64),
Boolean(bool),
User(UserId),
Channel(ChannelId),
Role(RoleId),
Mentionable(u64),
Number(f64),
}
recorded_command.action = command.map(|c| c.slash_action).flatten().clone();
impl OptionValue {
pub fn as_i64(&self) -> Option<i64> {
match self {
OptionValue::Integer(i) => Some(*i),
_ => None,
}
}
let command_macro = CommandMacro {
guild_id: ctx.guild_id().unwrap(),
name: row.name,
description: row.description,
commands,
};
pub fn as_bool(&self) -> Option<bool> {
match self {
OptionValue::Boolean(b) => Some(*b),
_ => None,
}
}
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::{
models::{channel_data::ChannelData, user_data::UserData},
CommandMacro, Context, Data, Error, GuildId,
Context,
};
#[async_trait]
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 channel_data(&self) -> Result<ChannelData, Error>;
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error>;
async fn channel_data(&self) -> Result<ChannelData, Box<dyn std::error::Error + Sync + Send>>;
}
#[async_trait]
@ -47,29 +48,4 @@ impl CtxData for Context<'_> {
ChannelData::from_channel(&channel, &self.data().database).await
}
async fn command_macros(&self) -> Result<Vec<CommandMacro<Data, Error>>, Error> {
self.data().command_macros(self.guild_id().unwrap()).await
}
}
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 crate::{
consts::{DAY, DEFAULT_AVATAR, MAX_TIME, MIN_INTERVAL},
interval_parser::Interval,
consts::{DEFAULT_AVATAR, MAX_TIME, MIN_INTERVAL},
models::{
channel_data::ChannelData,
reminder::{content::Content, errors::ReminderError, helper::generate_uid, Reminder},
@ -53,8 +52,7 @@ pub struct ReminderBuilder {
channel: u32,
utc_time: NaiveDateTime,
timezone: String,
interval_secs: Option<i64>,
interval_months: Option<i64>,
interval: Option<i64>,
expires: Option<NaiveDateTime>,
content: String,
tts: bool,
@ -86,8 +84,7 @@ INSERT INTO reminders (
`channel_id`,
`utc_time`,
`timezone`,
`interval_seconds`,
`interval_months`,
`interval`,
`expires`,
`content`,
`tts`,
@ -105,7 +102,6 @@ INSERT INTO reminders (
?,
?,
?,
?,
?
)
",
@ -113,8 +109,7 @@ INSERT INTO reminders (
self.channel,
utc_time,
self.timezone,
self.interval_secs,
self.interval_months,
self.interval,
self.expires,
self.content,
self.tts,
@ -139,7 +134,7 @@ pub struct MultiReminderBuilder<'a> {
scopes: Vec<ReminderScope>,
utc_time: NaiveDateTime,
timezone: Tz,
interval: Option<Interval>,
interval: Option<i64>,
expires: Option<NaiveDateTime>,
content: Content,
set_by: Option<u32>,
@ -148,7 +143,7 @@ pub struct 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 {
scopes: vec![],
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 {
self.content = content;
@ -197,7 +186,7 @@ impl<'a> MultiReminderBuilder<'a> {
self
}
pub fn interval(mut self, interval: Option<Interval>) -> Self {
pub fn interval(mut self, interval: Option<i64>) -> Self {
self.interval = interval;
self
@ -208,27 +197,24 @@ impl<'a> MultiReminderBuilder<'a> {
}
pub async fn build(self) -> (HashSet<ReminderError>, HashSet<ReminderScope>) {
let pool = self.ctx.data().database.clone();
let mut errors = 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);
} 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);
} else {
for scope in self.scopes {
let db_channel_id = match scope {
ReminderScope::User(user_id) => {
if let Ok(user) = UserId(user_id).to_user(&self.ctx.discord()).await {
let user_data = UserData::from_user(
&user,
&self.ctx.discord(),
&self.ctx.data().database,
)
.await
.unwrap();
let user_data = UserData::from_user(&user, &self.ctx.discord(), &pool)
.await
.unwrap();
if let Some(guild_id) = self.guild_id {
if guild_id.member(&self.ctx.discord(), user).await.is_err() {
@ -252,9 +238,7 @@ impl<'a> MultiReminderBuilder<'a> {
Err(ReminderError::InvalidTag)
} else {
let mut channel_data =
ChannelData::from_channel(&channel, &self.ctx.data().database)
.await
.unwrap();
ChannelData::from_channel(&channel, &pool).await.unwrap();
if channel_data.webhook_id.is_none()
|| channel_data.webhook_token.is_none()
@ -271,9 +255,7 @@ impl<'a> MultiReminderBuilder<'a> {
Some(webhook.id.as_u64().to_owned());
channel_data.webhook_token = webhook.token;
channel_data
.commit_changes(&self.ctx.data().database)
.await;
channel_data.commit_changes(&pool).await;
Ok(channel_data.id)
}
@ -293,13 +275,12 @@ impl<'a> MultiReminderBuilder<'a> {
match db_channel_id {
Ok(c) => {
let builder = ReminderBuilder {
pool: self.ctx.data().database.clone(),
pool: pool.clone(),
uid: generate_uid(),
channel: c,
utc_time: self.utc_time,
timezone: self.timezone.to_string(),
interval_secs: self.interval.map(|i| i.sec as i64),
interval_months: self.interval.map(|i| i.month as i64),
interval: self.interval,
expires: self.expires,
content: self.content.content.clone(),
tts: self.content.tts,

View File

@ -1,6 +1,25 @@
use num_integer::Integer;
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 {
let mut generator: OsRng = Default::default();

View File

@ -6,15 +6,15 @@ pub mod look_flags;
use chrono::{NaiveDateTime, TimeZone};
use chrono_tz::Tz;
use poise::{
serenity::model::id::{ChannelId, GuildId, UserId},
serenity_prelude::Cache,
};
use sqlx::Executor;
use poise::serenity::model::id::{ChannelId, GuildId, UserId};
use sqlx::{Executor, MySqlPool};
use crate::{
models::reminder::look_flags::{LookFlags, TimeDisplayType},
Database,
models::reminder::{
helper::longhand_displacement,
look_flags::{LookFlags, TimeDisplayType},
},
Context, Database,
};
#[derive(Debug, Clone)]
@ -23,8 +23,7 @@ pub struct Reminder {
pub uid: String,
pub channel: u64,
pub utc_time: NaiveDateTime,
pub interval_seconds: Option<u32>,
pub interval_months: Option<u32>,
pub interval: Option<u32>,
pub expires: Option<NaiveDateTime>,
pub enabled: bool,
pub content: String,
@ -33,10 +32,7 @@ pub struct Reminder {
}
impl Reminder {
pub async fn from_uid(
pool: impl Executor<'_, Database = Database>,
uid: String,
) -> Option<Self> {
pub async fn from_uid(pool: &MySqlPool, uid: String) -> Option<Self> {
sqlx::query_as_unchecked!(
Self,
"
@ -45,8 +41,7 @@ SELECT
reminders.uid,
channels.channel,
reminders.utc_time,
reminders.interval_seconds,
reminders.interval_months,
reminders.interval,
reminders.expires,
reminders.enabled,
reminders.content,
@ -73,7 +68,7 @@ WHERE
}
pub async fn from_channel<C: Into<ChannelId>>(
pool: impl Executor<'_, Database = Database>,
db_pool: impl Executor<'_, Database = Database>,
channel_id: C,
flags: &LookFlags,
) -> Vec<Self> {
@ -88,8 +83,7 @@ SELECT
reminders.uid,
channels.channel,
reminders.utc_time,
reminders.interval_seconds,
reminders.interval_months,
reminders.interval,
reminders.expires,
reminders.enabled,
reminders.content,
@ -114,19 +108,21 @@ ORDER BY
channel_id.as_u64(),
enabled,
)
.fetch_all(pool)
.fetch_all(db_pool)
.await
.unwrap()
}
pub async fn from_guild(
cache: impl AsRef<Cache>,
pool: impl Executor<'_, Database = Database>,
ctx: &Context<'_>,
guild_id: Option<GuildId>,
user: UserId,
) -> Vec<Self> {
// todo: see if this can be moved to just extract from the context
let pool = ctx.data().database.clone();
if let Some(guild_id) = guild_id {
let guild_opt = guild_id.to_guild_cached(cache);
let guild_opt = guild_id.to_guild_cached(&ctx.discord());
if let Some(guild) = guild_opt {
let channels = guild
@ -145,8 +141,7 @@ SELECT
reminders.uid,
channels.channel,
reminders.utc_time,
reminders.interval_seconds,
reminders.interval_months,
reminders.interval,
reminders.expires,
reminders.enabled,
reminders.content,
@ -167,7 +162,7 @@ WHERE
",
channels
)
.fetch_all(pool)
.fetch_all(&pool)
.await
} else {
sqlx::query_as_unchecked!(
@ -178,8 +173,7 @@ SELECT
reminders.uid,
channels.channel,
reminders.utc_time,
reminders.interval_seconds,
reminders.interval_months,
reminders.interval,
reminders.expires,
reminders.enabled,
reminders.content,
@ -200,7 +194,7 @@ WHERE
",
guild_id.as_u64()
)
.fetch_all(pool)
.fetch_all(&pool)
.await
}
} else {
@ -212,8 +206,7 @@ SELECT
reminders.uid,
channels.channel,
reminders.utc_time,
reminders.interval_seconds,
reminders.interval_months,
reminders.interval,
reminders.expires,
reminders.enabled,
reminders.content,
@ -234,7 +227,7 @@ WHERE
",
user.as_u64()
)
.fetch_all(pool)
.fetch_all(&pool)
.await
}
.unwrap()
@ -271,11 +264,12 @@ WHERE
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!(
"'{}' *occurs next at* **{}**, repeating (set by {})",
"'{}' *occurs next at* **{}**, repeating every **{}** (set by {})",
self.display_content(),
time_display,
longhand_displacement(interval as u64),
self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string())
)
} else {

View File

@ -1,10 +1,7 @@
use poise::{
serenity::{
builder::CreateApplicationCommands,
http::CacheHttp,
model::id::{GuildId, UserId},
},
serenity_prelude as serenity,
use poise::serenity::{
builder::CreateApplicationCommands,
http::CacheHttp,
model::id::{GuildId, UserId},
};
use crate::{
@ -68,40 +65,3 @@ pub async fn check_guild_subscription(
false
}
}
/// Sends the message, specified via [`crate::CreateReply`], to the interaction initial response
/// endpoint
pub fn send_as_initial_response(
data: poise::CreateReply<'_>,
f: &mut serenity::CreateInteractionResponseData,
) {
let poise::CreateReply {
content,
embeds,
attachments: _, // serenity doesn't support attachments in initial response yet
components,
ephemeral,
allowed_mentions,
reference_message: _, // can't reply to a message in interactions
} = data;
if let Some(content) = content {
f.content(content);
}
f.set_embeds(embeds);
if let Some(allowed_mentions) = allowed_mentions {
f.allowed_mentions(|f| {
*f = allowed_mentions.clone();
f
});
}
if let Some(components) = components {
f.components(|f| {
f.0 = components.0;
f
});
}
if ephemeral {
f.flags(serenity::InteractionApplicationCommandCallbackDataFlags::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"] }
serde_json = "1.0"
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"

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,52 +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/",
env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation")
)) as &[u8],
env!("WEBHOOK_AVATAR"),
)
.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,196 +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,
],
)
.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_reminder,
routes::dashboard::guild::get_reminders,
routes::dashboard::guild::edit_reminder,
routes::dashboard::guild::delete_reminder,
],
)
.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,119 +0,0 @@
macro_rules! check_length {
($max:ident, $field:expr) => {
if $field.len() > $max {
return 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 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 json!({"error": "User not in guild"})
}
Ok(_) => {}
}
}
None => {
return json!({"error": "Bot not in guild"})
}
}
}
None => {
return 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),+]);
};
}

View File

@ -1,733 +0,0 @@
use std::env;
use base64;
use chrono::Utc;
use rocket::{
http::CookieJar,
serde::json::{json, Json, Value as JsonValue},
State,
};
use serde::Serialize;
use serenity::{
client::Context,
model::{
channel::GuildChannel,
id::{ChannelId, GuildId, RoleId},
},
};
use sqlx::{MySql, Pool};
use crate::{
check_guild_subscription, check_subscription,
consts::{
DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
MIN_INTERVAL,
},
routes::dashboard::{
create_database_channel, generate_uid, name_default, template_name_default, DeleteReminder,
DeleteReminderTemplate, 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>,
) -> JsonValue {
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()))
});
json!({ "patreon": patreon })
}
None => {
json!({"error": "Bot not in guild"})
}
}
}
#[get("/api/guild/<id>/channels")]
pub async fn get_guild_channels(
id: u64,
cookies: &CookieJar<'_>,
ctx: &State<Context>,
) -> JsonValue {
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>>();
json!(channel_info)
}
None => {
json!({"error": "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>) -> JsonValue {
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>>();
json!(roles)
}
None => {
warn!("Could not fetch roles from {}", id);
json!({"error": "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>>,
) -> JsonValue {
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) => {
json!(templates)
}
Err(e) => {
warn!("Could not fetch templates from {}: {:?}", id, e);
json!({"error": "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>>,
) -> JsonValue {
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(_) => {
json!({})
}
Err(e) => {
warn!("Could not fetch templates from {}: {:?}", id, e);
json!({"error": "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>>,
) -> JsonValue {
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(_) => {
json!({})
}
Err(e) => {
warn!("Could not delete template from {}: {:?}", id, e);
json!({"error": "Could not delete template"})
}
}
}
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
pub async fn create_reminder(
id: u64,
reminder: Json<Reminder>,
cookies: &CookieJar<'_>,
serenity_context: &State<Context>,
pool: &State<Pool<MySql>>,
) -> JsonValue {
check_authorization!(cookies, serenity_context.inner(), id);
let user_id =
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
// validate channel
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
let channel_exists = channel.is_some();
let channel_matches_guild =
channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id.0 == id));
if !channel_matches_guild || !channel_exists {
warn!(
"Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})",
reminder.channel, id, channel_exists
);
return 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 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 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 json!({"error": "Interval too short"});
}
}
// check patreon if necessary
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
if !check_guild_subscription(serenity_context.inner(), GuildId(id)).await
&& !check_subscription(serenity_context.inner(), user_id).await
{
return 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,
pin,
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.pin,
reminder.restartable,
reminder.tts,
reminder.username,
reminder.utc_time,
)
.execute(pool.inner())
.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.pin,
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.inner())
.await
.map(|r| json!(r))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
json!({"error": "Could not load reminder"})
}),
Err(e) => {
warn!("Error in `create_reminder`: Could not execute query: {:?}", e);
json!({"error": "Unknown error"})
}
}
}
#[get("/api/guild/<id>/reminders")]
pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySql>>) -> JsonValue {
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.pin,
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| json!(r))
.unwrap_or_else(|e| {
warn!("Failed to complete SQL query: {:?}", e);
json!({"error": "Could not load reminders"})
})
}
Err(e) => {
warn!("Could not fetch channels from {}: {:?}", id, e);
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>>,
) -> JsonValue {
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,
pin,
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 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 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 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.pin,
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) => json!({"reminder": reminder, "errors": error}),
Err(e) => {
warn!("Error exiting `edit_reminder': {:?}", e);
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>>,
) -> JsonValue {
match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid)
.execute(pool.inner())
.await
{
Ok(_) => {
json!({})
}
Err(e) => {
warn!("Error in `delete_reminder`: {:?}", e);
json!({"error": "Could not delete reminder"})
}
}
}

View File

@ -1,314 +0,0 @@
use std::collections::HashMap;
use chrono::naive::NaiveDateTime;
use rand::{rngs::OsRng, seq::IteratorRandom};
use rocket::{http::CookieJar, response::Redirect};
use rocket_dyn_templates::Template;
use serde::{Deserialize, Serialize};
use serenity::{http::Http, model::id::ChannelId};
use sqlx::{types::Json, Executor};
use crate::{
consts::{CHARACTERS, DEFAULT_AVATAR},
Database, Error,
};
pub mod guild;
pub mod user;
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(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,
pin: bool,
restartable: bool,
tts: bool,
#[serde(default)]
uid: String,
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)]
pin: Unset<bool>,
#[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 = String::deserialize(deserializer)?;
Some(base64::decode(string).map_err(de::Error::custom)).transpose()
}
}
#[derive(Deserialize)]
pub struct DeleteReminder {
uid: String,
}
async fn create_database_channel(
ctx: impl AsRef<Http>,
channel: ChannelId,
pool: impl Executor<'_, Database = Database> + Copy,
) -> Result<u32, crate::Error> {
println!("{:?}", channel);
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,149 +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()))
.add_scope(Scope::new("email".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,88 +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)
}

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,578 +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%;
}
.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: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 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,913 +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");
let channels = [];
let roles = [];
let templates = {};
let globalPatreon = 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 resize_textareas() {
document.querySelectorAll("textarea.autoresize").forEach((element) => {
element.style.height = "";
element.style.height = element.scrollHeight + 3 + "px";
element.addEventListener("input", () => {
element.style.height = "";
element.style.height = element.scrollHeight + 3 + "px";
});
});
}
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");
resize_textareas();
}
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());
}
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 {
roles = data;
}
});
}
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"];
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,
pin: node.querySelector('input[name="pin"]').checked,
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");
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");
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);
});
});
resize_textareas();
$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");
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) {
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}?name=${guild.name}`
);
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 =
new URLSearchParams(window.location.search).get("name") || 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;
}
}
$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[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();
resize_textareas();
});
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

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 2.1 MiB

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