31 Commits

Author SHA1 Message Date
e2bf23f194 Add macro install command stub 2022-09-12 17:20:43 +01:00
8f8235a86e Move macro commands to own module
Lots of code here
2022-09-12 16:45:00 +01:00
c8f646a8fa Override timezone per command
Timezone option that will override the timezone on a per-command basis
2022-09-11 18:59:46 +01:00
ecaa382a1e Add join message 2022-09-11 17:38:53 +01:00
8991198fd3 Use autocomplete to ensure content box is shown 2022-09-11 15:24:02 +01:00
f20b95a482 Upgrade poise. Combine remind/multiline into one command 2022-09-08 17:58:05 +01:00
8dd7dc6409 Added command for multiline reminders 2022-09-07 18:27:13 +01:00
c799d10727 Move extra processes to user data setup 2022-09-03 16:19:59 +01:00
ceb6fb7b12 bump version 2022-09-03 15:49:05 +01:00
6708abdb0f Merge pull request #10 from reminder-bot/jellywx/fix-dm-reminders
group by channel instead of guild
2022-09-03 15:44:00 +01:00
a38f6024c1 Migrate natural commands 2022-09-03 15:40:29 +01:00
7d8748e3ef group by channel instead of guild 2022-08-19 09:04:12 +01:00
bb3386c4e8 migration for $r commands 2022-08-14 16:22:00 +01:00
25b84880a5 Don't send non-interval disabled reminders
Skip the sending logic as some users use disabled one-time reminders as presets
2022-08-04 19:06:29 +01:00
7b6e967a5d Block/allow DM reminders
Only affects slash commands but this is sort of a non-issue post September
2022-07-29 19:22:15 +01:00
2781f2923e Restrict reminder selection to one-per-guild during fetch loop 2022-07-28 19:20:15 +01:00
03f08f0a18 Update deps. Drop limiter on reminder query 2022-07-27 21:42:09 +01:00
79c86d43f2 Changed return types to results 2022-07-24 20:06:37 +01:00
e19af54caf Import todo lists. Export other data. 2022-07-22 23:30:45 +01:00
f4213c6a83 Cache channel in todo list command
Channel was not being cached, placing channel todos into the server todo list.
2022-07-02 08:31:17 +01:00
f56db14720 Webhook command
Add a command to view the webhook, as some users wish to use the webhook to edit past reminders.
2022-06-17 17:15:48 +01:00
6f7d0f67b3 mentions 2022-05-15 12:14:07 +01:00
bfc2d71ca0 patreon 2022-05-14 12:02:46 +01:00
8eb46f1f23 delete reminders when the user cannot be direct messaged 2022-05-14 10:56:03 +01:00
c4087bf569 pos mysql 2022-05-14 08:12:50 +01:00
f25cfed8d7 edit button 2022-05-13 23:30:01 +01:00
d2a8bd1982 update readme with better build instructions 2022-05-13 23:13:39 +01:00
437ee6b446 ver bump 2022-05-13 23:10:29 +01:00
7d43aa5918 cleared up all unwraps from the reminder sender. cleared up clippy lints. added undo button 2022-05-13 23:08:52 +01:00
8bad95510d configure playing status 2022-05-13 12:43:27 +01:00
d7a0b727fb Merge pull request #7 from reminder-bot/poise-2
Poise 2
2022-05-13 09:00:26 +01:00
66 changed files with 3221 additions and 1645 deletions

1240
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +1,30 @@
[package] [package]
name = "reminder_rs" name = "reminder_rs"
version = "1.6.0-beta3" version = "1.6.5"
authors = ["jellywx <judesouthworth@pm.me>"] authors = ["jellywx <judesouthworth@pm.me>"]
edition = "2018" edition = "2018"
[dependencies] [dependencies]
poise = "0.2" poise = "0.3"
dotenv = "0.15" dotenv = "0.15"
tokio = { version = "1", features = ["process", "full"] } tokio = { version = "1", features = ["process", "full"] }
reqwest = "0.11" reqwest = "0.11"
regex = "1.4" lazy-regex = "2.3.0"
regex = "1.6"
log = "0.4" log = "0.4"
env_logger = "0.8" env_logger = "0.9"
chrono = "0.4" chrono = "0.4"
chrono-tz = { version = "0.5", features = ["serde"] } chrono-tz = { version = "0.6", 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" serde_json = "1.0"
serde_repr = "0.1" serde_repr = "0.1"
rmp-serde = "0.15" rmp-serde = "1.1"
rand = "0.7" rand = "0.8"
levenshtein = "1.0" levenshtein = "1.0"
sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]} sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]}
base64 = "0.13.0" base64 = "0.13"
[dependencies.postman] [dependencies.postman]
path = "postman" path = "postman"

View File

@ -2,13 +2,20 @@
Reminder Bot for Discord. Reminder Bot for Discord.
## How do I use it? ## 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. reminders are paid on the hosted version of the bot. Keep reading if you want to host it yourself.
You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust) You'll need rustc and cargo for compilation. To run, you'll need Python 3 still (due to no suitable replacement for dateparser in Rust)
### Compiling ### Compiling
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 #### Compilation environment variables
These environment variables must be provided when compiling the bot These environment variables must be provided when compiling the bot

View File

@ -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;

View File

@ -32,3 +32,20 @@ CREATE TABLE reminder_template (
); );
ALTER TABLE reminders ADD COLUMN embed_fields JSON; ALTER TABLE reminders ADD COLUMN embed_fields JSON;
update reminders
inner join embed_fields as E
on E.reminder_id = reminders.id
set embed_fields = (
select JSON_ARRAYAGG(
JSON_OBJECT(
'title', E.title,
'value', E.value,
'inline',
if(inline = 1, cast(TRUE as json), cast(FALSE as json))
)
)
from embed_fields
group by reminder_id
having reminder_id = reminders.id
);

View File

@ -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.6", 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"] }

View File

