Compare commits
19 Commits
poise-2
...
jellywx/fi
Author | SHA1 | Date | |
---|---|---|---|
7d8748e3ef | |||
25b84880a5 | |||
7b6e967a5d | |||
2781f2923e | |||
03f08f0a18 | |||
79c86d43f2 | |||
e19af54caf | |||
f4213c6a83 | |||
f56db14720 | |||
6f7d0f67b3 | |||
bfc2d71ca0 | |||
8eb46f1f23 | |||
c4087bf569 | |||
f25cfed8d7 | |||
d2a8bd1982 | |||
437ee6b446 | |||
7d43aa5918 | |||
8bad95510d | |||
d7a0b727fb |
901
Cargo.lock
generated
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "reminder_rs"
|
||||
version = "1.6.0-beta3"
|
||||
version = "1.6.3"
|
||||
authors = ["jellywx <judesouthworth@pm.me>"]
|
||||
edition = "2018"
|
||||
|
||||
@ -23,7 +23,7 @@ rmp-serde = "0.15"
|
||||
rand = "0.7"
|
||||
levenshtein = "1.0"
|
||||
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
|
||||
base64 = "0.13.0"
|
||||
base64 = "0.13"
|
||||
|
||||
[dependencies.postman]
|
||||
path = "postman"
|
||||
|
11
README.md
@ -2,13 +2,20 @@
|
||||
Reminder Bot for Discord.
|
||||
|
||||
## How do I use it?
|
||||
We offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating
|
||||
I offer a hosted version of the bot. You can invite it with: **https://invite.reminder-bot.com**. The catch is that repeating
|
||||
reminders are paid on the hosted version of the bot. Keep reading if you want to host it yourself.
|
||||
|
||||
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust)
|
||||
|
||||
### Compiling
|
||||
Reminder Bot can be built by running `cargo build --release` in the top level directory. It is necessary to create a folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of dimensions 128x128px to be used as the webhook avatar.
|
||||
Install build requirements:
|
||||
`sudo apt install gcc gcc-multilib cmake libssl-dev build-essential`
|
||||
|
||||
Install Rust from https://rustup.rs
|
||||
|
||||
Reminder Bot can then be built by running `cargo build --release` in the top level directory. It is necessary to create a
|
||||
folder called 'assets' containing an image file with its name specified in the environment as `WEBHOOK_AVATAR`, of
|
||||
dimensions 128x128px to be used as the webhook avatar.
|
||||
|
||||
#### Compilation environment variables
|
||||
These environment variables must be provided when compiling the bot
|
||||
|
@ -157,4 +157,9 @@ CREATE TABLE events (
|
||||
FOREIGN KEY (reminder_id) REFERENCES reminders_new(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
DROP TABLE reminders;
|
||||
DROP TABLE embed_fields;
|
||||
RENAME TABLE reminders_new TO reminders;
|
||||
RENAME TABLE embed_fields_new TO embed_fields;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
@ -32,3 +32,20 @@ CREATE TABLE reminder_template (
|
||||
);
|
||||
|
||||
ALTER TABLE reminders ADD COLUMN embed_fields JSON;
|
||||
|
||||
update reminders
|
||||
inner join embed_fields as E
|
||||
on E.reminder_id = reminders.id
|
||||
set embed_fields = (
|
||||
select JSON_ARRAYAGG(
|
||||
JSON_OBJECT(
|
||||
'title', E.title,
|
||||
'value', E.value,
|
||||
'inline',
|
||||
if(inline = 1, cast(TRUE as json), cast(FALSE as json))
|
||||
)
|
||||
)
|
||||
from embed_fields
|
||||
group by reminder_id
|
||||
having reminder_id = reminders.id
|
||||
);
|
||||
|
@ -7,12 +7,10 @@ edition = "2021"
|
||||
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"] }
|
||||
|
@ -7,7 +7,7 @@ use regex::{Captures, Regex};
|
||||
use serde::Deserialize;
|
||||
use serenity::{
|
||||
builder::CreateEmbed,
|
||||
http::{CacheHttp, Http, StatusCode},
|
||||
http::{CacheHttp, Http, HttpError, StatusCode},
|
||||
model::{
|
||||
channel::{Channel, Embed as SerenityEmbed},
|
||||
id::ChannelId,
|
||||
@ -58,10 +58,10 @@ fn fmt_displacement(format: &str, seconds: u64) -> 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();
|
||||
let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten();
|
||||
let format = caps.name("format").map(|m| m.as_str());
|
||||
|
||||
if let Ok(final_time) = final_time.parse::<i64>() {
|
||||
if let (Some(final_time), Some(format)) = (final_time, format) {
|
||||
let dt = NaiveDateTime::from_timestamp(final_time, 0);
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
@ -81,13 +81,11 @@ pub fn substitute(string: &str) -> String {
|
||||
|
||||
TIMENOW_REGEX
|
||||
.replace(&new, |caps: &Captures| {
|
||||
let timezone = caps.name("timezone").unwrap().as_str();
|
||||
let timezone = caps.name("timezone").map(|m| m.as_str().parse::<Tz>().ok()).flatten();
|
||||
let format = caps.name("format").map(|m| m.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);
|
||||
if let (Some(timezone), Some(format)) = (timezone, format) {
|
||||
let now = Utc::now().with_timezone(&timezone);
|
||||
|
||||
now.format(format).to_string()
|
||||
} else {
|
||||
@ -122,7 +120,7 @@ impl Embed {
|
||||
pool: impl Executor<'_, Database = Database> + Copy,
|
||||
id: u32,
|
||||
) -> Option<Self> {
|
||||
let mut embed = sqlx::query_as!(
|
||||
match sqlx::query_as!(
|
||||
Self,
|
||||
r#"
|
||||
SELECT
|
||||
@ -142,21 +140,29 @@ impl Embed {
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
Ok(mut embed) => {
|
||||
embed.title = substitute(&embed.title);
|
||||
embed.description = substitute(&embed.description);
|
||||
embed.footer = substitute(&embed.footer);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if embed.has_content() {
|
||||
Some(embed)
|
||||
} else {
|
||||
None
|
||||
Err(e) => {
|
||||
warn!("Error loading embed from reminder: {:?}", e);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,7 +226,6 @@ impl Into<CreateEmbed> for Embed {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Reminder {
|
||||
id: u32,
|
||||
|
||||
@ -251,9 +256,9 @@ pub struct Reminder {
|
||||
|
||||
impl Reminder {
|
||||
pub async fn fetch_reminders(pool: impl Executor<'_, Database = Database> + Copy) -> Vec<Self> {
|
||||
sqlx::query_as_unchecked!(
|
||||
match sqlx::query_as_unchecked!(
|
||||
Reminder,
|
||||
"
|
||||
r#"
|
||||
SELECT
|
||||
reminders.`id` AS id,
|
||||
|
||||
@ -261,9 +266,9 @@ SELECT
|
||||
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,
|
||||
channels.`paused` AS 'channel_paused',
|
||||
channels.`paused_until` AS 'channel_paused_until',
|
||||
reminders.`enabled` AS 'enabled',
|
||||
|
||||
reminders.`tts` AS tts,
|
||||
reminders.`pin` AS pin,
|
||||
@ -274,7 +279,7 @@ SELECT
|
||||
reminders.`utc_time` AS 'utc_time',
|
||||
reminders.`timezone` AS timezone,
|
||||
reminders.`restartable` AS restartable,
|
||||
reminders.`expires` AS expires,
|
||||
reminders.`expires` AS 'expires',
|
||||
reminders.`interval_seconds` AS 'interval_seconds',
|
||||
reminders.`interval_months` AS 'interval_months',
|
||||
|
||||
@ -287,19 +292,40 @@ INNER JOIN
|
||||
ON
|
||||
reminders.channel_id = channels.id
|
||||
WHERE
|
||||
reminders.`utc_time` < NOW()
|
||||
",
|
||||
reminders.`id` IN (
|
||||
SELECT
|
||||
MIN(id)
|
||||
FROM
|
||||
reminders
|
||||
WHERE
|
||||
reminders.`utc_time` <= NOW()
|
||||
AND (
|
||||
reminders.`interval_seconds` IS NOT NULL
|
||||
OR reminders.`interval_months` IS NOT NULL
|
||||
OR reminders.enabled
|
||||
)
|
||||
GROUP BY channel_id
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|mut rem| {
|
||||
rem.content = substitute(&rem.content);
|
||||
{
|
||||
Ok(reminders) => reminders
|
||||
.into_iter()
|
||||
.map(|mut rem| {
|
||||
rem.content = substitute(&rem.content);
|
||||
|
||||
rem
|
||||
})
|
||||
.collect::<Vec<Self>>()
|
||||
rem
|
||||
})
|
||||
.collect::<Vec<Self>>(),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Could not fetch reminders: {:?}", e);
|
||||
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||
@ -319,7 +345,7 @@ UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
|
||||
let mut updated_reminder_time = self.utc_time;
|
||||
|
||||
if let Some(interval) = self.interval_months {
|
||||
let row = sqlx::query!(
|
||||
match sqlx::query!(
|
||||
// use the second date_add to force return value to datetime
|
||||
"SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time",
|
||||
updated_reminder_time,
|
||||
@ -327,9 +353,25 @@ UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
Ok(row) => match row.new_time {
|
||||
Some(datetime) => {
|
||||
updated_reminder_time = datetime;
|
||||
}
|
||||
None => {
|
||||
warn!("Could not update interval by months: got NULL");
|
||||
|
||||
updated_reminder_time = row.new_time.unwrap();
|
||||
updated_reminder_time += Duration::days(30);
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Could not update interval by months: {:?}", e);
|
||||
|
||||
// naively fallback to adding 30 days
|
||||
updated_reminder_time += Duration::days(30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(interval) = self.interval_seconds {
|
||||
@ -535,14 +577,19 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
error!("Error sending {:?}: {:?}", self, e);
|
||||
error!("Error sending reminder {}: {:?}", self.id, e);
|
||||
|
||||
if let Error::Http(error) = e {
|
||||
if error.status_code() == Some(StatusCode::from_u16(404).unwrap()) {
|
||||
error!("Seeing channel is deleted. Removing reminder");
|
||||
if error.status_code() == Some(StatusCode::NOT_FOUND) {
|
||||
warn!("Seeing channel is deleted. Removing reminder");
|
||||
self.force_delete(pool).await;
|
||||
} else {
|
||||
self.refresh(pool).await;
|
||||
} else if let HttpError::UnsuccessfulRequest(error) = *error {
|
||||
if error.error.code == 50007 {
|
||||
warn!("User cannot receive DMs");
|
||||
self.force_delete(pool).await;
|
||||
} else {
|
||||
self.refresh(pool).await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.refresh(pool).await;
|
||||
|
@ -49,6 +49,7 @@ __Todo Commands__
|
||||
|
||||
__Setup Commands__
|
||||
`/timezone` - Set your timezone (necessary for `/remind` to work properly)
|
||||
`/dm allow/block` - Change your DM settings for reminders.
|
||||
|
||||
__Advanced Commands__
|
||||
`/macro` - Record and replay command sequences
|
||||
@ -71,7 +72,7 @@ pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
|
||||
.send(|m| {
|
||||
m.ephemeral(true).embed(|e| {
|
||||
e.title("Info")
|
||||
.description(format!(
|
||||
.description(
|
||||
"Help: `/help`
|
||||
|
||||
**Welcome to Reminder Bot!**
|
||||
@ -81,7 +82,7 @@ Find me on https://discord.jellywx.com and on https://github.com/JellyWX :)
|
||||
|
||||
Invite the bot: https://invite.reminder-bot.com/
|
||||
Use our dashboard: https://reminder-bot.com/",
|
||||
))
|
||||
)
|
||||
.footer(footer)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
|
@ -1,3 +1,5 @@
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
use chrono::offset::Utc;
|
||||
use chrono_tz::{Tz, TZ_VARIANTS};
|
||||
use levenshtein::levenshtein;
|
||||
@ -52,7 +54,7 @@ pub async fn timezone(
|
||||
.description(format!(
|
||||
"Timezone has been set to **{}**. Your current time should be `{}`",
|
||||
timezone,
|
||||
now.format("%H:%M").to_string()
|
||||
now.format("%H:%M")
|
||||
))
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
@ -75,10 +77,7 @@ pub async fn timezone(
|
||||
let fields = filtered_tz.iter().map(|tz| {
|
||||
(
|
||||
tz.to_string(),
|
||||
format!(
|
||||
"🕗 `{}`",
|
||||
Utc::now().with_timezone(tz).format("%H:%M").to_string()
|
||||
),
|
||||
format!("🕗 `{}`", Utc::now().with_timezone(tz).format("%H:%M")),
|
||||
true,
|
||||
)
|
||||
});
|
||||
@ -98,11 +97,7 @@ pub async fn timezone(
|
||||
}
|
||||
} else {
|
||||
let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| {
|
||||
(
|
||||
t.to_string(),
|
||||
format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()),
|
||||
true,
|
||||
)
|
||||
(t.to_string(), format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M")), true)
|
||||
});
|
||||
|
||||
ctx.send(|m| {
|
||||
@ -129,6 +124,85 @@ You may want to use one of the popular timezones below, otherwise click [here](h
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure whether other users can set reminders to your direct messages
|
||||
#[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")]
|
||||
pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Allow other users to set reminders in your direct messages
|
||||
#[poise::command(slash_command, rename = "allow", identifying_name = "allowed_dm")]
|
||||
pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let mut user_data = ctx.author_data().await?;
|
||||
user_data.allowed_dm = true;
|
||||
user_data.commit_changes(&ctx.data().database).await;
|
||||
|
||||
ctx.send(|r| {
|
||||
r.ephemeral(true).embed(|e| {
|
||||
e.title("DMs permitted")
|
||||
.description("You will receive a message if a user sets a DM reminder for you.")
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Block other users from setting reminders in your direct messages
|
||||
#[poise::command(slash_command, rename = "block", identifying_name = "allowed_dm")]
|
||||
pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let mut user_data = ctx.author_data().await?;
|
||||
user_data.allowed_dm = false;
|
||||
user_data.commit_changes(&ctx.data().database).await;
|
||||
|
||||
ctx.send(|r| {
|
||||
r.ephemeral(true).embed(|e| {
|
||||
e.title("DMs blocked")
|
||||
.description(
|
||||
"You can still set DM reminders for yourself or for users with DMs enabled.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// View the webhook being used to send reminders to this channel
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
identifying_name = "webhook_url",
|
||||
required_permissions = "ADMINISTRATOR"
|
||||
)]
|
||||
pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
|
||||
match ctx.channel_data().await {
|
||||
Ok(data) => {
|
||||
if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
|
||||
let _ = ctx
|
||||
.send(|b| {
|
||||
b.ephemeral(true).content(format!(
|
||||
"**Warning!**
|
||||
This link can be used by users to anonymously send messages, with or without permissions.
|
||||
Do not share it!
|
||||
|| https://discord.com/api/webhooks/{}/{} ||",
|
||||
id, token,
|
||||
))
|
||||
})
|
||||
.await;
|
||||
} else {
|
||||
let _ = ctx.say("No webhook configured on this channel.").await;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = ctx.say("No webhook configured on this channel.").await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
|
||||
sqlx::query!(
|
||||
"
|
||||
@ -142,7 +216,7 @@ WHERE
|
||||
)
|
||||
.fetch_all(&ctx.data().database)
|
||||
.await
|
||||
.unwrap_or(vec![])
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect()
|
||||
@ -200,14 +274,11 @@ Please select a unique name for your macro.",
|
||||
let okay = {
|
||||
let mut lock = ctx.data().recording_macros.write().await;
|
||||
|
||||
if lock.contains_key(&(guild_id, ctx.author().id)) {
|
||||
false
|
||||
} else {
|
||||
lock.insert(
|
||||
(guild_id, ctx.author().id),
|
||||
CommandMacro { guild_id, name, description, commands: vec![] },
|
||||
);
|
||||
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
|
||||
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -9,13 +9,14 @@ use chrono_tz::Tz;
|
||||
use num_integer::Integer;
|
||||
use poise::{
|
||||
serenity::{builder::CreateEmbed, model::channel::Channel},
|
||||
serenity_prelude::{component::ButtonStyle, ReactionType},
|
||||
CreateReply,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
component_models::{
|
||||
pager::{DelPager, LookPager, Pager},
|
||||
ComponentDataModel, DelSelector,
|
||||
ComponentDataModel, DelSelector, UndoReminder,
|
||||
},
|
||||
consts::{
|
||||
EMBED_DESCRIPTION_MAX_LENGTH, HOUR, MINUTE, REGEX_CHANNEL_USER, SELECT_MAX_ENTRIES,
|
||||
@ -500,18 +501,16 @@ pub async fn start_timer(
|
||||
if count >= 25 {
|
||||
ctx.say("You already have 25 timers. Please delete some timers before creating a new one")
|
||||
.await?;
|
||||
} else {
|
||||
if name.len() <= 32 {
|
||||
Timer::create(&name, owner, &ctx.data().database).await;
|
||||
} else if name.len() <= 32 {
|
||||
Timer::create(&name, owner, &ctx.data().database).await;
|
||||
|
||||
ctx.say("Created a new timer").await?;
|
||||
} else {
|
||||
ctx.say(format!(
|
||||
"Please name your timer something shorted (max. 32 characters, you used {})",
|
||||
name.len()
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
ctx.say("Created a new timer").await?;
|
||||
} else {
|
||||
ctx.say(format!(
|
||||
"Please name your timer something shorted (max. 32 characters, you used {})",
|
||||
name.len()
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -589,8 +588,7 @@ pub async fn remind(
|
||||
};
|
||||
|
||||
let scopes = {
|
||||
let list =
|
||||
channels.map(|arg| parse_mention_list(&arg.to_string())).unwrap_or_default();
|
||||
let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default();
|
||||
|
||||
if list.is_empty() {
|
||||
if ctx.guild_id().is_some() {
|
||||
@ -610,7 +608,7 @@ pub async fn remind(
|
||||
{
|
||||
(
|
||||
parse_duration(repeat)
|
||||
.or_else(|_| parse_duration(&format!("1 {}", repeat.to_string())))
|
||||
.or_else(|_| parse_duration(&format!("1 {}", repeat)))
|
||||
.ok(),
|
||||
{
|
||||
if let Some(arg) = &expires {
|
||||
@ -653,17 +651,50 @@ pub async fn remind(
|
||||
|
||||
let (errors, successes) = builder.build().await;
|
||||
|
||||
let embed = create_response(successes, errors, time);
|
||||
let embed = create_response(&successes, &errors, time);
|
||||
|
||||
ctx.send(|m| {
|
||||
m.embed(|c| {
|
||||
*c = embed;
|
||||
c
|
||||
if successes.len() == 1 {
|
||||
let reminder = successes.iter().next().map(|(r, _)| r.id).unwrap();
|
||||
let undo_button = ComponentDataModel::UndoReminder(UndoReminder {
|
||||
user_id: ctx.author().id,
|
||||
reminder_id: reminder,
|
||||
});
|
||||
|
||||
ctx.send(|m| {
|
||||
m.embed(|c| {
|
||||
*c = embed;
|
||||
c
|
||||
})
|
||||
.components(|c| {
|
||||
c.create_action_row(|r| {
|
||||
r.create_button(|b| {
|
||||
b.emoji(ReactionType::Unicode("🔕".to_string()))
|
||||
.label("Cancel")
|
||||
.style(ButtonStyle::Danger)
|
||||
.custom_id(undo_button.to_custom_id())
|
||||
})
|
||||
.create_button(|b| {
|
||||
b.emoji(ReactionType::Unicode("📝".to_string()))
|
||||
.label("Edit")
|
||||
.style(ButtonStyle::Link)
|
||||
.url("https://reminder-bot.com/dashboard")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
.await?;
|
||||
} else {
|
||||
ctx.send(|m| {
|
||||
m.embed(|c| {
|
||||
*c = embed;
|
||||
c
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
ctx.say("Time could not be processed").await?;
|
||||
}
|
||||
@ -673,8 +704,8 @@ pub async fn remind(
|
||||
}
|
||||
|
||||
fn create_response(
|
||||
successes: HashSet<ReminderScope>,
|
||||
errors: HashSet<ReminderError>,
|
||||
successes: &HashSet<(Reminder, ReminderScope)>,
|
||||
errors: &HashSet<ReminderError>,
|
||||
time: i64,
|
||||
) -> CreateEmbed {
|
||||
let success_part = match successes.len() {
|
||||
@ -682,7 +713,8 @@ fn create_response(
|
||||
n => format!(
|
||||
"Reminder{s} for {locations} set for <t:{offset}:R>",
|
||||
s = if n > 1 { "s" } else { "" },
|
||||
locations = successes.iter().map(|l| l.mention()).collect::<Vec<String>>().join(", "),
|
||||
locations =
|
||||
successes.iter().map(|(_, l)| l.mention()).collect::<Vec<String>>().join(", "),
|
||||
offset = time
|
||||
),
|
||||
};
|
||||
|
@ -6,6 +6,7 @@ use crate::{
|
||||
ComponentDataModel, TodoSelector,
|
||||
},
|
||||
consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR},
|
||||
models::CtxData,
|
||||
Context, Error,
|
||||
};
|
||||
|
||||
@ -116,6 +117,9 @@ pub async fn todo_channel_add(
|
||||
ctx: Context<'_>,
|
||||
#[description = "The task to add to the todo list"] task: String,
|
||||
) -> Result<(), Error> {
|
||||
// ensure channel is cached
|
||||
let _ = ctx.channel_data().await;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO todos (guild_id, channel_id, value)
|
||||
VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)",
|
||||
@ -336,7 +340,7 @@ pub fn show_todo_page(
|
||||
opt.create_option(|o| {
|
||||
o.label(format!("Mark {} complete", count + first_num))
|
||||
.value(id)
|
||||
.description(disp.split_once(" ").unwrap_or(("", "")).1)
|
||||
.description(disp.split_once(' ').unwrap_or(("", "")).1)
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -3,14 +3,20 @@ pub(crate) mod pager;
|
||||
use std::io::Cursor;
|
||||
|
||||
use chrono_tz::Tz;
|
||||
use poise::serenity::{
|
||||
builder::CreateEmbed,
|
||||
client::Context,
|
||||
model::{
|
||||
channel::Channel,
|
||||
interactions::{message_component::MessageComponentInteraction, InteractionResponseType},
|
||||
prelude::InteractionApplicationCommandCallbackDataFlags,
|
||||
use log::warn;
|
||||
use poise::{
|
||||
serenity::{
|
||||
builder::CreateEmbed,
|
||||
client::Context,
|
||||
model::{
|
||||
application::interaction::{
|
||||
message_component::MessageComponentInteraction, InteractionResponseType,
|
||||
MessageFlags,
|
||||
},
|
||||
channel::Channel,
|
||||
},
|
||||
},
|
||||
serenity_prelude as serenity,
|
||||
};
|
||||
use rmp_serde::Serializer;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -38,6 +44,7 @@ pub enum ComponentDataModel {
|
||||
DelSelector(DelSelector),
|
||||
TodoSelector(TodoSelector),
|
||||
MacroPager(MacroPager),
|
||||
UndoReminder(UndoReminder),
|
||||
}
|
||||
|
||||
impl ComponentDataModel {
|
||||
@ -253,7 +260,7 @@ WHERE guilds.guild = ?",
|
||||
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|d| {
|
||||
d.flags(
|
||||
InteractionApplicationCommandCallbackDataFlags::EPHEMERAL,
|
||||
MessageFlags::EPHEMERAL,
|
||||
)
|
||||
.content("Only the user who performed the command can use these components")
|
||||
})
|
||||
@ -307,7 +314,7 @@ WHERE guilds.guild = ?",
|
||||
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|d| {
|
||||
d.flags(
|
||||
InteractionApplicationCommandCallbackDataFlags::EPHEMERAL,
|
||||
MessageFlags::EPHEMERAL,
|
||||
)
|
||||
.content("Only the user who performed the command can use these components")
|
||||
})
|
||||
@ -334,6 +341,70 @@ WHERE guilds.guild = ?",
|
||||
})
|
||||
.await;
|
||||
}
|
||||
ComponentDataModel::UndoReminder(undo_reminder) => {
|
||||
if component.user.id == undo_reminder.user_id {
|
||||
let reminder =
|
||||
Reminder::from_id(&data.database, undo_reminder.reminder_id).await;
|
||||
|
||||
if let Some(reminder) = reminder {
|
||||
match reminder.delete(&data.database).await {
|
||||
Ok(()) => {
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::UpdateMessage)
|
||||
.interaction_response_data(|d| {
|
||||
d.embed(|e| {
|
||||
e.title("Reminder Canceled")
|
||||
.description(
|
||||
"This reminder has been canceled.",
|
||||
)
|
||||
.color(*THEME_COLOR)
|
||||
})
|
||||
.components(|c| c)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error canceling reminder: {:?}", e);
|
||||
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|d| {
|
||||
d.content(
|
||||
"The reminder could not be canceled: it may have already been deleted. Check `/del`!")
|
||||
.ephemeral(true)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|d| {
|
||||
d.content(
|
||||
"The reminder could not be canceled: it may have already been deleted. Check `/del`!")
|
||||
.ephemeral(true)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
let _ = component
|
||||
.create_interaction_response(&ctx, |f| {
|
||||
f.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||
.interaction_response_data(|d| {
|
||||
d.content(
|
||||
"Only the user who performed the command can use this button.")
|
||||
.ephemeral(true)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -351,3 +422,9 @@ pub struct TodoSelector {
|
||||
pub channel_id: Option<u64>,
|
||||
pub guild_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UndoReminder {
|
||||
pub user_id: serenity::UserId,
|
||||
pub reminder_id: u32,
|
||||
}
|
||||
|
@ -1,8 +1,6 @@
|
||||
// todo split pager out into a single struct
|
||||
use chrono_tz::Tz;
|
||||
use poise::serenity::{
|
||||
builder::CreateComponents, model::interactions::message_component::ButtonStyle,
|
||||
};
|
||||
use poise::serenity::{builder::CreateComponents, model::application::component::ButtonStyle};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_repr::*;
|
||||
|
||||
|
@ -36,15 +36,11 @@ lazy_static! {
|
||||
);
|
||||
pub static ref CNC_GUILD: Option<u64> =
|
||||
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
|
||||
pub static ref MIN_INTERVAL: i64 = env::var("MIN_INTERVAL")
|
||||
.ok()
|
||||
.map(|inner| inner.parse::<i64>().ok())
|
||||
.flatten()
|
||||
.unwrap_or(600);
|
||||
pub static ref MIN_INTERVAL: i64 =
|
||||
env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
|
||||
pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
|
||||
.ok()
|
||||
.map(|inner| inner.parse::<i64>().ok())
|
||||
.flatten()
|
||||
.and_then(|inner| inner.parse::<i64>().ok())
|
||||
.unwrap_or(60 * 60 * 24 * 365 * 50);
|
||||
pub static ref LOCAL_TIMEZONE: String =
|
||||
env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string());
|
||||
|
@ -2,7 +2,7 @@ use std::{collections::HashMap, env, sync::atomic::Ordering};
|
||||
|
||||
use log::{error, info, warn};
|
||||
use poise::{
|
||||
serenity::{model::interactions::Interaction, utils::shard_id},
|
||||
serenity::{model::application::interaction::Interaction, utils::shard_id},
|
||||
serenity_prelude as serenity,
|
||||
};
|
||||
|
||||
@ -14,6 +14,9 @@ pub async fn listener(
|
||||
data: &Data,
|
||||
) -> Result<(), Error> {
|
||||
match event {
|
||||
poise::Event::Ready { .. } => {
|
||||
ctx.set_activity(serenity::Activity::watching("for /remind")).await;
|
||||
}
|
||||
poise::Event::CacheReady { .. } => {
|
||||
info!("Cache Ready! Preparing extra processes");
|
||||
|
||||
@ -39,7 +42,7 @@ pub async fn listener(
|
||||
};
|
||||
});
|
||||
} else {
|
||||
warn!("Not running postman")
|
||||
warn!("Not running postman");
|
||||
}
|
||||
|
||||
if !run_settings.contains("web") {
|
||||
@ -47,7 +50,7 @@ pub async fn listener(
|
||||
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
|
||||
});
|
||||
} else {
|
||||
warn!("Not running web")
|
||||
warn!("Not running web");
|
||||
}
|
||||
|
||||
data.is_loop_running.swap(true, Ordering::Relaxed);
|
||||
@ -111,14 +114,13 @@ pub async fn listener(
|
||||
.execute(&data.database)
|
||||
.await;
|
||||
}
|
||||
poise::Event::InteractionCreate { interaction } => match interaction {
|
||||
Interaction::MessageComponent(component) => {
|
||||
poise::Event::InteractionCreate { interaction } => {
|
||||
if let Interaction::MessageComponent(component) = interaction {
|
||||
let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
|
||||
|
||||
component_model.act(ctx, data, component).await;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
23
src/hooks.rs
@ -11,10 +11,10 @@ async fn macro_check(ctx: Context<'_>) -> bool {
|
||||
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),
|
||||
)
|
||||
})
|
||||
m.ephemeral(true).content(
|
||||
format!("{} commands already recorded. Please use `/macro finish` to end recording.", MACRO_MAX_COMMANDS),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
} else {
|
||||
let recorded = RecordedCommand {
|
||||
@ -30,19 +30,13 @@ async fn macro_check(ctx: Context<'_>) -> bool {
|
||||
.await;
|
||||
}
|
||||
|
||||
false
|
||||
} else {
|
||||
true
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
||||
@ -56,14 +50,13 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
|
||||
let (view_channel, send_messages, embed_links) = ctx
|
||||
.channel_id()
|
||||
.to_channel_cached(&ctx.discord())
|
||||
.map(|c| {
|
||||
.and_then(|c| {
|
||||
if let Channel::Guild(channel) = c {
|
||||
channel.permissions_for_user(&ctx.discord(), user_id).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.map_or((false, false, false), |p| {
|
||||
(p.view_channel(), p.send_messages(), p.embed_links())
|
||||
});
|
||||
|
@ -75,7 +75,7 @@ impl fmt::Display for Error {
|
||||
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 == &"" => {
|
||||
Error::UnknownUnit { unit, value, .. } if unit.is_empty() => {
|
||||
write!(f, "time unit needed, for example {0}sec or {0}ms", value,)
|
||||
}
|
||||
Error::UnknownUnit { unit, .. } => {
|
||||
@ -162,11 +162,11 @@ impl<'a> Parser<'a> {
|
||||
};
|
||||
let mut nsec = self.current.2 + nsec;
|
||||
if nsec > 1_000_000_000 {
|
||||
sec = sec + nsec / 1_000_000_000;
|
||||
sec += nsec / 1_000_000_000;
|
||||
nsec %= 1_000_000_000;
|
||||
}
|
||||
sec = self.current.1 + sec;
|
||||
month = self.current.0 + month;
|
||||
sec += self.current.1;
|
||||
month += self.current.0;
|
||||
|
||||
self.current = (month, sec, nsec);
|
||||
|
||||
|
15
src/main.rs
@ -1,4 +1,5 @@
|
||||
#![feature(int_roundings)]
|
||||
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
@ -23,7 +24,7 @@ use std::{
|
||||
use chrono_tz::Tz;
|
||||
use dotenv::dotenv;
|
||||
use poise::serenity::model::{
|
||||
gateway::{Activity, GatewayIntents},
|
||||
gateway::GatewayIntents,
|
||||
id::{GuildId, UserId},
|
||||
};
|
||||
use sqlx::{MySql, Pool};
|
||||
@ -52,7 +53,7 @@ pub struct Data {
|
||||
broadcast: Sender<()>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Data {
|
||||
impl Debug for Data {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Data {{ .. }}")
|
||||
}
|
||||
@ -100,6 +101,14 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||
info_cmds::clock_context_menu(),
|
||||
info_cmds::dashboard(),
|
||||
moderation_cmds::timezone(),
|
||||
poise::Command {
|
||||
subcommands: vec![
|
||||
moderation_cmds::set_allowed_dm(),
|
||||
moderation_cmds::unset_allowed_dm(),
|
||||
],
|
||||
..moderation_cmds::allowed_dm()
|
||||
},
|
||||
moderation_cmds::webhook(),
|
||||
poise::Command {
|
||||
subcommands: vec![
|
||||
moderation_cmds::delete_macro(),
|
||||
@ -171,8 +180,6 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||
.token(discord_token)
|
||||
.user_data_setup(move |ctx, _bot, framework| {
|
||||
Box::pin(async move {
|
||||
ctx.set_activity(Activity::watching("for /remind")).await;
|
||||
|
||||
register_application_commands(
|
||||
ctx,
|
||||
framework,
|
||||
|
@ -1,15 +1,15 @@
|
||||
use poise::serenity::model::{
|
||||
id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption,
|
||||
application::interaction::application_command::CommandDataOption, id::GuildId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Context, Data, Error};
|
||||
|
||||
fn default_none<U, E>() -> Option<
|
||||
for<'a> fn(
|
||||
poise::ApplicationContext<'a, U, E>,
|
||||
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>,
|
||||
> {
|
||||
type Func<U, E> = for<'a> fn(
|
||||
poise::ApplicationContext<'a, U, E>,
|
||||
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>;
|
||||
|
||||
fn default_none<U, E>() -> Option<Func<U, E>> {
|
||||
None
|
||||
}
|
||||
|
||||
@ -17,13 +17,9 @@ fn default_none<U, E>() -> Option<
|
||||
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 action: Option<Func<U, E>>,
|
||||
pub command_name: String,
|
||||
pub options: Vec<ApplicationCommandInteractionDataOption>,
|
||||
pub options: Vec<CommandDataOption>,
|
||||
}
|
||||
|
||||
pub struct CommandMacro<U, E> {
|
||||
@ -59,7 +55,7 @@ SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND
|
||||
.iter()
|
||||
.find(|c| c.identifying_name == recorded_command.command_name);
|
||||
|
||||
recorded_command.action = command.map(|c| c.slash_action).flatten().clone();
|
||||
recorded_command.action = command.map(|c| c.slash_action).flatten();
|
||||
}
|
||||
|
||||
let command_macro = CommandMacro {
|
||||
|
@ -126,7 +126,7 @@ INSERT INTO reminders (
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
Ok(Reminder::from_uid(&self.pool, self.uid).await.unwrap())
|
||||
Ok(Reminder::from_uid(&self.pool, &self.uid).await.unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
@ -207,7 +207,7 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
self.scopes = scopes;
|
||||
}
|
||||
|
||||
pub async fn build(self) -> (HashSet<ReminderError>, HashSet<ReminderScope>) {
|
||||
pub async fn build(self) -> (HashSet<ReminderError>, HashSet<(Reminder, ReminderScope)>) {
|
||||
let mut errors = HashSet::new();
|
||||
|
||||
let mut ok_locs = HashSet::new();
|
||||
@ -233,6 +233,10 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
if let Some(guild_id) = self.guild_id {
|
||||
if guild_id.member(&self.ctx.discord(), user).await.is_err() {
|
||||
Err(ReminderError::InvalidTag)
|
||||
} else if self.set_by.map_or(true, |i| i != user_data.id)
|
||||
&& !user_data.allowed_dm
|
||||
{
|
||||
Err(ReminderError::UserBlockedDm)
|
||||
} else {
|
||||
Ok(user_data.dm_channel)
|
||||
}
|
||||
@ -309,8 +313,8 @@ impl<'a> MultiReminderBuilder<'a> {
|
||||
};
|
||||
|
||||
match builder.build().await {
|
||||
Ok(_) => {
|
||||
ok_locs.insert(scope);
|
||||
Ok(r) => {
|
||||
ok_locs.insert((r, scope));
|
||||
}
|
||||
Err(e) => {
|
||||
errors.insert(e);
|
||||
|
@ -7,6 +7,7 @@ pub enum ReminderError {
|
||||
PastTime,
|
||||
ShortInterval,
|
||||
InvalidTag,
|
||||
UserBlockedDm,
|
||||
DiscordError(String),
|
||||
}
|
||||
|
||||
@ -30,6 +31,9 @@ impl ToString for ReminderError {
|
||||
ReminderError::InvalidTag => {
|
||||
"Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string()
|
||||
}
|
||||
ReminderError::UserBlockedDm => {
|
||||
"User has DM reminders disabled".to_string()
|
||||
}
|
||||
ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s),
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,8 @@ pub mod errors;
|
||||
mod helper;
|
||||
pub mod look_flags;
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use chrono::{NaiveDateTime, TimeZone};
|
||||
use chrono_tz::Tz;
|
||||
use poise::{
|
||||
@ -32,11 +34,22 @@ pub struct Reminder {
|
||||
pub set_by: Option<u64>,
|
||||
}
|
||||
|
||||
impl Hash for Reminder {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.uid.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<Self> for Reminder {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.uid == other.uid
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Reminder {}
|
||||
|
||||
impl Reminder {
|
||||
pub async fn from_uid(
|
||||
pool: impl Executor<'_, Database = Database>,
|
||||
uid: String,
|
||||
) -> Option<Self> {
|
||||
pub async fn from_uid(pool: impl Executor<'_, Database = Database>, uid: &str) -> Option<Self> {
|
||||
sqlx::query_as_unchecked!(
|
||||
Self,
|
||||
"
|
||||
@ -72,6 +85,42 @@ WHERE
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub async fn from_id(pool: impl Executor<'_, Database = Database>, id: u32) -> Option<Self> {
|
||||
sqlx::query_as_unchecked!(
|
||||
Self,
|
||||
"
|
||||
SELECT
|
||||
reminders.id,
|
||||
reminders.uid,
|
||||
channels.channel,
|
||||
reminders.utc_time,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.expires,
|
||||
reminders.enabled,
|
||||
reminders.content,
|
||||
reminders.embed_description,
|
||||
users.user AS set_by
|
||||
FROM
|
||||
reminders
|
||||
INNER JOIN
|
||||
channels
|
||||
ON
|
||||
reminders.channel_id = channels.id
|
||||
LEFT JOIN
|
||||
users
|
||||
ON
|
||||
reminders.set_by = users.id
|
||||
WHERE
|
||||
reminders.id = ?
|
||||
",
|
||||
id
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub async fn from_channel<C: Into<ChannelId>>(
|
||||
pool: impl Executor<'_, Database = Database>,
|
||||
channel_id: C,
|
||||
@ -240,6 +289,13 @@ WHERE
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
&self,
|
||||
db: impl Executor<'_, Database = Database>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!("DELETE FROM reminders WHERE uid = ?", self.uid).execute(db).await.map(|_| ())
|
||||
}
|
||||
|
||||
pub fn display_content(&self) -> &str {
|
||||
if self.content.is_empty() {
|
||||
&self.embed_description
|
||||
@ -254,10 +310,7 @@ WHERE
|
||||
count + 1,
|
||||
self.display_content(),
|
||||
self.channel,
|
||||
timezone
|
||||
.timestamp(self.utc_time.timestamp(), 0)
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string()
|
||||
timezone.timestamp(self.utc_time.timestamp(), 0).format("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,7 @@ pub struct UserData {
|
||||
pub user: u64,
|
||||
pub dm_channel: u32,
|
||||
pub timezone: String,
|
||||
pub allowed_dm: bool,
|
||||
}
|
||||
|
||||
impl UserData {
|
||||
@ -46,7 +47,7 @@ SELECT timezone FROM users WHERE user = ?
|
||||
match sqlx::query_as_unchecked!(
|
||||
Self,
|
||||
"
|
||||
SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ?
|
||||
SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone, allowed_dm FROM users WHERE user = ?
|
||||
",
|
||||
*LOCAL_TIMEZONE,
|
||||
user_id.0
|
||||
@ -71,7 +72,7 @@ INSERT IGNORE INTO channels (channel) VALUES (?)
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO users (user, dm_channel, timezone) VALUES (?, (SELECT id FROM channels WHERE channel = ?), ?)
|
||||
INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id FROM channels WHERE channel = ?), ?)
|
||||
",
|
||||
user_id.0,
|
||||
dm_channel.id.0,
|
||||
@ -83,7 +84,7 @@ INSERT INTO users (user, dm_channel, timezone) VALUES (?, (SELECT id FROM channe
|
||||
Ok(sqlx::query_as_unchecked!(
|
||||
Self,
|
||||
"
|
||||
SELECT id, user, dm_channel, timezone FROM users WHERE user = ?
|
||||
SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ?
|
||||
",
|
||||
user_id.0
|
||||
)
|
||||
@ -102,9 +103,10 @@ SELECT id, user, dm_channel, timezone FROM users WHERE user = ?
|
||||
pub async fn commit_changes(&self, pool: &MySqlPool) {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users SET timezone = ? WHERE id = ?
|
||||
UPDATE users SET timezone = ?, allowed_dm = ? WHERE id = ?
|
||||
",
|
||||
self.timezone,
|
||||
self.allowed_dm,
|
||||
self.id
|
||||
)
|
||||
.execute(pool)
|
||||
|
@ -211,14 +211,12 @@ pub async fn natural_parser(time: &str, timezone: &str) -> Option<i64> {
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.map(|inner| {
|
||||
.and_then(|inner| {
|
||||
if inner.status.success() {
|
||||
Some(from_utf8(&*inner.stdout).unwrap().parse::<i64>().unwrap())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flatten()
|
||||
.map(|inner| if inner < 0 { None } else { Some(inner) })
|
||||
.flatten()
|
||||
.and_then(|inner| if inner < 0 { None } else { Some(inner) })
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ use poise::{
|
||||
model::id::{GuildId, UserId},
|
||||
},
|
||||
serenity_prelude as serenity,
|
||||
serenity_prelude::interaction::MessageFlags,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@ -102,6 +103,6 @@ pub fn send_as_initial_response(
|
||||
});
|
||||
}
|
||||
if ephemeral {
|
||||
f.flags(serenity::InteractionApplicationCommandCallbackDataFlags::EPHEMERAL);
|
||||
f.flags(MessageFlags::EPHEMERAL);
|
||||
}
|
||||
}
|
||||
|
@ -12,10 +12,10 @@ 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"
|
||||
csv = "1.1"
|
||||
|
@ -26,12 +26,8 @@ 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"),
|
||||
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/webhook.jpg")) as &[u8],
|
||||
"webhook.jpg",
|
||||
)
|
||||
.into();
|
||||
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
||||
|
@ -126,6 +126,9 @@ pub async fn initialize(
|
||||
routes::help_timers,
|
||||
routes::help_todo_lists,
|
||||
routes::help_macros,
|
||||
routes::help_intervals,
|
||||
routes::help_dashboard,
|
||||
routes::help_iemanager,
|
||||
],
|
||||
)
|
||||
.mount("/login", routes![routes::login::discord_login, routes::login::discord_callback])
|
||||
@ -143,10 +146,15 @@ pub async fn initialize(
|
||||
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::create_guild_reminder,
|
||||
routes::dashboard::guild::get_reminders,
|
||||
routes::dashboard::guild::edit_reminder,
|
||||
routes::dashboard::guild::delete_reminder,
|
||||
routes::dashboard::export::export_reminders,
|
||||
routes::dashboard::export::export_reminder_templates,
|
||||
routes::dashboard::export::export_todos,
|
||||
routes::dashboard::export::import_reminders,
|
||||
routes::dashboard::export::import_todos,
|
||||
],
|
||||
)
|
||||
.launch()
|
||||
|
@ -1,7 +1,7 @@
|
||||
macro_rules! check_length {
|
||||
($max:ident, $field:expr) => {
|
||||
if $field.len() > $max {
|
||||
return json!({ "error": format!("{} exceeded", stringify!($max)) });
|
||||
return Err(json!({ "error": format!("{} exceeded", stringify!($max)) }));
|
||||
}
|
||||
};
|
||||
($max:ident, $field:expr, $($fields:expr),+) => {
|
||||
@ -25,7 +25,7 @@ macro_rules! check_length_opt {
|
||||
macro_rules! check_url {
|
||||
($field:expr) => {
|
||||
if !($field.starts_with("http://") || $field.starts_with("https://")) {
|
||||
return json!({ "error": "URL invalid" });
|
||||
return Err(json!({ "error": "URL invalid" }));
|
||||
}
|
||||
};
|
||||
($field:expr, $($fields:expr),+) => {
|
||||
@ -60,7 +60,7 @@ macro_rules! check_authorization {
|
||||
|
||||
match member {
|
||||
Err(_) => {
|
||||
return json!({"error": "User not in guild"})
|
||||
return Err(json!({"error": "User not in guild"}));
|
||||
}
|
||||
|
||||
Ok(_) => {}
|
||||
@ -68,13 +68,13 @@ macro_rules! check_authorization {
|
||||
}
|
||||
|
||||
None => {
|
||||
return json!({"error": "Bot not in guild"})
|
||||
return Err(json!({"error": "Bot not in guild"}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
return json!({"error": "User not authorized"});
|
||||
return Err(json!({"error": "User not authorized"}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -117,3 +117,9 @@ macro_rules! update_field {
|
||||
update_field!($pool, $error, $reminder.[$($fields),+]);
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! json_err {
|
||||
($message:expr) => {
|
||||
Err(json!({ "error": $message }))
|
||||
};
|
||||
}
|
||||
|
430
web/src/routes/dashboard/export.rs
Normal file
@ -0,0 +1,430 @@
|
||||
use csv::{QuoteStyle, WriterBuilder};
|
||||
use rocket::{
|
||||
http::CookieJar,
|
||||
serde::json::{json, serde_json, Json},
|
||||
State,
|
||||
};
|
||||
use serenity::{
|
||||
client::Context,
|
||||
model::id::{ChannelId, GuildId},
|
||||
};
|
||||
use sqlx::{MySql, Pool};
|
||||
|
||||
use crate::routes::dashboard::{
|
||||
create_reminder, generate_uid, ImportBody, JsonResult, Reminder, ReminderCsv,
|
||||
ReminderTemplateCsv, TodoCsv,
|
||||
};
|
||||
|
||||
#[get("/api/guild/<id>/export/reminders")]
|
||||
pub async fn export_reminders(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
||||
|
||||
let channels_res = GuildId(id).channels(&ctx.inner()).await;
|
||||
|
||||
match channels_res {
|
||||
Ok(channels) => {
|
||||
let channels = channels
|
||||
.keys()
|
||||
.into_iter()
|
||||
.map(|k| k.as_u64().to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(",");
|
||||
|
||||
let result = sqlx::query_as_unchecked!(
|
||||
ReminderCsv,
|
||||
"SELECT
|
||||
reminders.attachment,
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
CONCAT('#', channels.channel) AS channel,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
reminders.embed_color,
|
||||
reminders.embed_description,
|
||||
reminders.embed_footer,
|
||||
reminders.embed_footer_url,
|
||||
reminders.embed_image_url,
|
||||
reminders.embed_thumbnail_url,
|
||||
reminders.embed_title,
|
||||
reminders.embed_fields,
|
||||
reminders.enabled,
|
||||
reminders.expires,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.name,
|
||||
reminders.restartable,
|
||||
reminders.tts,
|
||||
reminders.username,
|
||||
reminders.utc_time
|
||||
FROM reminders
|
||||
LEFT JOIN channels ON channels.id = reminders.channel_id
|
||||
WHERE FIND_IN_SET(channels.channel, ?)",
|
||||
channels
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(reminders) => {
|
||||
reminders.iter().for_each(|reminder| {
|
||||
csv_writer.serialize(reminder).unwrap();
|
||||
});
|
||||
|
||||
match csv_writer.into_inner() {
|
||||
Ok(inner) => match String::from_utf8(inner) {
|
||||
Ok(encoded) => Ok(json!({ "body": encoded })),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to write UTF-8: {:?}", e);
|
||||
|
||||
Err(json!({"error": "Failed to write UTF-8"}))
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to extract CSV: {:?}", e);
|
||||
|
||||
Err(json!({"error": "Failed to extract CSV"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to complete SQL query: {:?}", e);
|
||||
|
||||
Err(json!({"error": "Failed to query reminders"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Could not fetch channels from {}: {:?}", id, e);
|
||||
|
||||
Err(json!({"error": "Failed to get guild channels"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[put("/api/guild/<id>/export/reminders", data = "<body>")]
|
||||
pub async fn import_reminders(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
body: Json<ImportBody>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
let user_id =
|
||||
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
||||
|
||||
match base64::decode(&body.body) {
|
||||
Ok(body) => {
|
||||
let mut reader = csv::Reader::from_reader(body.as_slice());
|
||||
|
||||
for result in reader.deserialize::<ReminderCsv>() {
|
||||
match result {
|
||||
Ok(record) => {
|
||||
let channel_id = record.channel.split_at(1).1;
|
||||
|
||||
match channel_id.parse::<u64>() {
|
||||
Ok(channel_id) => {
|
||||
let reminder = Reminder {
|
||||
attachment: record.attachment,
|
||||
attachment_name: record.attachment_name,
|
||||
avatar: record.avatar,
|
||||
channel: channel_id,
|
||||
content: record.content,
|
||||
embed_author: record.embed_author,
|
||||
embed_author_url: record.embed_author_url,
|
||||
embed_color: record.embed_color,
|
||||
embed_description: record.embed_description,
|
||||
embed_footer: record.embed_footer,
|
||||
embed_footer_url: record.embed_footer_url,
|
||||
embed_image_url: record.embed_image_url,
|
||||
embed_thumbnail_url: record.embed_thumbnail_url,
|
||||
embed_title: record.embed_title,
|
||||
embed_fields: record
|
||||
.embed_fields
|
||||
.map(|s| serde_json::from_str(&s).ok())
|
||||
.flatten(),
|
||||
enabled: record.enabled,
|
||||
expires: record.expires,
|
||||
interval_seconds: record.interval_seconds,
|
||||
interval_months: record.interval_months,
|
||||
name: record.name,
|
||||
restartable: record.restartable,
|
||||
tts: record.tts,
|
||||
uid: generate_uid(),
|
||||
username: record.username,
|
||||
utc_time: record.utc_time,
|
||||
};
|
||||
|
||||
create_reminder(
|
||||
ctx.inner(),
|
||||
pool.inner(),
|
||||
GuildId(id),
|
||||
UserId(user_id),
|
||||
reminder,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
return json_err!(format!(
|
||||
"Failed to parse channel {}",
|
||||
channel_id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Couldn't deserialize CSV row: {:?}", e);
|
||||
|
||||
return json_err!("Deserialize error. Aborted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
json_err!("Malformed base64")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/api/guild/<id>/export/todos")]
|
||||
pub async fn export_todos(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
||||
|
||||
match sqlx::query_as_unchecked!(
|
||||
TodoCsv,
|
||||
"SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos
|
||||
LEFT JOIN channels ON todos.channel_id = channels.id
|
||||
INNER JOIN guilds ON todos.guild_id = guilds.id
|
||||
WHERE guilds.guild = ?",
|
||||
id
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(todos) => {
|
||||
todos.iter().for_each(|todo| {
|
||||
csv_writer.serialize(todo).unwrap();
|
||||
});
|
||||
|
||||
match csv_writer.into_inner() {
|
||||
Ok(inner) => match String::from_utf8(inner) {
|
||||
Ok(encoded) => Ok(json!({ "body": encoded })),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to write UTF-8: {:?}", e);
|
||||
|
||||
json_err!("Failed to write UTF-8")
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to extract CSV: {:?}", e);
|
||||
|
||||
json_err!("Failed to extract CSV")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
warn!("Could not fetch templates from {}: {:?}", id, e);
|
||||
|
||||
json_err!("Failed to query templates")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[put("/api/guild/<id>/export/todos", data = "<body>")]
|
||||
pub async fn import_todos(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
body: Json<ImportBody>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
let channels_res = GuildId(id).channels(&ctx.inner()).await;
|
||||
|
||||
match channels_res {
|
||||
Ok(channels) => match base64::decode(&body.body) {
|
||||
Ok(body) => {
|
||||
let mut reader = csv::Reader::from_reader(body.as_slice());
|
||||
|
||||
let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?))";
|
||||
let mut query_params = vec![];
|
||||
|
||||
for result in reader.deserialize::<TodoCsv>() {
|
||||
match result {
|
||||
Ok(record) => match record.channel_id {
|
||||
Some(channel_id) => {
|
||||
let channel_id = channel_id.split_at(1).1;
|
||||
|
||||
match channel_id.parse::<u64>() {
|
||||
Ok(channel_id) => {
|
||||
if channels.contains_key(&ChannelId(channel_id)) {
|
||||
query_params.push((record.value, Some(channel_id), id));
|
||||
} else {
|
||||
return json_err!(format!(
|
||||
"Invalid channel ID {}",
|
||||
channel_id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
return json_err!(format!(
|
||||
"Invalid channel ID {}",
|
||||
channel_id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None => {
|
||||
query_params.push((record.value, None, id));
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Couldn't deserialize CSV row: {:?}", e);
|
||||
|
||||
return json_err!("Deserialize error. Aborted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM todos WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||
id
|
||||
)
|
||||
.execute(pool.inner())
|
||||
.await;
|
||||
|
||||
let query_str = format!(
|
||||
"INSERT INTO todos (value, channel_id, guild_id) VALUES {}",
|
||||
vec![query_placeholder].repeat(query_params.len()).join(",")
|
||||
);
|
||||
let mut query = sqlx::query(&query_str);
|
||||
|
||||
for param in query_params {
|
||||
query = query.bind(param.0).bind(param.1).bind(param.2);
|
||||
}
|
||||
|
||||
let res = query.execute(pool.inner()).await;
|
||||
|
||||
match res {
|
||||
Ok(_) => Ok(json!({})),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Couldn't execute todo query: {:?}", e);
|
||||
|
||||
json_err!("An unexpected error occured.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
json_err!("Malformed base64")
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Couldn't fetch channels for guild {}: {:?}", id, e);
|
||||
|
||||
json_err!("Couldn't fetch channels.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/api/guild/<id>/export/reminder_templates")]
|
||||
pub async fn export_reminder_templates(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
|
||||
|
||||
match sqlx::query_as_unchecked!(
|
||||
ReminderTemplateCsv,
|
||||
"SELECT
|
||||
name,
|
||||
attachment,
|
||||
attachment_name,
|
||||
avatar,
|
||||
content,
|
||||
embed_author,
|
||||
embed_author_url,
|
||||
embed_color,
|
||||
embed_description,
|
||||
embed_footer,
|
||||
embed_footer_url,
|
||||
embed_image_url,
|
||||
embed_thumbnail_url,
|
||||
embed_title,
|
||||
embed_fields,
|
||||
tts,
|
||||
username
|
||||
FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
|
||||
id
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(templates) => {
|
||||
templates.iter().for_each(|template| {
|
||||
csv_writer.serialize(template).unwrap();
|
||||
});
|
||||
|
||||
match csv_writer.into_inner() {
|
||||
Ok(inner) => match String::from_utf8(inner) {
|
||||
Ok(encoded) => Ok(json!({ "body": encoded })),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to write UTF-8: {:?}", e);
|
||||
|
||||
json_err!("Failed to write UTF-8")
|
||||
}
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
warn!("Failed to extract CSV: {:?}", e);
|
||||
|
||||
json_err!("Failed to extract CSV")
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Could not fetch templates from {}: {:?}", id, e);
|
||||
|
||||
json_err!("Failed to query templates")
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,8 @@
|
||||
use std::env;
|
||||
|
||||
use base64;
|
||||
use chrono::Utc;
|
||||
use rocket::{
|
||||
http::CookieJar,
|
||||
serde::json::{json, Json, Value as JsonValue},
|
||||
serde::json::{json, Json},
|
||||
State,
|
||||
};
|
||||
use serde::Serialize;
|
||||
@ -18,16 +16,14 @@ use serenity::{
|
||||
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_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,
|
||||
create_database_channel, create_reminder, template_name_default, DeleteReminder,
|
||||
DeleteReminderTemplate, JsonResult, PatchReminder, Reminder, ReminderTemplate,
|
||||
},
|
||||
};
|
||||
|
||||
@ -44,7 +40,7 @@ pub async fn get_guild_patreon(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
) -> JsonValue {
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
match GuildId(id).to_guild_cached(ctx.inner()) {
|
||||
@ -59,12 +55,10 @@ pub async fn get_guild_patreon(
|
||||
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
|
||||
});
|
||||
|
||||
json!({ "patreon": patreon })
|
||||
Ok(json!({ "patreon": patreon }))
|
||||
}
|
||||
|
||||
None => {
|
||||
json!({"error": "Bot not in guild"})
|
||||
}
|
||||
None => json_err!("Bot not in guild"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,7 +67,7 @@ pub async fn get_guild_channels(
|
||||
id: u64,
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
) -> JsonValue {
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
match GuildId(id).to_guild_cached(ctx.inner()) {
|
||||
@ -97,12 +91,10 @@ pub async fn get_guild_channels(
|
||||
})
|
||||
.collect::<Vec<ChannelInfo>>();
|
||||
|
||||
json!(channel_info)
|
||||
Ok(json!(channel_info))
|
||||
}
|
||||
|
||||
None => {
|
||||
json!({"error": "Bot not in guild"})
|
||||
}
|
||||
None => json_err!("Bot not in guild"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -113,7 +105,7 @@ struct RoleInfo {
|
||||
}
|
||||
|
||||
#[get("/api/guild/<id>/roles")]
|
||||
pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonValue {
|
||||
pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
let roles_res = ctx.cache.guild_roles(id);
|
||||
@ -125,12 +117,12 @@ pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Conte
|
||||
.map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
|
||||
.collect::<Vec<RoleInfo>>();
|
||||
|
||||
json!(roles)
|
||||
Ok(json!(roles))
|
||||
}
|
||||
None => {
|
||||
warn!("Could not fetch roles from {}", id);
|
||||
|
||||
json!({"error": "Could not get roles"})
|
||||
json_err!("Could not get roles")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -141,7 +133,7 @@ pub async fn get_reminder_templates(
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonValue {
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
match sqlx::query_as_unchecked!(
|
||||
@ -152,13 +144,11 @@ pub async fn get_reminder_templates(
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(templates) => {
|
||||
json!(templates)
|
||||
}
|
||||
Ok(templates) => Ok(json!(templates)),
|
||||
Err(e) => {
|
||||
warn!("Could not fetch templates from {}: {:?}", id, e);
|
||||
|
||||
json!({"error": "Could not get templates"})
|
||||
json_err!("Could not get templates")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -170,7 +160,7 @@ pub async fn create_reminder_template(
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonValue {
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
// validate lengths
|
||||
@ -254,12 +244,12 @@ pub async fn create_reminder_template(
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
json!({})
|
||||
Ok(json!({}))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Could not fetch templates from {}: {:?}", id, e);
|
||||
|
||||
json!({"error": "Could not get templates"})
|
||||
json_err!("Could not get templates")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -271,7 +261,7 @@ pub async fn delete_reminder_template(
|
||||
cookies: &CookieJar<'_>,
|
||||
ctx: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonValue {
|
||||
) -> JsonResult {
|
||||
check_authorization!(cookies, ctx.inner(), id);
|
||||
|
||||
match sqlx::query!(
|
||||
@ -282,233 +272,41 @@ pub async fn delete_reminder_template(
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
json!({})
|
||||
Ok(json!({}))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Could not delete template from {}: {:?}", id, e);
|
||||
|
||||
json!({"error": "Could not delete template"})
|
||||
json_err!("Could not delete template")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
|
||||
pub async fn create_reminder(
|
||||
pub async fn create_guild_reminder(
|
||||
id: u64,
|
||||
reminder: Json<Reminder>,
|
||||
cookies: &CookieJar<'_>,
|
||||
serenity_context: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonValue {
|
||||
) -> JsonResult {
|
||||
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(
|
||||
create_reminder(
|
||||
serenity_context.inner(),
|
||||
ChannelId(reminder.channel),
|
||||
pool.inner(),
|
||||
GuildId(id),
|
||||
UserId(user_id),
|
||||
reminder.into_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 {
|
||||
pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySql>>) -> JsonResult {
|
||||
let channels_res = GuildId(id).channels(&ctx.inner()).await;
|
||||
|
||||
match channels_res {
|
||||
@ -543,7 +341,6 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.name,
|
||||
reminders.pin,
|
||||
reminders.restartable,
|
||||
reminders.tts,
|
||||
reminders.uid,
|
||||
@ -556,17 +353,17 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq
|
||||
)
|
||||
.fetch_all(pool.inner())
|
||||
.await
|
||||
.map(|r| json!(r))
|
||||
.map(|r| Ok(json!(r)))
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to complete SQL query: {:?}", e);
|
||||
|
||||
json!({"error": "Could not load reminders"})
|
||||
json_err!("Could not load reminders")
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Could not fetch channels from {}: {:?}", id, e);
|
||||
|
||||
json!([])
|
||||
Ok(json!([]))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -577,7 +374,7 @@ pub async fn edit_reminder(
|
||||
reminder: Json<PatchReminder>,
|
||||
serenity_context: &State<Context>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonValue {
|
||||
) -> JsonResult {
|
||||
let mut error = vec![];
|
||||
|
||||
update_field!(pool.inner(), error, reminder.[
|
||||
@ -600,7 +397,6 @@ pub async fn edit_reminder(
|
||||
interval_seconds,
|
||||
interval_months,
|
||||
name,
|
||||
pin,
|
||||
restartable,
|
||||
tts,
|
||||
username,
|
||||
@ -619,7 +415,7 @@ pub async fn edit_reminder(
|
||||
reminder.channel, id
|
||||
);
|
||||
|
||||
return json!({"error": "Channel not found"});
|
||||
return Err(json!({"error": "Channel not found"}));
|
||||
}
|
||||
|
||||
let channel = create_database_channel(
|
||||
@ -632,7 +428,9 @@ pub async fn edit_reminder(
|
||||
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"});
|
||||
return Err(
|
||||
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
|
||||
);
|
||||
}
|
||||
|
||||
let channel = channel.unwrap();
|
||||
@ -660,7 +458,7 @@ pub async fn edit_reminder(
|
||||
reminder.channel, id
|
||||
);
|
||||
|
||||
return json!({"error": "Channel not found"});
|
||||
return Err(json!({"error": "Channel not found"}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -687,7 +485,6 @@ pub async fn edit_reminder(
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.name,
|
||||
reminders.pin,
|
||||
reminders.restartable,
|
||||
reminders.tts,
|
||||
reminders.uid,
|
||||
@ -701,12 +498,12 @@ pub async fn edit_reminder(
|
||||
.fetch_one(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(reminder) => json!({"reminder": reminder, "errors": error}),
|
||||
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error exiting `edit_reminder': {:?}", e);
|
||||
|
||||
json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]})
|
||||
Err(json!({"reminder": Option::<Reminder>::None, "errors": vec!["Unknown error"]}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -715,19 +512,17 @@ pub async fn edit_reminder(
|
||||
pub async fn delete_reminder(
|
||||
reminder: Json<DeleteReminder>,
|
||||
pool: &State<Pool<MySql>>,
|
||||
) -> JsonValue {
|
||||
) -> JsonResult {
|
||||
match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid)
|
||||
.execute(pool.inner())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
json!({})
|
||||
}
|
||||
Ok(_) => Ok(json!({})),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error in `delete_reminder`: {:?}", e);
|
||||
|
||||
json!({"error": "Could not delete reminder"})
|
||||
Err(json!({"error": "Could not delete reminder"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,21 +1,37 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::naive::NaiveDateTime;
|
||||
use chrono::{naive::NaiveDateTime, Utc};
|
||||
use rand::{rngs::OsRng, seq::IteratorRandom};
|
||||
use rocket::{http::CookieJar, response::Redirect};
|
||||
use rocket::{
|
||||
http::CookieJar,
|
||||
response::Redirect,
|
||||
serde::json::{json, Value as JsonValue},
|
||||
};
|
||||
use rocket_dyn_templates::Template;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serenity::{http::Http, model::id::ChannelId};
|
||||
use sqlx::{types::Json, Executor};
|
||||
use serenity::{
|
||||
client::Context,
|
||||
http::Http,
|
||||
model::id::{ChannelId, GuildId, UserId},
|
||||
};
|
||||
use sqlx::{types::Json, Executor, MySql, Pool};
|
||||
|
||||
use crate::{
|
||||
consts::{CHARACTERS, DEFAULT_AVATAR},
|
||||
check_guild_subscription, check_subscription,
|
||||
consts::{
|
||||
CHARACTERS, DAY, DEFAULT_AVATAR, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH,
|
||||
MAX_EMBED_DESCRIPTION_LENGTH, MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH,
|
||||
MAX_EMBED_FIELD_VALUE_LENGTH, MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH,
|
||||
MAX_URL_LENGTH, MAX_USERNAME_LENGTH, MIN_INTERVAL,
|
||||
},
|
||||
Database, Error,
|
||||
};
|
||||
|
||||
pub mod export;
|
||||
pub mod guild;
|
||||
pub mod user;
|
||||
|
||||
pub type JsonResult = Result<JsonValue, JsonValue>;
|
||||
type Unset<T> = Option<T>;
|
||||
|
||||
fn name_default() -> String {
|
||||
@ -60,6 +76,28 @@ pub struct ReminderTemplate {
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ReminderTemplateCsv {
|
||||
#[serde(default = "template_name_default")]
|
||||
name: String,
|
||||
attachment: Option<Vec<u8>>,
|
||||
attachment_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
content: String,
|
||||
embed_author: String,
|
||||
embed_author_url: Option<String>,
|
||||
embed_color: u32,
|
||||
embed_description: String,
|
||||
embed_footer: String,
|
||||
embed_footer_url: Option<String>,
|
||||
embed_image_url: Option<String>,
|
||||
embed_thumbnail_url: Option<String>,
|
||||
embed_title: String,
|
||||
embed_fields: Option<String>,
|
||||
tts: bool,
|
||||
username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteReminderTemplate {
|
||||
id: u32,
|
||||
@ -97,7 +135,6 @@ pub struct Reminder {
|
||||
interval_months: Option<u32>,
|
||||
#[serde(default = "name_default")]
|
||||
name: String,
|
||||
pin: bool,
|
||||
restartable: bool,
|
||||
tts: bool,
|
||||
#[serde(default)]
|
||||
@ -106,6 +143,36 @@ pub struct Reminder {
|
||||
utc_time: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ReminderCsv {
|
||||
#[serde(with = "base64s")]
|
||||
attachment: Option<Vec<u8>>,
|
||||
attachment_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
channel: String,
|
||||
content: String,
|
||||
embed_author: String,
|
||||
embed_author_url: Option<String>,
|
||||
embed_color: u32,
|
||||
embed_description: String,
|
||||
embed_footer: String,
|
||||
embed_footer_url: Option<String>,
|
||||
embed_image_url: Option<String>,
|
||||
embed_thumbnail_url: Option<String>,
|
||||
embed_title: String,
|
||||
embed_fields: Option<String>,
|
||||
enabled: bool,
|
||||
expires: Option<NaiveDateTime>,
|
||||
interval_seconds: Option<u32>,
|
||||
interval_months: Option<u32>,
|
||||
#[serde(default = "name_default")]
|
||||
name: String,
|
||||
restartable: bool,
|
||||
tts: bool,
|
||||
username: Option<String>,
|
||||
utc_time: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PatchReminder {
|
||||
uid: String,
|
||||
@ -151,8 +218,6 @@ pub struct PatchReminder {
|
||||
#[serde(default)]
|
||||
name: Unset<String>,
|
||||
#[serde(default)]
|
||||
pin: Unset<bool>,
|
||||
#[serde(default)]
|
||||
restartable: Unset<bool>,
|
||||
#[serde(default)]
|
||||
tts: Unset<bool>,
|
||||
@ -213,8 +278,8 @@ mod base64s {
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let string = String::deserialize(deserializer)?;
|
||||
Some(base64::decode(string).map_err(de::Error::custom)).transpose()
|
||||
let string = Option::<String>::deserialize(deserializer)?;
|
||||
Some(string.map(|b| base64::decode(b).map_err(de::Error::custom))).flatten().transpose()
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,13 +288,225 @@ pub struct DeleteReminder {
|
||||
uid: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ImportBody {
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TodoCsv {
|
||||
value: String,
|
||||
channel_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_reminder(
|
||||
ctx: &Context,
|
||||
pool: &Pool<MySql>,
|
||||
guild_id: GuildId,
|
||||
user_id: UserId,
|
||||
reminder: Reminder,
|
||||
) -> JsonResult {
|
||||
// validate channel
|
||||
let channel = ChannelId(reminder.channel).to_channel_cached(&ctx);
|
||||
let channel_exists = channel.is_some();
|
||||
|
||||
let channel_matches_guild =
|
||||
channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id == guild_id));
|
||||
|
||||
if !channel_matches_guild || !channel_exists {
|
||||
warn!(
|
||||
"Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})",
|
||||
reminder.channel, guild_id, channel_exists
|
||||
);
|
||||
|
||||
return Err(json!({"error": "Channel not found"}));
|
||||
}
|
||||
|
||||
let channel = create_database_channel(&ctx, ChannelId(reminder.channel), pool).await;
|
||||
|
||||
if let Err(e) = channel {
|
||||
warn!("`create_database_channel` returned an error code: {:?}", e);
|
||||
|
||||
return Err(
|
||||
json!({"error": "Failed to configure channel for reminders. Please check the bot permissions"}),
|
||||
);
|
||||
}
|
||||
|
||||
let channel = channel.unwrap();
|
||||
|
||||
// validate lengths
|
||||
check_length!(MAX_CONTENT_LENGTH, reminder.content);
|
||||
check_length!(MAX_EMBED_DESCRIPTION_LENGTH, reminder.embed_description);
|
||||
check_length!(MAX_EMBED_TITLE_LENGTH, reminder.embed_title);
|
||||
check_length!(MAX_EMBED_AUTHOR_LENGTH, reminder.embed_author);
|
||||
check_length!(MAX_EMBED_FOOTER_LENGTH, reminder.embed_footer);
|
||||
check_length_opt!(MAX_EMBED_FIELDS, reminder.embed_fields);
|
||||
if let Some(fields) = &reminder.embed_fields {
|
||||
for field in &fields.0 {
|
||||
check_length!(MAX_EMBED_FIELD_VALUE_LENGTH, field.value);
|
||||
check_length!(MAX_EMBED_FIELD_TITLE_LENGTH, field.title);
|
||||
}
|
||||
}
|
||||
check_length_opt!(MAX_USERNAME_LENGTH, reminder.username);
|
||||
check_length_opt!(
|
||||
MAX_URL_LENGTH,
|
||||
reminder.embed_footer_url,
|
||||
reminder.embed_thumbnail_url,
|
||||
reminder.embed_author_url,
|
||||
reminder.embed_image_url,
|
||||
reminder.avatar
|
||||
);
|
||||
|
||||
// validate urls
|
||||
check_url_opt!(
|
||||
reminder.embed_footer_url,
|
||||
reminder.embed_thumbnail_url,
|
||||
reminder.embed_author_url,
|
||||
reminder.embed_image_url,
|
||||
reminder.avatar
|
||||
);
|
||||
|
||||
// validate time and interval
|
||||
if reminder.utc_time < Utc::now().naive_utc() {
|
||||
return Err(json!({"error": "Time must be in the future"}));
|
||||
}
|
||||
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
|
||||
if reminder.interval_months.unwrap_or(0) * 30 * DAY as u32
|
||||
+ reminder.interval_seconds.unwrap_or(0)
|
||||
< *MIN_INTERVAL
|
||||
{
|
||||
return Err(json!({"error": "Interval too short"}));
|
||||
}
|
||||
}
|
||||
|
||||
// check patreon if necessary
|
||||
if reminder.interval_seconds.is_some() || reminder.interval_months.is_some() {
|
||||
if !check_guild_subscription(&ctx, guild_id).await
|
||||
&& !check_subscription(&ctx, user_id).await
|
||||
{
|
||||
return Err(json!({"error": "Patreon is required to set intervals"}));
|
||||
}
|
||||
}
|
||||
|
||||
// base64 decode error dropped here
|
||||
let attachment_data = reminder.attachment.as_ref().map(|s| base64::decode(s).ok()).flatten();
|
||||
let name = if reminder.name.is_empty() { name_default() } else { reminder.name.clone() };
|
||||
|
||||
let new_uid = generate_uid();
|
||||
|
||||
// write to db
|
||||
match sqlx::query!(
|
||||
"INSERT INTO reminders (
|
||||
uid,
|
||||
attachment,
|
||||
attachment_name,
|
||||
channel_id,
|
||||
avatar,
|
||||
content,
|
||||
embed_author,
|
||||
embed_author_url,
|
||||
embed_color,
|
||||
embed_description,
|
||||
embed_footer,
|
||||
embed_footer_url,
|
||||
embed_image_url,
|
||||
embed_thumbnail_url,
|
||||
embed_title,
|
||||
embed_fields,
|
||||
enabled,
|
||||
expires,
|
||||
interval_seconds,
|
||||
interval_months,
|
||||
name,
|
||||
restartable,
|
||||
tts,
|
||||
username,
|
||||
`utc_time`
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
new_uid,
|
||||
attachment_data,
|
||||
reminder.attachment_name,
|
||||
channel,
|
||||
reminder.avatar,
|
||||
reminder.content,
|
||||
reminder.embed_author,
|
||||
reminder.embed_author_url,
|
||||
reminder.embed_color,
|
||||
reminder.embed_description,
|
||||
reminder.embed_footer,
|
||||
reminder.embed_footer_url,
|
||||
reminder.embed_image_url,
|
||||
reminder.embed_thumbnail_url,
|
||||
reminder.embed_title,
|
||||
reminder.embed_fields,
|
||||
reminder.enabled,
|
||||
reminder.expires,
|
||||
reminder.interval_seconds,
|
||||
reminder.interval_months,
|
||||
name,
|
||||
reminder.restartable,
|
||||
reminder.tts,
|
||||
reminder.username,
|
||||
reminder.utc_time,
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
{
|
||||
Ok(_) => sqlx::query_as_unchecked!(
|
||||
Reminder,
|
||||
"SELECT
|
||||
reminders.attachment,
|
||||
reminders.attachment_name,
|
||||
reminders.avatar,
|
||||
channels.channel,
|
||||
reminders.content,
|
||||
reminders.embed_author,
|
||||
reminders.embed_author_url,
|
||||
reminders.embed_color,
|
||||
reminders.embed_description,
|
||||
reminders.embed_footer,
|
||||
reminders.embed_footer_url,
|
||||
reminders.embed_image_url,
|
||||
reminders.embed_thumbnail_url,
|
||||
reminders.embed_title,
|
||||
reminders.embed_fields,
|
||||
reminders.enabled,
|
||||
reminders.expires,
|
||||
reminders.interval_seconds,
|
||||
reminders.interval_months,
|
||||
reminders.name,
|
||||
reminders.restartable,
|
||||
reminders.tts,
|
||||
reminders.uid,
|
||||
reminders.username,
|
||||
reminders.utc_time
|
||||
FROM reminders
|
||||
LEFT JOIN channels ON channels.id = reminders.channel_id
|
||||
WHERE uid = ?",
|
||||
new_uid
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map(|r| Ok(json!(r)))
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to complete SQL query: {:?}", e);
|
||||
|
||||
Err(json!({"error": "Could not load reminder"}))
|
||||
}),
|
||||
|
||||
Err(e) => {
|
||||
warn!("Error in `create_reminder`: Could not execute query: {:?}", e);
|
||||
|
||||
Err(json!({"error": "Unknown error"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_database_channel(
|
||||
ctx: impl AsRef<Http>,
|
||||
channel: ChannelId,
|
||||
pool: impl Executor<'_, Database = Database> + Copy,
|
||||
) -> Result<u32, crate::Error> {
|
||||
println!("{:?}", channel);
|
||||
|
||||
let row =
|
||||
sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0)
|
||||
.fetch_one(pool)
|
||||
|
@ -25,7 +25,6 @@ pub async fn discord_login(
|
||||
// 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();
|
||||
|
@ -86,3 +86,21 @@ pub async fn help_macros() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/macros", &map)
|
||||
}
|
||||
|
||||
#[get("/intervals")]
|
||||
pub async fn help_intervals() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/intervals", &map)
|
||||
}
|
||||
|
||||
#[get("/dashboard")]
|
||||
pub async fn help_dashboard() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/dashboard", &map)
|
||||
}
|
||||
|
||||
#[get("/iemanager")]
|
||||
pub async fn help_iemanager() -> Template {
|
||||
let map: HashMap<&str, String> = HashMap::new();
|
||||
Template::render("support/iemanager", &map)
|
||||
}
|
||||
|
@ -288,6 +288,10 @@ textarea, input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input.default-width {
|
||||
width: initial;
|
||||
}
|
||||
|
||||
.message-input:placeholder-shown {
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
|
BIN
web/static/img/support/delete_reminder/cancel-1.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
web/static/img/support/delete_reminder/cancel-2.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
web/static/img/support/iemanager/edit_spreadsheet.png
Normal file
After Width: | Height: | Size: 44 KiB |
BIN
web/static/img/support/iemanager/format_text.png
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
web/static/img/support/iemanager/import.png
Normal file
After Width: | Height: | Size: 44 KiB |
BIN
web/static/img/support/iemanager/select_export.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
web/static/img/support/iemanager/sheets_settings.png
Normal file
After Width: | Height: | Size: 20 KiB |
@ -12,12 +12,25 @@ const $createTemplateBtn = $createReminder.querySelector("button#createTemplate"
|
||||
const $loadTemplateBtn = document.querySelector("button#load-template");
|
||||
const $deleteTemplateBtn = document.querySelector("button#delete-template");
|
||||
const $templateSelect = document.querySelector("select#templateSelect");
|
||||
const $exportBtn = document.querySelector("button#export-data");
|
||||
const $importBtn = document.querySelector("button#import-data");
|
||||
const $downloader = document.querySelector("a#downloader");
|
||||
const $uploader = document.querySelector("input#uploader");
|
||||
|
||||
let channels = [];
|
||||
let guildNames = {};
|
||||
let roles = [];
|
||||
let templates = {};
|
||||
let mentions = new Tribute({
|
||||
values: [],
|
||||
allowSpaces: true,
|
||||
selectTemplate: (item) => {
|
||||
return `<@&${item.original.value}>`;
|
||||
},
|
||||
});
|
||||
|
||||
let globalPatreon = false;
|
||||
let guildPatreon = false;
|
||||
|
||||
function guildId() {
|
||||
return document.querySelector(".guildList a.is-active").dataset["guild"];
|
||||
@ -31,18 +44,6 @@ 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");
|
||||
@ -52,8 +53,6 @@ function switch_pane(selector) {
|
||||
});
|
||||
|
||||
document.getElementById(selector).classList.remove("is-hidden");
|
||||
|
||||
resize_textareas();
|
||||
}
|
||||
|
||||
function update_select(sel) {
|
||||
@ -78,6 +77,18 @@ function reset_guild_pane() {
|
||||
.forEach((opt) => opt.remove());
|
||||
}
|
||||
|
||||
async function fetch_patreon(guild_id) {
|
||||
fetch(`/dashboard/api/guild/${guild_id}/patreon`)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
show_error(data.error);
|
||||
} else {
|
||||
return data.patreon;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fetch_roles(guild_id) {
|
||||
fetch(`/dashboard/api/guild/${guild_id}/roles`)
|
||||
.then((response) => response.json())
|
||||
@ -85,7 +96,16 @@ function fetch_roles(guild_id) {
|
||||
if (data.error) {
|
||||
show_error(data.error);
|
||||
} else {
|
||||
roles = data;
|
||||
let values = Array.from(
|
||||
data.map((role) => {
|
||||
return {
|
||||
key: role.name,
|
||||
value: role.id,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
mentions.collection[0].values = values;
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -158,6 +178,8 @@ async function fetch_reminders(guild_id) {
|
||||
newFrame.querySelector(".reminderContent").dataset["uid"] =
|
||||
reminder["uid"];
|
||||
|
||||
mentions.attach(newFrame.querySelector("textarea"));
|
||||
|
||||
deserialize_reminder(reminder, newFrame, "load");
|
||||
|
||||
$reminderBox.appendChild(newFrame);
|
||||
@ -299,7 +321,6 @@ async function serialize_reminder(node, mode) {
|
||||
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,
|
||||
@ -370,6 +391,10 @@ function deserialize_reminder(reminder, frame, mode) {
|
||||
document.addEventListener("guildSwitched", async (e) => {
|
||||
$loader.classList.remove("is-hidden");
|
||||
|
||||
document
|
||||
.querySelectorAll(".patreon-only")
|
||||
.forEach((el) => el.classList.add("is-locked"));
|
||||
|
||||
let $anchor = document.querySelector(
|
||||
`.switch-pane[data-guild="${e.detail.guild_id}"]`
|
||||
);
|
||||
@ -378,6 +403,12 @@ document.addEventListener("guildSwitched", async (e) => {
|
||||
reset_guild_pane();
|
||||
$anchor.classList.add("is-active");
|
||||
|
||||
if (globalPatreon || (await fetch_patreon(e.detail.guild_id))) {
|
||||
document
|
||||
.querySelectorAll(".patreon-only")
|
||||
.forEach((el) => el.classList.remove("is-locked"));
|
||||
}
|
||||
|
||||
fetch_roles(e.detail.guild_id);
|
||||
fetch_templates(e.detail.guild_id);
|
||||
await fetch_channels(e.detail.guild_id);
|
||||
@ -392,8 +423,6 @@ document.addEventListener("guildSwitched", async (e) => {
|
||||
});
|
||||
});
|
||||
|
||||
resize_textareas();
|
||||
|
||||
$loader.classList.add("is-hidden");
|
||||
});
|
||||
|
||||
@ -531,6 +560,8 @@ document.querySelectorAll(".show-modal").forEach((element) => {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
$loader.classList.remove("is-hidden");
|
||||
|
||||
mentions.attach(document.querySelectorAll("textarea"));
|
||||
|
||||
document.querySelectorAll(".navbar-burger").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
const target = el.dataset["target"];
|
||||
@ -569,6 +600,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const $template = document.getElementById("guildListEntry");
|
||||
|
||||
for (let guild of data) {
|
||||
guildNames[guild.id] = guild.name;
|
||||
|
||||
document.querySelectorAll(".guildList").forEach((element) => {
|
||||
const $clone = $template.content.cloneNode(true);
|
||||
const $anchor = $clone.querySelector("a");
|
||||
@ -585,11 +618,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
$anchor.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
window.history.pushState(
|
||||
{},
|
||||
"",
|
||||
`/dashboard/${guild.id}?name=${guild.name}`
|
||||
);
|
||||
window.history.pushState({}, "", `/dashboard/${guild.id}`);
|
||||
const event = new CustomEvent("guildSwitched", {
|
||||
detail: {
|
||||
guild_name: guild.name,
|
||||
@ -607,8 +636,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const matches = window.location.href.match(/dashboard\/(\d+)/);
|
||||
if (matches) {
|
||||
let id = matches[1];
|
||||
let name =
|
||||
new URLSearchParams(window.location.search).get("name") || id;
|
||||
let name = guildNames[id];
|
||||
|
||||
const event = new CustomEvent("guildSwitched", {
|
||||
detail: {
|
||||
guild_name: name,
|
||||
@ -645,6 +674,39 @@ function has_source(string) {
|
||||
}
|
||||
}
|
||||
|
||||
$uploader.addEventListener("change", (ev) => {
|
||||
const urlTail = document.querySelector('input[name="exportSelect"]:checked').value;
|
||||
|
||||
new Promise((resolve) => {
|
||||
let fileReader = new FileReader();
|
||||
fileReader.onload = (e) => resolve(fileReader.result);
|
||||
fileReader.readAsDataURL($uploader.files[0]);
|
||||
}).then((dataUrl) => {
|
||||
fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ body: dataUrl.split(",")[1] }),
|
||||
}).then(() => {
|
||||
delete $uploader.files[0];
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$importBtn.addEventListener("click", () => {
|
||||
$uploader.click();
|
||||
});
|
||||
|
||||
$exportBtn.addEventListener("click", () => {
|
||||
const urlTail = document.querySelector('input[name="exportSelect"]:checked').value;
|
||||
|
||||
fetch(`/dashboard/api/guild/${guildId()}/export/${urlTail}`)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
$downloader.href =
|
||||
"data:text/plain;charset=utf-8," + encodeURIComponent(data.body);
|
||||
$downloader.click();
|
||||
});
|
||||
});
|
||||
|
||||
$createReminderBtn.addEventListener("click", async () => {
|
||||
$createReminderBtn.querySelector("span.icon > i").classList = [
|
||||
"fas fa-spinner fa-spin",
|
||||
@ -809,7 +871,7 @@ document.addEventListener("remindersLoaded", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const fileInput = document.querySelectorAll("input[type=file]");
|
||||
const fileInput = document.querySelectorAll("input.file-input[type=file]");
|
||||
|
||||
fileInput.forEach((element) => {
|
||||
element.addEventListener("change", () => {
|
||||
@ -901,7 +963,6 @@ document.addEventListener("DOMNodeInserted", () => {
|
||||
});
|
||||
|
||||
check_embed_fields();
|
||||
resize_textareas();
|
||||
});
|
||||
|
||||
document.addEventListener("click", (ev) => {
|
||||
|
@ -27,8 +27,10 @@
|
||||
<link rel="stylesheet" href="/static/css/font.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/dtsel.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tributejs/5.1.3/tribute.css" integrity="sha512-GnwBnXd+ZGO9CdP343MUr0jCcJXCr++JVtQRnllexRW2IDq4Zvrh/McTQjooAKnSUbXZ7wamp7AQSweTnfMVoA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<script src="/static/js/luxon.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/tributejs/5.1.3/tribute.min.js" integrity="sha512-KJYWC7RKz/Abtsu1QXd7VJ1IJua7P7GTpl3IKUqfa21Otg2opvRYmkui/CXBC6qeDYCNlQZ7c+7JfDXnKdILUA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-spaced is-size-4 is-hidden-desktop dashboard-navbar" role="navigation"
|
||||
@ -173,10 +175,43 @@
|
||||
<button class="delete close-modal" aria-label="close"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
<div class="control">
|
||||
<div class="field">
|
||||
<label>
|
||||
<input type="radio" class="default-width" name="exportSelect" value="reminders" checked>
|
||||
Reminders
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="field">
|
||||
<label>
|
||||
<input type="radio" class="default-width" name="exportSelect" value="todos">
|
||||
Todo Lists
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<div class="field">
|
||||
<label>
|
||||
<input type="radio" class="default-width" name="exportSelect" value="reminder_templates">
|
||||
Reminder templates
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<div class="has-text-centered">
|
||||
<div style="color: red; font-weight: bold;">
|
||||
By selecting "Import", you understand that this will overwrite existing data.
|
||||
</div>
|
||||
<div style="color: red">
|
||||
Please first read the <a href="/help/iemanager">support page</a>
|
||||
</div>
|
||||
<button class="button is-success is-outlined" id="import-data">Import Data</button>
|
||||
<button class="button is-success" id="export-data">Export Data</button>
|
||||
</div>
|
||||
<a id="downloader" download="export.csv" class="is-hidden"></a>
|
||||
<input id="uploader" type="file" hidden></input>
|
||||
</section>
|
||||
</div>
|
||||
<button class="modal-close is-large close-modal" aria-label="close"></button>
|
||||
|
@ -5,5 +5,5 @@
|
||||
{% set show_contact = True %}
|
||||
|
||||
{% set page_title = "An Error Has Occurred" %}
|
||||
{% set page_subtitle = "A server error has occurred. Please contact me and I will try and resolve this" %}
|
||||
{% set page_subtitle = "A server error has occurred. Please retry, or ask in our Discord." %}
|
||||
{% endblock %}
|
||||
|
@ -93,6 +93,65 @@
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-ancestor">
|
||||
<div class="tile is-parent">
|
||||
<article class="tile is-child notification">
|
||||
<p class="title">Intervals</p>
|
||||
<p class="subtitle">Learn about repeating reminders</p>
|
||||
<div class="content has-text-centered">
|
||||
<a class="button is-size-4 is-rounded is-light" href="/help/intervals">
|
||||
<p class="is-size-4">
|
||||
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="tile is-parent">
|
||||
<article class="tile is-child notification">
|
||||
<p class="title">Dashboard</p>
|
||||
<p class="subtitle">Learn to use the interactive web dashboard</p>
|
||||
<div class="content has-text-centered">
|
||||
<a class="button is-size-4 is-rounded is-light" href="/help/dashboard">
|
||||
<p class="is-size-4">
|
||||
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="tile is-parent is-vertical">
|
||||
<article class="tile is-child notification">
|
||||
<p class="title">Import/Export</p>
|
||||
<p class="subtitle">Learn how to import and export data from the dashboard</p>
|
||||
<div class="content has-text-centered">
|
||||
<a class="button is-size-4 is-rounded is-light" href="/help/iemanager">
|
||||
<p class="is-size-4">
|
||||
Read <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container has-text-centered">
|
||||
<p class="title">Need more help?</p>
|
||||
<p class="content">
|
||||
Feel free to come and ask us!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-foot has-text-centered">
|
||||
<a class="button is-size-6 is-rounded is-primary" href="https://discord.jellywx.com">
|
||||
<p class="is-size-6">
|
||||
Join Discord <span class="icon"><i class="fas fa-chevron-right"></i></span>
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
|
@ -49,7 +49,7 @@
|
||||
<div class="container">
|
||||
<h2 class="title">Who your data is shared with</h2>
|
||||
<p class="is-size-5 pl-6">
|
||||
Your data may also be guarded by the privacy policies of <strong>MEGA</strong>, our backup provider, and
|
||||
Your data is also guarded by the privacy policies of <strong>MEGA</strong>, our backup provider, and
|
||||
<strong>Hetzner</strong>, our hosting provider.
|
||||
</p>
|
||||
</div>
|
||||
@ -68,7 +68,7 @@
|
||||
<br>
|
||||
<br>
|
||||
Reminders deleted with <strong>/del</strong> or via the dashboard are removed from the live database
|
||||
instantly, but may persist in backups.
|
||||
instantly, but may persist in backups for up to a year.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -158,9 +158,9 @@
|
||||
</div>
|
||||
|
||||
<div class="collapses">
|
||||
<div class="is-locked">
|
||||
<div class="patreon-only">
|
||||
<div class="field">
|
||||
<label class="label">Interval <a class="foreground" href="/help/interval"><i class="fas fa-question-circle"></i></a></label>
|
||||
<label class="label">Interval <a class="foreground" href="/help/intervals"><i class="fas fa-question-circle"></i></a></label>
|
||||
<div class="control intervalSelector" style="min-width: 400px;" >
|
||||
<div class="input interval-group">
|
||||
<div class="interval-group-left">
|
||||
@ -206,11 +206,6 @@
|
||||
<label class="label">Enable TTS <input type="checkbox" name="tts"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column has-text-centered">
|
||||
<div class="is-boxed">
|
||||
<label class="label">Pin Message <input type="checkbox" name="pin"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column has-text-centered">
|
||||
<div class="file is-small is-boxed">
|
||||
<label class="file-label">
|
||||
|
22
web/templates/support/dashboard.html.tera
Normal file
@ -0,0 +1,22 @@
|
||||
{% extends "base" %}
|
||||
|
||||
{% block init %}
|
||||
{% set title = "Support" %}
|
||||
|
||||
{% set page_title = "Dashboard" %}
|
||||
{% set page_subtitle = "" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<p class="title">Accessing the dashboard</p>
|
||||
<p class="content">
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
@ -33,6 +33,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<p class="title">Deleting reminders you've just created</p>
|
||||
<p class="content">
|
||||
If you made a mistake, you can quickly delete a reminder you made by pressing "Cancel"
|
||||
<br>
|
||||
</p>
|
||||
<figure>
|
||||
<img src="/static/img/support/delete_reminder/cancel-1.png" alt="Cancel button">
|
||||
</figure>
|
||||
<figure>
|
||||
<img src="/static/img/support/delete_reminder/cancel-2.png" alt="Reminder deleted">
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
|
88
web/templates/support/iemanager.html.tera
Normal file
@ -0,0 +1,88 @@
|
||||
{% extends "base" %}
|
||||
|
||||
{% block init %}
|
||||
{% set title = "Support" %}
|
||||
|
||||
{% set page_title = "Import/Export" %}
|
||||
{% set page_subtitle = "" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<p class="title">Export your data</p>
|
||||
<p class="content">
|
||||
You can export data associated with your server from the dashboard. The data will export as a CSV
|
||||
file. The CSV file can then be edited and imported to bulk edit server data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<p class="title">Import data</p>
|
||||
<p class="content">
|
||||
You can import previous exports or modified exports. When importing a file, <strong>existing data
|
||||
will be overwritten</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container content">
|
||||
<p class="title">Edit your data</p>
|
||||
<p>
|
||||
The CSV can be edited either as a text file or in a spreadsheet editor such as LibreOffice Calc. To
|
||||
set up LibreOffice Calc for editing, do the following:
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
Export data from dashboard.
|
||||
<figure>
|
||||
<img src="/static/img/support/iemanager/select_export.png" alt="Selecting export button">
|
||||
</figure>
|
||||
</li>
|
||||
<li>
|
||||
Open the file in LibreOffice. <strong>During the import dialogue, select "Format quoted field as text".</strong>
|
||||
<figure>
|
||||
<img src="/static/img/support/iemanager/format_text.png" alt="Selecting format button">
|
||||
</figure>
|
||||
</li>
|
||||
<li>
|
||||
Make edits to the spreadsheet. You can add, edit, and remove rows for reminders. Don't remove the title row.
|
||||
<figure>
|
||||
<img src="/static/img/support/iemanager/edit_spreadsheet.png" alt="Editing spreadsheet">
|
||||
</figure>
|
||||
</li>
|
||||
<li>
|
||||
Save the edited CSV file and import it on the dashboard.
|
||||
<figure>
|
||||
<img src="/static/img/support/iemanager/import.png" alt="Import new reminders">
|
||||
</figure>
|
||||
</li>
|
||||
</ol>
|
||||
Other spreadsheet tools can also be used to edit exports, as long as they are properly configured:
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Google Sheets</strong>: Create a new blank spreadsheet. Select <strong>File >> Import >> Upload >> export.csv</strong>.
|
||||
Use the following import settings:
|
||||
<figure>
|
||||
<img src="/static/img/support/iemanager/sheets_settings.png" alt="Google sheets import settings">
|
||||
</figure>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Excel (including Excel Online)</strong>: Avoid using Excel. Excel will not correctly import channels, or give
|
||||
clear options to correct imports.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
70
web/templates/support/intervals.html.tera
Normal file
@ -0,0 +1,70 @@
|
||||
{% extends "base" %}
|
||||
|
||||
{% block init %}
|
||||
{% set title = "Support" %}
|
||||
|
||||
{% set page_title = "Intervals" %}
|
||||
{% set page_subtitle = "Interval reminders, or repeating reminders, are available to our Patreon supporters" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<p class="title">Fixed intervals</p>
|
||||
<p class="content">
|
||||
The main type of interval is the fixed interval. Fixed intervals are ideal for hourly, daily, or
|
||||
reminders repeating at any other fixed amount of time.
|
||||
<br>
|
||||
You can create fixed interval reminders via the dashboard or via the <code>/remind</code> command.
|
||||
When you have filled out the "time" and "content" on the command, press <kbd>tab</kbd>. Select the
|
||||
"interval" option. Then, write the interval you wish to use: for example, "1 day" for daily (starting
|
||||
at the time specified in "time").
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<p class="title">Daylight savings</p>
|
||||
<p class="content">
|
||||
If you live in a region that uses daylight savings (DST), then your interval reminders may become
|
||||
offset by an hour due to clock changes.
|
||||
<br>
|
||||
Reminder Bot offers a quick solution to this via the <code>/offset</code> command. This command
|
||||
moves all existing reminders on a server by a certain amount of time. You can use offset to move
|
||||
your reminders forward or backward by an hour when daylight savings happens.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<p class="title">Monthly/yearly intervals</p>
|
||||
<p class="content">
|
||||
Monthly or yearly intervals are configured the same as fixed intervals. Instead of a fixed time
|
||||
interval, these reminders repeat on a certain day each month or each year. This makes them ideal
|
||||
for marking certain dates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<p class="title">Interval expiration</p>
|
||||
<p class="content">
|
||||
An expiration time can also be specified, both via commands and dashboard, for repeating reminders.
|
||||
This is optional, and if omitted, the reminder will repeat indefinitely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
@ -3,8 +3,8 @@
|
||||
{% block init %}
|
||||
{% set title = "Support" %}
|
||||
|
||||
{% set page_title = "Timezone Help" %}
|
||||
{% set page_subtitle = "Timezones are tricky. Read on for help" %}
|
||||
{% set page_title = "Timezones" %}
|
||||
{% set page_subtitle = "" %}
|
||||
{% set show_invite = false %}
|
||||
{% endblock %}
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
<div class="container">
|
||||
<p class="title">Selecting your timezone automatically</p>
|
||||
<p class="content">
|
||||
A new feature we offer is the ability to configure Reminder Bot's timezone from your browser. To do
|
||||
You can also configure Reminder Bot's timezone from your browser. To do
|
||||
this, go to our dashboard, press 'Timezone' in the bottom left (desktop) or at the bottom of the
|
||||
navigation menu (mobile). Then, choose 'Set Bot Timezone' to set Reminder Bot to use your browser's
|
||||
timezone.
|
||||
|
@ -20,11 +20,12 @@
|
||||
<br>
|
||||
Violating the Terms of Service may result in receiving a permanent ban from the Discord server,
|
||||
permanent restriction on your usage of Reminder Bot, or removal of some or all of your content on
|
||||
Reminder Bot or the Discord server.
|
||||
Reminder Bot or the Discord server. None of these will necessarily be preceded or succeeded by a warning
|
||||
or notice.
|
||||
<br>
|
||||
<br>
|
||||
The Terms of Service may be updated at any time, and should be considered a guideline for appropriate
|
||||
behaviour.
|
||||
The Terms of Service may be updated. Notice will be provided via the Discord server. You
|
||||
should consider the Terms of Service to be a strong for appropriate behaviour.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@ -35,7 +36,14 @@
|
||||
<ul class="is-size-5 pl-6">
|
||||
<li>Reasonably disclose potential exploits or bugs to me by email or by Discord private message</li>
|
||||
<li>Do not use the bot to harass other Discord users</li>
|
||||
<li>Do not use the bot to send more than 30 messages during a 60 second period</li>
|
||||
<li>Do not use the bot to transmit malware or other illegal content</li>
|
||||
<li>Do not use the bot to send more than 15 messages during a 60 second period</li>
|
||||
<li>
|
||||
Do not attempt to circumvent restrictions imposed by the bot or website, including trying to access
|
||||
data of other users, circumvent Patreon restrictions, or uploading files and creating reminders that
|
||||
are too large for the bot to send or process. Some or all of these actions may be illegal in your
|
||||
country
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|