Compare commits
9 Commits
postgres
...
jellywx/fi
Author | SHA1 | Date | |
---|---|---|---|
7d8748e3ef | |||
25b84880a5 | |||
7b6e967a5d | |||
2781f2923e | |||
03f08f0a18 | |||
79c86d43f2 | |||
e19af54caf | |||
f4213c6a83 | |||
f56db14720 |
901
Cargo.lock
generated
901
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "reminder_rs"
|
name = "reminder_rs"
|
||||||
version = "1.6.0"
|
version = "1.6.3"
|
||||||
authors = ["jellywx <judesouthworth@pm.me>"]
|
authors = ["jellywx <judesouthworth@pm.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ rmp-serde = "0.15"
|
|||||||
rand = "0.7"
|
rand = "0.7"
|
||||||
levenshtein = "1.0"
|
levenshtein = "1.0"
|
||||||
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
|
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
|
||||||
base64 = "0.13.0"
|
base64 = "0.13"
|
||||||
|
|
||||||
[dependencies.postman]
|
[dependencies.postman]
|
||||||
path = "postman"
|
path = "postman"
|
||||||
|
@ -157,4 +157,9 @@ CREATE TABLE events (
|
|||||||
FOREIGN KEY (reminder_id) REFERENCES reminders_new(id) ON DELETE SET NULL
|
FOREIGN KEY (reminder_id) REFERENCES reminders_new(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
DROP TABLE reminders;
|
||||||
|
DROP TABLE embed_fields;
|
||||||
|
RENAME TABLE reminders_new TO reminders;
|
||||||
|
RENAME TABLE embed_fields_new TO embed_fields;
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
@ -7,12 +7,10 @@ edition = "2021"
|
|||||||
tokio = { version = "1", features = ["process", "full"] }
|
tokio = { version = "1", features = ["process", "full"] }
|
||||||
regex = "1.4"
|
regex = "1.4"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.8"
|
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
chrono-tz = { version = "0.5", features = ["serde"] }
|
chrono-tz = { version = "0.5", features = ["serde"] }
|
||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
num-integer = "0.1"
|
num-integer = "0.1"
|
||||||
serde = "1.0"
|
serde = "1.0"
|
||||||
serde_json = "1.0"
|
|
||||||
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono", "json"]}
|
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"] }
|
serenity = { version = "0.11.1", default-features = false, features = ["builder", "cache", "client", "gateway", "http", "model", "utils", "rustls_backend"] }
|
||||||
|
@ -226,7 +226,6 @@ impl Into<CreateEmbed> for Embed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Reminder {
|
pub struct Reminder {
|
||||||
id: u32,
|
id: u32,
|
||||||
|
|
||||||
@ -293,9 +292,21 @@ INNER JOIN
|
|||||||
ON
|
ON
|
||||||
reminders.channel_id = channels.id
|
reminders.channel_id = channels.id
|
||||||
WHERE
|
WHERE
|
||||||
reminders.`utc_time` < NOW()
|
reminders.`id` IN (
|
||||||
LIMIT 25
|
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)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
@ -566,7 +577,7 @@ UPDATE `channels` SET paused = 0, paused_until = NULL WHERE `channel` = ?
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
error!("Error sending {:?}: {:?}", self, e);
|
error!("Error sending reminder {}: {:?}", self.id, e);
|
||||||
|
|
||||||
if let Error::Http(error) = e {
|
if let Error::Http(error) = e {
|
||||||
if error.status_code() == Some(StatusCode::NOT_FOUND) {
|
if error.status_code() == Some(StatusCode::NOT_FOUND) {
|
||||||
|
@ -49,6 +49,7 @@ __Todo Commands__
|
|||||||
|
|
||||||
__Setup Commands__
|
__Setup Commands__
|
||||||
`/timezone` - Set your timezone (necessary for `/remind` to work properly)
|
`/timezone` - Set your timezone (necessary for `/remind` to work properly)
|
||||||
|
`/dm allow/block` - Change your DM settings for reminders.
|
||||||
|
|
||||||
__Advanced Commands__
|
__Advanced Commands__
|
||||||
`/macro` - Record and replay command sequences
|
`/macro` - Record and replay command sequences
|
||||||
|
@ -124,6 +124,85 @@ You may want to use one of the popular timezones below, otherwise click [here](h
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configure whether other users can set reminders to your direct messages
|
||||||
|
#[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")]
|
||||||
|
pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allow other users to set reminders in your direct messages
|
||||||
|
#[poise::command(slash_command, rename = "allow", identifying_name = "allowed_dm")]
|
||||||
|
pub async fn set_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let mut user_data = ctx.author_data().await?;
|
||||||
|
user_data.allowed_dm = true;
|
||||||
|
user_data.commit_changes(&ctx.data().database).await;
|
||||||
|
|
||||||
|
ctx.send(|r| {
|
||||||
|
r.ephemeral(true).embed(|e| {
|
||||||
|
e.title("DMs permitted")
|
||||||
|
.description("You will receive a message if a user sets a DM reminder for you.")
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block other users from setting reminders in your direct messages
|
||||||
|
#[poise::command(slash_command, rename = "block", identifying_name = "allowed_dm")]
|
||||||
|
pub async fn unset_allowed_dm(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let mut user_data = ctx.author_data().await?;
|
||||||
|
user_data.allowed_dm = false;
|
||||||
|
user_data.commit_changes(&ctx.data().database).await;
|
||||||
|
|
||||||
|
ctx.send(|r| {
|
||||||
|
r.ephemeral(true).embed(|e| {
|
||||||
|
e.title("DMs blocked")
|
||||||
|
.description(
|
||||||
|
"You can still set DM reminders for yourself or for users with DMs enabled.",
|
||||||
|
)
|
||||||
|
.color(*THEME_COLOR)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// View the webhook being used to send reminders to this channel
|
||||||
|
#[poise::command(
|
||||||
|
slash_command,
|
||||||
|
identifying_name = "webhook_url",
|
||||||
|
required_permissions = "ADMINISTRATOR"
|
||||||
|
)]
|
||||||
|
pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
match ctx.channel_data().await {
|
||||||
|
Ok(data) => {
|
||||||
|
if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
|
||||||
|
let _ = ctx
|
||||||
|
.send(|b| {
|
||||||
|
b.ephemeral(true).content(format!(
|
||||||
|
"**Warning!**
|
||||||
|
This link can be used by users to anonymously send messages, with or without permissions.
|
||||||
|
Do not share it!
|
||||||
|
|| https://discord.com/api/webhooks/{}/{} ||",
|
||||||
|
id, token,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
let _ = ctx.say("No webhook configured on this channel.").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = ctx.say("No webhook configured on this channel.").await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
|
async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
|
@ -9,7 +9,7 @@ use chrono_tz::Tz;
|
|||||||
use num_integer::Integer;
|
use num_integer::Integer;
|
||||||
use poise::{
|
use poise::{
|
||||||
serenity::{builder::CreateEmbed, model::channel::Channel},
|
serenity::{builder::CreateEmbed, model::channel::Channel},
|
||||||
serenity_prelude::{ButtonStyle, ReactionType},
|
serenity_prelude::{component::ButtonStyle, ReactionType},
|
||||||
CreateReply,
|
CreateReply,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -694,6 +694,7 @@ pub async fn remind(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
None => {
|
None => {
|
||||||
ctx.say("Time could not be processed").await?;
|
ctx.say("Time could not be processed").await?;
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ use crate::{
|
|||||||
ComponentDataModel, TodoSelector,
|
ComponentDataModel, TodoSelector,
|
||||||
},
|
},
|
||||||
consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR},
|
consts::{EMBED_DESCRIPTION_MAX_LENGTH, SELECT_MAX_ENTRIES, THEME_COLOR},
|
||||||
|
models::CtxData,
|
||||||
Context, Error,
|
Context, Error,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -116,6 +117,9 @@ pub async fn todo_channel_add(
|
|||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
#[description = "The task to add to the todo list"] task: String,
|
#[description = "The task to add to the todo list"] task: String,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
|
// ensure channel is cached
|
||||||
|
let _ = ctx.channel_data().await;
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"INSERT INTO todos (guild_id, channel_id, value)
|
"INSERT INTO todos (guild_id, channel_id, value)
|
||||||
VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)",
|
VALUES ((SELECT id FROM guilds WHERE guild = ?), (SELECT id FROM channels WHERE channel = ?), ?)",
|
||||||
|
@ -9,11 +9,11 @@ use poise::{
|
|||||||
builder::CreateEmbed,
|
builder::CreateEmbed,
|
||||||
client::Context,
|
client::Context,
|
||||||
model::{
|
model::{
|
||||||
channel::Channel,
|
application::interaction::{
|
||||||
interactions::{
|
|
||||||
message_component::MessageComponentInteraction, InteractionResponseType,
|
message_component::MessageComponentInteraction, InteractionResponseType,
|
||||||
|
MessageFlags,
|
||||||
},
|
},
|
||||||
prelude::InteractionApplicationCommandCallbackDataFlags,
|
channel::Channel,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
serenity_prelude as serenity,
|
serenity_prelude as serenity,
|
||||||
@ -260,7 +260,7 @@ WHERE guilds.guild = ?",
|
|||||||
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||||
.interaction_response_data(|d| {
|
.interaction_response_data(|d| {
|
||||||
d.flags(
|
d.flags(
|
||||||
InteractionApplicationCommandCallbackDataFlags::EPHEMERAL,
|
MessageFlags::EPHEMERAL,
|
||||||
)
|
)
|
||||||
.content("Only the user who performed the command can use these components")
|
.content("Only the user who performed the command can use these components")
|
||||||
})
|
})
|
||||||
@ -314,7 +314,7 @@ WHERE guilds.guild = ?",
|
|||||||
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
r.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||||
.interaction_response_data(|d| {
|
.interaction_response_data(|d| {
|
||||||
d.flags(
|
d.flags(
|
||||||
InteractionApplicationCommandCallbackDataFlags::EPHEMERAL,
|
MessageFlags::EPHEMERAL,
|
||||||
)
|
)
|
||||||
.content("Only the user who performed the command can use these components")
|
.content("Only the user who performed the command can use these components")
|
||||||
})
|
})
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
// todo split pager out into a single struct
|
// todo split pager out into a single struct
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use poise::serenity::{
|
use poise::serenity::{builder::CreateComponents, model::application::component::ButtonStyle};
|
||||||
builder::CreateComponents, model::interactions::message_component::ButtonStyle,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_repr::*;
|
use serde_repr::*;
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ use std::{collections::HashMap, env, sync::atomic::Ordering};
|
|||||||
|
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use poise::{
|
use poise::{
|
||||||
serenity::{model::interactions::Interaction, utils::shard_id},
|
serenity::{model::application::interaction::Interaction, utils::shard_id},
|
||||||
serenity_prelude as serenity,
|
serenity_prelude as serenity,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -101,6 +101,14 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
|||||||
info_cmds::clock_context_menu(),
|
info_cmds::clock_context_menu(),
|
||||||
info_cmds::dashboard(),
|
info_cmds::dashboard(),
|
||||||
moderation_cmds::timezone(),
|
moderation_cmds::timezone(),
|
||||||
|
poise::Command {
|
||||||
|
subcommands: vec![
|
||||||
|
moderation_cmds::set_allowed_dm(),
|
||||||
|
moderation_cmds::unset_allowed_dm(),
|
||||||
|
],
|
||||||
|
..moderation_cmds::allowed_dm()
|
||||||
|
},
|
||||||
|
moderation_cmds::webhook(),
|
||||||
poise::Command {
|
poise::Command {
|
||||||
subcommands: vec![
|
subcommands: vec![
|
||||||
moderation_cmds::delete_macro(),
|
moderation_cmds::delete_macro(),
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
use poise::serenity::model::{
|
use poise::serenity::model::{
|
||||||
id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption,
|
application::interaction::application_command::CommandDataOption, id::GuildId,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ pub struct RecordedCommand<U, E> {
|
|||||||
#[serde(default = "default_none::<U, E>")]
|
#[serde(default = "default_none::<U, E>")]
|
||||||
pub action: Option<Func<U, E>>,
|
pub action: Option<Func<U, E>>,
|
||||||
pub command_name: String,
|
pub command_name: String,
|
||||||
pub options: Vec<ApplicationCommandInteractionDataOption>,
|
pub options: Vec<CommandDataOption>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct CommandMacro<U, E> {
|
pub struct CommandMacro<U, E> {
|
||||||
|
@ -233,6 +233,10 @@ impl<'a> MultiReminderBuilder<'a> {
|
|||||||
if let Some(guild_id) = self.guild_id {
|
if let Some(guild_id) = self.guild_id {
|
||||||
if guild_id.member(&self.ctx.discord(), user).await.is_err() {
|
if guild_id.member(&self.ctx.discord(), user).await.is_err() {
|
||||||
Err(ReminderError::InvalidTag)
|
Err(ReminderError::InvalidTag)
|
||||||
|
} else if self.set_by.map_or(true, |i| i != user_data.id)
|
||||||
|
&& !user_data.allowed_dm
|
||||||
|
{
|
||||||
|
Err(ReminderError::UserBlockedDm)
|
||||||
} else {
|
} else {
|
||||||
Ok(user_data.dm_channel)
|
Ok(user_data.dm_channel)
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ pub enum ReminderError {
|
|||||||
PastTime,
|
PastTime,
|
||||||
ShortInterval,
|
ShortInterval,
|
||||||
InvalidTag,
|
InvalidTag,
|
||||||
|
UserBlockedDm,
|
||||||
DiscordError(String),
|
DiscordError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +31,9 @@ impl ToString for ReminderError {
|
|||||||
ReminderError::InvalidTag => {
|
ReminderError::InvalidTag => {
|
||||||
"Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string()
|
"Couldn't find a location by your tag. Your tag must be either a channel or a user (not a role)".to_string()
|
||||||
}
|
}
|
||||||
|
ReminderError::UserBlockedDm => {
|
||||||
|
"User has DM reminders disabled".to_string()
|
||||||
|
}
|
||||||
ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s),
|
ReminderError::DiscordError(s) => format!("A Discord error occurred: **{}**", s),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ pub struct UserData {
|
|||||||
pub user: u64,
|
pub user: u64,
|
||||||
pub dm_channel: u32,
|
pub dm_channel: u32,
|
||||||
pub timezone: String,
|
pub timezone: String,
|
||||||
|
pub allowed_dm: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserData {
|
impl UserData {
|
||||||
@ -46,7 +47,7 @@ SELECT timezone FROM users WHERE user = ?
|
|||||||
match sqlx::query_as_unchecked!(
|
match sqlx::query_as_unchecked!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone FROM users WHERE user = ?
|
SELECT id, user, dm_channel, IF(timezone IS NULL, ?, timezone) AS timezone, allowed_dm FROM users WHERE user = ?
|
||||||
",
|
",
|
||||||
*LOCAL_TIMEZONE,
|
*LOCAL_TIMEZONE,
|
||||||
user_id.0
|
user_id.0
|
||||||
@ -83,7 +84,7 @@ INSERT INTO users (name, user, dm_channel, timezone) VALUES ('', ?, (SELECT id F
|
|||||||
Ok(sqlx::query_as_unchecked!(
|
Ok(sqlx::query_as_unchecked!(
|
||||||
Self,
|
Self,
|
||||||
"
|
"
|
||||||
SELECT id, user, dm_channel, timezone FROM users WHERE user = ?
|
SELECT id, user, dm_channel, timezone, allowed_dm FROM users WHERE user = ?
|
||||||
",
|
",
|
||||||
user_id.0
|
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) {
|
pub async fn commit_changes(&self, pool: &MySqlPool) {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
UPDATE users SET timezone = ? WHERE id = ?
|
UPDATE users SET timezone = ?, allowed_dm = ? WHERE id = ?
|
||||||
",
|
",
|
||||||
self.timezone,
|
self.timezone,
|
||||||
|
self.allowed_dm,
|
||||||
self.id
|
self.id
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
@ -5,6 +5,7 @@ use poise::{
|
|||||||
model::id::{GuildId, UserId},
|
model::id::{GuildId, UserId},
|
||||||
},
|
},
|
||||||
serenity_prelude as serenity,
|
serenity_prelude as serenity,
|
||||||
|
serenity_prelude::interaction::MessageFlags,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@ -102,6 +103,6 @@ pub fn send_as_initial_response(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if ephemeral {
|
if ephemeral {
|
||||||
f.flags(serenity::InteractionApplicationCommandCallbackDataFlags::EPHEMERAL);
|
f.flags(MessageFlags::EPHEMERAL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,10 +12,10 @@ oauth2 = "4"
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
reqwest = "0.11"
|
reqwest = "0.11"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
|
||||||
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
|
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "chrono", "json"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
chrono-tz = "0.5"
|
chrono-tz = "0.5"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
rand = "0.7"
|
rand = "0.7"
|
||||||
base64 = "0.13"
|
base64 = "0.13"
|
||||||
|
csv = "1.1"
|
||||||
|
@ -26,12 +26,8 @@ use serenity::model::prelude::AttachmentType;
|
|||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
|
pub static ref DEFAULT_AVATAR: AttachmentType<'static> = (
|
||||||
include_bytes!(concat!(
|
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/webhook.jpg")) as &[u8],
|
||||||
env!("CARGO_MANIFEST_DIR"),
|
"webhook.jpg",
|
||||||
"/../assets/",
|
|
||||||
env!("WEBHOOK_AVATAR", "WEBHOOK_AVATAR not provided for compilation")
|
|
||||||
)) as &[u8],
|
|
||||||
env!("WEBHOOK_AVATAR"),
|
|
||||||
)
|
)
|
||||||
.into();
|
.into();
|
||||||
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
pub static ref SUBSCRIPTION_ROLES: HashSet<u64> = HashSet::from_iter(
|
||||||
|
@ -146,10 +146,15 @@ pub async fn initialize(
|
|||||||
routes::dashboard::guild::get_reminder_templates,
|
routes::dashboard::guild::get_reminder_templates,
|
||||||
routes::dashboard::guild::create_reminder_template,
|
routes::dashboard::guild::create_reminder_template,
|
||||||
routes::dashboard::guild::delete_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::get_reminders,
|
||||||
routes::dashboard::guild::edit_reminder,
|
routes::dashboard::guild::edit_reminder,
|
||||||
routes::dashboard::guild::delete_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()
|
.launch()
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
macro_rules! check_length {
|
macro_rules! check_length {
|
||||||
($max:ident, $field:expr) => {
|
($max:ident, $field:expr) => {
|
||||||
if $field.len() > $max {
|
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),+) => {
|
($max:ident, $field:expr, $($fields:expr),+) => {
|
||||||
@ -25,7 +25,7 @@ macro_rules! check_length_opt {
|
|||||||
macro_rules! check_url {
|
macro_rules! check_url {
|
||||||
($field:expr) => {
|
($field:expr) => {
|
||||||
if !($field.starts_with("http://") || $field.starts_with("https://")) {
|
if !($field.starts_with("http://") || $field.starts_with("https://")) {
|
||||||
return json!({ "error": "URL invalid" });
|
return Err(json!({ "error": "URL invalid" }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
($field:expr, $($fields:expr),+) => {
|
($field:expr, $($fields:expr),+) => {
|
||||||
@ -60,7 +60,7 @@ macro_rules! check_authorization {
|
|||||||
|
|
||||||
match member {
|
match member {
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return json!({"error": "User not in guild"})
|
return Err(json!({"error": "User not in guild"}));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
@ -68,13 +68,13 @@ macro_rules! check_authorization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
None => {
|
None => {
|
||||||
return json!({"error": "Bot not in guild"})
|
return Err(json!({"error": "Bot not in guild"}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
None => {
|
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),+]);
|
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
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 std::env;
|
||||||
|
|
||||||
use base64;
|
|
||||||
use chrono::Utc;
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
http::CookieJar,
|
http::CookieJar,
|
||||||
serde::json::{json, Json, Value as JsonValue},
|
serde::json::{json, Json},
|
||||||
State,
|
State,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@ -18,16 +16,14 @@ use serenity::{
|
|||||||
use sqlx::{MySql, Pool};
|
use sqlx::{MySql, Pool};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
check_guild_subscription, check_subscription,
|
|
||||||
consts::{
|
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_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,
|
MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
|
||||||
MIN_INTERVAL,
|
|
||||||
},
|
},
|
||||||
routes::dashboard::{
|
routes::dashboard::{
|
||||||
create_database_channel, generate_uid, name_default, template_name_default, DeleteReminder,
|
create_database_channel, create_reminder, template_name_default, DeleteReminder,
|
||||||
DeleteReminderTemplate, PatchReminder, Reminder, ReminderTemplate,
|
DeleteReminderTemplate, JsonResult, PatchReminder, Reminder, ReminderTemplate,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -44,7 +40,7 @@ pub async fn get_guild_patreon(
|
|||||||
id: u64,
|
id: u64,
|
||||||
cookies: &CookieJar<'_>,
|
cookies: &CookieJar<'_>,
|
||||||
ctx: &State<Context>,
|
ctx: &State<Context>,
|
||||||
) -> JsonValue {
|
) -> JsonResult {
|
||||||
check_authorization!(cookies, ctx.inner(), id);
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
match GuildId(id).to_guild_cached(ctx.inner()) {
|
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()))
|
.contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
|
||||||
});
|
});
|
||||||
|
|
||||||
json!({ "patreon": patreon })
|
Ok(json!({ "patreon": patreon }))
|
||||||
}
|
}
|
||||||
|
|
||||||
None => {
|
None => json_err!("Bot not in guild"),
|
||||||
json!({"error": "Bot not in guild"})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,7 +67,7 @@ pub async fn get_guild_channels(
|
|||||||
id: u64,
|
id: u64,
|
||||||
cookies: &CookieJar<'_>,
|
cookies: &CookieJar<'_>,
|
||||||
ctx: &State<Context>,
|
ctx: &State<Context>,
|
||||||
) -> JsonValue {
|
) -> JsonResult {
|
||||||
check_authorization!(cookies, ctx.inner(), id);
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
match GuildId(id).to_guild_cached(ctx.inner()) {
|
match GuildId(id).to_guild_cached(ctx.inner()) {
|
||||||
@ -97,12 +91,10 @@ pub async fn get_guild_channels(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<ChannelInfo>>();
|
.collect::<Vec<ChannelInfo>>();
|
||||||
|
|
||||||
json!(channel_info)
|
Ok(json!(channel_info))
|
||||||
}
|
}
|
||||||
|
|
||||||
None => {
|
None => json_err!("Bot not in guild"),
|
||||||
json!({"error": "Bot not in guild"})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,7 +105,7 @@ struct RoleInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/api/guild/<id>/roles")]
|
#[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);
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
let roles_res = ctx.cache.guild_roles(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() })
|
.map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
|
||||||
.collect::<Vec<RoleInfo>>();
|
.collect::<Vec<RoleInfo>>();
|
||||||
|
|
||||||
json!(roles)
|
Ok(json!(roles))
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
warn!("Could not fetch roles from {}", id);
|
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<'_>,
|
cookies: &CookieJar<'_>,
|
||||||
ctx: &State<Context>,
|
ctx: &State<Context>,
|
||||||
pool: &State<Pool<MySql>>,
|
pool: &State<Pool<MySql>>,
|
||||||
) -> JsonValue {
|
) -> JsonResult {
|
||||||
check_authorization!(cookies, ctx.inner(), id);
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
match sqlx::query_as_unchecked!(
|
match sqlx::query_as_unchecked!(
|
||||||
@ -152,13 +144,11 @@ pub async fn get_reminder_templates(
|
|||||||
.fetch_all(pool.inner())
|
.fetch_all(pool.inner())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(templates) => {
|
Ok(templates) => Ok(json!(templates)),
|
||||||
json!(templates)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Could not fetch templates from {}: {:?}", id, 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<'_>,
|
cookies: &CookieJar<'_>,
|
||||||
ctx: &State<Context>,
|
ctx: &State<Context>,
|
||||||
pool: &State<Pool<MySql>>,
|
pool: &State<Pool<MySql>>,
|
||||||
) -> JsonValue {
|
) -> JsonResult {
|
||||||
check_authorization!(cookies, ctx.inner(), id);
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
// validate lengths
|
// validate lengths
|
||||||
@ -254,12 +244,12 @@ pub async fn create_reminder_template(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
json!({})
|
Ok(json!({}))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Could not fetch templates from {}: {:?}", id, 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<'_>,
|
cookies: &CookieJar<'_>,
|
||||||
ctx: &State<Context>,
|
ctx: &State<Context>,
|
||||||
pool: &State<Pool<MySql>>,
|
pool: &State<Pool<MySql>>,
|
||||||
) -> JsonValue {
|
) -> JsonResult {
|
||||||
check_authorization!(cookies, ctx.inner(), id);
|
check_authorization!(cookies, ctx.inner(), id);
|
||||||
|
|
||||||
match sqlx::query!(
|
match sqlx::query!(
|
||||||
@ -282,230 +272,41 @@ pub async fn delete_reminder_template(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
json!({})
|
Ok(json!({}))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Could not delete template from {}: {:?}", id, 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>")]
|
#[post("/api/guild/<id>/reminders", data = "<reminder>")]
|
||||||
pub async fn create_reminder(
|
pub async fn create_guild_reminder(
|
||||||
id: u64,
|
id: u64,
|
||||||
reminder: Json<Reminder>,
|
reminder: Json<Reminder>,
|
||||||
cookies: &CookieJar<'_>,
|
cookies: &CookieJar<'_>,
|
||||||
serenity_context: &State<Context>,
|
serenity_context: &State<Context>,
|
||||||
pool: &State<Pool<MySql>>,
|
pool: &State<Pool<MySql>>,
|
||||||
) -> JsonValue {
|
) -> JsonResult {
|
||||||
check_authorization!(cookies, serenity_context.inner(), id);
|
check_authorization!(cookies, serenity_context.inner(), id);
|
||||||
|
|
||||||
let user_id =
|
let user_id =
|
||||||
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
|
||||||
|
|
||||||
// validate channel
|
create_reminder(
|
||||||
let channel = ChannelId(reminder.channel).to_channel_cached(&serenity_context.inner());
|
|
||||||
let channel_exists = channel.is_some();
|
|
||||||
|
|
||||||
let channel_matches_guild =
|
|
||||||
channel.map_or(false, |c| c.guild().map_or(false, |c| c.guild_id.0 == id));
|
|
||||||
|
|
||||||
if !channel_matches_guild || !channel_exists {
|
|
||||||
warn!(
|
|
||||||
"Error in `create_reminder`: channel {} not found for guild {} (channel exists: {})",
|
|
||||||
reminder.channel, id, channel_exists
|
|
||||||
);
|
|
||||||
|
|
||||||
return json!({"error": "Channel not found"});
|
|
||||||
}
|
|
||||||
|
|
||||||
let channel = create_database_channel(
|
|
||||||
serenity_context.inner(),
|
serenity_context.inner(),
|
||||||
ChannelId(reminder.channel),
|
|
||||||
pool.inner(),
|
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,
|
|
||||||
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.inner())
|
|
||||||
.await
|
.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.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")]
|
#[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;
|
let channels_res = GuildId(id).channels(&ctx.inner()).await;
|
||||||
|
|
||||||
match channels_res {
|
match channels_res {
|
||||||
@ -552,17 +353,17 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq
|
|||||||
)
|
)
|
||||||
.fetch_all(pool.inner())
|
.fetch_all(pool.inner())
|
||||||
.await
|
.await
|
||||||
.map(|r| json!(r))
|
.map(|r| Ok(json!(r)))
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
warn!("Failed to complete SQL query: {:?}", e);
|
warn!("Failed to complete SQL query: {:?}", e);
|
||||||
|
|
||||||
json!({"error": "Could not load reminders"})
|
json_err!("Could not load reminders")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Could not fetch channels from {}: {:?}", id, e);
|
warn!("Could not fetch channels from {}: {:?}", id, e);
|
||||||
|
|
||||||
json!([])
|
Ok(json!([]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -573,7 +374,7 @@ pub async fn edit_reminder(
|
|||||||
reminder: Json<PatchReminder>,
|
reminder: Json<PatchReminder>,
|
||||||
serenity_context: &State<Context>,
|
serenity_context: &State<Context>,
|
||||||
pool: &State<Pool<MySql>>,
|
pool: &State<Pool<MySql>>,
|
||||||
) -> JsonValue {
|
) -> JsonResult {
|
||||||
let mut error = vec![];
|
let mut error = vec![];
|
||||||
|
|
||||||
update_field!(pool.inner(), error, reminder.[
|
update_field!(pool.inner(), error, reminder.[
|
||||||
@ -614,7 +415,7 @@ pub async fn edit_reminder(
|
|||||||
reminder.channel, id
|
reminder.channel, id
|
||||||
);
|
);
|
||||||
|
|
||||||
return json!({"error": "Channel not found"});
|
return Err(json!({"error": "Channel not found"}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let channel = create_database_channel(
|
let channel = create_database_channel(
|
||||||
@ -627,7 +428,9 @@ pub async fn edit_reminder(
|
|||||||
if let Err(e) = channel {
|
if let Err(e) = channel {
|
||||||
warn!("`create_database_channel` returned an error code: {:?}", e);
|
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();
|
let channel = channel.unwrap();
|
||||||
@ -655,7 +458,7 @@ pub async fn edit_reminder(
|
|||||||
reminder.channel, id
|
reminder.channel, id
|
||||||
);
|
);
|
||||||
|
|
||||||
return json!({"error": "Channel not found"});
|
return Err(json!({"error": "Channel not found"}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -695,12 +498,12 @@ pub async fn edit_reminder(
|
|||||||
.fetch_one(pool.inner())
|
.fetch_one(pool.inner())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(reminder) => json!({"reminder": reminder, "errors": error}),
|
Ok(reminder) => Ok(json!({"reminder": reminder, "errors": error})),
|
||||||
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Error exiting `edit_reminder': {:?}", 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"]}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -709,19 +512,17 @@ pub async fn edit_reminder(
|
|||||||
pub async fn delete_reminder(
|
pub async fn delete_reminder(
|
||||||
reminder: Json<DeleteReminder>,
|
reminder: Json<DeleteReminder>,
|
||||||
pool: &State<Pool<MySql>>,
|
pool: &State<Pool<MySql>>,
|
||||||
) -> JsonValue {
|
) -> JsonResult {
|
||||||
match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid)
|
match sqlx::query!("DELETE FROM reminders WHERE uid = ?", reminder.uid)
|
||||||
.execute(pool.inner())
|
.execute(pool.inner())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => Ok(json!({})),
|
||||||
json!({})
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Error in `delete_reminder`: {:?}", 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 std::collections::HashMap;
|
||||||
|
|
||||||
use chrono::naive::NaiveDateTime;
|
use chrono::{naive::NaiveDateTime, Utc};
|
||||||
use rand::{rngs::OsRng, seq::IteratorRandom};
|
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 rocket_dyn_templates::Template;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serenity::{http::Http, model::id::ChannelId};
|
use serenity::{
|
||||||
use sqlx::{types::Json, Executor};
|
client::Context,
|
||||||
|
http::Http,
|
||||||
|
model::id::{ChannelId, GuildId, UserId},
|
||||||
|
};
|
||||||
|
use sqlx::{types::Json, Executor, MySql, Pool};
|
||||||
|
|
||||||
use crate::{
|
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,
|
Database, Error,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod export;
|
||||||
pub mod guild;
|
pub mod guild;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|
||||||
|
pub type JsonResult = Result<JsonValue, JsonValue>;
|
||||||
type Unset<T> = Option<T>;
|
type Unset<T> = Option<T>;
|
||||||
|
|
||||||
fn name_default() -> String {
|
fn name_default() -> String {
|
||||||
@ -60,6 +76,28 @@ pub struct ReminderTemplate {
|
|||||||
username: Option<String>,
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct DeleteReminderTemplate {
|
pub struct DeleteReminderTemplate {
|
||||||
id: u32,
|
id: u32,
|
||||||
@ -105,6 +143,36 @@ pub struct Reminder {
|
|||||||
utc_time: NaiveDateTime,
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct PatchReminder {
|
pub struct PatchReminder {
|
||||||
uid: String,
|
uid: String,
|
||||||
@ -220,13 +288,225 @@ pub struct DeleteReminder {
|
|||||||
uid: String,
|
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(
|
async fn create_database_channel(
|
||||||
ctx: impl AsRef<Http>,
|
ctx: impl AsRef<Http>,
|
||||||
channel: ChannelId,
|
channel: ChannelId,
|
||||||
pool: impl Executor<'_, Database = Database> + Copy,
|
pool: impl Executor<'_, Database = Database> + Copy,
|
||||||
) -> Result<u32, crate::Error> {
|
) -> Result<u32, crate::Error> {
|
||||||
println!("{:?}", channel);
|
|
||||||
|
|
||||||
let row =
|
let row =
|
||||||
sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0)
|
sqlx::query!("SELECT webhook_token, webhook_id FROM channels WHERE channel = ?", channel.0)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
@ -25,7 +25,6 @@ pub async fn discord_login(
|
|||||||
// Set the desired scopes.
|
// Set the desired scopes.
|
||||||
.add_scope(Scope::new("identify".to_string()))
|
.add_scope(Scope::new("identify".to_string()))
|
||||||
.add_scope(Scope::new("guilds".to_string()))
|
.add_scope(Scope::new("guilds".to_string()))
|
||||||
.add_scope(Scope::new("email".to_string()))
|
|
||||||
// Set the PKCE code challenge.
|
// Set the PKCE code challenge.
|
||||||
.set_pkce_challenge(pkce_challenge)
|
.set_pkce_challenge(pkce_challenge)
|
||||||
.url();
|
.url();
|
||||||
|
BIN
web/static/img/support/iemanager/edit_spreadsheet.png
Normal file
BIN
web/static/img/support/iemanager/edit_spreadsheet.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
BIN
web/static/img/support/iemanager/format_text.png
Normal file
BIN
web/static/img/support/iemanager/format_text.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
BIN
web/static/img/support/iemanager/import.png
Normal file
BIN
web/static/img/support/iemanager/import.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
BIN
web/static/img/support/iemanager/select_export.png
Normal file
BIN
web/static/img/support/iemanager/select_export.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
BIN
web/static/img/support/iemanager/sheets_settings.png
Normal file
BIN
web/static/img/support/iemanager/sheets_settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
@ -12,6 +12,10 @@ const $createTemplateBtn = $createReminder.querySelector("button#createTemplate"
|
|||||||
const $loadTemplateBtn = document.querySelector("button#load-template");
|
const $loadTemplateBtn = document.querySelector("button#load-template");
|
||||||
const $deleteTemplateBtn = document.querySelector("button#delete-template");
|
const $deleteTemplateBtn = document.querySelector("button#delete-template");
|
||||||
const $templateSelect = document.querySelector("select#templateSelect");
|
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 channels = [];
|
||||||
let guildNames = {};
|
let guildNames = {};
|
||||||
@ -670,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.addEventListener("click", async () => {
|
||||||
$createReminderBtn.querySelector("span.icon > i").classList = [
|
$createReminderBtn.querySelector("span.icon > i").classList = [
|
||||||
"fas fa-spinner fa-spin",
|
"fas fa-spinner fa-spin",
|
||||||
@ -834,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) => {
|
fileInput.forEach((element) => {
|
||||||
element.addEventListener("change", () => {
|
element.addEventListener("change", () => {
|
||||||
|
@ -177,38 +177,41 @@
|
|||||||
<section class="modal-card-body">
|
<section class="modal-card-body">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input type="checkbox" class="default-width">
|
<label>
|
||||||
<label>Reminders</label>
|
<input type="radio" class="default-width" name="exportSelect" value="reminders" checked>
|
||||||
|
Reminders
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input type="checkbox" class="default-width">
|
<label>
|
||||||
<label>Todo Lists</label>
|
<input type="radio" class="default-width" name="exportSelect" value="todos">
|
||||||
|
Todo Lists
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input type="checkbox" class="default-width">
|
<label>
|
||||||
<label>Timers</label>
|
<input type="radio" class="default-width" name="exportSelect" value="reminder_templates">
|
||||||
</div>
|
Reminder templates
|
||||||
</div>
|
</label>
|
||||||
<div class="control">
|
|
||||||
<div class="field">
|
|
||||||
<input type="checkbox" class="default-width">
|
|
||||||
<label>Reminder templates</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<div class="field">
|
|
||||||
<input type="checkbox" class="default-width">
|
|
||||||
<label>Macros</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<br>
|
||||||
<div class="has-text-centered">
|
<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 is-outlined" id="import-data">Import Data</button>
|
||||||
<button class="button is-success" id="export-data">Export Data</button>
|
<button class="button is-success" id="export-data">Export Data</button>
|
||||||
</div>
|
</div>
|
||||||
|
<a id="downloader" download="export.csv" class="is-hidden"></a>
|
||||||
|
<input id="uploader" type="file" hidden></input>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close is-large close-modal" aria-label="close"></button>
|
<button class="modal-close is-large close-modal" aria-label="close"></button>
|
||||||
|
@ -5,5 +5,5 @@
|
|||||||
{% set show_contact = True %}
|
{% set show_contact = True %}
|
||||||
|
|
||||||
{% set page_title = "An Error Has Occurred" %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="title">Who your data is shared with</h2>
|
<h2 class="title">Who your data is shared with</h2>
|
||||||
<p class="is-size-5 pl-6">
|
<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.
|
<strong>Hetzner</strong>, our hosting provider.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -68,7 +68,7 @@
|
|||||||
<br>
|
<br>
|
||||||
<br>
|
<br>
|
||||||
Reminders deleted with <strong>/del</strong> or via the dashboard are removed from the live database
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@ -14,13 +14,75 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="title">Export your data</p>
|
<p class="title">Export your data</p>
|
||||||
<p class="content">
|
<p class="content">
|
||||||
You can create reminders with the <code>/remind</code> command.
|
You can export data associated with your server from the dashboard. The data will export as a CSV
|
||||||
<br>
|
file. The CSV file can then be edited and imported to bulk edit server data.
|
||||||
Fill out the "time" and "content" fields. If you wish, press on "Optional" to view other options
|
|
||||||
for the reminder.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 %}
|
{% endblock %}
|
||||||
|
@ -20,11 +20,12 @@
|
|||||||
<br>
|
<br>
|
||||||
Violating the Terms of Service may result in receiving a permanent ban from the Discord server,
|
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
|
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>
|
||||||
<br>
|
<br>
|
||||||
The Terms of Service may be updated at any time. Notice will be provided via the Discord server. You
|
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 guideline for appropriate behaviour.
|
should consider the Terms of Service to be a strong for appropriate behaviour.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -37,6 +38,12 @@
|
|||||||
<li>Do not use the bot to harass other Discord users</li>
|
<li>Do not use the bot to harass other Discord users</li>
|
||||||
<li>Do not use the bot to transmit malware or other illegal content</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 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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
Reference in New Issue
Block a user