@ -7,7 +7,7 @@ use regex::{Captures, Regex};
use serde::Deserialize; use serde::Deserialize;
use serenity::{ use serenity::{
builder::CreateEmbed, builder::CreateEmbed,
http::{CacheHttp, Http, StatusCode}, http::{CacheHttp, Http, HttpError, StatusCode},
model::{ model::{
channel::{Channel, Embed as SerenityEmbed}, channel::{Channel, Embed as SerenityEmbed},
id::ChannelId, id::ChannelId,
@ -58,10 +58,10 @@ fn fmt_displacement(format: &str, seconds: u64) -> String {
pub fn substitute(string: &str) -> String { pub fn substitute(string: &str) -> String {
let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| { let new = TIMEFROM_REGEX.replace(string, |caps: &Captures| {
let final_time = caps.name("time").unwrap().as_str(); let final_time = caps.name("time").map(|m| m.as_str().parse::<i64>().ok()).flatten();
let format = caps.name("format").unwrap().as_str(); 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 dt = NaiveDateTime::from_timestamp(final_time, 0);
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
@ -81,13 +81,11 @@ pub fn substitute(string: &str) -> String {
TIMENOW_REGEX TIMENOW_REGEX
.replace(&new, |caps: &Captures| { .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 (Some(timezone), Some(format)) = (timezone, format) {
let now = Utc::now().with_timezone(&timezone);
if let Ok(tz) = timezone.parse::<Tz>() {
let format = caps.name("format").unwrap().as_str();
let now = Utc::now().with_timezone(&tz);
now.format(format).to_string() now.format(format).to_string()
} else { } else {
@ -122,7 +120,7 @@ impl Embed {
pool: impl Executor<'_, Database = Database> + Copy, pool: impl Executor<'_, Database = Database> + Copy,
id: u32, id: u32,
) -> Option<Self> { ) -> Option<Self> {
let mut embed = sqlx::query_as!( match sqlx::query_as!(
Self, Self,
r#" r#"
SELECT SELECT
@ -142,8 +140,8 @@ impl Embed {
) )
.fetch_one(pool) .fetch_one(pool)
.await .await
.unwrap(); {
Ok(mut embed) => {
embed.title = substitute(&embed.title); embed.title = substitute(&embed.title);
embed.description = substitute(&embed.description); embed.description = substitute(&embed.description);
embed.footer = substitute(&embed.footer); embed.footer = substitute(&embed.footer);
@ -160,6 +158,14 @@ impl Embed {
} }
} }
Err(e) => {
warn!("Error loading embed from reminder: {:?}", e);
None
}
}
}
pub fn has_content(&self) -> bool { pub fn has_content(&self) -> bool {
if self.title.is_empty() if self.title.is_empty()
&& self.description.is_empty() && self.description.is_empty()
@ -220,7 +226,6 @@ impl Into<CreateEmbed> for Embed {
} }
} }
#[derive(Debug)]
pub struct Reminder { pub struct Reminder {
id: u32, id: u32,
@ -251,9 +256,9 @@ pub struct Reminder {
impl Reminder { impl Reminder {
pub async fn fetch_reminders(pool: impl Executor<'_, Database = Database> + Copy) -> Vec<Self> { pub async fn fetch_reminders(pool: impl Executor<'_, Database = Database> + Copy) -> Vec<Self> {
sqlx::query_as_unchecked!( match sqlx::query_as_unchecked!(
Reminder, Reminder,
" r#"
SELECT SELECT
reminders.`id` AS id, reminders.`id` AS id,
@ -261,9 +266,9 @@ SELECT
channels.`webhook_id` AS webhook_id, channels.`webhook_id` AS webhook_id,
channels.`webhook_token` AS webhook_token, channels.`webhook_token` AS webhook_token,
channels.`paused` AS channel_paused, channels.`paused` AS 'channel_paused',
channels.`paused_until` AS channel_paused_until, channels.`paused_until` AS 'channel_paused_until',
reminders.`enabled` AS enabled, reminders.`enabled` AS 'enabled',
reminders.`tts` AS tts, reminders.`tts` AS tts,
reminders.`pin` AS pin, reminders.`pin` AS pin,
@ -274,7 +279,7 @@ SELECT
reminders.`utc_time` AS 'utc_time', reminders.`utc_time` AS 'utc_time',
reminders.`timezone` AS timezone, reminders.`timezone` AS timezone,
reminders.`restartable` AS restartable, reminders.`restartable` AS restartable,
reminders.`expires` AS expires, reminders.`expires` AS 'expires',
reminders.`interval_seconds` AS 'interval_seconds', reminders.`interval_seconds` AS 'interval_seconds',
reminders.`interval_months` AS 'interval_months', reminders.`interval_months` AS 'interval_months',
@ -287,19 +292,40 @@ INNER JOIN
ON ON
reminders.channel_id = channels.id reminders.channel_id = channels.id
WHERE 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) .fetch_all(pool)
.await .await
.unwrap() {
Ok(reminders) => reminders
.into_iter() .into_iter()
.map(|mut rem| { .map(|mut rem| {
rem.content = substitute(&rem.content); rem.content = substitute(&rem.content);
rem rem
}) })
.collect::<Vec<Self>>() .collect::<Vec<Self>>(),
Err(e) => {
warn!("Could not fetch reminders: {:?}", e);
vec![]
}
}
} }
async fn reset_webhook(&self, pool: impl Executor<'_, Database = Database> + Copy) { 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; let mut updated_reminder_time = self.utc_time;
if let Some(interval) = self.interval_months { 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 // use the second date_add to force return value to datetime
"SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time", "SELECT DATE_ADD(DATE_ADD(?, INTERVAL ? MONTH), INTERVAL 0 SECOND) AS new_time",
updated_reminder_time, updated_reminder_time,
@ -327,9 +353,25 @@ UPDATE channels SET webhook_id = NULL, webhook_token = NULL WHERE channel = ?
) )
.fetch_one(pool) .fetch_one(pool)
.await .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 { if let Some(interval) = self.interval_seconds {
@ -535,15 +577,20 @@ 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::from_u16(404).unwrap()) { if error.status_code() == Some(StatusCode::NOT_FOUND) {
error!("Seeing channel is deleted. Removing reminder"); warn!("Seeing channel is deleted. Removing reminder");
self.force_delete(pool).await;
} else if let HttpError::UnsuccessfulRequest(error) = *error {
if error.error.code == 50007 {
warn!("User cannot receive DMs");
self.force_delete(pool).await; self.force_delete(pool).await;
} else { } else {
self.refresh(pool).await; self.refresh(pool).await;
} }
}
} else { } else {
self.refresh(pool).await; self.refresh(pool).await;
} }

View File

@ -0,0 +1,35 @@
use chrono_tz::TZ_VARIANTS;
use crate::Context;
pub async fn timezone_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
if partial.is_empty() {
ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
} else {
TZ_VARIANTS
.iter()
.filter(|tz| tz.to_string().contains(&partial))
.take(25)
.map(|t| t.to_string())
.collect::<Vec<String>>()
}
}
pub async fn macro_name_autocomplete(ctx: Context<'_>, partial: &str) -> Vec<String> {
sqlx::query!(
"
SELECT name
FROM macro
WHERE
guild_id = (SELECT id FROM guilds WHERE guild = ?)
AND name LIKE CONCAT(?, '%')",
ctx.guild_id().unwrap().0,
partial,
)
.fetch_all(&ctx.data().database)
.await
.unwrap_or_default()
.iter()
.map(|s| s.name.clone())
.collect()
}

View File

@ -0,0 +1,46 @@
use super::super::autocomplete::macro_name_autocomplete;
use crate::{Context, Error};
/// Delete a recorded macro
#[poise::command(
slash_command,
rename = "delete",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "delete_macro"
)]
pub async fn delete_macro(
ctx: Context<'_>,
#[description = "Name of macro to delete"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match sqlx::query!(
"
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
ctx.guild_id().unwrap().0,
name
)
.fetch_one(&ctx.data().database)
.await
{
Ok(row) => {
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
}
Err(sqlx::Error::RowNotFound) => {
ctx.say(format!("Macro \"{}\" not found", name)).await?;
}
Err(e) => {
panic!("{}", e);
}
}
Ok(())
}

View File

@ -0,0 +1,38 @@
use poise::serenity_prelude::CommandType;
use crate::{
commands::autocomplete::macro_name_autocomplete, models::command_macro::guild_command_macro,
Context, Error,
};
/// Add a macro as a slash-command to this server. Enables controlling permissions per-macro.
#[poise::command(
slash_command,
rename = "install",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "install_macro"
)]
pub async fn install_macro(
ctx: Context<'_>,
#[description = "Name of macro to install"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();
if let Some(command_macro) = guild_command_macro(&ctx, &name).await {
guild_id
.create_application_command(&ctx.discord(), |a| {
a.kind(CommandType::ChatInput)
.name(command_macro.name)
.description(command_macro.description.unwrap_or_else(|| "".to_string()))
})
.await?;
ctx.send(|r| r.ephemeral(true).content("Macro installed. Go to Server Settings 🠚 Integrations 🠚 Reminder Bot to configure permissions.")).await?;
} else {
ctx.send(|r| r.ephemeral(true).content("No macro found with that name")).await?;
}
Ok(())
}

View File

@ -0,0 +1,127 @@
use poise::CreateReply;
use crate::{
component_models::pager::{MacroPager, Pager},
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
models::{command_macro::CommandMacro, CtxData},
Context, Error,
};
/// List recorded macros
#[poise::command(
slash_command,
rename = "list",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "list_macro"
)]
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
let macros = ctx.command_macros().await?;
let resp = show_macro_page(&macros, 0);
ctx.send(|m| {
*m = resp;
m
})
.await?;
Ok(())
}
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
let mut skipped_char_count = 0;
macros
.iter()
.map(|m| {
if let Some(description) = &m.description {
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
} else {
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
}
})
.fold(1, |mut pages, p| {
skipped_char_count += p.len();
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
skipped_char_count = p.len();
pages += 1;
}
pages
})
}
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
let pager = MacroPager::new(page);
if macros.is_empty() {
let mut reply = CreateReply::default();
reply.embed(|e| {
e.title("Macros")
.description("No Macros Set Up. Use `/macro record` to get started.")
.color(*THEME_COLOR)
});
return reply;
}
let pages = max_macro_page(macros);
let mut page = page;
if page >= pages {
page = pages - 1;
}
let mut char_count = 0;
let mut skipped_char_count = 0;
let mut skipped_pages = 0;
let display_vec: Vec<String> = macros
.iter()
.map(|m| {
if let Some(description) = &m.description {
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
} else {
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
}
})
.skip_while(|p| {
skipped_char_count += p.len();
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
skipped_char_count = p.len();
skipped_pages += 1;
}
skipped_pages < page
})
.take_while(|p| {
char_count += p.len();
char_count < EMBED_DESCRIPTION_MAX_LENGTH
})
.collect::<Vec<String>>();
let display = display_vec.join("\n");
let mut reply = CreateReply::default();
reply
.embed(|e| {
e.title("Macros")
.description(display)
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
.color(*THEME_COLOR)
})
.components(|comp| {
pager.create_button_row(pages, comp);
comp
});
reply
}

View File

@ -0,0 +1,229 @@
use lazy_regex::regex;
use poise::serenity_prelude::command::CommandOptionType;
use regex::Captures;
use serde_json::{json, Value};
use crate::{models::command_macro::RawCommandMacro, Context, Error, GuildId};
struct Alias {
name: String,
command: String,
}
/// Migrate old $alias reminder commands to macros. Only macro names that are not taken will be used.
#[poise::command(
slash_command,
rename = "migrate",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "migrate_macro"
)]
pub async fn migrate_macro(ctx: Context<'_>) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();
let mut transaction = ctx.data().database.begin().await?;
let aliases = sqlx::query_as!(
Alias,
"SELECT name, command FROM command_aliases WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
guild_id.0
)
.fetch_all(&mut transaction)
.await?;
let mut added_aliases = 0;
for alias in aliases {
match parse_text_command(guild_id, alias.name, &alias.command) {
Some(cmd_macro) => {
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
cmd_macro.guild_id.0,
cmd_macro.name,
cmd_macro.description,
cmd_macro.commands
)
.execute(&mut transaction)
.await?;
added_aliases += 1;
}
None => {}
}
}
transaction.commit().await?;
ctx.send(|b| b.content(format!("Added {} macros.", added_aliases))).await?;
Ok(())
}
fn parse_text_command(
guild_id: GuildId,
alias_name: String,
command: &str,
) -> Option<RawCommandMacro> {
match command.split_once(" ") {
Some((command_word, args)) => {
let command_word = command_word.to_lowercase();
if command_word == "r"
|| command_word == "i"
|| command_word == "remind"
|| command_word == "interval"
{
let matcher = regex!(
r#"(?P<mentions>(?:<@\d+>\s+|<@!\d+>\s+|<#\d+>\s+)*)(?P<time>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+)(?:\s+(?P<interval>(?:(?:\d+)(?:s|m|h|d|))+))?(?:\s+(?P<expires>(?:(?:\d+)(?:s|m|h|d|:|/|-|))+))?\s+(?P<content>.*)"#s
);
match matcher.captures(&args) {
Some(captures) => {
let mut args: Vec<Value> = vec![];
if let Some(group) = captures.name("time") {
let content = group.as_str();
args.push(json!({
"name": "time",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("content") {
let content = group.as_str();
args.push(json!({
"name": "content",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("interval") {
let content = group.as_str();
args.push(json!({
"name": "interval",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("expires") {
let content = group.as_str();
args.push(json!({
"name": "expires",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("mentions") {
let content = group.as_str();
args.push(json!({
"name": "channels",
"value": content,
"type": CommandOptionType::String,
}));
}
Some(RawCommandMacro {
guild_id,
name: alias_name,
description: None,
commands: json!([
{
"command_name": "remind",
"options": args,
}
]),
})
}
None => None,
}
} else if command_word == "n" || command_word == "natural" {
let matcher_primary = regex!(
r#"(?P<time>.*?)(?:\s+)(?:send|say)(?:\s+)(?P<content>.*?)(?:(?:\s+)to(?:\s+)(?P<mentions>((?:<@\d+>)|(?:<@!\d+>)|(?:<#\d+>)|(?:\s+))+))?$"#s
);
let matcher_secondary = regex!(
r#"(?P<msg>.*)(?:\s+)every(?:\s+)(?P<interval>.*?)(?:(?:\s+)(?:until|for)(?:\s+)(?P<expires>.*?))?$"#s
);
match matcher_primary.captures(&args) {
Some(captures) => {
let captures_secondary = matcher_secondary.captures(&args);
let mut args: Vec<Value> = vec![];
if let Some(group) = captures.name("time") {
let content = group.as_str();
args.push(json!({
"name": "time",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("content") {
let content = group.as_str();
args.push(json!({
"name": "content",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) =
captures_secondary.as_ref().and_then(|c: &Captures| c.name("interval"))
{
let content = group.as_str();
args.push(json!({
"name": "interval",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) =
captures_secondary.and_then(|c: Captures| c.name("expires"))
{
let content = group.as_str();
args.push(json!({
"name": "expires",
"value": content,
"type": CommandOptionType::String,
}));
}
if let Some(group) = captures.name("mentions") {
let content = group.as_str();
args.push(json!({
"name": "channels",
"value": content,
"type": CommandOptionType::String,
}));
}
Some(RawCommandMacro {
guild_id,
name: alias_name,
description: None,
commands: json!([
{
"command_name": "remind",
"options": args,
}
]),
})
}
None => None,
}
} else {
None
}
}
None => None,
}
}

View File

@ -0,0 +1,20 @@
use crate::{Context, Error};
pub mod delete;
pub mod install;
pub mod list;
pub mod migrate;
pub mod record;
pub mod run;
/// Record and replay command sequences
#[poise::command(
slash_command,
rename = "macro",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "macro_base"
)]
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}

View File

@ -0,0 +1,139 @@
use std::collections::hash_map::Entry;
use crate::{consts::THEME_COLOR, models::command_macro::CommandMacro, Context, Error};
/// Start recording up to 5 commands to replay
#[poise::command(
slash_command,
rename = "record",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "record_macro"
)]
pub async fn record_macro(
ctx: Context<'_>,
#[description = "Name for the new macro"] name: String,
#[description = "Description for the new macro"] description: Option<String>,
) -> Result<(), Error> {
let guild_id = ctx.guild_id().unwrap();
let row = sqlx::query!(
"
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
guild_id.0,
name
)
.fetch_one(&ctx.data().database)
.await;
if row.is_ok() {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Unique Name Required")
.description(
"A macro already exists under this name.
Please select a unique name for your macro.",
)
.color(*THEME_COLOR)
})
})
.await?;
} else {
let okay = {
let mut lock = ctx.data().recording_macros.write().await;
if let Entry::Vacant(e) = lock.entry((guild_id, ctx.author().id)) {
e.insert(CommandMacro { guild_id, name, description, commands: vec![] });
true
} else {
false
}
};
if okay {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Recording Started")
.description(
"Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands ran as part of recording will be inconsequential",
)
.color(*THEME_COLOR)
})
})
.await?;
} else {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Already Recording")
.description(
"You are already recording a macro in this server.
Please use `/macro finish` to end this recording before starting another.",
)
.color(*THEME_COLOR)
})
})
.await?;
}
}
Ok(())
}
/// Finish current macro recording
#[poise::command(
slash_command,
rename = "finish",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "finish_macro"
)]
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
let key = (ctx.guild_id().unwrap(), ctx.author().id);
{
let lock = ctx.data().recording_macros.read().await;
let contained = lock.get(&key);
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
ctx.send(|m| {
m.embed(|e| {
e.title("No Macro Recorded")
.description("Use `/macro record` to start recording a macro")
.color(*THEME_COLOR)
})
})
.await?;
} else {
let command_macro = contained.unwrap();
let json = serde_json::to_string(&command_macro.commands).unwrap();
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
command_macro.guild_id.0,
command_macro.name,
command_macro.description,
json
)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.send(|m| {
m.embed(|e| {
e.title("Macro Recorded")
.description("Use `/macro run` to execute the macro")
.color(*THEME_COLOR)
})
})
.await?;
}
}
{
let mut lock = ctx.data().recording_macros.write().await;
lock.remove(&key);
}
Ok(())
}

View File

@ -0,0 +1,46 @@
use super::super::autocomplete::macro_name_autocomplete;
use crate::{models::command_macro::guild_command_macro, Context, Data, Error};
/// Run a recorded macro
#[poise::command(
slash_command,
rename = "run",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "run_macro"
)]
pub async fn run_macro(
ctx: poise::ApplicationContext<'_, Data, Error>,
#[description = "Name of macro to run"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match guild_command_macro(&Context::Application(ctx), &name).await {
Some(command_macro) => {
ctx.defer_response(false).await?;
for command in command_macro.commands {
if let Some(action) = command.action {
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
.await
{
Ok(()) => {}
Err(e) => {
println!("{:?}", e);
}
}
} else {
Context::Application(ctx)
.say(format!("Command \"{}\" not found", command.command_name))
.await?;
}
}
}
None => {
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
}
}
Ok(())
}

View File

@ -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
@ -71,7 +72,7 @@ pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
.send(|m| { .send(|m| {
m.ephemeral(true).embed(|e| { m.ephemeral(true).embed(|e| {
e.title("Info") e.title("Info")
.description(format!( .description(
"Help: `/help` "Help: `/help`
**Welcome to Reminder Bot!** **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/ Invite the bot: https://invite.reminder-bot.com/
Use our dashboard: https://reminder-bot.com/", Use our dashboard: https://reminder-bot.com/",
)) )
.footer(footer) .footer(footer)
.color(*THEME_COLOR) .color(*THEME_COLOR)
}) })

View File

@ -1,3 +1,5 @@
pub mod autocomplete;
pub mod command_macro;
pub mod info_cmds; pub mod info_cmds;
pub mod moderation_cmds; pub mod moderation_cmds;
pub mod reminder_cmds; pub mod reminder_cmds;

View File

@ -1,30 +1,9 @@
use chrono::offset::Utc; use chrono::offset::Utc;
use chrono_tz::{Tz, TZ_VARIANTS}; use chrono_tz::{Tz, TZ_VARIANTS};
use levenshtein::levenshtein; use levenshtein::levenshtein;
use poise::CreateReply;
use crate::{ use super::autocomplete::timezone_autocomplete;
component_models::pager::{MacroPager, Pager}, use crate::{consts::THEME_COLOR, models::CtxData, Context, Error};
consts::{EMBED_DESCRIPTION_MAX_LENGTH, THEME_COLOR},
models::{
command_macro::{guild_command_macro, CommandMacro},
CtxData,
},
Context, Data, Error,
};
async fn timezone_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> {
if partial.is_empty() {
ctx.data().popular_timezones.iter().map(|t| t.to_string()).collect::<Vec<String>>()
} else {
TZ_VARIANTS
.iter()
.filter(|tz| tz.to_string().contains(&partial))
.take(25)
.map(|t| t.to_string())
.collect::<Vec<String>>()
}
}
/// Select your timezone /// Select your timezone
#[poise::command(slash_command, identifying_name = "timezone")] #[poise::command(slash_command, identifying_name = "timezone")]
@ -52,7 +31,7 @@ pub async fn timezone(
.description(format!( .description(format!(
"Timezone has been set to **{}**. Your current time should be `{}`", "Timezone has been set to **{}**. Your current time should be `{}`",
timezone, timezone,
now.format("%H:%M").to_string() now.format("%H:%M")
)) ))
.color(*THEME_COLOR) .color(*THEME_COLOR)
}) })
@ -75,10 +54,7 @@ pub async fn timezone(
let fields = filtered_tz.iter().map(|tz| { let fields = filtered_tz.iter().map(|tz| {
( (
tz.to_string(), tz.to_string(),
format!( format!("🕗 `{}`", Utc::now().with_timezone(tz).format("%H:%M")),
"🕗 `{}`",
Utc::now().with_timezone(tz).format("%H:%M").to_string()
),
true, true,
) )
}); });
@ -98,11 +74,7 @@ pub async fn timezone(
} }
} else { } else {
let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| { let popular_timezones_iter = ctx.data().popular_timezones.iter().map(|t| {
( (t.to_string(), format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M")), true)
t.to_string(),
format!("🕗 `{}`", Utc::now().with_timezone(t).format("%H:%M").to_string()),
true,
)
}); });
ctx.send(|m| { ctx.send(|m| {
@ -129,379 +101,81 @@ You may want to use one of the popular timezones below, otherwise click [here](h
Ok(()) Ok(())
} }
async fn macro_name_autocomplete(ctx: Context<'_>, partial: String) -> Vec<String> { /// Configure whether other users can set reminders to your direct messages
sqlx::query!( #[poise::command(slash_command, rename = "dm", identifying_name = "allowed_dm")]
" pub async fn allowed_dm(_ctx: Context<'_>) -> Result<(), Error> {
SELECT name
FROM macro
WHERE
guild_id = (SELECT id FROM guilds WHERE guild = ?)
AND name LIKE CONCAT(?, '%')",
ctx.guild_id().unwrap().0,
partial,
)
.fetch_all(&ctx.data().database)
.await
.unwrap_or(vec![])
.iter()
.map(|s| s.name.clone())
.collect()
}
/// Record and replay command sequences
#[poise::command(
slash_command,
rename = "macro",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "macro_base"
)]
pub async fn macro_base(_ctx: Context<'_>) -> Result<(), Error> {
Ok(()) Ok(())
} }
/// Start recording up to 5 commands to replay /// 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( #[poise::command(
slash_command, slash_command,
rename = "record", identifying_name = "webhook_url",
guild_only = true, required_permissions = "ADMINISTRATOR"
default_member_permissions = "MANAGE_GUILD",
identifying_name = "record_macro"
)] )]
pub async fn record_macro( pub async fn webhook(ctx: Context<'_>) -> Result<(), Error> {
ctx: Context<'_>, match ctx.channel_data().await {
#[description = "Name for the new macro"] name: String, Ok(data) => {
#[description = "Description for the new macro"] description: Option<String>, if let (Some(id), Some(token)) = (data.webhook_id, data.webhook_token) {
) -> Result<(), Error> { let _ = ctx
let guild_id = ctx.guild_id().unwrap(); .send(|b| {
b.ephemeral(true).content(format!(
let row = sqlx::query!( "**Warning!**
" This link can be used by users to anonymously send messages, with or without permissions.
SELECT 1 as _e FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?", Do not share it!
guild_id.0, || https://discord.com/api/webhooks/{}/{} ||",
name id, token,
) ))
.fetch_one(&ctx.data().database) })
.await; .await;
if row.is_ok() {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Unique Name Required")
.description(
"A macro already exists under this name.
Please select a unique name for your macro.",
)
.color(*THEME_COLOR)
})
})
.await?;
} else { } else {
let okay = { let _ = ctx.say("No webhook configured on this channel.").await;
let mut lock = ctx.data().recording_macros.write().await;
if lock.contains_key(&(guild_id, ctx.author().id)) {
false
} else {
lock.insert(
(guild_id, ctx.author().id),
CommandMacro { guild_id, name, description, commands: vec![] },
);
true
} }
}; }
Err(_) => {
if okay { let _ = ctx.say("No webhook configured on this channel.").await;
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Recording Started")
.description(
"Run up to 5 commands, or type `/macro finish` to stop at any point.
Any commands ran as part of recording will be inconsequential",
)
.color(*THEME_COLOR)
})
})
.await?;
} else {
ctx.send(|m| {
m.ephemeral(true).embed(|e| {
e.title("Macro Already Recording")
.description(
"You are already recording a macro in this server.
Please use `/macro finish` to end this recording before starting another.",
)
.color(*THEME_COLOR)
})
})
.await?;
} }
} }
Ok(()) Ok(())
} }
/// Finish current macro recording
#[poise::command(
slash_command,
rename = "finish",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "finish_macro"
)]
pub async fn finish_macro(ctx: Context<'_>) -> Result<(), Error> {
let key = (ctx.guild_id().unwrap(), ctx.author().id);
{
let lock = ctx.data().recording_macros.read().await;
let contained = lock.get(&key);
if contained.map_or(true, |cmacro| cmacro.commands.is_empty()) {
ctx.send(|m| {
m.embed(|e| {
e.title("No Macro Recorded")
.description("Use `/macro record` to start recording a macro")
.color(*THEME_COLOR)
})
})
.await?;
} else {
let command_macro = contained.unwrap();
let json = serde_json::to_string(&command_macro.commands).unwrap();
sqlx::query!(
"INSERT INTO macro (guild_id, name, description, commands) VALUES ((SELECT id FROM guilds WHERE guild = ?), ?, ?, ?)",
command_macro.guild_id.0,
command_macro.name,
command_macro.description,
json
)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.send(|m| {
m.embed(|e| {
e.title("Macro Recorded")
.description("Use `/macro run` to execute the macro")
.color(*THEME_COLOR)
})
})
.await?;
}
}
{
let mut lock = ctx.data().recording_macros.write().await;
lock.remove(&key);
}
Ok(())
}
/// List recorded macros
#[poise::command(
slash_command,
rename = "list",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "list_macro"
)]
pub async fn list_macro(ctx: Context<'_>) -> Result<(), Error> {
let macros = ctx.command_macros().await?;
let resp = show_macro_page(&macros, 0);
ctx.send(|m| {
*m = resp;
m
})
.await?;
Ok(())
}
/// Run a recorded macro
#[poise::command(
slash_command,
rename = "run",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "run_macro"
)]
pub async fn run_macro(
ctx: poise::ApplicationContext<'_, Data, Error>,
#[description = "Name of macro to run"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match guild_command_macro(&Context::Application(ctx), &name).await {
Some(command_macro) => {
ctx.defer_response(false).await?;
for command in command_macro.commands {
if let Some(action) = command.action {
match (action)(poise::ApplicationContext { args: &command.options, ..ctx })
.await
{
Ok(()) => {}
Err(e) => {
println!("{:?}", e);
}
}
} else {
Context::Application(ctx)
.say(format!("Command \"{}\" not found", command.command_name))
.await?;
}
}
}
None => {
Context::Application(ctx).say(format!("Macro \"{}\" not found", name)).await?;
}
}
Ok(())
}
/// Delete a recorded macro
#[poise::command(
slash_command,
rename = "delete",
guild_only = true,
default_member_permissions = "MANAGE_GUILD",
identifying_name = "delete_macro"
)]
pub async fn delete_macro(
ctx: Context<'_>,
#[description = "Name of macro to delete"]
#[autocomplete = "macro_name_autocomplete"]
name: String,
) -> Result<(), Error> {
match sqlx::query!(
"
SELECT id FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND name = ?",
ctx.guild_id().unwrap().0,
name
)
.fetch_one(&ctx.data().database)
.await
{
Ok(row) => {
sqlx::query!("DELETE FROM macro WHERE id = ?", row.id)
.execute(&ctx.data().database)
.await
.unwrap();
ctx.say(format!("Macro \"{}\" deleted", name)).await?;
}
Err(sqlx::Error::RowNotFound) => {
ctx.say(format!("Macro \"{}\" not found", name)).await?;
}
Err(e) => {
panic!("{}", e);
}
}
Ok(())
}
pub fn max_macro_page<U, E>(macros: &[CommandMacro<U, E>]) -> usize {
let mut skipped_char_count = 0;
macros
.iter()
.map(|m| {
if let Some(description) = &m.description {
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
} else {
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
}
})
.fold(1, |mut pages, p| {
skipped_char_count += p.len();
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
skipped_char_count = p.len();
pages += 1;
}
pages
})
}
pub fn show_macro_page<U, E>(macros: &[CommandMacro<U, E>], page: usize) -> CreateReply {
let pager = MacroPager::new(page);
if macros.is_empty() {
let mut reply = CreateReply::default();
reply.embed(|e| {
e.title("Macros")
.description("No Macros Set Up. Use `/macro record` to get started.")
.color(*THEME_COLOR)
});
return reply;
}
let pages = max_macro_page(macros);
let mut page = page;
if page >= pages {
page = pages - 1;
}
let mut char_count = 0;
let mut skipped_char_count = 0;
let mut skipped_pages = 0;
let display_vec: Vec<String> = macros
.iter()
.map(|m| {
if let Some(description) = &m.description {
format!("**{}**\n- *{}*\n- Has {} commands", m.name, description, m.commands.len())
} else {
format!("**{}**\n- Has {} commands", m.name, m.commands.len())
}
})
.skip_while(|p| {
skipped_char_count += p.len();
if skipped_char_count > EMBED_DESCRIPTION_MAX_LENGTH {
skipped_char_count = p.len();
skipped_pages += 1;
}
skipped_pages < page
})
.take_while(|p| {
char_count += p.len();
char_count < EMBED_DESCRIPTION_MAX_LENGTH
})
.collect::<Vec<String>>();
let display = display_vec.join("\n");
let mut reply = CreateReply::default();
reply
.embed(|e| {
e.title("Macros")
.description(display)
.footer(|f| f.text(format!("Page {} of {}", page + 1, pages)))
.color(*THEME_COLOR)
})
.components(|comp| {
pager.create_button_row(pages, comp);
comp
});
reply
}

