From 53006d4d391f1ec547191329deeba8553e229a44 Mon Sep 17 00:00:00 2001 From: oceaann <47253870+oceaann@users.noreply.github.com> Date: Mon, 1 May 2023 20:57:04 +0100 Subject: [PATCH 1/7] render discord messages with the tera templating --- Cargo.toml | 4 +- src/assets/load.rs | 14 ++++ src/assets/message.rs | 62 +++++++++++++++++ src/assets/mod.rs | 24 +++++++ src/assets/render.rs | 151 ++++++++++++++++++++++++++++++++++++++++++ src/context.rs | 10 +-- src/main.rs | 1 + 7 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 src/assets/load.rs create mode 100644 src/assets/message.rs create mode 100644 src/assets/mod.rs create mode 100644 src/assets/render.rs diff --git a/Cargo.toml b/Cargo.toml index 025c51e..8bc1ca1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,11 +14,13 @@ twilight-validate = "0.15" serde_json = "1.0.78" serde_repr = "0.1.9" +serde_yaml = "0.9" serde = "1.0.136" +tera = "1" async-trait = "0.1.57" futures-util = "0.3.19" -tokio = "1.16.1" +tokio = { version = "1.16.1", features = ["fs"] } mongodb = "2.1.0" redis = "0.21.6" diff --git a/src/assets/load.rs b/src/assets/load.rs new file mode 100644 index 0000000..eb42abb --- /dev/null +++ b/src/assets/load.rs @@ -0,0 +1,14 @@ +use tokio::fs; + +use super::Asset; + +pub async fn load(path: Option) -> Asset { + let path = path.unwrap_or("messages.yaml".to_string()); + let default_asset = fs::read(path.to_owned()) + .await.expect(format!("Cannot load {path}").as_str()); + + let default_asset: Asset = serde_yaml::from_slice(&default_asset) + .expect(format!("Cannot parse {path}").as_str()); + + default_asset +} \ No newline at end of file diff --git a/src/assets/message.rs b/src/assets/message.rs new file mode 100644 index 0000000..f8599db --- /dev/null +++ b/src/assets/message.rs @@ -0,0 +1,62 @@ +use serde::{Serialize, Deserialize}; +use twilight_model::channel::message::embed::{EmbedAuthor, EmbedFooter}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Message { + pub content: Option, + #[serde(default)] + pub ephemeral: bool, + #[serde(default)] + pub embeds: Vec, + #[serde(default, rename = "add-case")] + pub add_case: bool +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Embed { + pub title: Option, + pub description: Option, + pub thumbnail: Option, + pub footer: Option, + #[serde(default)] + pub fields: Vec, + pub author: Option, + pub color: Option, + pub image: Option, + pub video: Option, + pub url: Option +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct EmbedField { + pub inline: Option, + pub name: String, + pub value: String +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct TextIcon { + pub text: String, + pub icon_url: Option +} + +impl Into for TextIcon { + fn into(self) -> EmbedAuthor { + EmbedAuthor { + icon_url: self.icon_url.to_owned(), + name: self.text, + proxy_icon_url: self.icon_url, + url: None, + } + } +} + +impl Into for TextIcon { + fn into(self) -> EmbedFooter { + EmbedFooter { + icon_url: self.icon_url.to_owned(), + proxy_icon_url: self.icon_url, + text: self.text, + } + } +} \ No newline at end of file diff --git a/src/assets/mod.rs b/src/assets/mod.rs new file mode 100644 index 0000000..93a240c --- /dev/null +++ b/src/assets/mod.rs @@ -0,0 +1,24 @@ +use std::{sync::Arc, collections::HashMap}; + +use tokio::sync::RwLock; + +use self::message::Message; + +mod load; +mod message; +mod render; + +pub type Asset = HashMap; + +pub struct AssetsManager { + pub default: Asset, + pub custom: RwLock>> +} + +pub struct GuildAssets(Vec); + +impl AssetsManager { + pub async fn new() -> Self { + Self { default: load::load(None).await, custom: Default::default() } + } +} diff --git a/src/assets/render.rs b/src/assets/render.rs new file mode 100644 index 0000000..dc79bf9 --- /dev/null +++ b/src/assets/render.rs @@ -0,0 +1,151 @@ +use std::str::FromStr; + +use tera::{Tera, Context, Error as TeraError}; +use twilight_model::{http::interaction::InteractionResponseData, channel::message::{MessageFlags, AllowedMentions, embed::{Embed as DiscordEmbed, EmbedVideo, EmbedImage, EmbedThumbnail, EmbedField as DiscordEmbedField}}}; + +use super::{message::{Message, Embed, EmbedField, TextIcon}, AssetsManager, GuildAssets}; + +#[derive(Debug)] +pub enum RenderErrorKind { + Tera(TeraError), + InvalidMessage, + BoolConvertion +} + +#[derive(Debug)] +pub struct RenderError(RenderErrorKind); + +impl GuildAssets { + pub async fn render_message( + &self, + manager: &AssetsManager, + name: &str, + data: &Context + ) -> Result { + for asset_name in &(self.0) { + let asset = manager.custom.read().await.get(asset_name).cloned(); + // Option::map won't be used becouse of lifetiems + if let Some(asset) = asset { + if let Some(message) = asset.get(name) { + return message.render(data) + } + } + } + + let message = manager.default.get(name).ok_or(RenderError(RenderErrorKind::InvalidMessage))?; + message.render(data) + } +} + +macro_rules! render_option { + ($target: expr, $data: expr) => { + if let Some(name) = ($target).as_ref() { + let content = render_string(name, $data)?; + if content == "!Skip" { None } else { Some(content) } + } else { None } + }; +} + +macro_rules! render_optional_text_icon { + ($target: expr, $data: expr) => { + if let Some(name) = ($target).as_ref() { + Some(name.render($data)?.into()) + } else { None } + }; +} + +impl Message { + pub fn render(&self, data: &Context) -> Result { + let mut embeds = vec![]; + for embed in &self.embeds { + embeds.push(embed.render(&data)?) + } + + Ok(InteractionResponseData { + allowed_mentions: Some(AllowedMentions { + parse: vec![], + replied_user: true, + roles: vec![], + users: vec![], + }), + attachments: None, + choices: None, + components: None, + content: render_option!(self.content, data), + custom_id: None, + embeds: Some(embeds), + flags: if self.ephemeral { Some(MessageFlags::EPHEMERAL) } else { None }, + title: None, + tts: Some(false), + }) + } +} + +impl Embed { + pub fn render(&self, data: &Context) -> Result { + let mut fields = vec![]; + for field in &self.fields { + let field = field.render(data)?; + if &field.name == "!SkipAll" || &field.value == "!SkipAll" { continue } + fields.push(field); + } + + Ok(DiscordEmbed { + author: render_optional_text_icon!(self.author, data), + color: self.color, + description: render_option!(self.description, data), + fields, + footer: render_optional_text_icon!(self.footer, data), + image: render_option!(self.image, data).map(|image| EmbedImage { + height: None, + proxy_url: Some(image.to_owned()), + url: image, + width: None, + }), + kind: "".to_string(), + provider: None, + thumbnail: render_option!(self.thumbnail, data).map(|thumbnail| EmbedThumbnail { + height: None, + proxy_url: Some(thumbnail.to_owned()), + url: thumbnail, + width: None, + }), + timestamp: None, + title: render_option!(self.title, data), + url: render_option!(self.url, data), + video: render_option!(self.video, data).map(|video| EmbedVideo { + height: None, + proxy_url: Some(video.to_owned()), + url: Some(video), + width: None, + }), + }) + } +} + +impl TextIcon { + pub fn render(&self, data: &Context) -> Result { + Ok(TextIcon { + text: render_string(&self.text, data)?, + icon_url: render_option!(self.icon_url, data), + }) + } +} + +impl EmbedField { + pub fn render(&self, data: &Context) -> Result { + Ok(DiscordEmbedField { + inline: bool::from_str( + render_option!(self.inline, data).unwrap_or_else(String::new).as_str() + ).map_err(|_| RenderError(RenderErrorKind::BoolConvertion))?, + name: render_string(&self.name, data)?, + value: render_string(&self.value, data)?, + }) + } +} + +fn render_string(content: &String, context: &Context) -> Result { + Tera::one_off( + content.as_str(), &context, false + ).map_err(|err| RenderError(RenderErrorKind::Tera(err))) +} diff --git a/src/context.rs b/src/context.rs index 7a784a1..5eb4be7 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,11 +1,12 @@ -use crate::{database::{mongodb::MongoDBConnection, redis::RedisConnection}, links::ScamLinks, bucket::Bucket, application::Application}; +use crate::{database::{mongodb::MongoDBConnection, redis::RedisConnection}, links::ScamLinks, bucket::Bucket, application::Application, assets::AssetsManager}; pub struct Context { pub application: Application, pub mongodb: MongoDBConnection, pub redis: RedisConnection, pub scam_domains: ScamLinks, - pub bucket: Bucket + pub bucket: Bucket, + pub assets: AssetsManager } impl Context { @@ -18,11 +19,10 @@ impl Context { let scam_domains = ScamLinks::new().await.expect("Cannot load scam links manager"); scam_domains.connect(); - let bucket: Bucket = Default::default(); - let application = Application::new(); + let assets = AssetsManager::new().await; - Self { mongodb, redis, scam_domains, bucket, application } + Self { mongodb, redis, scam_domains, bucket, application, assets } } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 9d877b5..28381c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ mod tasks; mod models; mod gateway; mod application; +mod assets; #[tokio::main] async fn main() { From db842f57364a29fa84e2dab9e55722d2b91bf011 Mon Sep 17 00:00:00 2001 From: oceaann <47253870+oceaann@users.noreply.github.com> Date: Thu, 4 May 2023 11:59:57 +0100 Subject: [PATCH 2/7] change yaml files to toml --- Cargo.toml | 2 +- src/assets/load.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8bc1ca1..4ea2675 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ twilight-validate = "0.15" serde_json = "1.0.78" serde_repr = "0.1.9" -serde_yaml = "0.9" +toml = "0.7.3" serde = "1.0.136" tera = "1" diff --git a/src/assets/load.rs b/src/assets/load.rs index eb42abb..39d0068 100644 --- a/src/assets/load.rs +++ b/src/assets/load.rs @@ -3,12 +3,12 @@ use tokio::fs; use super::Asset; pub async fn load(path: Option) -> Asset { - let path = path.unwrap_or("messages.yaml".to_string()); - let default_asset = fs::read(path.to_owned()) + let path = path.unwrap_or("messages.toml".to_string()); + let default_asset = fs::read_to_string(path.to_owned()) .await.expect(format!("Cannot load {path}").as_str()); - let default_asset: Asset = serde_yaml::from_slice(&default_asset) + let default_asset: Asset = toml::from_str(&default_asset) .expect(format!("Cannot parse {path}").as_str()); - + default_asset } \ No newline at end of file From a1f69754687f6b2f5374b166ba22f7d62045653b Mon Sep 17 00:00:00 2001 From: oceaann <47253870+oceaann@users.noreply.github.com> Date: Thu, 4 May 2023 12:00:51 +0100 Subject: [PATCH 3/7] impl to_string on error --- src/assets/render.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/assets/render.rs b/src/assets/render.rs index dc79bf9..1036ae3 100644 --- a/src/assets/render.rs +++ b/src/assets/render.rs @@ -1,3 +1,4 @@ + use std::str::FromStr; use tera::{Tera, Context, Error as TeraError}; @@ -8,10 +9,22 @@ use super::{message::{Message, Embed, EmbedField, TextIcon}, AssetsManager, Guil #[derive(Debug)] pub enum RenderErrorKind { Tera(TeraError), - InvalidMessage, + InvalidMessage(String), BoolConvertion } +impl ToString for RenderError { + fn to_string(&self) -> String { + match &self.0 { + RenderErrorKind::Tera(err) => { + format!("{err:#?}") + } + RenderErrorKind::InvalidMessage(name) => format!("Cannot find matching message asset for {name}"), + RenderErrorKind::BoolConvertion => "".to_string(), + } + } +} + #[derive(Debug)] pub struct RenderError(RenderErrorKind); @@ -32,7 +45,7 @@ impl GuildAssets { } } - let message = manager.default.get(name).ok_or(RenderError(RenderErrorKind::InvalidMessage))?; + let message = manager.default.get(name).ok_or(RenderError(RenderErrorKind::InvalidMessage(name.to_string())))?; message.render(data) } } From 52755b815d1ffcb38664e62c3f68e5fb321d2eaf Mon Sep 17 00:00:00 2001 From: oceaann <47253870+oceaann@users.noreply.github.com> Date: Thu, 4 May 2023 12:01:17 +0100 Subject: [PATCH 4/7] add the render_context macro --- src/assets/mod.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/assets/mod.rs b/src/assets/mod.rs index 93a240c..fa563e1 100644 --- a/src/assets/mod.rs +++ b/src/assets/mod.rs @@ -1,12 +1,13 @@ use std::{sync::Arc, collections::HashMap}; +use serde::{Serialize, Deserialize}; use tokio::sync::RwLock; use self::message::Message; mod load; mod message; -mod render; +pub mod render; pub type Asset = HashMap; @@ -15,10 +16,24 @@ pub struct AssetsManager { pub custom: RwLock>> } -pub struct GuildAssets(Vec); +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GuildAssets(pub Vec); impl AssetsManager { pub async fn new() -> Self { - Self { default: load::load(None).await, custom: Default::default() } + Self { default: self::load::load(None).await, custom: Default::default() } } } + +#[macro_export] +macro_rules! render_context { + ($([$name: expr, $value: expr]),* ) => { + { + let mut ctx = tera::Context::new(); + $( + ctx.insert($name, $value); + )* + ctx + } + }; +} From 67022ea46a9b2fcbbf6eced37edd51374316b0fa Mon Sep 17 00:00:00 2001 From: oceaann <47253870+oceaann@users.noreply.github.com> Date: Fri, 7 Jul 2023 19:20:56 +0100 Subject: [PATCH 5/7] file parsing and loading messages.toml --- src/assets/load.rs | 14 -------------- src/assets/message.rs | 20 ++++++++++++++------ src/utils/cli.rs | 11 +++++++++++ 3 files changed, 25 insertions(+), 20 deletions(-) delete mode 100644 src/assets/load.rs create mode 100644 src/utils/cli.rs diff --git a/src/assets/load.rs b/src/assets/load.rs deleted file mode 100644 index 39d0068..0000000 --- a/src/assets/load.rs +++ /dev/null @@ -1,14 +0,0 @@ -use tokio::fs; - -use super::Asset; - -pub async fn load(path: Option) -> Asset { - let path = path.unwrap_or("messages.toml".to_string()); - let default_asset = fs::read_to_string(path.to_owned()) - .await.expect(format!("Cannot load {path}").as_str()); - - let default_asset: Asset = toml::from_str(&default_asset) - .expect(format!("Cannot parse {path}").as_str()); - - default_asset -} \ No newline at end of file diff --git a/src/assets/message.rs b/src/assets/message.rs index f8599db..6a4d25b 100644 --- a/src/assets/message.rs +++ b/src/assets/message.rs @@ -1,6 +1,14 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use twilight_model::channel::message::embed::{EmbedAuthor, EmbedFooter}; +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] +pub enum CaseMessageType { + #[serde(rename = "non-server")] + NonServer, + #[serde(rename = "moderation-log")] + ModerationLog, +} + #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Message { pub content: Option, @@ -9,7 +17,7 @@ pub struct Message { #[serde(default)] pub embeds: Vec, #[serde(default, rename = "add-case")] - pub add_case: bool + pub add_case: Option, } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -24,20 +32,20 @@ pub struct Embed { pub color: Option, pub image: Option, pub video: Option, - pub url: Option + pub url: Option, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct EmbedField { pub inline: Option, pub name: String, - pub value: String + pub value: String, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct TextIcon { pub text: String, - pub icon_url: Option + pub icon_url: Option, } impl Into for TextIcon { @@ -59,4 +67,4 @@ impl Into for TextIcon { text: self.text, } } -} \ No newline at end of file +} diff --git a/src/utils/cli.rs b/src/utils/cli.rs new file mode 100644 index 0000000..5a79257 --- /dev/null +++ b/src/utils/cli.rs @@ -0,0 +1,11 @@ +use std::io::stdin; + +pub fn confirm() -> bool { + let mut buffer = String::new(); + stdin().read_line(&mut buffer).unwrap(); + match buffer.to_lowercase().as_str() { + "y" => true, + "n" => false, + _ => confirm() + } +} \ No newline at end of file From 197a9a76e39cec5d09666768fe2a387a2ef0d652 Mon Sep 17 00:00:00 2001 From: oceaann <47253870+oceaann@users.noreply.github.com> Date: Fri, 7 Jul 2023 19:22:13 +0100 Subject: [PATCH 6/7] assets rendering --- Cargo.toml | 5 +- src/assets/mod.rs | 68 ++++++++++++++++++-- src/assets/render.rs | 148 ++++++++++++++++++++++++++++++++++--------- 3 files changed, 183 insertions(+), 38 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4ea2675..da4c8cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ twilight-validate = "0.15" serde_json = "1.0.78" serde_repr = "0.1.9" toml = "0.7.3" -serde = "1.0.136" +serde = { version = "1.0", features = ["rc"] } tera = "1" async-trait = "0.1.57" @@ -35,4 +35,5 @@ chrono = "0.4.19" reqwest = "0.11.9" regex = "1.5.4" dashmap = "5.2.0" -hex = "0.4.3" \ No newline at end of file +hex = "0.4.3" +console = "0.15" \ No newline at end of file diff --git a/src/assets/mod.rs b/src/assets/mod.rs index fa563e1..6e7df64 100644 --- a/src/assets/mod.rs +++ b/src/assets/mod.rs @@ -1,19 +1,72 @@ -use std::{sync::Arc, collections::HashMap}; +use std::{collections::HashMap, sync::Arc}; -use serde::{Serialize, Deserialize}; +use crate::utils::cli; +use serde::{Deserialize, Serialize}; +use tokio::fs; use tokio::sync::RwLock; use self::message::Message; -mod load; mod message; pub mod render; -pub type Asset = HashMap; +pub type Asset = HashMap>; pub struct AssetsManager { pub default: Asset, - pub custom: RwLock>> + pub custom: RwLock>>, +} + +const URL: &str = "https://raw.githubusercontent.com/oceaann/custom/dev/messages.yaml"; +async fn fetch_default_asset() -> Option { + match reqwest::get(URL).await { + Ok(res) => res.text().await.ok(), + Err(err) => { + eprintln!("Cannot fetch assets - applying defaults (empty values): {err}"); + None + } + } +} + +fn parse_asset_config(asset: &str) -> Result { + toml::from_str(asset) +} + +const ASSETS_CONFIG_PATH: &str = "./messages.toml"; +const CANNOT_PARSE_FETCHED_CONFIG: &str = "Cannot parse auto-downloaded message configuration"; + +async fn load_default(path: Option) -> Asset { + let path = path.unwrap_or(ASSETS_CONFIG_PATH.to_string()); + let file = fs::read_to_string(path.to_owned()).await; + + match file { + Ok(file) => { + parse_asset_config(file.as_str()).expect(format!("Cannot parse {path}").as_str()) + } + Err(error) => { + if error.kind() == std::io::ErrorKind::NotFound { + if cli::confirm( + "Cannot load file {path} do you want to fetch the recommended configuration", + ) { + println!("Fetching {URL}"); + fetch_default_asset() + .await + .map(|asset| { + parse_asset_config(asset.as_str()).expect(CANNOT_PARSE_FETCHED_CONFIG) + }) + .unwrap_or_default() + } else { + Default::default() + } + } else { + eprintln!( + "Cannot load assets with default messages: {}", + error.to_string() + ); + Default::default() + } + } + } } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -21,7 +74,10 @@ pub struct GuildAssets(pub Vec); impl AssetsManager { pub async fn new() -> Self { - Self { default: self::load::load(None).await, custom: Default::default() } + Self { + default: load_default(None).await, + custom: Default::default(), + } } } diff --git a/src/assets/render.rs b/src/assets/render.rs index 1036ae3..1f4e819 100644 --- a/src/assets/render.rs +++ b/src/assets/render.rs @@ -1,16 +1,32 @@ +use std::{ + str::{FromStr, ParseBoolError}, + sync::Arc, +}; -use std::str::FromStr; +use tera::{Context, Error as TeraError, Tera}; +use twilight_model::{ + channel::message::{ + embed::{ + Embed as DiscordEmbed, EmbedField as DiscordEmbedField, EmbedImage, EmbedThumbnail, + EmbedVideo, + }, + AllowedMentions, MessageFlags, + }, + http::interaction::InteractionResponseData, +}; -use tera::{Tera, Context, Error as TeraError}; -use twilight_model::{http::interaction::InteractionResponseData, channel::message::{MessageFlags, AllowedMentions, embed::{Embed as DiscordEmbed, EmbedVideo, EmbedImage, EmbedThumbnail, EmbedField as DiscordEmbedField}}}; +use crate::database::redis::RedisConnection; -use super::{message::{Message, Embed, EmbedField, TextIcon}, AssetsManager, GuildAssets}; +use super::{ + message::{CaseMessageType, Embed, EmbedField, Message, TextIcon}, + AssetsManager, GuildAssets, +}; #[derive(Debug)] pub enum RenderErrorKind { Tera(TeraError), InvalidMessage(String), - BoolConvertion + BoolConvertion(ParseBoolError), } impl ToString for RenderError { @@ -19,8 +35,12 @@ impl ToString for RenderError { RenderErrorKind::Tera(err) => { format!("{err:#?}") } - RenderErrorKind::InvalidMessage(name) => format!("Cannot find matching message asset for {name}"), - RenderErrorKind::BoolConvertion => "".to_string(), + RenderErrorKind::InvalidMessage(name) => { + format!("Cannot find matching message asset for {name}") + } + RenderErrorKind::BoolConvertion(output) => { + format!("{output} while converting to boolen") + } } } } @@ -29,24 +49,54 @@ impl ToString for RenderError { pub struct RenderError(RenderErrorKind); impl GuildAssets { - pub async fn render_message( - &self, - manager: &AssetsManager, - name: &str, - data: &Context - ) -> Result { + async fn get_message(&self, manager: &AssetsManager, name: &str) -> Option> { for asset_name in &(self.0) { let asset = manager.custom.read().await.get(asset_name).cloned(); // Option::map won't be used becouse of lifetiems if let Some(asset) = asset { if let Some(message) = asset.get(name) { - return message.render(data) + return Some(Arc::clone(message)); } } } - - let message = manager.default.get(name).ok_or(RenderError(RenderErrorKind::InvalidMessage(name.to_string())))?; - message.render(data) + + manager.default.get(name).map(Arc::clone) + } + + pub async fn render_message( + &self, + manager: &AssetsManager, + name: &str, + data: &mut Context, + redis: &RedisConnection, + ) -> Result { + self.get_message(manager, name) + .await + .ok_or(RenderError(RenderErrorKind::InvalidMessage( + name.to_string(), + )))? + .render(data, &self, manager, redis) + .await + } + + pub async fn render_additional_embed( + &self, + manager: &AssetsManager, + name: &str, + data: &Context, + ) -> Result { + Ok(self + .get_message(manager, name) + .await + .ok_or(RenderError(RenderErrorKind::InvalidMessage( + name.to_string(), + )))? + .embeds + .get(0) + .ok_or(RenderError(RenderErrorKind::InvalidMessage( + name.to_string(), + )))? + .render(data)?) } } @@ -54,8 +104,14 @@ macro_rules! render_option { ($target: expr, $data: expr) => { if let Some(name) = ($target).as_ref() { let content = render_string(name, $data)?; - if content == "!Skip" { None } else { Some(content) } - } else { None } + if content == "!Skip" { + None + } else { + Some(content) + } + } else { + None + } }; } @@ -63,17 +119,41 @@ macro_rules! render_optional_text_icon { ($target: expr, $data: expr) => { if let Some(name) = ($target).as_ref() { Some(name.render($data)?.into()) - } else { None } + } else { + None + } }; } impl Message { - pub fn render(&self, data: &Context) -> Result { + pub async fn render( + &self, + data: &mut Context, + guild_assets: &GuildAssets, + manager: &AssetsManager, + redis: &RedisConnection, + ) -> Result { let mut embeds = vec![]; for embed in &self.embeds { embeds.push(embed.render(&data)?) } - + + if let Some(case_type) = self.add_case { + let embed = match case_type { + CaseMessageType::NonServer => { + guild_assets + .render_additional_embed(manager, "case.non-server", data) + .await? + } + CaseMessageType::ModerationLog => { + guild_assets + .render_additional_embed(manager, "case.moderation-log", data) + .await? + } + }; + embeds.push(embed); + } + Ok(InteractionResponseData { allowed_mentions: Some(AllowedMentions { parse: vec![], @@ -87,7 +167,11 @@ impl Message { content: render_option!(self.content, data), custom_id: None, embeds: Some(embeds), - flags: if self.ephemeral { Some(MessageFlags::EPHEMERAL) } else { None }, + flags: if self.ephemeral { + Some(MessageFlags::EPHEMERAL) + } else { + None + }, title: None, tts: Some(false), }) @@ -99,10 +183,12 @@ impl Embed { let mut fields = vec![]; for field in &self.fields { let field = field.render(data)?; - if &field.name == "!SkipAll" || &field.value == "!SkipAll" { continue } + if &field.name == "!SkipAll" || &field.value == "!SkipAll" { + continue; + } fields.push(field); } - + Ok(DiscordEmbed { author: render_optional_text_icon!(self.author, data), color: self.color, @@ -149,8 +235,11 @@ impl EmbedField { pub fn render(&self, data: &Context) -> Result { Ok(DiscordEmbedField { inline: bool::from_str( - render_option!(self.inline, data).unwrap_or_else(String::new).as_str() - ).map_err(|_| RenderError(RenderErrorKind::BoolConvertion))?, + render_option!(self.inline, data) + .unwrap_or_else(String::new) + .as_str(), + ) + .map_err(|err| RenderError(RenderErrorKind::BoolConvertion(err)))?, name: render_string(&self.name, data)?, value: render_string(&self.value, data)?, }) @@ -158,7 +247,6 @@ impl EmbedField { } fn render_string(content: &String, context: &Context) -> Result { - Tera::one_off( - content.as_str(), &context, false - ).map_err(|err| RenderError(RenderErrorKind::Tera(err))) + Tera::one_off(content.as_str(), &context, false) + .map_err(|err| RenderError(RenderErrorKind::Tera(err))) } From 7133f44b90b7d682060fa761d07f9bf38ce6f655 Mon Sep 17 00:00:00 2001 From: oceaann <47253870+oceaann@users.noreply.github.com> Date: Fri, 7 Jul 2023 19:27:32 +0100 Subject: [PATCH 7/7] implement for some commands --- messages.toml | 37 +++++++++ src/commands/case/details.rs | 46 ++++++----- src/commands/moderation/clear.rs | 136 +++++++++++++++++++------------ src/commands/settings/setup.rs | 43 +++++----- src/commands/top/all.rs | 57 +++++++------ src/commands/top/me.rs | 104 +++++++++++++---------- src/context.rs | 13 ++- src/events/setup.rs | 5 +- src/main.rs | 46 +++++------ src/models/config/mod.rs | 5 +- src/utils/cli.rs | 66 +++++++++++++-- src/utils/config.rs | 3 +- src/utils/errors.rs | 6 ++ src/utils/mod.rs | 3 +- 14 files changed, 365 insertions(+), 205 deletions(-) create mode 100644 messages.toml diff --git a/messages.toml b/messages.toml new file mode 100644 index 0000000..0566343 --- /dev/null +++ b/messages.toml @@ -0,0 +1,37 @@ +["commands.setup"] +content = """ +**The server setup is not completed yet or commands are not synced** + +To complete a setup open the dashboard https://custom.fail/setup?guild={{ interaction.guild_id }} +To sync commands open a server settings https://custom.fail/servers/{{ interaction.guild_id} } +""" +ephemeral = true + +["commands.clear"] +content = "Succesfully cleared {{ cleared }} from {{ amount }} messages" +ephemeral = true + +[["commands.top.all".embeds]] +title = "Top of the {{ weekOrDay }} users" +description = """ +{% set emotes = [":first_place:", ":second_place:", ":third_place:"] %} +{% for rank in leaderboard %} + {% set i = loop.index - 1 %} + {{ emotes[i] }} > <@{{rank[0]}}> ({{ rank[1] }}) +{% endfor %} +""" + +[["commands.top.me".embeds]] +title = "Top of the {{ weekOrDay }} for {{ interaction.user.name }}" +description = """ +You are **{{ userPosition + 1 }}** with **{{ userScore }}** +{% for rank in leaderboard %} + {% set i = loop.index - 1 %} + {% set rankScore = rank[1] %} + {% set toRank = userScore - rankScore %} + {% +{% endfor %} +""" + +[["case.non-server".embeds]] +title = "test" \ No newline at end of file diff --git a/src/commands/case/details.rs b/src/commands/case/details.rs index fc91da4..cf1e7ef 100644 --- a/src/commands/case/details.rs +++ b/src/commands/case/details.rs @@ -1,37 +1,45 @@ -use std::sync::Arc; -use mongodb::bson::doc; -use twilight_http::Client; -use twilight_model::application::interaction::application_command::CommandOptionValue; use crate::commands::context::InteractionContext; use crate::commands::ResponseData; use crate::context::Context; -use crate::{extract, get_required_option, get_option}; use crate::models::config::GuildConfig; use crate::utils::embeds::interaction_response_data_from_embed; use crate::utils::errors::Error; +use crate::{extract, get_option, get_required_option}; +use mongodb::bson::doc; +use std::sync::Arc; +use twilight_http::Client; +use twilight_model::application::interaction::application_command::CommandOptionValue; pub async fn run( interaction: InteractionContext, context: Arc, discord_http: Arc, - _: GuildConfig + _: GuildConfig, ) -> ResponseData { extract!(interaction.orginal, guild_id); let case_index = get_required_option!( - interaction.options.get("number"), CommandOptionValue::Integer + interaction.options.get("number"), + CommandOptionValue::Integer ); - let case = context.mongodb.cases.find_one( - doc! { - "guild_id": guild_id.to_string(), - "index": case_index, - "removed": false - }, None - ).await.map_err(Error::from)?.ok_or("Cannot find case with selected id")?; + let case = context + .mongodb + .cases + .find_one( + doc! { + "guild_id": guild_id.to_string(), + "index": case_index, + "removed": false + }, + None, + ) + .await + .map_err(Error::from)? + .ok_or("Cannot find case with selected id")?; - Ok((interaction_response_data_from_embed( - case.to_embed(discord_http).await?, - false - ), None)) -} \ No newline at end of file + Ok(( + interaction_response_data_from_embed(case.to_embed(discord_http).await?, false), + None, + )) +} diff --git a/src/commands/moderation/clear.rs b/src/commands/moderation/clear.rs index db0bc64..43e7c10 100644 --- a/src/commands/moderation/clear.rs +++ b/src/commands/moderation/clear.rs @@ -1,73 +1,93 @@ +use crate::commands::context::InteractionContext; +use crate::commands::ResponseData; +use crate::context::Context; +use crate::models::config::GuildConfig; +use crate::utils::errors::Error; +use crate::{extract, get_option, get_required_option, render_context}; use std::sync::Arc; use twilight_http::Client; use twilight_model::application::interaction::application_command::CommandOptionValue; +use twilight_model::channel::message::MessageType; use twilight_model::channel::Message; -use twilight_model::channel::message::{MessageFlags, MessageType}; -use twilight_model::http::interaction::InteractionResponseData; -use twilight_model::id::Id; use twilight_model::id::marker::MessageMarker; -use crate::commands::ResponseData; -use crate::context::Context; -use crate::{extract, get_option, get_required_option}; -use crate::commands::context::InteractionContext; -use crate::models::config::GuildConfig; -use crate::utils::errors::Error; +use twilight_model::id::Id; pub async fn run( interaction: InteractionContext, - _: Arc, + context: Arc, discord_http: Arc, - _: GuildConfig + config: GuildConfig, ) -> ResponseData { - extract!(interaction.orginal, channel_id); let amount = get_required_option!( - interaction.options.get("amount"), CommandOptionValue::Integer + interaction.options.get("amount"), + CommandOptionValue::Integer ); - let member = get_option!( - interaction.options.get("member"), CommandOptionValue::User - ).copied(); + let member = get_option!(interaction.options.get("member"), CommandOptionValue::User).copied(); let filter = get_option!( - interaction.options.get("member"), CommandOptionValue::String + interaction.options.get("member"), + CommandOptionValue::String ); if !(&2..=&600).contains(&amount) { - return Err(Error::from("You can clear up to 600 messages")) + return Err(Error::from("You can clear up to 600 messages")); } - let loops = if amount % 100 != 0 { (amount / 100) + 1 } else { amount / 50 }; + let loops = if amount % 100 != 0 { + (amount / 100) + 1 + } else { + amount / 50 + }; let mut last = None; - for i in 0..loops { + let mut total = 0; - let amount = if loops - i == 1 { amount - (i * 100) } else { 100 }; + for i in 0..loops { + let amount = if loops - i == 1 { + amount - (i * 100) + } else { + 100 + }; let mut messages = if let Some(last) = last { - discord_http.channel_messages(channel_id) - .limit(amount as u16).map_err(Error::from)? + discord_http + .channel_messages(channel_id) + .limit(amount as u16) + .map_err(Error::from)? .after(last) - .await.map_err(Error::from)? - .model().await.map_err(Error::from)? + .await + .map_err(Error::from)? + .model() + .await + .map_err(Error::from)? } else { - discord_http.channel_messages(channel_id) - .limit(amount as u16).map_err(Error::from)? - .await.map_err(Error::from)? - .model().await.map_err(Error::from)? + discord_http + .channel_messages(channel_id) + .limit(amount as u16) + .map_err(Error::from)? + .await + .map_err(Error::from)? + .model() + .await + .map_err(Error::from)? }; last = messages.last().map(|msg| msg.id); if let Some(member) = member { - messages = messages.iter() + messages = messages + .iter() .filter(|msg| msg.author.id == member) - .cloned().collect::>(); + .cloned() + .collect::>(); } if let Some(filter) = filter { - messages = messages.iter() + messages = messages + .iter() .filter(|msg| { match filter.as_str() { "system" => msg.kind != MessageType::Regular, @@ -76,28 +96,42 @@ pub async fn run( "embeds" => !msg.embeds.is_empty(), _ /*bots*/ => msg.author.bot } - }).cloned().collect::>(); + }) + .cloned() + .collect::>(); } - let messages = messages.iter() - .map(|message| message.id).collect::>>(); + let messages = messages + .iter() + .map(|message| message.id) + .collect::>>(); - if messages.is_empty() { continue } + if messages.is_empty() { + continue; + } + total += messages.len(); - discord_http.delete_messages(channel_id, &messages).map_err(Error::from)?.await.map_err(Error::from)?; + discord_http + .delete_messages(channel_id, &messages) + .map_err(Error::from)? + .await + .map_err(Error::from)?; } - Ok((InteractionResponseData { - allowed_mentions: None, - attachments: None, - choices: None, - components: None, - content: Some("Deleting messages".to_string()), - custom_id: None, - embeds: None, - flags: Some(MessageFlags::EPHEMERAL), - title: None, - tts: None - }, None)) - -} \ No newline at end of file + Ok(( + config + .assets + .render_message( + &context.assets, + "commands.clear", + &mut render_context!( + ["interaction", &interaction.orginal], + ["amount", &amount], + ["cleared", &total] + ), + &context.redis, + ) + .await?, + None, + )) +} diff --git a/src/commands/settings/setup.rs b/src/commands/settings/setup.rs index 016ec28..7e45aae 100644 --- a/src/commands/settings/setup.rs +++ b/src/commands/settings/setup.rs @@ -1,32 +1,27 @@ -use std::sync::Arc; -use twilight_http::Client; -use twilight_model::channel::message::MessageFlags; -use twilight_model::http::interaction::InteractionResponseData; use crate::commands::context::InteractionContext; -use crate::context::Context; -use crate::extract; use crate::commands::ResponseData; +use crate::context::Context; use crate::models::config::GuildConfig; +use crate::render_context; +use std::sync::Arc; +use twilight_http::Client; pub async fn run( interaction: InteractionContext, - _: Arc, + context: Arc, _: Arc, - _: GuildConfig + config: GuildConfig, ) -> ResponseData { - extract!(interaction.orginal, guild_id); - Ok((InteractionResponseData { - allowed_mentions: None, - attachments: None, - choices: None, - components: None, - content: Some( - format!("**The server setup is not completed yet or commands are not synced**\n\nTo complete a setup open the dashboard https://custom.fail/setup?guild={guild_id}\nTo sync commands open a server settings https://custom.fail/servers/{guild_id}") - ), - custom_id: None, - embeds: None, - flags: Some(MessageFlags::EPHEMERAL), - title: None, - tts: None - }, None)) -} \ No newline at end of file + Ok(( + config + .assets + .render_message( + &context.assets, + "commands.setup", + &mut render_context!(["interaction", &interaction.orginal]), + &context.redis, + ) + .await?, + None, + )) +} diff --git a/src/commands/top/all.rs b/src/commands/top/all.rs index e998613..bd211ec 100644 --- a/src/commands/top/all.rs +++ b/src/commands/top/all.rs @@ -1,45 +1,48 @@ -use std::sync::Arc; -use twilight_http::Client; +use crate::commands::context::InteractionContext; +use crate::commands::ResponseData; use crate::context::Context; use crate::models::config::GuildConfig; -use crate::utils::embeds::EmbedBuilder; use crate::utils::errors::Error; -use crate::commands::context::InteractionContext; -use crate::commands::ResponseData; -use crate::extract; - -const PLACES_EMOTES: [&str; 3] = [":first_place:", ":second_place:", ":third_place:"]; +use crate::{extract, render_context}; +use std::sync::Arc; +use twilight_http::Client; pub async fn run( interaction: InteractionContext, context: Arc, _: Arc, - _: GuildConfig + config: GuildConfig, ) -> ResponseData { extract!(interaction.orginal, guild_id); - let week_or_day = interaction.command_vec.get(1).cloned() + let week_or_day = interaction + .command_vec + .get(1) + .cloned() .ok_or("Invalid command")?; if !["week", "day"].contains(&week_or_day.as_str()) { - return Err(Error::from("Invalid command")) + return Err(Error::from("Invalid command")); } - let leaderboard = context.redis.get_all(format!("top_{week_or_day}.{guild_id}"), 3).map_err(Error::from)?; - - let leaderboard_string = leaderboard - .iter() - .enumerate() - .map(|(index, (user_id, messages))| -> String { - format!("{} > <@{user_id}> ({messages})", PLACES_EMOTES[index]) - }) - .collect::>() - .join("\n"); + let leaderboard = context + .redis + .get_all(format!("top_{week_or_day}.{guild_id}"), 3) + .map_err(Error::from)?; Ok(( - EmbedBuilder::new() - .title(format!("Top {week_or_day} users")) - .description(leaderboard_string) - .to_interaction_response_data(false), - None + config + .assets + .render_message( + &context.assets, + "commands.top.all", + &mut render_context!( + ["interaction", &interaction.orginal], + ["leaderboard", &leaderboard], + ["weekOrDay", &week_or_day] + ), + &context.redis, + ) + .await?, + None, )) -} \ No newline at end of file +} diff --git a/src/commands/top/me.rs b/src/commands/top/me.rs index a2e3348..7c048a1 100644 --- a/src/commands/top/me.rs +++ b/src/commands/top/me.rs @@ -1,67 +1,81 @@ -use std::sync::Arc; -use twilight_http::Client; -use crate::context::Context; -use crate::utils::embeds::EmbedBuilder; -use crate::utils::errors::Error; use crate::commands::context::InteractionContext; use crate::commands::ResponseData; -use crate::extract; +use crate::context::Context; use crate::models::config::GuildConfig; +use crate::utils::errors::Error; +use crate::{extract, render_context}; +use std::sync::Arc; +use twilight_http::Client; pub async fn run( interaction: InteractionContext, context: Arc, _: Arc, - _: GuildConfig + config: GuildConfig, ) -> ResponseData { - extract!(interaction.orginal, guild_id, member); - extract!(member, user); + extract!(interaction.orginal, guild_id); + let user_id = interaction + .orginal + .author_id() + .ok_or("Cannot find user information")?; - let week_or_day = interaction.command_vec.get(1).cloned() + let week_or_day = interaction + .command_vec + .get(1) + .cloned() .ok_or("Invalid command")?; if !["week", "day"].contains(&week_or_day.as_str()) { - return Err(Error::from("Invalid command")) + return Err(Error::from("Invalid command")); } - let (user_score, user_position) = context.redis.get_by_user( - format!("top_{week_or_day}.{guild_id}"), user.id - ).map_err(Error::from)?; + let (user_score, user_position) = context + .redis + .get_by_user(format!("top_{week_or_day}.{guild_id}"), user_id) + .map_err(Error::from)?; - let mut result = format!("You are **{}** with **{user_score}** messages", user_position + 1); + let leaderboard = context + .redis + .get_all(format!("top_{week_or_day}.{guild_id}"), 3) + .map_err(Error::from)?; - let leaderboard = context.redis.get_all( - format!("top_{week_or_day}.{guild_id}"), 3 - ).map_err(Error::from)?; + let user_after = if user_position > 0 { + context + .redis + .get_by_position( + format!("top_{week_or_day}.{guild_id}"), + (user_position - 1) as usize, + ) + .map_err(Error::from)? + } else { + None + }; - let leaderboard_string = leaderboard.iter().enumerate() - .map(|(index, top)| - if user_position <= (index as u32) { - format!("You are **{}** messages behind **{}** place", user_score - top.1, index + 1) } - else { format!("You need **{}** messages to be **{}**", top.1 - user_score, index + 1) } + let user_before = context + .redis + .get_by_position( + format!("top_{week_or_day}.{guild_id}"), + (user_position + 1) as usize, ) - .collect::>().join("\n"); - - result = format!("{result}\n\n{leaderboard_string}\n\n"); - - if user_position > 0 { - let user_after = context.redis.get_by_position( - format!("top_{week_or_day}.{guild_id}"), (user_position - 1) as usize - ).map_err(Error::from)?.ok_or("There is no user_after")?; - result = format!("{result}**{user_after}** messages to beat next user\n"); - } - - let user_before = context.redis.get_by_position( - format!("top_{week_or_day}.{guild_id}"), (user_position + 1) as usize - ).map_err(Error::from)?.ok_or("There is no `user_before`")?; - result = format!("{result}**{user_before}** messages for user before (to you)"); + .map_err(Error::from)?; Ok(( - EmbedBuilder::new() - .title( - format!("Top of the {week_or_day} for {}#{}", user.name, user.discriminator) + config + .assets + .render_message( + &context.assets, + "commands.top.all", + &mut render_context!( + ["interaction", &interaction.orginal], + ["leaderboard", &leaderboard], + ["weekOrDay", &week_or_day], + ["userPosition", &user_position], + ["userScore", &user_score], + ["userBefore", &user_before], + ["userAfter", &user_after] + ), + &context.redis, ) - .description(result) - .to_interaction_response_data(false), - None + .await?, + None, )) -} \ No newline at end of file +} diff --git a/src/context.rs b/src/context.rs index 5eb4be7..1f56c22 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,4 +1,5 @@ use crate::{database::{mongodb::MongoDBConnection, redis::RedisConnection}, links::ScamLinks, bucket::Bucket, application::Application, assets::AssetsManager}; +use crate::utils::cli::LoadingAnimation; pub struct Context { pub application: Application, @@ -14,14 +15,22 @@ impl Context { let mongodb_url = std::env::var("MONGODB_URL").expect("Cannot load MONGODB_URL from .env"); let redis_url = std::env::var("REDIS_URL").expect("Cannot load REDIS_URL from .env"); + let loading = LoadingAnimation::new("Connecting to: MongoDB"); let mongodb = MongoDBConnection::connect(mongodb_url).await.unwrap(); + loading.finish("Connected to: MongoDB "); + + let loading = LoadingAnimation::new("Connecting to: Redis"); let redis = RedisConnection::connect(redis_url).unwrap(); + loading.finish("Connected to: Redis "); - let scam_domains = ScamLinks::new().await.expect("Cannot load scam links manager"); + // loading it after scam_domains messages makes stdout writing over stdin + let assets = AssetsManager::new().await; + let scam_domains = ScamLinks::new() + .await + .expect("Cannot load scam links manager"); scam_domains.connect(); let bucket: Bucket = Default::default(); let application = Application::new(); - let assets = AssetsManager::new().await; Self { mongodb, redis, scam_domains, bucket, application, assets } } diff --git a/src/events/setup.rs b/src/events/setup.rs index 6aaf00f..9afe612 100644 --- a/src/events/setup.rs +++ b/src/events/setup.rs @@ -11,17 +11,18 @@ pub async fn run( twilight_http: Arc ) -> Result<(), Error> { if let Some(joined_at) = joined_at { - if chrono::Utc::now().timestamp_millis() - (joined_at.as_micros() / 1000) > 2 * 1000 { + if chrono::Utc::now().timestamp_millis() - (joined_at.as_micros() / 1000) > 30 * 1000 { return Ok(()) } } else { return Ok(()); }; let application_id = twilight_http.current_user() .await.map_err(Error::from)?.model().await.map_err(Error::from)?.id; + twilight_http .interaction(application_id.cast()) .create_guild_command(guild_id) .chat_input( - "setup", "Shows where you can setup the bot" + "setup", "Shows how and where you can setup the bot" ).map_err(Error::from)?.await.map_err(Error::from).map(|_| ()) } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 28381c8..1318256 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -use std::sync::Arc; use crate::bucket::Bucket; use crate::context::Context; use crate::database::mongodb::MongoDBConnection; @@ -8,21 +7,22 @@ use crate::gateway::shard::connect_shards; use crate::links::ScamLinks; use dotenv::dotenv; use ed25519_dalek::PublicKey; +use std::sync::Arc; use twilight_http::Client; +mod application; +mod assets; +mod bucket; +mod commands; mod context; +mod database; mod events; +mod gateway; mod links; -mod bucket; +mod models; mod server; -mod database; -mod utils; -mod commands; mod tasks; -mod models; -mod gateway; -mod application; -mod assets; +mod utils; #[tokio::main] async fn main() { @@ -31,8 +31,8 @@ async fn main() { let args: Vec = std::env::args().collect(); let context = Arc::new(Context::new().await); - let discord_token = std::env::var("DISCORD_TOKEN") - .expect("Cannot load DISCORD_TOKEN from .env"); + let discord_token = + std::env::var("DISCORD_TOKEN").expect("Cannot load DISCORD_TOKEN from .env"); let main_http = Arc::new(Client::new(discord_token.to_owned())); if args.contains(&"--gateway".to_string()) || args.contains(&"-A".to_string()) { @@ -43,7 +43,7 @@ async fn main() { tasks::run( context.mongodb.to_owned(), discord_clients.to_owned(), - main_http.to_owned() + main_http.to_owned(), ); } @@ -51,27 +51,27 @@ async fn main() { } let run = connect_shards( - ("main".to_string(), Arc::new( - Client::new(discord_token.to_owned()) - )), - context.to_owned() + ( + "main".to_string(), + Arc::new(Client::new(discord_token.to_owned())), + ), + context.to_owned(), ); if args.contains(&"--http".to_string()) || args.contains(&"-A".to_string()) { tokio::spawn(run); - } else { run.await; } + } else { + run.await; + } } const INVALID_PUBLIC_KEY: &str = "PUBLIC_KEY provided in .env is invalid"; if args.contains(&"--http".to_string()) || args.contains(&"-A".to_string()) { - let public_key = std::env::var("PUBLIC_KEY") - .expect("Cannot load PUBLIC_KEY from .env"); + let public_key = std::env::var("PUBLIC_KEY").expect("Cannot load PUBLIC_KEY from .env"); let pbk_bytes = hex::decode(public_key.as_str()).expect(INVALID_PUBLIC_KEY); let public_key = PublicKey::from_bytes(&pbk_bytes).expect(INVALID_PUBLIC_KEY); - crate::server::listen( - 80, context, main_http, public_key - ).await; + crate::server::listen(80, context, main_http, public_key).await; } -} \ No newline at end of file +} diff --git a/src/models/config/mod.rs b/src/models/config/mod.rs index 5e32dc2..81bdec0 100644 --- a/src/models/config/mod.rs +++ b/src/models/config/mod.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use serde::{Serialize, Deserialize}; use twilight_model::id::Id; use twilight_model::id::marker::{ApplicationMarker, GuildMarker}; +use crate::assets::GuildAssets; use crate::models::config::activity::{Levels, Top}; use crate::models::config::moderation::{Moderation, MuteMode}; @@ -15,6 +16,7 @@ pub mod automod; pub struct GuildConfig { pub guild_id: Id, pub application_id: Option>, + pub assets: GuildAssets, pub enabled: HashMap, pub moderation: Moderation, pub premium: bool, @@ -46,7 +48,8 @@ impl GuildConfig { week: false, day: false, webhook_url: "".to_string() - } + }, + assets: GuildAssets(vec![]), } } diff --git a/src/utils/cli.rs b/src/utils/cli.rs index 5a79257..b03f55d 100644 --- a/src/utils/cli.rs +++ b/src/utils/cli.rs @@ -1,11 +1,59 @@ -use std::io::stdin; +use console::Term; +use std::io::{stdout, Write}; +use std::time::Duration; +use tokio::task::JoinHandle; +use tokio::time::sleep; -pub fn confirm() -> bool { - let mut buffer = String::new(); - stdin().read_line(&mut buffer).unwrap(); - match buffer.to_lowercase().as_str() { - "y" => true, - "n" => false, - _ => confirm() +pub fn confirm(msg: &str) -> bool { + let mut term = Term::stdout(); + term.write(&format!("{msg} [y/n]: ").into_bytes()).unwrap(); + let result = match term.read_char().unwrap() { + 'Y' | 'y' => true, + 'N' | 'n' => false, + _ => { + term.write("\n".as_bytes()).unwrap(); + confirm(msg) + } + }; + term.write("\n".as_bytes()).unwrap(); + result +} + +const STATES: [char; 8] = ['⢿', '⣻', '⣽', '⣾', '⣷', '⣯', '⣟', '⡿']; + +pub struct LoadingAnimation { + task: JoinHandle<()>, +} + +impl LoadingAnimation { + pub fn new(msg: &'static str) -> Self { + Self { + task: tokio::spawn(Self::animate(msg)), + } + } + + pub fn finish(&self, msg: &str) { + self.task.abort(); + print!("\r{msg} "); + stdout().flush().unwrap(); + println!(); + } + + async fn animate(msg: &'static str) { + let i = &mut 0; + loop { + Self::frame(i, msg).await; + } + } + + async fn frame(i: &mut usize, msg: &'static str) { + print!("\r{} {}", STATES[*i], msg); + stdout().flush().unwrap(); + if *i >= 7 { + *i = 0 + } else { + *i += 1 + } + sleep(Duration::from_millis(150)).await; } -} \ No newline at end of file +} diff --git a/src/utils/config.rs b/src/utils/config.rs index e04898a..c81ee2b 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use twilight_model::id::Id; -use crate::models::config::{GuildConfig, moderation::{Moderation, MuteMode}, automod::{AutoModeration, AutoModerationRule, ignore::{Ignore, IgnoreMode}, actions::{ActionMetadata, Action, IncreaseBucket, IncreaseBucketAmount, BucketAction, Timeout}}, activity::{Levels, Top}}; +use crate::{models::config::{GuildConfig, moderation::{Moderation, MuteMode}, automod::{AutoModeration, AutoModerationRule, ignore::{Ignore, IgnoreMode}, actions::{ActionMetadata, Action, IncreaseBucket, IncreaseBucketAmount, BucketAction, Timeout}}, activity::{Levels, Top}}, assets::GuildAssets}; #[allow(dead_code)] @@ -74,5 +74,6 @@ pub fn create_debug_config() -> GuildConfig { day: true, webhook_url: String::new(), }, + assets: GuildAssets(vec![]), } } \ No newline at end of file diff --git a/src/utils/errors.rs b/src/utils/errors.rs index e32488f..8073234 100644 --- a/src/utils/errors.rs +++ b/src/utils/errors.rs @@ -89,6 +89,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: crate::assets::render::RenderError) -> Self { + Self::Debug(vec![format!("{}", error.to_string())]) + } +} + impl Error { pub fn to_interaction_data_response(&self) -> InteractionResponseData { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index b15a40e..07df238 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -6,4 +6,5 @@ pub mod avatars; pub mod uppercase; pub mod constants; pub mod message; -pub mod config; \ No newline at end of file +pub mod config; +pub mod cli; \ No newline at end of file