Compare commits
	
		
			31 Commits
		
	
	
		
			poise-2
			...
			jellywx/ma
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e2bf23f194 | |||
| 8f8235a86e | |||
| c8f646a8fa | |||
| ecaa382a1e | |||
| 8991198fd3 | |||
| 
						 | 
					f20b95a482 | ||
| 
						 | 
					8dd7dc6409 | ||
| 
						 | 
					c799d10727 | ||
| 
						 | 
					ceb6fb7b12 | ||
| 
						 | 
					6708abdb0f | ||
| 
						 | 
					a38f6024c1 | ||
| 
						 | 
					7d8748e3ef | ||
| 
						 | 
					bb3386c4e8 | ||
| 
						 | 
					25b84880a5 | ||
| 
						 | 
					7b6e967a5d | ||
| 
						 | 
					2781f2923e | ||
| 
						 | 
					03f08f0a18 | ||
| 
						 | 
					79c86d43f2 | ||
| 
						 | 
					e19af54caf | ||
| 
						 | 
					f4213c6a83 | ||
| 
						 | 
					f56db14720 | ||
| 
						 | 
					6f7d0f67b3 | ||
| 
						 | 
					bfc2d71ca0 | ||
| 
						 | 
					8eb46f1f23 | ||
| 
						 | 
					c4087bf569 | ||
| 
						 | 
					f25cfed8d7 | ||
| 
						 | 
					d2a8bd1982 | ||
| 
						 | 
					437ee6b446 | ||
| 
						 | 
					7d43aa5918 | ||
| 
						 | 
					8bad95510d | ||