View File

@ -8,14 +8,17 @@ use chrono::NaiveDateTime;
use chrono_tz::Tz; use chrono_tz::Tz;
use num_integer::Integer; use num_integer::Integer;
use poise::{ use poise::{
serenity::{builder::CreateEmbed, model::channel::Channel}, serenity_prelude::{
CreateReply, builder::CreateEmbed, component::ButtonStyle, model::channel::Channel, ReactionType,
},
AutocompleteChoice, CreateReply, Modal,
}; };
use super::autocomplete::timezone_autocomplete;
use crate::{ use crate::{
component_models::{ component_models::{
pager::{DelPager, LookPager, Pager}, pager::{DelPager, LookPager, Pager},
ComponentDataModel, DelSelector, ComponentDataModel, DelSelector, UndoReminder,
}, },
consts::{ consts::{
EMBED_DESCRIPTION_MAX_LENGTH, HOUR, MINUTE, REGEX_CHANNEL_USER, SELECT_MAX_ENTRIES, EMBED_DESCRIPTION_MAX_LENGTH, HOUR, MINUTE, REGEX_CHANNEL_USER, SELECT_MAX_ENTRIES,
@ -35,7 +38,7 @@ use crate::{
}, },
time_parser::natural_parser, time_parser::natural_parser,
utils::{check_guild_subscription, check_subscription}, utils::{check_guild_subscription, check_subscription},
Context, Error, ApplicationContext, Context, Error,
}; };
/// Pause all reminders on the current channel until a certain time or indefinitely /// Pause all reminders on the current channel until a certain time or indefinitely
@ -500,8 +503,7 @@ pub async fn start_timer(
if count >= 25 { if count >= 25 {
ctx.say("You already have 25 timers. Please delete some timers before creating a new one") ctx.say("You already have 25 timers. Please delete some timers before creating a new one")
.await?; .await?;
} else { } else if name.len() <= 32 {
if name.len() <= 32 {
Timer::create(&name, owner, &ctx.data().database).await; Timer::create(&name, owner, &ctx.data().database).await;
ctx.say("Created a new timer").await?; ctx.say("Created a new timer").await?;
@ -512,7 +514,6 @@ pub async fn start_timer(
)) ))
.await?; .await?;
} }
}
Ok(()) Ok(())
} }
@ -549,23 +550,93 @@ pub async fn delete_timer(
Ok(()) Ok(())
} }
/// Create a new reminder async fn multiline_autocomplete(
_ctx: Context<'_>,
partial: &str,
) -> Vec<AutocompleteChoice<String>> {
if partial.is_empty() {
vec![AutocompleteChoice { name: "Multiline content...".to_string(), value: "".to_string() }]
} else {
vec![
AutocompleteChoice { name: partial.to_string(), value: partial.to_string() },
AutocompleteChoice { name: "Multiline content...".to_string(), value: "".to_string() },
]
}
}
#[derive(poise::Modal)]
#[name = "Reminder"]
struct ContentModal {
#[name = "Content"]
#[placeholder = "Message..."]
#[paragraph]
#[max_length = 2000]
content: String,
}
/// Create a reminder. Press "+5 more" for other options. A modal will open if "content" is not provided
#[poise::command( #[poise::command(
slash_command, slash_command,
identifying_name = "remind", identifying_name = "remind",
default_member_permissions = "MANAGE_GUILD" default_member_permissions = "MANAGE_GUILD"
)] )]
pub async fn remind( pub async fn remind(
ctx: Context<'_>, ctx: ApplicationContext<'_>,
#[description = "A description of the time to set the reminder for"] time: String, #[description = "A description of the time to set the reminder for"] time: String,
#[description = "The message content to send"] content: String, #[description = "The message content to send"]
#[autocomplete = "multiline_autocomplete"]
content: String,
#[description = "Channel or user mentions to set the reminder for"] channels: Option<String>, #[description = "Channel or user mentions to set the reminder for"] channels: Option<String>,
#[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"] #[description = "(Patreon only) Time to wait before repeating the reminder. Leave blank for one-shot reminder"]
interval: Option<String>, interval: Option<String>,
#[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop sending"] #[description = "(Patreon only) For repeating reminders, the time at which the reminder will stop repeating"]
expires: Option<String>, expires: Option<String>,
#[description = "Set the TTS flag on the reminder message, similar to the /tts command"] #[description = "Set the TTS flag on the reminder message, similar to the /tts command"]
tts: Option<bool>, tts: Option<bool>,
#[description = "Set a timezone override for this reminder only"]
#[autocomplete = "timezone_autocomplete"]
timezone: Option<String>,
) -> Result<(), Error> {
let tz = timezone.map(|t| t.parse::<Tz>().ok()).flatten();
if content.is_empty() {
let data = ContentModal::execute(ctx).await?;
create_reminder(
Context::Application(ctx),
time,
data.content,
channels,
interval,
expires,
tts,
tz,
)
.await
} else {
create_reminder(
Context::Application(ctx),
time,
content,
channels,
interval,
expires,
tts,
tz,
)
.await
}
}
async fn create_reminder(
ctx: Context<'_>,
time: String,
content: String,
channels: Option<String>,
interval: Option<String>,
expires: Option<String>,
tts: Option<bool>,
timezone: Option<Tz>,
) -> Result<(), Error> { ) -> Result<(), Error> {
if interval.is_none() && expires.is_some() { if interval.is_none() && expires.is_some() {
ctx.say("`expires` can only be used with `interval`").await?; ctx.say("`expires` can only be used with `interval`").await?;
@ -576,7 +647,7 @@ pub async fn remind(
ctx.defer().await?; ctx.defer().await?;
let user_data = ctx.author_data().await.unwrap(); let user_data = ctx.author_data().await.unwrap();
let timezone = ctx.timezone().await; let timezone = timezone.unwrap_or(ctx.timezone().await);
let time = natural_parser(&time, &timezone.to_string()).await; let time = natural_parser(&time, &timezone.to_string()).await;
@ -589,8 +660,7 @@ pub async fn remind(
}; };
let scopes = { let scopes = {
let list = let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default();
channels.map(|arg| parse_mention_list(&arg.to_string())).unwrap_or_default();
if list.is_empty() { if list.is_empty() {
if ctx.guild_id().is_some() { if ctx.guild_id().is_some() {
@ -610,7 +680,7 @@ pub async fn remind(
{ {
( (
parse_duration(repeat) parse_duration(repeat)
.or_else(|_| parse_duration(&format!("1 {}", repeat.to_string()))) .or_else(|_| parse_duration(&format!("1 {}", repeat)))
.ok(), .ok(),
{ {
if let Some(arg) = &expires { if let Some(arg) = &expires {
@ -653,8 +723,39 @@ pub async fn remind(
let (errors, successes) = builder.build().await; let (errors, successes) = builder.build().await;
let embed = create_response(successes, errors, time); let embed = create_response(&successes, &errors, time);
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?;
} else {
ctx.send(|m| { ctx.send(|m| {
m.embed(|c| { m.embed(|c| {
*c = embed; *c = embed;
@ -664,6 +765,8 @@ pub async fn remind(
.await?; .await?;
} }
} }
}
None => { None => {
ctx.say("Time could not be processed").await?; ctx.say("Time could not be processed").await?;
} }
@ -673,8 +776,8 @@ pub async fn remind(
} }
fn create_response( fn create_response(
successes: HashSet<ReminderScope>, successes: &HashSet<(Reminder, ReminderScope)>,
errors: HashSet<ReminderError>, errors: &HashSet<ReminderError>,
time: i64, time: i64,
) -> CreateEmbed { ) -> CreateEmbed {
let success_part = match successes.len() { let success_part = match successes.len() {
@ -682,7 +785,8 @@ fn create_response(
n => format!( n => format!(
"Reminder{s} for {locations} set for <t:{offset}:R>", "Reminder{s} for {locations} set for <t:{offset}:R>",
s = if n > 1 { "s" } else { "" }, 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 offset = time
), ),
}; };

View File

@ -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 = ?), ?)",
@ -336,7 +340,7 @@ pub fn show_todo_page(
opt.create_option(|o| { opt.create_option(|o| {
o.label(format!("Mark {} complete", count + first_num)) o.label(format!("Mark {} complete", count + first_num))
.value(id) .value(id)
.description(disp.split_once(" ").unwrap_or(("", "")).1) .description(disp.split_once(' ').unwrap_or(("", "")).1)
}); });
} }

View File

@ -3,13 +3,19 @@ pub(crate) mod pager;
use std::io::Cursor; use std::io::Cursor;
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::serenity::{ use log::warn;
use poise::{
serenity_prelude as serenity,
serenity_prelude::{
builder::CreateEmbed, builder::CreateEmbed,
client::Context,
model::{ model::{
application::interaction::{
message_component::MessageComponentInteraction, InteractionResponseType,
MessageFlags,
},
channel::Channel, channel::Channel,
interactions::{message_component::MessageComponentInteraction, InteractionResponseType}, },
prelude::InteractionApplicationCommandCallbackDataFlags, Context,
}, },
}; };
use rmp_serde::Serializer; use rmp_serde::Serializer;
@ -17,7 +23,7 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
commands::{ commands::{
moderation_cmds::{max_macro_page, show_macro_page}, command_macro::list::{max_macro_page, show_macro_page},
reminder_cmds::{max_delete_page, show_delete_page}, reminder_cmds::{max_delete_page, show_delete_page},
todo_cmds::{max_todo_page, show_todo_page}, todo_cmds::{max_todo_page, show_todo_page},
}, },
@ -38,6 +44,7 @@ pub enum ComponentDataModel {
DelSelector(DelSelector), DelSelector(DelSelector),
TodoSelector(TodoSelector), TodoSelector(TodoSelector),
MacroPager(MacroPager), MacroPager(MacroPager),
UndoReminder(UndoReminder),
} }
impl ComponentDataModel { impl ComponentDataModel {
@ -253,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")
}) })
@ -307,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")
}) })
@ -334,6 +341,70 @@ WHERE guilds.guild = ?",
}) })
.await; .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 channel_id: Option<u64>,
pub guild_id: Option<u64>, pub guild_id: Option<u64>,
} }
#[derive(Serialize, Deserialize)]
pub struct UndoReminder {
pub user_id: serenity::UserId,
pub reminder_id: u32,
}

View File

@ -1,7 +1,7 @@
// 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_prelude::{
builder::CreateComponents, model::interactions::message_component::ButtonStyle, builder::CreateComponents, model::application::component::ButtonStyle,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_repr::*; use serde_repr::*;

View File

@ -12,7 +12,7 @@ pub const MACRO_MAX_COMMANDS: usize = 5;
use std::{collections::HashSet, env, iter::FromIterator}; use std::{collections::HashSet, env, iter::FromIterator};
use poise::serenity::model::prelude::AttachmentType; use poise::serenity_prelude::model::prelude::AttachmentType;
use regex::Regex; use regex::Regex;
lazy_static! { lazy_static! {
@ -36,15 +36,11 @@ lazy_static! {
); );
pub static ref CNC_GUILD: Option<u64> = pub static ref CNC_GUILD: Option<u64> =
env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten(); env::var("CNC_GUILD").map(|var| var.parse::<u64>().ok()).ok().flatten();
pub static ref MIN_INTERVAL: i64 = env::var("MIN_INTERVAL") pub static ref MIN_INTERVAL: i64 =
.ok() env::var("MIN_INTERVAL").ok().and_then(|inner| inner.parse::<i64>().ok()).unwrap_or(600);
.map(|inner| inner.parse::<i64>().ok())
.flatten()
.unwrap_or(600);
pub static ref MAX_TIME: i64 = env::var("MAX_TIME") pub static ref MAX_TIME: i64 = env::var("MAX_TIME")
.ok() .ok()
.map(|inner| inner.parse::<i64>().ok()) .and_then(|inner| inner.parse::<i64>().ok())
.flatten()
.unwrap_or(60 * 60 * 24 * 365 * 50); .unwrap_or(60 * 60 * 24 * 365 * 50);
pub static ref LOCAL_TIMEZONE: String = pub static ref LOCAL_TIMEZONE: String =
env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string()); env::var("LOCAL_TIMEZONE").unwrap_or_else(|_| "UTC".to_string());

View File

@ -1,12 +1,12 @@
use std::{collections::HashMap, env, sync::atomic::Ordering}; use std::{collections::HashMap, env};
use log::{error, info, warn}; use log::error;
use poise::{ use poise::{
serenity::{model::interactions::Interaction, utils::shard_id},
serenity_prelude as serenity, serenity_prelude as serenity,
serenity_prelude::{model::application::interaction::Interaction, utils::shard_id},
}; };
use crate::{component_models::ComponentDataModel, Data, Error}; use crate::{component_models::ComponentDataModel, Data, Error, THEME_COLOR};
pub async fn listener( pub async fn listener(
ctx: &serenity::Context, ctx: &serenity::Context,
@ -14,44 +14,8 @@ pub async fn listener(
data: &Data, data: &Data,
) -> Result<(), Error> { ) -> Result<(), Error> {
match event { match event {
poise::Event::CacheReady { .. } => { poise::Event::Ready { .. } => {
info!("Cache Ready! Preparing extra processes"); ctx.set_activity(serenity::Activity::watching("for /remind")).await;
if !data.is_loop_running.load(Ordering::Relaxed) {
let kill_tx = data.broadcast.clone();
let kill_recv = data.broadcast.subscribe();
let ctx1 = ctx.clone();
let ctx2 = ctx.clone();
let pool1 = data.database.clone();
let pool2 = data.database.clone();
let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string());
if !run_settings.contains("postman") {
tokio::spawn(async move {
match postman::initialize(kill_recv, ctx1, &pool1).await {
Ok(_) => {}
Err(e) => {
error!("postman exiting: {}", e);
}
};
});
} else {
warn!("Not running postman")
}
if !run_settings.contains("web") {
tokio::spawn(async move {
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
});
} else {
warn!("Not running web")
}
data.is_loop_running.swap(true, Ordering::Relaxed);
}
} }
poise::Event::ChannelDelete { channel } => { poise::Event::ChannelDelete { channel } => {
sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64()) sqlx::query!("DELETE FROM channels WHERE channel = ?", channel.id.as_u64())
@ -63,46 +27,36 @@ pub async fn listener(
if *is_new { if *is_new {
let guild_id = guild.id.as_u64().to_owned(); let guild_id = guild.id.as_u64().to_owned();
sqlx::query!("INSERT INTO guilds (guild) VALUES (?)", guild_id) sqlx::query!("INSERT IGNORE INTO guilds (guild) VALUES (?)", guild_id)
.execute(&data.database) .execute(&data.database)
.await .await?;
.unwrap();
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") { if let Err(e) = post_guild_count(ctx, &data.http, guild_id).await {
let shard_count = ctx.cache.shard_count(); error!("DiscordBotList: {:?}", e);
let current_shard_id = shard_id(guild_id, shard_count);
let guild_count = ctx
.cache
.guilds()
.iter()
.filter(|g| {
shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id
})
.count() as u64;
let mut hm = HashMap::new();
hm.insert("server_count", guild_count);
hm.insert("shard_id", current_shard_id);
hm.insert("shard_count", shard_count);
let response = data
.http
.post(
format!(
"https://top.gg/api/bots/{}/stats",
ctx.cache.current_user_id().as_u64()
)
.as_str(),
)
.header("Authorization", token)
.json(&hm)
.send()
.await;
if let Err(res) = response {
println!("DiscordBots Response: {:?}", res);
} }
let default_channel = guild.default_channel_guaranteed();
if let Some(default_channel) = default_channel {
default_channel
.send_message(&ctx, |m| {
m.embed(|e| {
e.title("Thank you for adding Reminder Bot!").description(
"To get started:
• Set your timezone with `/timezone`
• Set up permissions in Server Settings 🠚 Integrations 🠚 Reminder Bot (desktop only)
• Create your first reminder with `/remind`
__Support__
If you need any support, please come and ask us! Join our [Discord](https://discord.jellywx.com).
__Updates__
To stay up to date on the latest features and fixes, join our [Discord](https://discord.jellywx.com).
",
).color(*THEME_COLOR)
})
})
.await?;
} }
} }
} }
@ -111,16 +65,50 @@ pub async fn listener(
.execute(&data.database) .execute(&data.database)
.await; .await;
} }
poise::Event::InteractionCreate { interaction } => match interaction { poise::Event::InteractionCreate { interaction } => {
Interaction::MessageComponent(component) => { if let Interaction::MessageComponent(component) = interaction {
let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id); let component_model = ComponentDataModel::from_custom_id(&component.data.custom_id);
component_model.act(ctx, data, component).await; component_model.act(ctx, data, component).await;
} }
_ => {} }
},
_ => {} _ => {}
} }
Ok(()) Ok(())
} }
async fn post_guild_count(
ctx: &serenity::Context,
http: &reqwest::Client,
guild_id: u64,
) -> Result<(), reqwest::Error> {
if let Ok(token) = env::var("DISCORDBOTS_TOKEN") {
let shard_count = ctx.cache.shard_count();
let current_shard_id = shard_id(guild_id, shard_count);
let guild_count = ctx
.cache
.guilds()
.iter()
.filter(|g| shard_id(g.as_u64().to_owned(), shard_count) == current_shard_id)
.count() as u64;
let mut hm = HashMap::new();
hm.insert("server_count", guild_count);
hm.insert("shard_id", current_shard_id);
hm.insert("shard_count", shard_count);
http.post(
format!("https://top.gg/api/bots/{}/stats", ctx.cache.current_user_id().as_u64())
.as_str(),
)
.header("Authorization", token)
.json(&hm)
.send()
.await
.map(|_| ())
} else {
Ok(())
}
}

View File

@ -1,9 +1,14 @@
use poise::serenity::model::channel::Channel; use poise::{
serenity_prelude::model::channel::Channel, ApplicationCommandOrAutocompleteInteraction,
};
use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error}; use crate::{consts::MACRO_MAX_COMMANDS, models::command_macro::RecordedCommand, Context, Error};
async fn macro_check(ctx: Context<'_>) -> bool { async fn recording_macro_check(ctx: Context<'_>) -> bool {
if let Context::Application(app_ctx) = ctx { if let Context::Application(app_ctx) = ctx {
if let ApplicationCommandOrAutocompleteInteraction::ApplicationCommand(_) =
app_ctx.interaction
{
if let Some(guild_id) = ctx.guild_id() { if let Some(guild_id) = ctx.guild_id() {
if ctx.command().identifying_name != "finish_macro" { if ctx.command().identifying_name != "finish_macro" {
let mut lock = ctx.data().recording_macros.write().await; let mut lock = ctx.data().recording_macros.write().await;
@ -30,21 +35,16 @@ async fn macro_check(ctx: Context<'_>) -> bool {
.await; .await;
} }
false return false;
} else {
true
} }
} else {
true
} }
} else {
true
} }
} else {
true
} }
} }
true
}
async fn check_self_permissions(ctx: Context<'_>) -> bool { async fn check_self_permissions(ctx: Context<'_>) -> bool {
if let Some(guild) = ctx.guild() { if let Some(guild) = ctx.guild() {
let user_id = ctx.discord().cache.current_user_id(); let user_id = ctx.discord().cache.current_user_id();
@ -56,14 +56,13 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
let (view_channel, send_messages, embed_links) = ctx let (view_channel, send_messages, embed_links) = ctx
.channel_id() .channel_id()
.to_channel_cached(&ctx.discord()) .to_channel_cached(&ctx.discord())
.map(|c| { .and_then(|c| {
if let Channel::Guild(channel) = c { if let Channel::Guild(channel) = c {
channel.permissions_for_user(&ctx.discord(), user_id).ok() channel.permissions_for_user(&ctx.discord(), user_id).ok()
} else { } else {
None None
} }
}) })
.flatten()
.map_or((false, false, false), |p| { .map_or((false, false, false), |p| {
(p.view_channel(), p.send_messages(), p.embed_links()) (p.view_channel(), p.send_messages(), p.embed_links())
}); });
@ -96,5 +95,5 @@ async fn check_self_permissions(ctx: Context<'_>) -> bool {
} }
pub async fn all_checks(ctx: Context<'_>) -> Result<bool, Error> { pub async fn all_checks(ctx: Context<'_>) -> Result<bool, Error> {
Ok(macro_check(ctx).await && check_self_permissions(ctx).await) Ok(recording_macro_check(ctx).await && check_self_permissions(ctx).await)
} }

View File

@ -75,7 +75,7 @@ impl fmt::Display for Error {
match self { match self {
Error::InvalidCharacter(offset) => write!(f, "invalid character at {}", offset), Error::InvalidCharacter(offset) => write!(f, "invalid character at {}", offset),
Error::NumberExpected(offset) => write!(f, "expected number 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,) write!(f, "time unit needed, for example {0}sec or {0}ms", value,)
} }
Error::UnknownUnit { unit, .. } => { Error::UnknownUnit { unit, .. } => {
@ -162,11 +162,11 @@ impl<'a> Parser<'a> {
}; };
let mut nsec = self.current.2 + nsec; let mut nsec = self.current.2 + nsec;
if nsec > 1_000_000_000 { if nsec > 1_000_000_000 {
sec = sec + nsec / 1_000_000_000; sec += nsec / 1_000_000_000;
nsec %= 1_000_000_000; nsec %= 1_000_000_000;
} }
sec = self.current.1 + sec; sec += self.current.1;
month = self.current.0 + month; month += self.current.0;
self.current = (month, sec, nsec); self.current = (month, sec, nsec);

View File

@ -1,4 +1,5 @@
#![feature(int_roundings)] #![feature(int_roundings)]
#[macro_use] #[macro_use]
extern crate lazy_static; extern crate lazy_static;
@ -17,20 +18,20 @@ use std::{
env, env,
error::Error as StdError, error::Error as StdError,
fmt::{Debug, Display, Formatter}, fmt::{Debug, Display, Formatter},
sync::atomic::AtomicBool,
}; };
use chrono_tz::Tz; use chrono_tz::Tz;
use dotenv::dotenv; use dotenv::dotenv;
use poise::serenity::model::{ use log::{error, warn};
gateway::{Activity, GatewayIntents}, use poise::serenity_prelude::model::{
gateway::GatewayIntents,
id::{GuildId, UserId}, id::{GuildId, UserId},
}; };
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use tokio::sync::{broadcast, broadcast::Sender, RwLock}; use tokio::sync::{broadcast, broadcast::Sender, RwLock};
use crate::{ use crate::{
commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds}, commands::{command_macro, info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
consts::THEME_COLOR, consts::THEME_COLOR,
event_handlers::listener, event_handlers::listener,
hooks::all_checks, hooks::all_checks,
@ -42,17 +43,17 @@ type Database = MySql;
type Error = Box<dyn std::error::Error + Send + Sync>; type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>; type Context<'a> = poise::Context<'a, Data, Error>;
type ApplicationContext<'a> = poise::ApplicationContext<'a, Data, Error>;
pub struct Data { pub struct Data {
database: Pool<Database>, database: Pool<Database>,
http: reqwest::Client, http: reqwest::Client,
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>, recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>,
popular_timezones: Vec<Tz>, popular_timezones: Vec<Tz>,
is_loop_running: AtomicBool, _broadcast: Sender<()>,
broadcast: Sender<()>,
} }
impl std::fmt::Debug for Data { impl Debug for Data {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Data {{ .. }}") write!(f, "Data {{ .. }}")
} }
@ -102,13 +103,23 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
moderation_cmds::timezone(), moderation_cmds::timezone(),
poise::Command { poise::Command {
subcommands: vec![ subcommands: vec![
moderation_cmds::delete_macro(), moderation_cmds::set_allowed_dm(),
moderation_cmds::finish_macro(), moderation_cmds::unset_allowed_dm(),
moderation_cmds::list_macro(),
moderation_cmds::record_macro(),
moderation_cmds::run_macro(),
], ],
..moderation_cmds::macro_base() ..moderation_cmds::allowed_dm()
},
moderation_cmds::webhook(),
poise::Command {
subcommands: vec![
command_macro::delete::delete_macro(),
command_macro::record::finish_macro(),
command_macro::list::list_macro(),
command_macro::record::record_macro(),
command_macro::run::run_macro(),
command_macro::migrate::migrate_macro(),
command_macro::install::install_macro(),
],
..command_macro::macro_base()
}, },
reminder_cmds::pause(), reminder_cmds::pause(),
reminder_cmds::offset(), reminder_cmds::offset(),
@ -167,29 +178,50 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
.map(|t| t.timezone.parse::<Tz>().unwrap()) .map(|t| t.timezone.parse::<Tz>().unwrap())
.collect::<Vec<Tz>>(); .collect::<Vec<Tz>>();
poise::Framework::build() poise::Framework::builder()
.token(discord_token) .token(discord_token)
.user_data_setup(move |ctx, _bot, framework| { .user_data_setup(move |ctx, _bot, framework| {
Box::pin(async move { Box::pin(async move {
ctx.set_activity(Activity::watching("for /remind")).await; register_application_commands(ctx, framework, None).await.unwrap();
register_application_commands( let kill_tx = tx.clone();
ctx, let kill_recv = tx.subscribe();
framework,
env::var("DEBUG_GUILD") let ctx1 = ctx.clone();
.map(|inner| GuildId(inner.parse().expect("DEBUG_GUILD not valid"))) let ctx2 = ctx.clone();
.ok(),
) let pool1 = database.clone();
.await let pool2 = database.clone();
.unwrap();
let run_settings = env::var("DONTRUN").unwrap_or_else(|_| "".to_string());
if !run_settings.contains("postman") {
tokio::spawn(async move {
match postman::initialize(kill_recv, ctx1, &pool1).await {
Ok(_) => {}
Err(e) => {
error!("postman exiting: {}", e);
}
};
});
} else {
warn!("Not running postman");
}
if !run_settings.contains("web") {
tokio::spawn(async move {
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
});
} else {
warn!("Not running web");
}
Ok(Data { Ok(Data {
http: reqwest::Client::new(), http: reqwest::Client::new(),
database, database,
popular_timezones, popular_timezones,
recording_macros: Default::default(), recording_macros: Default::default(),
is_loop_running: AtomicBool::new(false), _broadcast: tx,
broadcast: tx,
}) })
}) })
}) })

View File

@ -1,5 +1,5 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use poise::serenity::model::channel::Channel; use poise::serenity_prelude::model::channel::Channel;
use sqlx::MySqlPool; use sqlx::MySqlPool;
pub struct ChannelData { pub struct ChannelData {

View File

@ -1,15 +1,16 @@
use poise::serenity::model::{ use poise::serenity_prelude::model::{
id::GuildId, interactions::application_command::ApplicationCommandInteractionDataOption, application::interaction::application_command::CommandDataOption, id::GuildId,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{Context, Data, Error}; use crate::{Context, Data, Error};
fn default_none<U, E>() -> Option< type Func<U, E> = for<'a> fn(
for<'a> fn(
poise::ApplicationContext<'a, U, E>, poise::ApplicationContext<'a, U, E>,
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>, ) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, U, E>>>;
> {
fn default_none<U, E>() -> Option<Func<U, E>> {
None None
} }
@ -17,13 +18,9 @@ fn default_none<U, E>() -> Option<
pub struct RecordedCommand<U, E> { pub struct RecordedCommand<U, E> {
#[serde(skip)] #[serde(skip)]
#[serde(default = "default_none::<U, E>")] #[serde(default = "default_none::<U, E>")]
pub action: Option< pub action: Option<Func<U, E>>,
for<'a> fn(
poise::ApplicationContext<'a, U, E>,
) -> poise::BoxFuture<'a, Result<(), poise::FrameworkError<'a, 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> {
@ -33,6 +30,14 @@ pub struct CommandMacro<U, E> {
pub commands: Vec<RecordedCommand<U, E>>, pub commands: Vec<RecordedCommand<U, E>>,
} }
pub struct RawCommandMacro {
pub guild_id: GuildId,
pub name: String,
pub description: Option<String>,
pub commands: Value,
}
/// Get a macro by name form a guild.
pub async fn guild_command_macro( pub async fn guild_command_macro(
ctx: &Context<'_>, ctx: &Context<'_>,
name: &str, name: &str,
@ -59,7 +64,7 @@ SELECT * FROM macro WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?) AND
.iter() .iter()
.find(|c| c.identifying_name == recorded_command.command_name); .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 { let command_macro = CommandMacro {

View File

@ -5,7 +5,7 @@ pub mod timer;
pub mod user_data; pub mod user_data;
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::serenity::{async_trait, model::id::UserId}; use poise::serenity_prelude::{async_trait, model::id::UserId};
use crate::{ use crate::{
models::{channel_data::ChannelData, user_data::UserData}, models::{channel_data::ChannelData, user_data::UserData},

View File

@ -2,7 +2,7 @@ use std::{collections::HashSet, fmt::Display};
use chrono::{Duration, NaiveDateTime, Utc}; use chrono::{Duration, NaiveDateTime, Utc};
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::serenity::{ use poise::serenity_prelude::{
http::CacheHttp, http::CacheHttp,
model::{ model::{
channel::GuildChannel, channel::GuildChannel,
@ -126,7 +126,7 @@ INSERT INTO reminders (
.await .await
.unwrap(); .unwrap();
Ok(Reminder::from_uid(&self.pool, self.uid).await.unwrap()) Ok(Reminder::from_uid(&self.pool, &self.uid).await.unwrap())
} }
} }
@ -207,7 +207,7 @@ impl<'a> MultiReminderBuilder<'a> {
self.scopes = scopes; 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 errors = HashSet::new();
let mut ok_locs = 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 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)
} }
@ -309,8 +313,8 @@ impl<'a> MultiReminderBuilder<'a> {
}; };
match builder.build().await { match builder.build().await {
Ok(_) => { Ok(r) => {
ok_locs.insert(scope); ok_locs.insert((r, scope));
} }
Err(e) => { Err(e) => {
errors.insert(e); errors.insert(e);

View File

@ -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),
} }
} }

View File

@ -1,4 +1,4 @@
use poise::serenity::model::id::ChannelId; use poise::serenity_prelude::model::id::ChannelId;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_repr::*; use serde_repr::*;

View File

@ -4,11 +4,13 @@ pub mod errors;
mod helper; mod helper;
pub mod look_flags; pub mod look_flags;
use std::hash::{Hash, Hasher};
use chrono::{NaiveDateTime, TimeZone}; use chrono::{NaiveDateTime, TimeZone};
use chrono_tz::Tz; use chrono_tz::Tz;
use poise::{ use poise::serenity_prelude::{
serenity::model::id::{ChannelId, GuildId, UserId}, model::id::{ChannelId, GuildId, UserId},
serenity_prelude::Cache, Cache,
}; };
use sqlx::Executor; use sqlx::Executor;
@ -32,11 +34,22 @@ pub struct Reminder {
pub set_by: Option<u64>, pub set_by: Option<u64>,
} }
impl Hash for Reminder {
fn hash<H: Hasher>(&self, state: &mut H) {
self.uid.hash(state);
}
}
impl PartialEq<Self> for Reminder {
fn eq(&self, other: &Self) -> bool {
self.uid == other.uid
}
}
impl Eq for Reminder {}
impl Reminder { impl Reminder {
pub async fn from_uid( pub async fn from_uid(pool: impl Executor<'_, Database = Database>, uid: &str) -> Option<Self> {
pool: impl Executor<'_, Database = Database>,
uid: String,
) -> Option<Self> {
sqlx::query_as_unchecked!( sqlx::query_as_unchecked!(
Self, Self,
" "
@ -72,6 +85,42 @@ WHERE
.ok() .ok()
} }
pub async fn from_id(pool: impl Executor<'_, Database = Database>, id: u32) -> Option<Self> {
sqlx::query_as_unchecked!(
Self,
"
SELECT
reminders.id,
reminders.uid,
channels.channel,
reminders.utc_time,
reminders.interval_seconds,
reminders.interval_months,
reminders.expires,
reminders.enabled,
reminders.content,
reminders.embed_description,
users.user AS set_by
FROM
reminders
INNER JOIN
channels
ON
reminders.channel_id = channels.id
LEFT JOIN
users
ON
reminders.set_by = users.id
WHERE
reminders.id = ?
",
id
)
.fetch_one(pool)
.await
.ok()
}
pub async fn from_channel<C: Into<ChannelId>>( pub async fn from_channel<C: Into<ChannelId>>(
pool: impl Executor<'_, Database = Database>, pool: impl Executor<'_, Database = Database>,
channel_id: C, channel_id: C,
@ -240,6 +289,13 @@ WHERE
.unwrap() .unwrap()
} }
pub async fn delete(
&self,
db: impl Executor<'_, Database = Database>,
) -> Result<(), sqlx::Error> {
sqlx::query!("DELETE FROM reminders WHERE uid = ?", self.uid).execute(db).await.map(|_| ())
}
pub fn display_content(&self) -> &str { pub fn display_content(&self) -> &str {
if self.content.is_empty() { if self.content.is_empty() {
&self.embed_description &self.embed_description
@ -254,10 +310,7 @@ WHERE
count + 1, count + 1,
self.display_content(), self.display_content(),
self.channel, self.channel,
timezone timezone.timestamp(self.utc_time.timestamp(), 0).format("%Y-%m-%d %H:%M:%S")
.timestamp(self.utc_time.timestamp(), 0)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
) )
} }

View File

@ -1,6 +1,6 @@
use chrono_tz::Tz; use chrono_tz::Tz;
use log::error; use log::error;
use poise::serenity::{http::CacheHttp, model::id::UserId}; use poise::serenity_prelude::{http::CacheHttp, model::id::UserId};
use sqlx::MySqlPool; use sqlx::MySqlPool;
use crate::consts::LOCAL_TIMEZONE; use crate::consts::LOCAL_TIMEZONE;
@ -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
@ -71,7 +72,7 @@ INSERT IGNORE INTO channels (channel) VALUES (?)
sqlx::query!( 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, user_id.0,
dm_channel.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!( 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)

View File

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

View File

@ -1,10 +1,11 @@
use poise::{ use poise::{
serenity::{ serenity_prelude as serenity,
serenity_prelude::{
builder::CreateApplicationCommands, builder::CreateApplicationCommands,
http::CacheHttp, http::CacheHttp,
interaction::MessageFlags,
model::id::{GuildId, UserId}, model::id::{GuildId, UserId},
}, },
serenity_prelude as serenity,
}; };
use crate::{ use crate::{
@ -13,10 +14,10 @@ use crate::{
}; };
pub async fn register_application_commands( pub async fn register_application_commands(
ctx: &poise::serenity::client::Context, ctx: &serenity::Context,
framework: &poise::Framework<Data, Error>, framework: &poise::Framework<Data, Error>,
guild_id: Option<GuildId>, guild_id: Option<GuildId>,
) -> Result<(), poise::serenity::Error> { ) -> Result<(), serenity::Error> {
let mut commands_builder = CreateApplicationCommands::default(); let mut commands_builder = CreateApplicationCommands::default();
let commands = &framework.options().commands; let commands = &framework.options().commands;
for command in commands { for command in commands {
@ -27,7 +28,7 @@ pub async fn register_application_commands(
commands_builder.add_application_command(context_menu_command); commands_builder.add_application_command(context_menu_command);
} }
} }
let commands_builder = poise::serenity::json::Value::Array(commands_builder.0); let commands_builder = poise::serenity_prelude::json::Value::Array(commands_builder.0);
if let Some(guild_id) = guild_id { if let Some(guild_id) = guild_id {
ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?; ctx.http.create_guild_application_commands(guild_id.0, &commands_builder).await?;
@ -102,6 +103,6 @@ pub fn send_as_initial_response(
}); });
} }
if ephemeral { if ephemeral {
f.flags(serenity::InteractionApplicationCommandCallbackDataFlags::EPHEMERAL); f.flags(MessageFlags::EPHEMERAL);
} }
} }

View File

@ -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.6", 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"

View File

@ -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(

View File

@ -126,6 +126,9 @@ pub async fn initialize(
routes::help_timers, routes::help_timers,
routes::help_todo_lists, routes::help_todo_lists,
routes::help_macros, routes::help_macros,
routes::help_intervals,
routes::help_dashboard,
routes::help_iemanager,
], ],
) )
.mount("/login", routes![routes::login::discord_login, routes::login::discord_callback]) .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::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()

View File

@ -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 }))
};
}

View 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")
}
}
}

View File

@ -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,233 +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,
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 .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")] #[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 {
@ -543,7 +341,6 @@ pub async fn get_reminders(id: u64, ctx: &State<Context>, pool: &State<Pool<MySq
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_months, reminders.interval_months,
reminders.name, reminders.name,
reminders.pin,
reminders.restartable, reminders.restartable,
reminders.tts, reminders.tts,
reminders.uid, reminders.uid,
@ -556,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!([]))
} }
} }
} }
@ -577,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.[
@ -600,7 +397,6 @@ pub async fn edit_reminder(
interval_seconds, interval_seconds,
interval_months, interval_months,
name, name,
pin,
restartable, restartable,
tts, tts,
username, username,
@ -619,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(
@ -632,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();
@ -660,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"}));
} }
} }
} }
@ -687,7 +485,6 @@ pub async fn edit_reminder(
reminders.interval_seconds, reminders.interval_seconds,
reminders.interval_months, reminders.interval_months,
reminders.name, reminders.name,
reminders.pin,
reminders.restartable, reminders.restartable,
reminders.tts, reminders.tts,
reminders.uid, reminders.uid,
@ -701,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"]}))
} }
} }
} }
@ -715,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"}))
} }
} }
} }

