From 59bf42998be9ac2d122ceaa0f724515b8cf1bf27 Mon Sep 17 00:00:00 2001 From: seth Date: Fri, 29 Mar 2024 17:54:53 -0400 Subject: [PATCH] feat: add /set_welcome command --- src/commands/general/mod.rs | 25 +-- src/commands/general/say.rs | 21 +-- src/commands/mod.rs | 45 +++++- src/commands/moderation/mod.rs | 1 + src/commands/moderation/set_welcome.rs | 203 +++++++++++++++++++++++++ src/config/discord.rs | 34 +++-- src/utils/mod.rs | 8 + 7 files changed, 286 insertions(+), 51 deletions(-) create mode 100644 src/commands/moderation/mod.rs create mode 100644 src/commands/moderation/set_welcome.rs diff --git a/src/commands/general/mod.rs b/src/commands/general/mod.rs index 050caff..7e2d92f 100644 --- a/src/commands/general/mod.rs +++ b/src/commands/general/mod.rs @@ -1,17 +1,8 @@ -mod help; -mod joke; -mod members; -mod ping; -mod rory; -mod say; -mod stars; -mod tag; - -pub use help::help; -pub use joke::joke; -pub use members::members; -pub use ping::ping; -pub use rory::rory; -pub use say::say; -pub use stars::stars; -pub use tag::tag; +pub mod help; +pub mod joke; +pub mod members; +pub mod ping; +pub mod rory; +pub mod say; +pub mod stars; +pub mod tag; diff --git a/src/commands/general/say.rs b/src/commands/general/say.rs index ebb754c..5979c3c 100644 --- a/src/commands/general/say.rs +++ b/src/commands/general/say.rs @@ -1,8 +1,8 @@ -use crate::Context; +use crate::{utils, Context}; use eyre::{OptionExt, Result}; use log::trace; -use poise::serenity_prelude::{CreateEmbed, CreateEmbedAuthor, CreateMessage}; +use poise::serenity_prelude::{CreateEmbed, CreateMessage}; /// Say something through the bot #[poise::command( @@ -17,7 +17,6 @@ pub async fn say( ctx: Context<'_>, #[description = "the message content"] content: String, ) -> Result<()> { - let guild = ctx.guild().ok_or_eyre("Couldn't get guild!")?.to_owned(); let channel = ctx .guild_channel() .await @@ -41,19 +40,9 @@ pub async fn say( .clone() .discord_config() .channels() - .say_log_channel_id() + .log_channel_id() { - let log_channel = guild - .channels - .iter() - .find(|c| c.0 == &channel_id) - .ok_or_eyre("Couldn't get log channel from guild!")?; - - let author = CreateEmbedAuthor::new(ctx.author().tag()).icon_url( - ctx.author() - .avatar_url() - .unwrap_or_else(|| ctx.author().default_avatar_url()), - ); + let author = utils::embed_author_from_user(ctx.author()); let embed = CreateEmbed::default() .title("Say command used!") @@ -61,7 +50,7 @@ pub async fn say( .author(author); let message = CreateMessage::new().embed(embed); - log_channel.1.send_message(ctx, message).await?; + channel_id.send_message(ctx, message).await?; } else { trace!("Not sending /say log as no channel is set"); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b8f7418..33216d3 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,18 +3,47 @@ use crate::Data; use eyre::Report; mod general; +mod moderation; + +macro_rules! command { + ($module: ident, $name: ident) => { + $module::$name::$name() + }; + + ($module: ident, $name: ident, $func: ident) => { + $module::$name::$func() + }; +} + +macro_rules! module_macro { + ($module: ident) => { + macro_rules! $module { + ($name: ident) => { + command!($module, $name) + }; + + ($name: ident, $func: ident) => { + command!($module, $name, $func) + }; + } + }; +} + +module_macro!(general); +module_macro!(moderation); pub type Command = poise::Command; pub fn get() -> Vec { vec![ - general::joke(), - general::members(), - general::ping(), - general::rory(), - general::say(), - general::stars(), - general::tag(), - general::help(), + general!(help), + general!(joke), + general!(members), + general!(ping), + general!(rory), + general!(say), + general!(stars), + general!(tag), + moderation!(set_welcome), ] } diff --git a/src/commands/moderation/mod.rs b/src/commands/moderation/mod.rs new file mode 100644 index 0000000..d5578a0 --- /dev/null +++ b/src/commands/moderation/mod.rs @@ -0,0 +1 @@ +pub mod set_welcome; diff --git a/src/commands/moderation/set_welcome.rs b/src/commands/moderation/set_welcome.rs new file mode 100644 index 0000000..c5c7f03 --- /dev/null +++ b/src/commands/moderation/set_welcome.rs @@ -0,0 +1,203 @@ +use std::{fmt::Write, str::FromStr}; + +use crate::{utils, Context}; + +use eyre::{bail, Result}; +use log::trace; +use poise::serenity_prelude::{ + futures::StreamExt, Attachment, CreateActionRow, CreateButton, CreateEmbed, CreateMessage, + Mentionable, ReactionType, +}; +use serde::{Deserialize, Serialize}; +use url::Url; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct WelcomeEmbed { + title: String, + description: Option, + url: Option, + hex_color: Option, + image: Option, +} + +impl From for CreateMessage { + fn from(val: WelcomeEmbed) -> Self { + let mut embed = CreateEmbed::new(); + + embed = embed.title(val.title); + if let Some(description) = val.description { + embed = embed.description(description); + } + + if let Some(url) = val.url { + embed = embed.url(url); + } + + if let Some(color) = val.hex_color { + let hex = i32::from_str_radix(&color, 16).unwrap(); + embed = embed.color(hex); + } + + if let Some(image) = val.image { + embed = embed.image(image); + } + + Self::new().embed(embed) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct WelcomeRole { + title: String, + id: u64, + emoji: Option, +} + +impl From for CreateButton { + fn from(value: WelcomeRole) -> Self { + let mut button = Self::new(value.id.to_string()).label(value.title); + if let Some(emoji) = value.emoji { + button = button.emoji(ReactionType::from_str(&emoji).unwrap()); + } + + button + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct WelcomeRoleCategory { + title: String, + description: Option, + roles: Vec, +} + +impl From for CreateMessage { + fn from(value: WelcomeRoleCategory) -> Self { + let mut content = format!("**{}**", value.title); + if let Some(description) = value.description { + write!(content, "\n{description}").ok(); + } + + let buttons: Vec = value + .roles + .iter() + .map(|role| CreateButton::from(role.clone())) + .collect(); + + let components = vec![CreateActionRow::Buttons(buttons)]; + Self::new().content(content).components(components) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct WelcomeLayout { + embeds: Vec, + messages: Vec, + roles: Vec, +} + +/// Sets your welcome channel info +#[poise::command( + slash_command, + guild_only, + ephemeral, + default_member_permissions = "MANAGE_GUILD", + required_permissions = "MANAGE_GUILD" +)] +pub async fn set_welcome( + ctx: Context<'_>, + #[description = "A file to use"] file: Option, + #[description = "A URL for a file to use"] url: Option, +) -> Result<()> { + trace!("Running set_welcome command!"); + + let configured_channels = ctx.data().config.clone().discord_config().channels(); + let Some(channel_id) = configured_channels.welcome_channel_id() else { + ctx.say("You don't have a welcome channel ID set, so I can't do anything :(") + .await?; + return Ok(()); + }; + + ctx.defer_ephemeral().await?; + + // download attachment from discord or URL + let file = if let Some(attachment) = file { + let Some(content_type) = &attachment.content_type else { + bail!("Welcome channel attachment was sent without a content type!"); + }; + + if !content_type.starts_with("application/json;") { + trace!("Not attempting to read non-json content type {content_type}"); + ctx.say("Invalid file! Please only send json").await?; + return Ok(()); + } + + let downloaded = attachment.download().await?; + String::from_utf8(downloaded)? + } else if let Some(url) = url { + if Url::parse(&url).is_err() { + ctx.say("Invalid url!").await?; + return Ok(()); + } + + utils::text_from_url(&url).await? + } else { + ctx.say("A text file or URL must be provided!").await?; + return Ok(()); + }; + + // parse and create messages from file + let welcome_layout: WelcomeLayout = serde_json::from_str(&file)?; + let embed_messages: Vec = welcome_layout + .embeds + .iter() + .map(|e| CreateMessage::from(e.clone())) + .collect(); + let roles_messages: Vec = welcome_layout + .roles + .iter() + .map(|c| CreateMessage::from(c.clone())) + .collect(); + + // clear previous messages + let mut prev_messages = channel_id.messages_iter(ctx).boxed(); + while let Some(prev_message) = prev_messages.next().await { + if let Ok(message) = prev_message { + message.delete(ctx).await?; + } + } + + // send our new ones + for embed in embed_messages { + channel_id.send_message(ctx, embed).await?; + } + + for message in roles_messages { + channel_id.send_message(ctx, message).await?; + } + + for message in welcome_layout.messages { + channel_id.say(ctx, message).await?; + } + + if let Some(log_channel) = configured_channels.log_channel_id() { + let author = utils::embed_author_from_user(ctx.author()); + let embed = CreateEmbed::new() + .title("set_welcome command used!") + .author(author); + let message = CreateMessage::new().embed(embed); + + log_channel.send_message(ctx, message).await?; + } else { + trace!("Not sending /set_welcome log as no channel is set"); + } + + ctx.reply(format!("Updated {}!", channel_id.mention())) + .await?; + + Ok(()) +} diff --git a/src/config/discord.rs b/src/config/discord.rs index e1051ee..837c860 100644 --- a/src/config/discord.rs +++ b/src/config/discord.rs @@ -5,7 +5,8 @@ use poise::serenity_prelude::ChannelId; #[derive(Clone, Copy, Debug, Default)] pub struct RefractionChannels { - say_log_channel_id: Option, + log_channel_id: Option, + welcome_channel_id: Option, } #[derive(Clone, Debug, Default)] @@ -14,20 +15,29 @@ pub struct Config { } impl RefractionChannels { - pub fn new(say_log_channel_id: Option) -> Self { - Self { say_log_channel_id } + pub fn new(log_channel_id: Option, welcome_channel_id: Option) -> Self { + Self { + log_channel_id, + welcome_channel_id, + } } pub fn new_from_env() -> Self { - let say_log_channel_id = Self::get_channel_from_env("DISCORD_SAY_LOG_CHANNELID"); - - if let Some(channel_id) = say_log_channel_id { + let log_channel_id = Self::get_channel_from_env("DISCORD_LOG_CHANNEL_ID"); + if let Some(channel_id) = log_channel_id { info!("Log channel is {channel_id}"); } else { - warn!("DISCORD_SAY_LOG_CHANNELID is empty; this will disable logging in your server."); + warn!("DISCORD_LOG_CHANNEL_ID is empty; this will disable logging in your server."); } - Self::new(say_log_channel_id) + let welcome_channel_id = Self::get_channel_from_env("DISCORD_WELCOME_CHANNEL_ID"); + if let Some(channel_id) = welcome_channel_id { + info!("Welcome channel is {channel_id}"); + } else { + warn!("DISCORD_WELCOME_CHANNEL_ID is empty; this will disable welcome channel features in your server"); + } + + Self::new(log_channel_id, welcome_channel_id) } fn get_channel_from_env(var: &str) -> Option { @@ -36,8 +46,12 @@ impl RefractionChannels { .and_then(|env_var| ChannelId::from_str(&env_var).ok()) } - pub fn say_log_channel_id(self) -> Option { - self.say_log_channel_id + pub fn log_channel_id(self) -> Option { + self.log_channel_id + } + + pub fn welcome_channel_id(self) -> Option { + self.welcome_channel_id } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 64172ca..62aa051 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,6 +2,7 @@ use crate::api::REQWEST_CLIENT; use eyre::Result; use log::debug; +use poise::serenity_prelude::{CreateEmbedAuthor, User}; use reqwest::Response; pub mod resolve_message; @@ -27,3 +28,10 @@ pub async fn bytes_from_url(url: &str) -> Result> { let bytes = resp.bytes().await?; Ok(bytes.to_vec()) } + +pub fn embed_author_from_user(user: &User) -> CreateEmbedAuthor { + CreateEmbedAuthor::new(user.tag()).icon_url( + user.avatar_url() + .unwrap_or_else(|| user.default_avatar_url()), + ) +}