| 
						 | 
					d7a0b727fb | 
							
								
								
									
										1240
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
							
								
								
									
										19
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						@@ -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"
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										11
									
								
								README.md
									
									
									
									
									
								
							
							
						
						@@ -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
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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"] }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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;
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										35
									
								
								src/commands/autocomplete.rs
									
									
									
									
									
										Normal 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()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										46
									
								
								src/commands/command_macro/delete.rs
									
									
									
									
									
										Normal 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(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										38
									
								
								src/commands/command_macro/install.rs
									
									
									
									
									
										Normal 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(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										127
									
								
								src/commands/command_macro/list.rs
									
									
									
									
									
										Normal 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(¯os, 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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										229
									
								
								src/commands/command_macro/migrate.rs
									
									
									
									
									
										Normal 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,
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										20
									
								
								src/commands/command_macro/mod.rs
									
									
									
									
									
										Normal 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(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										139
									
								
								src/commands/command_macro/record.rs
									
									
									
									
									
										Normal 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(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										46
									
								
								src/commands/command_macro/run.rs
									
									
									
									
									
										Normal 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(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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)
 | 
				
			||||||
            })
 | 
					            })
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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(¯os, 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
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
                                });
 | 
					                                });
 | 
				
			||||||
                            }
 | 
					                            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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::*;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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());
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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(())
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										27
									
								
								src/hooks.rs
									
									
									
									
									
								
							
							
						
						@@ -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)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										84
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						@@ -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,
 | 
					 | 
				
			||||||
                })
 | 
					                })
 | 
				
			||||||
            })
 | 
					            })
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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},
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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),
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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::*;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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()
 | 
					 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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()
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										13
									
								
								src/utils.rs
									
									
									
									
									
								
							
							
						
						@@ -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);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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(
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,7 @@
 | 
				
			|||||||
macro_rules! check_length {
 | 
					macro_rules! check_length {
 | 
				
			||||||
    ($max:ident, $field:expr) => {
 | 
					    ($max:ident, $field:expr) => {
 | 
				
			||||||
        if $field.len() > $max {
 | 
					        if $field.len() > $max {
 | 
				
			||||||
            return json!({ "error": format!("{} exceeded", stringify!($max)) });
 | 
					            return Err(json!({ "error": format!("{} exceeded", stringify!($max)) }));
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
    ($max:ident, $field:expr, $($fields:expr),+) => {
 | 
					    ($max:ident, $field:expr, $($fields:expr),+) => {
 | 
				
			||||||
@@ -25,7 +25,7 @@ macro_rules! check_length_opt {
 | 
				
			|||||||
macro_rules! check_url {
 | 
					macro_rules! check_url {
 | 
				
			||||||
    ($field:expr) => {
 | 
					    ($field:expr) => {
 | 
				
			||||||
        if !($field.starts_with("http://") || $field.starts_with("https://")) {
 | 
					        if !($field.starts_with("http://") || $field.starts_with("https://")) {
 | 
				
			||||||
            return json!({ "error": "URL invalid" });
 | 
					            return Err(json!({ "error": "URL invalid" }));
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
    ($field:expr, $($fields:expr),+) => {
 | 
					    ($field:expr, $($fields:expr),+) => {
 | 
				
			||||||
@@ -60,7 +60,7 @@ macro_rules! check_authorization {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
                        match member {
 | 
					                        match member {
 | 
				
			||||||
                            Err(_) => {
 | 
					                            Err(_) => {
 | 
				
			||||||
                                return json!({"error": "User not in guild"})
 | 
					                                return Err(json!({"error": "User not in guild"}));
 | 
				
			||||||
                            }
 | 
					                            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                            Ok(_) => {}
 | 
					                            Ok(_) => {}
 | 
				
			||||||
@@ -68,13 +68,13 @@ macro_rules! check_authorization {
 | 
				
			|||||||
                    }
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    None => {
 | 
					                    None => {
 | 
				
			||||||
                        return json!({"error": "Bot not in guild"})
 | 
					                        return Err(json!({"error": "Bot not in guild"}));
 | 
				
			||||||
                    }
 | 
					                    }
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            None => {
 | 
					            None => {
 | 
				
			||||||
                return json!({"error": "User not authorized"});
 | 
					                return Err(json!({"error": "User not authorized"}));
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -117,3 +117,9 @@ macro_rules! update_field {
 | 
				
			|||||||
        update_field!($pool, $error, $reminder.[$($fields),+]);
 | 
					        update_field!($pool, $error, $reminder.[$($fields),+]);
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					macro_rules! json_err {
 | 
				
			||||||
 | 
					    ($message:expr) => {
 | 
				
			||||||
 | 
					        Err(json!({ "error": $message }))
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										430
									
								
								web/src/routes/dashboard/export.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,430 @@
 | 
				
			|||||||
 | 
					use csv::{QuoteStyle, WriterBuilder};
 | 
				
			||||||
 | 
					use rocket::{
 | 
				
			||||||
 | 
					    http::CookieJar,
 | 
				
			||||||
 | 
					    serde::json::{json, serde_json, Json},
 | 
				
			||||||
 | 
					    State,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use serenity::{
 | 
				
			||||||
 | 
					    client::Context,
 | 
				
			||||||
 | 
					    model::id::{ChannelId, GuildId},
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					use sqlx::{MySql, Pool};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					use crate::routes::dashboard::{
 | 
				
			||||||
 | 
					    create_reminder, generate_uid, ImportBody, JsonResult, Reminder, ReminderCsv,
 | 
				
			||||||
 | 
					    ReminderTemplateCsv, TodoCsv,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/api/guild/<id>/export/reminders")]
 | 
				
			||||||
 | 
					pub async fn export_reminders(
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    check_authorization!(cookies, ctx.inner(), id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let channels_res = GuildId(id).channels(&ctx.inner()).await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match channels_res {
 | 
				
			||||||
 | 
					        Ok(channels) => {
 | 
				
			||||||
 | 
					            let channels = channels
 | 
				
			||||||
 | 
					                .keys()
 | 
				
			||||||
 | 
					                .into_iter()
 | 
				
			||||||
 | 
					                .map(|k| k.as_u64().to_string())
 | 
				
			||||||
 | 
					                .collect::<Vec<String>>()
 | 
				
			||||||
 | 
					                .join(",");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            let result = sqlx::query_as_unchecked!(
 | 
				
			||||||
 | 
					                ReminderCsv,
 | 
				
			||||||
 | 
					                "SELECT
 | 
				
			||||||
 | 
					                 reminders.attachment,
 | 
				
			||||||
 | 
					                 reminders.attachment_name,
 | 
				
			||||||
 | 
					                 reminders.avatar,
 | 
				
			||||||
 | 
					                 CONCAT('#', channels.channel) AS channel,
 | 
				
			||||||
 | 
					                 reminders.content,
 | 
				
			||||||
 | 
					                 reminders.embed_author,
 | 
				
			||||||
 | 
					                 reminders.embed_author_url,
 | 
				
			||||||
 | 
					                 reminders.embed_color,
 | 
				
			||||||
 | 
					                 reminders.embed_description,
 | 
				
			||||||
 | 
					                 reminders.embed_footer,
 | 
				
			||||||
 | 
					                 reminders.embed_footer_url,
 | 
				
			||||||
 | 
					                 reminders.embed_image_url,
 | 
				
			||||||
 | 
					                 reminders.embed_thumbnail_url,
 | 
				
			||||||
 | 
					                 reminders.embed_title,
 | 
				
			||||||
 | 
					                 reminders.embed_fields,
 | 
				
			||||||
 | 
					                 reminders.enabled,
 | 
				
			||||||
 | 
					                 reminders.expires,
 | 
				
			||||||
 | 
					                 reminders.interval_seconds,
 | 
				
			||||||
 | 
					                 reminders.interval_months,
 | 
				
			||||||
 | 
					                 reminders.name,
 | 
				
			||||||
 | 
					                 reminders.restartable,
 | 
				
			||||||
 | 
					                 reminders.tts,
 | 
				
			||||||
 | 
					                 reminders.username,
 | 
				
			||||||
 | 
					                 reminders.utc_time
 | 
				
			||||||
 | 
					                FROM reminders
 | 
				
			||||||
 | 
					                LEFT JOIN channels ON channels.id = reminders.channel_id
 | 
				
			||||||
 | 
					                WHERE FIND_IN_SET(channels.channel, ?)",
 | 
				
			||||||
 | 
					                channels
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					            .await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            match result {
 | 
				
			||||||
 | 
					                Ok(reminders) => {
 | 
				
			||||||
 | 
					                    reminders.iter().for_each(|reminder| {
 | 
				
			||||||
 | 
					                        csv_writer.serialize(reminder).unwrap();
 | 
				
			||||||
 | 
					                    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    match csv_writer.into_inner() {
 | 
				
			||||||
 | 
					                        Ok(inner) => match String::from_utf8(inner) {
 | 
				
			||||||
 | 
					                            Ok(encoded) => Ok(json!({ "body": encoded })),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                            Err(e) => {
 | 
				
			||||||
 | 
					                                warn!("Failed to write UTF-8: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                Err(json!({"error": "Failed to write UTF-8"}))
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        Err(e) => {
 | 
				
			||||||
 | 
					                            warn!("Failed to extract CSV: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                            Err(json!({"error": "Failed to extract CSV"}))
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                Err(e) => {
 | 
				
			||||||
 | 
					                    warn!("Failed to complete SQL query: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    Err(json!({"error": "Failed to query reminders"}))
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            warn!("Could not fetch channels from {}: {:?}", id, e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Err(json!({"error": "Failed to get guild channels"}))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[put("/api/guild/<id>/export/reminders", data = "<body>")]
 | 
				
			||||||
 | 
					pub async fn import_reminders(
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    body: Json<ImportBody>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    check_authorization!(cookies, ctx.inner(), id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let user_id =
 | 
				
			||||||
 | 
					        cookies.get_private("userid").map(|c| c.value().parse::<u64>().ok()).flatten().unwrap();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match base64::decode(&body.body) {
 | 
				
			||||||
 | 
					        Ok(body) => {
 | 
				
			||||||
 | 
					            let mut reader = csv::Reader::from_reader(body.as_slice());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            for result in reader.deserialize::<ReminderCsv>() {
 | 
				
			||||||
 | 
					                match result {
 | 
				
			||||||
 | 
					                    Ok(record) => {
 | 
				
			||||||
 | 
					                        let channel_id = record.channel.split_at(1).1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        match channel_id.parse::<u64>() {
 | 
				
			||||||
 | 
					                            Ok(channel_id) => {
 | 
				
			||||||
 | 
					                                let reminder = Reminder {
 | 
				
			||||||
 | 
					                                    attachment: record.attachment,
 | 
				
			||||||
 | 
					                                    attachment_name: record.attachment_name,
 | 
				
			||||||
 | 
					                                    avatar: record.avatar,
 | 
				
			||||||
 | 
					                                    channel: channel_id,
 | 
				
			||||||
 | 
					                                    content: record.content,
 | 
				
			||||||
 | 
					                                    embed_author: record.embed_author,
 | 
				
			||||||
 | 
					                                    embed_author_url: record.embed_author_url,
 | 
				
			||||||
 | 
					                                    embed_color: record.embed_color,
 | 
				
			||||||
 | 
					                                    embed_description: record.embed_description,
 | 
				
			||||||
 | 
					                                    embed_footer: record.embed_footer,
 | 
				
			||||||
 | 
					                                    embed_footer_url: record.embed_footer_url,
 | 
				
			||||||
 | 
					                                    embed_image_url: record.embed_image_url,
 | 
				
			||||||
 | 
					                                    embed_thumbnail_url: record.embed_thumbnail_url,
 | 
				
			||||||
 | 
					                                    embed_title: record.embed_title,
 | 
				
			||||||
 | 
					                                    embed_fields: record
 | 
				
			||||||
 | 
					                                        .embed_fields
 | 
				
			||||||
 | 
					                                        .map(|s| serde_json::from_str(&s).ok())
 | 
				
			||||||
 | 
					                                        .flatten(),
 | 
				
			||||||
 | 
					                                    enabled: record.enabled,
 | 
				
			||||||
 | 
					                                    expires: record.expires,
 | 
				
			||||||
 | 
					                                    interval_seconds: record.interval_seconds,
 | 
				
			||||||
 | 
					                                    interval_months: record.interval_months,
 | 
				
			||||||
 | 
					                                    name: record.name,
 | 
				
			||||||
 | 
					                                    restartable: record.restartable,
 | 
				
			||||||
 | 
					                                    tts: record.tts,
 | 
				
			||||||
 | 
					                                    uid: generate_uid(),
 | 
				
			||||||
 | 
					                                    username: record.username,
 | 
				
			||||||
 | 
					                                    utc_time: record.utc_time,
 | 
				
			||||||
 | 
					                                };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                create_reminder(
 | 
				
			||||||
 | 
					                                    ctx.inner(),
 | 
				
			||||||
 | 
					                                    pool.inner(),
 | 
				
			||||||
 | 
					                                    GuildId(id),
 | 
				
			||||||
 | 
					                                    UserId(user_id),
 | 
				
			||||||
 | 
					                                    reminder,
 | 
				
			||||||
 | 
					                                )
 | 
				
			||||||
 | 
					                                .await?;
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                            Err(_) => {
 | 
				
			||||||
 | 
					                                return json_err!(format!(
 | 
				
			||||||
 | 
					                                    "Failed to parse channel {}",
 | 
				
			||||||
 | 
					                                    channel_id
 | 
				
			||||||
 | 
					                                ));
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    Err(e) => {
 | 
				
			||||||
 | 
					                        warn!("Couldn't deserialize CSV row: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        return json_err!("Deserialize error. Aborted");
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Ok(json!({}))
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Err(_) => {
 | 
				
			||||||
 | 
					            json_err!("Malformed base64")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/api/guild/<id>/export/todos")]
 | 
				
			||||||
 | 
					pub async fn export_todos(
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    check_authorization!(cookies, ctx.inner(), id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match sqlx::query_as_unchecked!(
 | 
				
			||||||
 | 
					        TodoCsv,
 | 
				
			||||||
 | 
					        "SELECT value, CONCAT('#', channels.channel) AS channel_id FROM todos
 | 
				
			||||||
 | 
					        LEFT JOIN channels ON todos.channel_id = channels.id
 | 
				
			||||||
 | 
					        INNER JOIN guilds ON todos.guild_id = guilds.id
 | 
				
			||||||
 | 
					        WHERE guilds.guild = ?",
 | 
				
			||||||
 | 
					        id
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        Ok(todos) => {
 | 
				
			||||||
 | 
					            todos.iter().for_each(|todo| {
 | 
				
			||||||
 | 
					                csv_writer.serialize(todo).unwrap();
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            match csv_writer.into_inner() {
 | 
				
			||||||
 | 
					                Ok(inner) => match String::from_utf8(inner) {
 | 
				
			||||||
 | 
					                    Ok(encoded) => Ok(json!({ "body": encoded })),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    Err(e) => {
 | 
				
			||||||
 | 
					                        warn!("Failed to write UTF-8: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        json_err!("Failed to write UTF-8")
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                Err(e) => {
 | 
				
			||||||
 | 
					                    warn!("Failed to extract CSV: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    json_err!("Failed to extract CSV")
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            warn!("Could not fetch templates from {}: {:?}", id, e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            json_err!("Failed to query templates")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[put("/api/guild/<id>/export/todos", data = "<body>")]
 | 
				
			||||||
 | 
					pub async fn import_todos(
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    body: Json<ImportBody>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    check_authorization!(cookies, ctx.inner(), id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let channels_res = GuildId(id).channels(&ctx.inner()).await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match channels_res {
 | 
				
			||||||
 | 
					        Ok(channels) => match base64::decode(&body.body) {
 | 
				
			||||||
 | 
					            Ok(body) => {
 | 
				
			||||||
 | 
					                let mut reader = csv::Reader::from_reader(body.as_slice());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                let query_placeholder = "(?, (SELECT id FROM channels WHERE channel = ?), (SELECT id FROM guilds WHERE guild = ?))";
 | 
				
			||||||
 | 
					                let mut query_params = vec![];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                for result in reader.deserialize::<TodoCsv>() {
 | 
				
			||||||
 | 
					                    match result {
 | 
				
			||||||
 | 
					                        Ok(record) => match record.channel_id {
 | 
				
			||||||
 | 
					                            Some(channel_id) => {
 | 
				
			||||||
 | 
					                                let channel_id = channel_id.split_at(1).1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                match channel_id.parse::<u64>() {
 | 
				
			||||||
 | 
					                                    Ok(channel_id) => {
 | 
				
			||||||
 | 
					                                        if channels.contains_key(&ChannelId(channel_id)) {
 | 
				
			||||||
 | 
					                                            query_params.push((record.value, Some(channel_id), id));
 | 
				
			||||||
 | 
					                                        } else {
 | 
				
			||||||
 | 
					                                            return json_err!(format!(
 | 
				
			||||||
 | 
					                                                "Invalid channel ID {}",
 | 
				
			||||||
 | 
					                                                channel_id
 | 
				
			||||||
 | 
					                                            ));
 | 
				
			||||||
 | 
					                                        }
 | 
				
			||||||
 | 
					                                    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                                    Err(_) => {
 | 
				
			||||||
 | 
					                                        return json_err!(format!(
 | 
				
			||||||
 | 
					                                            "Invalid channel ID {}",
 | 
				
			||||||
 | 
					                                            channel_id
 | 
				
			||||||
 | 
					                                        ));
 | 
				
			||||||
 | 
					                                    }
 | 
				
			||||||
 | 
					                                }
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                            None => {
 | 
				
			||||||
 | 
					                                query_params.push((record.value, None, id));
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        Err(e) => {
 | 
				
			||||||
 | 
					                            warn!("Couldn't deserialize CSV row: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                            return json_err!("Deserialize error. Aborted");
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                let _ = sqlx::query!(
 | 
				
			||||||
 | 
					                    "DELETE FROM todos WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
 | 
				
			||||||
 | 
					                    id
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                .execute(pool.inner())
 | 
				
			||||||
 | 
					                .await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                let query_str = format!(
 | 
				
			||||||
 | 
					                    "INSERT INTO todos (value, channel_id, guild_id) VALUES {}",
 | 
				
			||||||
 | 
					                    vec![query_placeholder].repeat(query_params.len()).join(",")
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					                let mut query = sqlx::query(&query_str);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                for param in query_params {
 | 
				
			||||||
 | 
					                    query = query.bind(param.0).bind(param.1).bind(param.2);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                let res = query.execute(pool.inner()).await;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                match res {
 | 
				
			||||||
 | 
					                    Ok(_) => Ok(json!({})),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    Err(e) => {
 | 
				
			||||||
 | 
					                        warn!("Couldn't execute todo query: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        json_err!("An unexpected error occured.")
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Err(_) => {
 | 
				
			||||||
 | 
					                json_err!("Malformed base64")
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            warn!("Couldn't fetch channels for guild {}: {:?}", id, e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            json_err!("Couldn't fetch channels.")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[get("/api/guild/<id>/export/reminder_templates")]
 | 
				
			||||||
 | 
					pub async fn export_reminder_templates(
 | 
				
			||||||
 | 
					    id: u64,
 | 
				
			||||||
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
 | 
					) -> JsonResult {
 | 
				
			||||||
 | 
					    check_authorization!(cookies, ctx.inner(), id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let mut csv_writer = WriterBuilder::new().quote_style(QuoteStyle::Always).from_writer(vec![]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    match sqlx::query_as_unchecked!(
 | 
				
			||||||
 | 
					        ReminderTemplateCsv,
 | 
				
			||||||
 | 
					        "SELECT
 | 
				
			||||||
 | 
					         name,
 | 
				
			||||||
 | 
					         attachment,
 | 
				
			||||||
 | 
					         attachment_name,
 | 
				
			||||||
 | 
					         avatar,
 | 
				
			||||||
 | 
					         content,
 | 
				
			||||||
 | 
					         embed_author,
 | 
				
			||||||
 | 
					         embed_author_url,
 | 
				
			||||||
 | 
					         embed_color,
 | 
				
			||||||
 | 
					         embed_description,
 | 
				
			||||||
 | 
					         embed_footer,
 | 
				
			||||||
 | 
					         embed_footer_url,
 | 
				
			||||||
 | 
					         embed_image_url,
 | 
				
			||||||
 | 
					         embed_thumbnail_url,
 | 
				
			||||||
 | 
					         embed_title,
 | 
				
			||||||
 | 
					         embed_fields,
 | 
				
			||||||
 | 
					         tts,
 | 
				
			||||||
 | 
					         username
 | 
				
			||||||
 | 
					        FROM reminder_template WHERE guild_id = (SELECT id FROM guilds WHERE guild = ?)",
 | 
				
			||||||
 | 
					        id
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    .fetch_all(pool.inner())
 | 
				
			||||||
 | 
					    .await
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        Ok(templates) => {
 | 
				
			||||||
 | 
					            templates.iter().for_each(|template| {
 | 
				
			||||||
 | 
					                csv_writer.serialize(template).unwrap();
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            match csv_writer.into_inner() {
 | 
				
			||||||
 | 
					                Ok(inner) => match String::from_utf8(inner) {
 | 
				
			||||||
 | 
					                    Ok(encoded) => Ok(json!({ "body": encoded })),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    Err(e) => {
 | 
				
			||||||
 | 
					                        warn!("Failed to write UTF-8: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        json_err!("Failed to write UTF-8")
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                Err(e) => {
 | 
				
			||||||
 | 
					                    warn!("Failed to extract CSV: {:?}", e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    json_err!("Failed to extract CSV")
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        Err(e) => {
 | 
				
			||||||
 | 
					            warn!("Could not fetch templates from {}: {:?}", id, e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            json_err!("Failed to query templates")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,10 +1,8 @@
 | 
				
			|||||||
use std::env;
 | 
					use std::env;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use base64;
 | 
					 | 
				
			||||||
use chrono::Utc;
 | 
					 | 
				
			||||||
use rocket::{
 | 
					use rocket::{
 | 
				
			||||||
    http::CookieJar,
 | 
					    http::CookieJar,
 | 
				
			||||||
    serde::json::{json, Json, Value as JsonValue},
 | 
					    serde::json::{json, Json},
 | 
				
			||||||
    State,
 | 
					    State,
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
use serde::Serialize;
 | 
					use serde::Serialize;
 | 
				
			||||||
@@ -18,16 +16,14 @@ use serenity::{
 | 
				
			|||||||
use sqlx::{MySql, Pool};
 | 
					use sqlx::{MySql, Pool};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use crate::{
 | 
					use crate::{
 | 
				
			||||||
    check_guild_subscription, check_subscription,
 | 
					 | 
				
			||||||
    consts::{
 | 
					    consts::{
 | 
				
			||||||
        DAY, MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
 | 
					        MAX_CONTENT_LENGTH, MAX_EMBED_AUTHOR_LENGTH, MAX_EMBED_DESCRIPTION_LENGTH,
 | 
				
			||||||
        MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
 | 
					        MAX_EMBED_FIELDS, MAX_EMBED_FIELD_TITLE_LENGTH, MAX_EMBED_FIELD_VALUE_LENGTH,
 | 
				
			||||||
        MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
 | 
					        MAX_EMBED_FOOTER_LENGTH, MAX_EMBED_TITLE_LENGTH, MAX_URL_LENGTH, MAX_USERNAME_LENGTH,
 | 
				
			||||||
        MIN_INTERVAL,
 | 
					 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    routes::dashboard::{
 | 
					    routes::dashboard::{
 | 
				
			||||||
        create_database_channel, generate_uid, name_default, template_name_default, DeleteReminder,
 | 
					        create_database_channel, create_reminder, template_name_default, DeleteReminder,
 | 
				
			||||||
        DeleteReminderTemplate, PatchReminder, Reminder, ReminderTemplate,
 | 
					        DeleteReminderTemplate, JsonResult, PatchReminder, Reminder, ReminderTemplate,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -44,7 +40,7 @@ pub async fn get_guild_patreon(
 | 
				
			|||||||
    id: u64,
 | 
					    id: u64,
 | 
				
			||||||
    cookies: &CookieJar<'_>,
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
    ctx: &State<Context>,
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
) -> JsonValue {
 | 
					) -> JsonResult {
 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					    check_authorization!(cookies, ctx.inner(), id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    match GuildId(id).to_guild_cached(ctx.inner()) {
 | 
					    match GuildId(id).to_guild_cached(ctx.inner()) {
 | 
				
			||||||
@@ -59,12 +55,10 @@ pub async fn get_guild_patreon(
 | 
				
			|||||||
                    .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
 | 
					                    .contains(&RoleId(env::var("PATREON_ROLE_ID").unwrap().parse().unwrap()))
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            json!({ "patreon": patreon })
 | 
					            Ok(json!({ "patreon": patreon }))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        None => {
 | 
					        None => json_err!("Bot not in guild"),
 | 
				
			||||||
            json!({"error": "Bot not in guild"})
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -73,7 +67,7 @@ pub async fn get_guild_channels(
 | 
				
			|||||||
    id: u64,
 | 
					    id: u64,
 | 
				
			||||||
    cookies: &CookieJar<'_>,
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
    ctx: &State<Context>,
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
) -> JsonValue {
 | 
					) -> JsonResult {
 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					    check_authorization!(cookies, ctx.inner(), id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    match GuildId(id).to_guild_cached(ctx.inner()) {
 | 
					    match GuildId(id).to_guild_cached(ctx.inner()) {
 | 
				
			||||||
@@ -97,12 +91,10 @@ pub async fn get_guild_channels(
 | 
				
			|||||||
                })
 | 
					                })
 | 
				
			||||||
                .collect::<Vec<ChannelInfo>>();
 | 
					                .collect::<Vec<ChannelInfo>>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            json!(channel_info)
 | 
					            Ok(json!(channel_info))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        None => {
 | 
					        None => json_err!("Bot not in guild"),
 | 
				
			||||||
            json!({"error": "Bot not in guild"})
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -113,7 +105,7 @@ struct RoleInfo {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[get("/api/guild/<id>/roles")]
 | 
					#[get("/api/guild/<id>/roles")]
 | 
				
			||||||
pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonValue {
 | 
					pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Context>) -> JsonResult {
 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					    check_authorization!(cookies, ctx.inner(), id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let roles_res = ctx.cache.guild_roles(id);
 | 
					    let roles_res = ctx.cache.guild_roles(id);
 | 
				
			||||||
@@ -125,12 +117,12 @@ pub async fn get_guild_roles(id: u64, cookies: &CookieJar<'_>, ctx: &State<Conte
 | 
				
			|||||||
                .map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
 | 
					                .map(|(_, r)| RoleInfo { id: r.id.to_string(), name: r.name.to_string() })
 | 
				
			||||||
                .collect::<Vec<RoleInfo>>();
 | 
					                .collect::<Vec<RoleInfo>>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            json!(roles)
 | 
					            Ok(json!(roles))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        None => {
 | 
					        None => {
 | 
				
			||||||
            warn!("Could not fetch roles from {}", id);
 | 
					            warn!("Could not fetch roles from {}", id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            json!({"error": "Could not get roles"})
 | 
					            json_err!("Could not get roles")
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -141,7 +133,7 @@ pub async fn get_reminder_templates(
 | 
				
			|||||||
    cookies: &CookieJar<'_>,
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
    ctx: &State<Context>,
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
) -> JsonValue {
 | 
					) -> JsonResult {
 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					    check_authorization!(cookies, ctx.inner(), id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    match sqlx::query_as_unchecked!(
 | 
					    match sqlx::query_as_unchecked!(
 | 
				
			||||||
@@ -152,13 +144,11 @@ pub async fn get_reminder_templates(
 | 
				
			|||||||
    .fetch_all(pool.inner())
 | 
					    .fetch_all(pool.inner())
 | 
				
			||||||
    .await
 | 
					    .await
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        Ok(templates) => {
 | 
					        Ok(templates) => Ok(json!(templates)),
 | 
				
			||||||
            json!(templates)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        Err(e) => {
 | 
					        Err(e) => {
 | 
				
			||||||
            warn!("Could not fetch templates from {}: {:?}", id, e);
 | 
					            warn!("Could not fetch templates from {}: {:?}", id, e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            json!({"error": "Could not get templates"})
 | 
					            json_err!("Could not get templates")
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -170,7 +160,7 @@ pub async fn create_reminder_template(
 | 
				
			|||||||
    cookies: &CookieJar<'_>,
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
    ctx: &State<Context>,
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
) -> JsonValue {
 | 
					) -> JsonResult {
 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					    check_authorization!(cookies, ctx.inner(), id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // validate lengths
 | 
					    // validate lengths
 | 
				
			||||||
@@ -254,12 +244,12 @@ pub async fn create_reminder_template(
 | 
				
			|||||||
    .await
 | 
					    .await
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        Ok(_) => {
 | 
					        Ok(_) => {
 | 
				
			||||||
            json!({})
 | 
					            Ok(json!({}))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        Err(e) => {
 | 
					        Err(e) => {
 | 
				
			||||||
            warn!("Could not fetch templates from {}: {:?}", id, e);
 | 
					            warn!("Could not fetch templates from {}: {:?}", id, e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            json!({"error": "Could not get templates"})
 | 
					            json_err!("Could not get templates")
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -271,7 +261,7 @@ pub async fn delete_reminder_template(
 | 
				
			|||||||
    cookies: &CookieJar<'_>,
 | 
					    cookies: &CookieJar<'_>,
 | 
				
			||||||
    ctx: &State<Context>,
 | 
					    ctx: &State<Context>,
 | 
				
			||||||
    pool: &State<Pool<MySql>>,
 | 
					    pool: &State<Pool<MySql>>,
 | 
				
			||||||
) -> JsonValue {
 | 
					) -> JsonResult {
 | 
				
			||||||
    check_authorization!(cookies, ctx.inner(), id);
 | 
					    check_authorization!(cookies, ctx.inner(), id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    match sqlx::query!(
 | 
					    match sqlx::query!(
 | 
				
			||||||
@@ -282,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"}))
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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();
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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;
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/delete_reminder/cancel-1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 17 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/delete_reminder/cancel-2.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 14 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/edit_spreadsheet.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 44 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/format_text.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 40 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/import.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 44 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/select_export.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 18 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								web/static/img/support/iemanager/sheets_settings.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 20 KiB  | 
@@ -12,12 +12,25 @@ const $createTemplateBtn = $createReminder.querySelector("button#createTemplate"
 | 
				
			|||||||
const $loadTemplateBtn = document.querySelector("button#load-template");
 | 
					const $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) => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 %}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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">
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										22
									
								
								web/templates/support/dashboard.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,22 @@
 | 
				
			|||||||
 | 
					{% extends "base" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block init %}
 | 
				
			||||||
 | 
					    {% set title = "Support" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    {% set page_title = "Dashboard" %}
 | 
				
			||||||
 | 
					    {% set page_subtitle = "" %}
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <section class="hero is-small">
 | 
				
			||||||
 | 
					        <div class="hero-body">
 | 
				
			||||||
 | 
					            <div class="container">
 | 
				
			||||||
 | 
					                <p class="title">Accessing the dashboard</p>
 | 
				
			||||||
 | 
					                <p class="content">
 | 
				
			||||||
 | 
					                </p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
@@ -33,6 +33,24 @@
 | 
				
			|||||||
        </div>
 | 
					        </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">
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										88
									
								
								web/templates/support/iemanager.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,88 @@
 | 
				
			|||||||
 | 
					{% extends "base" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block init %}
 | 
				
			||||||
 | 
					    {% set title = "Support" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    {% set page_title = "Import/Export" %}
 | 
				
			||||||
 | 
					    {% set page_subtitle = "" %}
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <section class="hero is-small">
 | 
				
			||||||
 | 
					        <div class="hero-body">
 | 
				
			||||||
 | 
					            <div class="container">
 | 
				
			||||||
 | 
					                <p class="title">Export your data</p>
 | 
				
			||||||
 | 
					                <p class="content">
 | 
				
			||||||
 | 
					                    You can export data associated with your server from the dashboard. The data will export as a CSV
 | 
				
			||||||
 | 
					                    file. The CSV file can then be edited and imported to bulk edit server data.
 | 
				
			||||||
 | 
					                </p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <section class="hero is-small">
 | 
				
			||||||
 | 
					        <div class="hero-body">
 | 
				
			||||||
 | 
					            <div class="container">
 | 
				
			||||||
 | 
					                <p class="title">Import data</p>
 | 
				
			||||||
 | 
					                <p class="content">
 | 
				
			||||||
 | 
					                    You can import previous exports or modified exports. When importing a file, <strong>existing data
 | 
				
			||||||
 | 
					                    will be overwritten</strong>.
 | 
				
			||||||
 | 
					                </p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <section class="hero is-small">
 | 
				
			||||||
 | 
					        <div class="hero-body">
 | 
				
			||||||
 | 
					            <div class="container content">
 | 
				
			||||||
 | 
					                <p class="title">Edit your data</p>
 | 
				
			||||||
 | 
					                <p>
 | 
				
			||||||
 | 
					                    The CSV can be edited either as a text file or in a spreadsheet editor such as LibreOffice Calc. To
 | 
				
			||||||
 | 
					                    set up LibreOffice Calc for editing, do the following:
 | 
				
			||||||
 | 
					                </p>
 | 
				
			||||||
 | 
					                <ol>
 | 
				
			||||||
 | 
					                    <li>
 | 
				
			||||||
 | 
					                        Export data from dashboard.
 | 
				
			||||||
 | 
					                        <figure>
 | 
				
			||||||
 | 
					                            <img src="/static/img/support/iemanager/select_export.png" alt="Selecting export button">
 | 
				
			||||||
 | 
					                        </figure>
 | 
				
			||||||
 | 
					                    </li>
 | 
				
			||||||
 | 
					                    <li>
 | 
				
			||||||
 | 
					                        Open the file in LibreOffice. <strong>During the import dialogue, select "Format quoted field as text".</strong>
 | 
				
			||||||
 | 
					                        <figure>
 | 
				
			||||||
 | 
					                            <img src="/static/img/support/iemanager/format_text.png" alt="Selecting format button">
 | 
				
			||||||
 | 
					                        </figure>
 | 
				
			||||||
 | 
					                    </li>
 | 
				
			||||||
 | 
					                    <li>
 | 
				
			||||||
 | 
					                        Make edits to the spreadsheet. You can add, edit, and remove rows for reminders. Don't remove the title row.
 | 
				
			||||||
 | 
					                        <figure>
 | 
				
			||||||
 | 
					                            <img src="/static/img/support/iemanager/edit_spreadsheet.png" alt="Editing spreadsheet">
 | 
				
			||||||
 | 
					                        </figure>
 | 
				
			||||||
 | 
					                    </li>
 | 
				
			||||||
 | 
					                    <li>
 | 
				
			||||||
 | 
					                        Save the edited CSV file and import it on the dashboard.
 | 
				
			||||||
 | 
					                        <figure>
 | 
				
			||||||
 | 
					                            <img src="/static/img/support/iemanager/import.png" alt="Import new reminders">
 | 
				
			||||||
 | 
					                        </figure>
 | 
				
			||||||
 | 
					                    </li>
 | 
				
			||||||
 | 
					                </ol>
 | 
				
			||||||
 | 
					                Other spreadsheet tools can also be used to edit exports, as long as they are properly configured:
 | 
				
			||||||
 | 
					                <ul>
 | 
				
			||||||
 | 
					                    <li>
 | 
				
			||||||
 | 
					                        <strong>Google Sheets</strong>: Create a new blank spreadsheet. Select <strong>File >> Import >> Upload >> export.csv</strong>.
 | 
				
			||||||
 | 
					                        Use the following import settings:
 | 
				
			||||||
 | 
					                        <figure>
 | 
				
			||||||
 | 
					                            <img src="/static/img/support/iemanager/sheets_settings.png" alt="Google sheets import settings">
 | 
				
			||||||
 | 
					                        </figure>
 | 
				
			||||||
 | 
					                    </li>
 | 
				
			||||||
 | 
					                    <li>
 | 
				
			||||||
 | 
					                        <strong>Excel (including Excel Online)</strong>: Avoid using Excel. Excel will not correctly import channels, or give
 | 
				
			||||||
 | 
					                        clear options to correct imports.
 | 
				
			||||||
 | 
					                    </li>
 | 
				
			||||||
 | 
					                </ul>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
							
								
								
									
										70
									
								
								web/templates/support/intervals.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,70 @@
 | 
				
			|||||||
 | 
					{% extends "base" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block init %}
 | 
				
			||||||
 | 
					    {% set title = "Support" %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    {% set page_title = "Intervals" %}
 | 
				
			||||||
 | 
					    {% set page_subtitle = "Interval reminders, or repeating reminders, are available to our Patreon supporters" %}
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block content %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <section class="hero is-small">
 | 
				
			||||||
 | 
					        <div class="hero-body">
 | 
				
			||||||
 | 
					            <div class="container">
 | 
				
			||||||
 | 
					                <p class="title">Fixed intervals</p>
 | 
				
			||||||
 | 
					                <p class="content">
 | 
				
			||||||
 | 
					                    The main type of interval is the fixed interval. Fixed intervals are ideal for hourly, daily, or
 | 
				
			||||||
 | 
					                    reminders repeating at any other fixed amount of time.
 | 
				
			||||||
 | 
					                    <br>
 | 
				
			||||||
 | 
					                    You can create fixed interval reminders via the dashboard or via the <code>/remind</code> command.
 | 
				
			||||||
 | 
					                    When you have filled out the "time" and "content" on the command, press <kbd>tab</kbd>. Select the
 | 
				
			||||||
 | 
					                    "interval" option. Then, write the interval you wish to use: for example, "1 day" for daily (starting
 | 
				
			||||||
 | 
					                    at the time specified in "time").
 | 
				
			||||||
 | 
					                </p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <section class="hero is-small">
 | 
				
			||||||
 | 
					        <div class="hero-body">
 | 
				
			||||||
 | 
					            <div class="container">
 | 
				
			||||||
 | 
					                <p class="title">Daylight savings</p>
 | 
				
			||||||
 | 
					                <p class="content">
 | 
				
			||||||
 | 
					                    If you live in a region that uses daylight savings (DST), then your interval reminders may become
 | 
				
			||||||
 | 
					                    offset by an hour due to clock changes.
 | 
				
			||||||
 | 
					                    <br>
 | 
				
			||||||
 | 
					                    Reminder Bot offers a quick solution to this via the <code>/offset</code> command. This command
 | 
				
			||||||
 | 
					                    moves all existing reminders on a server by a certain amount of time. You can use offset to move
 | 
				
			||||||
 | 
					                    your reminders forward or backward by an hour when daylight savings happens.
 | 
				
			||||||
 | 
					                </p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <section class="hero is-small">
 | 
				
			||||||
 | 
					        <div class="hero-body">
 | 
				
			||||||
 | 
					            <div class="container">
 | 
				
			||||||
 | 
					                <p class="title">Monthly/yearly intervals</p>
 | 
				
			||||||
 | 
					                <p class="content">
 | 
				
			||||||
 | 
					                    Monthly or yearly intervals are configured the same as fixed intervals. Instead of a fixed time
 | 
				
			||||||
 | 
					                    interval, these reminders repeat on a certain day each month or each year. This makes them ideal
 | 
				
			||||||
 | 
					                    for marking certain dates.
 | 
				
			||||||
 | 
					                </p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <section class="hero is-small">
 | 
				
			||||||
 | 
					        <div class="hero-body">
 | 
				
			||||||
 | 
					            <div class="container">
 | 
				
			||||||
 | 
					                <p class="title">Interval expiration</p>
 | 
				
			||||||
 | 
					                <p class="content">
 | 
				
			||||||
 | 
					                    An expiration time can also be specified, both via commands and dashboard, for repeating reminders.
 | 
				
			||||||
 | 
					                    This is optional, and if omitted, the reminder will repeat indefinitely.
 | 
				
			||||||
 | 
					                </p>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					    </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
@@ -3,8 +3,8 @@
 | 
				
			|||||||
{% block init %}
 | 
					{% 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.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||