View File

@ -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,
@ -97,7 +135,6 @@ pub struct Reminder {
interval_months: Option<u32>, interval_months: Option<u32>,
#[serde(default = "name_default")] #[serde(default = "name_default")]
name: String, name: String,
pin: bool,
restartable: bool, restartable: bool,
tts: bool, tts: bool,
#[serde(default)] #[serde(default)]
@ -106,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,
@ -151,8 +218,6 @@ pub struct PatchReminder {
#[serde(default)] #[serde(default)]
name: Unset<String>, name: Unset<String>,
#[serde(default)] #[serde(default)]
pin: Unset<bool>,
#[serde(default)]
restartable: Unset<bool>, restartable: Unset<bool>,
#[serde(default)] #[serde(default)]
tts: Unset<bool>, tts: Unset<bool>,
@ -213,8 +278,8 @@ mod base64s {
where where
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
let string = String::deserialize(deserializer)?; let string = Option::<String>::deserialize(deserializer)?;
Some(base64::decode(string).map_err(de::Error::custom)).transpose() Some(string.map(|b| base64::decode(b).map_err(de::Error::custom))).flatten().transpose()
} }
} }
@ -223,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)

View File

@ -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();

View File

@ -86,3 +86,21 @@ pub async fn help_macros() -> Template {
let map: HashMap<&str, String> = HashMap::new(); let map: HashMap<&str, String> = HashMap::new();
Template::render("support/macros", &map) 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)
}

View File

@ -288,6 +288,10 @@ textarea, input {
width: 100%; width: 100%;
} }
input.default-width {
width: initial;
}
.message-input:placeholder-shown { .message-input:placeholder-shown {
border-top: none; border-top: none;
border-left: none; border-left: none;

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -12,12 +12,25 @@ 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 roles = []; let roles = [];
let templates = {}; let templates = {};
let mentions = new Tribute({
values: [],
allowSpaces: true,
selectTemplate: (item) => {
return `<@&${item.original.value}>`;
},
});
let globalPatreon = false; let globalPatreon = false;
let guildPatreon = false;
function guildId() { function guildId() {
return document.querySelector(".guildList a.is-active").dataset["guild"]; return document.querySelector(".guildList a.is-active").dataset["guild"];
@ -31,18 +44,6 @@ function intToColor(i) {
return `#${i.toString(16).padStart(6, "0")}`; 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) { function switch_pane(selector) {
document.querySelectorAll("aside a").forEach((el) => { document.querySelectorAll("aside a").forEach((el) => {
el.classList.remove("is-active"); el.classList.remove("is-active");
@ -52,8 +53,6 @@ function switch_pane(selector) {
}); });
document.getElementById(selector).classList.remove("is-hidden"); document.getElementById(selector).classList.remove("is-hidden");
resize_textareas();
} }
function update_select(sel) { function update_select(sel) {
@ -78,6 +77,18 @@ function reset_guild_pane() {
.forEach((opt) => opt.remove()); .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) { function fetch_roles(guild_id) {
fetch(`/dashboard/api/guild/${guild_id}/roles`) fetch(`/dashboard/api/guild/${guild_id}/roles`)
.then((response) => response.json()) .then((response) => response.json())
@ -85,7 +96,16 @@ function fetch_roles(guild_id) {
if (data.error) { if (data.error) {
show_error(data.error); show_error(data.error);
} else { } 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"] = newFrame.querySelector(".reminderContent").dataset["uid"] =
reminder["uid"]; reminder["uid"];
mentions.attach(newFrame.querySelector("textarea"));
deserialize_reminder(reminder, newFrame, "load"); deserialize_reminder(reminder, newFrame, "load");
$reminderBox.appendChild(newFrame); $reminderBox.appendChild(newFrame);
@ -299,7 +321,6 @@ async function serialize_reminder(node, mode) {
interval_seconds: mode !== "template" ? interval.seconds : null, interval_seconds: mode !== "template" ? interval.seconds : null,
interval_months: mode !== "template" ? interval.months : null, interval_months: mode !== "template" ? interval.months : null,
name: node.querySelector('input[name="name"]').value, name: node.querySelector('input[name="name"]').value,
pin: node.querySelector('input[name="pin"]').checked,
tts: node.querySelector('input[name="tts"]').checked, tts: node.querySelector('input[name="tts"]').checked,
username: node.querySelector('input[name="username"]').value, username: node.querySelector('input[name="username"]').value,
utc_time: utc_time, utc_time: utc_time,
@ -370,6 +391,10 @@ function deserialize_reminder(reminder, frame, mode) {
document.addEventListener("guildSwitched", async (e) => { document.addEventListener("guildSwitched", async (e) => {
$loader.classList.remove("is-hidden"); $loader.classList.remove("is-hidden");
document
.querySelectorAll(".patreon-only")
.forEach((el) => el.classList.add("is-locked"));
let $anchor = document.querySelector( let $anchor = document.querySelector(
`.switch-pane[data-guild="${e.detail.guild_id}"]` `.switch-pane[data-guild="${e.detail.guild_id}"]`
); );
@ -378,6 +403,12 @@ document.addEventListener("guildSwitched", async (e) => {
reset_guild_pane(); reset_guild_pane();
$anchor.classList.add("is-active"); $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_roles(e.detail.guild_id);
fetch_templates(e.detail.guild_id); fetch_templates(e.detail.guild_id);
await fetch_channels(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"); $loader.classList.add("is-hidden");
}); });
@ -531,6 +560,8 @@ document.querySelectorAll(".show-modal").forEach((element) => {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
$loader.classList.remove("is-hidden"); $loader.classList.remove("is-hidden");
mentions.attach(document.querySelectorAll("textarea"));
document.querySelectorAll(".navbar-burger").forEach((el) => { document.querySelectorAll(".navbar-burger").forEach((el) => {
el.addEventListener("click", () => { el.addEventListener("click", () => {
const target = el.dataset["target"]; const target = el.dataset["target"];
@ -569,6 +600,8 @@ document.addEventListener("DOMContentLoaded", () => {
const $template = document.getElementById("guildListEntry"); const $template = document.getElementById("guildListEntry");
for (let guild of data) { for (let guild of data) {
guildNames[guild.id] = guild.name;
document.querySelectorAll(".guildList").forEach((element) => { document.querySelectorAll(".guildList").forEach((element) => {
const $clone = $template.content.cloneNode(true); const $clone = $template.content.cloneNode(true);
const $anchor = $clone.querySelector("a"); const $anchor = $clone.querySelector("a");
@ -585,11 +618,7 @@ document.addEventListener("DOMContentLoaded", () => {
$anchor.addEventListener("click", async (e) => { $anchor.addEventListener("click", async (e) => {
e.preventDefault(); e.preventDefault();
window.history.pushState( window.history.pushState({}, "", `/dashboard/${guild.id}`);
{},
"",
`/dashboard/${guild.id}?name=${guild.name}`
);
const event = new CustomEvent("guildSwitched", { const event = new CustomEvent("guildSwitched", {
detail: { detail: {
guild_name: guild.name, guild_name: guild.name,
@ -607,8 +636,8 @@ document.addEventListener("DOMContentLoaded", () => {
const matches = window.location.href.match(/dashboard\/(\d+)/); const matches = window.location.href.match(/dashboard\/(\d+)/);
if (matches) { if (matches) {
let id = matches[1]; let id = matches[1];
let name = let name = guildNames[id];
new URLSearchParams(window.location.search).get("name") || id;
const event = new CustomEvent("guildSwitched", { const event = new CustomEvent("guildSwitched", {
detail: { detail: {
guild_name: name, 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.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",
@ -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) => { fileInput.forEach((element) => {
element.addEventListener("change", () => { element.addEventListener("change", () => {
@ -901,7 +963,6 @@ document.addEventListener("DOMNodeInserted", () => {
}); });
check_embed_fields(); check_embed_fields();
resize_textareas();
}); });
document.addEventListener("click", (ev) => { document.addEventListener("click", (ev) => {

View File

@ -27,8 +27,10 @@
<link rel="stylesheet" href="/static/css/font.css"> <link rel="stylesheet" href="/static/css/font.css">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/dtsel.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="/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> </head>
<body> <body>
<nav class="navbar is-spaced is-size-4 is-hidden-desktop dashboard-navbar" role="navigation" <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> <button class="delete close-modal" aria-label="close"></button>
</header> </header>
<section class="modal-card-body"> <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 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>

View File

@ -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 %}

View File

@ -93,6 +93,65 @@
</article> </article>
</div> </div>
</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> </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 %} {% endblock %}

View File

@ -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>

View File

@ -158,9 +158,9 @@
</div> </div>
<div class="collapses"> <div class="collapses">
<div class="is-locked"> <div class="patreon-only">
<div class="field"> <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="control intervalSelector" style="min-width: 400px;" >
<div class="input interval-group"> <div class="input interval-group">
<div class="interval-group-left"> <div class="interval-group-left">
@ -206,11 +206,6 @@
<label class="label">Enable TTS <input type="checkbox" name="tts"></label> <label class="label">Enable TTS <input type="checkbox" name="tts"></label>
</div> </div>
</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="column has-text-centered">
<div class="file is-small is-boxed"> <div class="file is-small is-boxed">
<label class="file-label"> <label class="file-label">

View 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 %}

View File

@ -33,6 +33,24 @@
</div> </div>
</section> </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"> <section class="hero is-small">
<div class="hero-body"> <div class="hero-body">
<div class="container"> <div class="container">

View 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 %}

View 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 %}

View File

@ -3,8 +3,8 @@
{% block init %} {% block init %}
{% set title = "Support" %} {% set title = "Support" %}
{% set page_title = "Timezone Help" %} {% set page_title = "Timezones" %}
{% set page_subtitle = "Timezones are tricky. Read on for help" %} {% set page_subtitle = "" %}
{% set show_invite = false %} {% set show_invite = false %}
{% endblock %} {% endblock %}
@ -31,7 +31,7 @@
<div class="container"> <div class="container">
<p class="title">Selecting your timezone automatically</p> <p class="title">Selecting your timezone automatically</p>
<p class="content"> <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 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 navigation menu (mobile). Then, choose 'Set Bot Timezone' to set Reminder Bot to use your browser's
timezone. timezone.

View File

@ -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, and should be considered a guideline for appropriate The Terms of Service may be updated. Notice will be provided via the Discord server. You
behaviour. should consider the Terms of Service to be a strong for appropriate behaviour.
</p> </p>
</div> </div>
</section> </section>
@ -35,7 +36,14 @@
<ul class="is-size-5 pl-6"> <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>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 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> </ul>
</div> </div>
</section> </section>