diff --git a/Cargo.lock b/Cargo.lock index 3d7601a..1778c94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1224,6 +1224,7 @@ dependencies = [ name = "refraction" version = "2.0.0" dependencies = [ + "async-trait", "color-eyre", "dotenvy", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index d38897d..a629f5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ serde = "1.0.193" serde_json = "1.0.108" [dependencies] +async-trait = "0.1.74" color-eyre = "0.6.2" dotenvy = "0.15.7" env_logger = "0.10.0" diff --git a/src/commands/mod.rs b/src/commands/mod.rs index dadec27..10f9681 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -15,7 +15,8 @@ pub fn to_global_commands() -> Vec> { general::say(), general::stars(), general::tag(), - moderation::ban(), - moderation::kick(), + moderation::ban_user(), + moderation::mass_ban(), + moderation::kick_user(), ] } diff --git a/src/commands/moderation/actions.rs b/src/commands/moderation/actions.rs index 4a0ed5a..b7ab8b2 100644 --- a/src/commands/moderation/actions.rs +++ b/src/commands/moderation/actions.rs @@ -1,146 +1,233 @@ use crate::{consts::COLORS, Context}; -use color_eyre::eyre::{eyre, Result}; -use poise::serenity_prelude::{ - futures::TryFutureExt, CreateEmbed, CreateMessage, FutureExt, Guild, Timestamp, User, UserId, -}; +use async_trait::async_trait; +use color_eyre::eyre::{eyre, Context as _, Result}; +use log::*; +use poise::serenity_prelude::{CacheHttp, Http, Member, Timestamp}; -struct Action { - reason: String, - data: ActionData, +type Fields<'a> = Vec<(&'a str, String, bool)>; + +#[async_trait] +pub trait ModActionInfo { + fn to_fields(&self) -> Option; + fn description(&self) -> String; + async fn run_action( + &self, + http: impl CacheHttp + AsRef, + user: &Member, + reason: String, + ) -> Result<()>; } -enum ActionData { - Kick, - Ban { purge: u8 }, - Timeout { until: Timestamp }, +pub struct ModAction +where + T: ModActionInfo, +{ + pub reason: Option, + pub data: T, } -fn build_dm<'a, 'b>( - message: &'b mut CreateMessage<'a>, - guild: &Guild, - action: &Action, -) -> &'b mut CreateMessage<'a> { - let description = match &action.data { - ActionData::Kick => "kicked from".to_string(), - ActionData::Ban { purge: _ } => "banned from".to_string(), - ActionData::Timeout { until } => { - format!("timed out until in", until.unix_timestamp()) - } - }; - let guild_name = &guild.name; - let reason = &action.reason; - message.content(format!( - "You have been {description} {guild_name}.\nReason: {reason}" - )) -} +impl ModAction { + fn get_all_fields(&self) -> Fields { + let mut fields = vec![]; -async fn moderate( - ctx: &Context<'_>, - users: &Vec, - action: &Action, - quiet: bool, -) -> Result<()> { - let guild = ctx - .guild() - .ok_or_else(|| eyre!("Couldn't get guild from message!"))?; - let reason = &action.reason; - - let mut count = 0; - - for user in users { - if quiet { - if let Ok(channel) = user.create_dm_channel(ctx.http()).await { - let _ = channel - .send_message(ctx.http(), |message| build_dm(message, &guild, action)) - .await; - } + if let Some(reason) = self.reason.clone() { + fields.push(("Reason:", reason, false)); } - let success = match action.data { - ActionData::Kick => guild - .kick_with_reason(ctx.http(), user, reason) - .await - .is_ok(), - - ActionData::Ban { purge } => guild - .ban_with_reason(ctx.http(), user, purge, reason) - .await - .is_ok(), - - ActionData::Timeout { until } => guild - .edit_member(ctx.http(), user, |member| { - member.disable_communication_until_datetime(until) - }) - .await - .is_ok(), - }; - if success { - count += 1; + if let Some(mut action_fields) = self.data.to_fields() { + fields.append(&mut action_fields); } + + fields } - let total = users.len(); - if count == total { - ctx.reply("✅ Done!").await?; - } else { - ctx.reply(format!("⚠️ {count}/{total} succeeded!")) + /// internal mod logging + pub async fn log_action(&self, ctx: &Context<'_>) -> Result<()> { + let channel_id = ctx + .data() + .config + .discord + .channels + .say_log_channel_id + .ok_or_else(|| eyre!("Couldn't find say_log_channel_id! Unable to log mod action"))?; + + let channel = ctx + .http() + .get_channel(channel_id.into()) + .await + .wrap_err_with(|| "Couldn't resolve say_log_channel_id as a Channel! Are you sure you sure you used the right one?")?; + + let channel = channel + .guild() + .ok_or_else(|| eyre!("Couldn't resolve say_log_channel_id as a GuildChannel! Are you sure you used the right one?"))?; + + let fields = self.get_all_fields(); + let title = format!("{} user!", self.data.description()); + + channel + .send_message(ctx, |m| { + m.embed(|e| e.title(title).fields(fields).color(COLORS["red"])) + }) .await?; + + Ok(()) } - Ok(()) + /// public facing message + pub async fn reply( + &self, + ctx: &Context<'_>, + user: &Member, + dm_user: Option, + ) -> Result<()> { + let mut resp = format!("{} {}!", self.data.description(), user.user.name); + + if dm_user.unwrap_or_default() { + resp = format!("{resp} (user notified with direct message)"); + } + + ctx.reply(resp).await?; + + Ok(()) + } + + pub async fn dm_user(&self, ctx: &Context<'_>, user: &Member) -> Result<()> { + let guild = ctx.http().get_guild(*user.guild_id.as_u64()).await?; + let title = format!("{} from {}!", self.data.description(), guild.name); + + user.user + .dm(ctx, |m| { + m.embed(|e| { + e.title(title).color(COLORS["red"]); + + if let Some(reason) = &self.reason { + e.description(format!("Reason: {}", reason)); + } + + e + }) + }) + .await?; + + Ok(()) + } + + pub async fn handle( + &self, + ctx: &Context<'_>, + user: &Member, + quiet: Option, + dm_user: Option, + handle_reply: bool, + ) -> Result<()> { + let actual_reason = self.reason.clone().unwrap_or("".to_string()); + self.data.run_action(ctx, user, actual_reason).await?; + + if quiet.unwrap_or_default() { + ctx.defer_ephemeral().await?; + } else { + ctx.defer().await?; + } + + self.log_action(ctx).await?; + + if dm_user.unwrap_or_default() { + self.dm_user(ctx, user).await?; + } + + if handle_reply { + self.reply(ctx, user, dm_user).await?; + } + + Ok(()) + } } -/// Ban a user -#[poise::command( - slash_command, - prefix_command, - default_member_permissions = "BAN_MEMBERS", - required_permissions = "BAN_MEMBERS", - aliases("ban") -)] -pub async fn ban( - ctx: Context<'_>, - users: Vec, - purge: Option, - reason: Option, - quiet: Option, -) -> Result<()> { - moderate( - &ctx, - &users, - &Action { - reason: reason.unwrap_or_default(), - data: ActionData::Ban { - purge: purge.unwrap_or(0), - }, - }, - quiet.unwrap_or(false), - ) - .await +pub struct Ban { + pub purge_messages_days: u8, } -/// Kick a user -#[poise::command( - slash_command, - prefix_command, - default_member_permissions = "KICK_MEMBERS", - required_permissions = "KICK_MEMBERS" -)] -pub async fn kick( - ctx: Context<'_>, - users: Vec, - reason: Option, - quiet: Option, -) -> Result<()> { - moderate( - &ctx, - &users, - &Action { - reason: reason.unwrap_or_default(), - data: ActionData::Kick {}, - }, - quiet.unwrap_or(false), - ) - .await +#[async_trait] +impl ModActionInfo for Ban { + fn to_fields(&self) -> Option { + let fields = vec![( + "Purged messages:", + format!("Last {} day(s)", self.purge_messages_days), + false, + )]; + + Some(fields) + } + + fn description(&self) -> String { + "Banned".to_string() + } + + async fn run_action( + &self, + http: impl CacheHttp + AsRef, + user: &Member, + reason: String, + ) -> Result<()> { + debug!("Banning user {user} with reason: \"{reason}\""); + + user.ban_with_reason(http, self.purge_messages_days, reason) + .await?; + + Ok(()) + } +} + +pub struct Timeout { + pub time_until: Timestamp, +} + +#[async_trait] +impl ModActionInfo for Timeout { + fn to_fields(&self) -> Option { + let fields = vec![("Timed out until:", self.time_until.to_string(), false)]; + + Some(fields) + } + + fn description(&self) -> String { + "Timed out".to_string() + } + + #[allow(unused_variables)] + async fn run_action( + &self, + http: impl CacheHttp + AsRef, + user: &Member, + reason: String, + ) -> Result<()> { + todo!() + } +} + +pub struct Kick {} + +#[async_trait] +impl ModActionInfo for Kick { + fn to_fields(&self) -> Option { + None + } + + fn description(&self) -> String { + "Kicked".to_string() + } + + async fn run_action( + &self, + http: impl CacheHttp + AsRef, + user: &Member, + reason: String, + ) -> Result<()> { + debug!("Kicked user {user} with reason: \"{reason}\""); + + user.kick_with_reason(http, &reason).await?; + + Ok(()) + } } diff --git a/src/commands/moderation/mod.rs b/src/commands/moderation/mod.rs index 13f51de..6f33185 100644 --- a/src/commands/moderation/mod.rs +++ b/src/commands/moderation/mod.rs @@ -1,3 +1,166 @@ -mod actions; +use crate::Context; +use std::error::Error; -pub use actions::*; +use color_eyre::eyre::{eyre, Result}; +use poise::serenity_prelude::{ArgumentConvert, ChannelId, GuildId, Member}; + +mod actions; +use actions::{Ban, Kick, ModAction}; + +async fn split_argument( + ctx: &Context<'_>, + guild_id: Option, + channel_id: Option, + list: String, +) -> Result> +where + T: ArgumentConvert, + T::Err: Error + Send + Sync + 'static, +{ + // yes i should be using something like `filter_map()` here. async closures + // are unstable though so woooooo + let mut res: Vec = vec![]; + for item in list.split(',') { + let item = T::convert(ctx.serenity_context(), guild_id, channel_id, item.trim()).await?; + + res.push(item); + } + + Ok(res) +} + +/// Ban a user +#[poise::command( + slash_command, + prefix_command, + guild_only, + default_member_permissions = "BAN_MEMBERS", + required_permissions = "BAN_MEMBERS", + aliases("ban") +)] +pub async fn ban_user( + ctx: Context<'_>, + #[description = "User to ban"] user: Member, + #[description = "Reason to ban"] reason: Option, + #[description = "Number of days to purge their messages from (defaults to 0)"] + purge_messages_days: Option, + #[description = "If true, the reply from the bot will be ephemeral"] quiet: Option, + #[description = "If true, the affected user will be sent a DM"] dm_user: Option, +) -> Result<()> { + let dmd = purge_messages_days.unwrap_or(0); + + let action = ModAction { + reason, + data: Ban { + purge_messages_days: dmd, + }, + }; + + action.handle(&ctx, &user, quiet, dm_user, true).await?; + + Ok(()) +} + +/// Ban multiple users +#[poise::command( + slash_command, + prefix_command, + guild_only, + default_member_permissions = "BAN_MEMBERS", + required_permissions = "BAN_MEMBERS", + aliases("ban_multi") +)] +pub async fn mass_ban( + ctx: Context<'_>, + #[description = "Comma separated list of users to ban"] users: String, + #[description = "Reason to ban"] reason: Option, + #[description = "Number of days to purge their messages from (defaults to 0)"] + purge_messages_days: Option, + #[description = "If true, the reply from the bot will be ephemeral"] quiet: Option, + #[description = "If true, the affected user will be sent a DM"] dm_user: Option, +) -> Result<()> { + let gid = ctx + .guild_id() + .ok_or_else(|| eyre!("Couldn't get GuildId!"))?; + + let dmd = purge_messages_days.unwrap_or(0); + let users: Vec = split_argument(&ctx, Some(gid), None, users).await?; + + for user in &users { + let action = ModAction { + reason: reason.clone(), + data: Ban { + purge_messages_days: dmd, + }, + }; + + action.handle(&ctx, user, quiet, dm_user, false).await?; + } + + let resp = format!("{} users banned!", users.len()); + ctx.reply(resp).await?; + + Ok(()) +} + +/// Kick a user +#[poise::command( + slash_command, + prefix_command, + guild_only, + default_member_permissions = "KICK_MEMBERS", + required_permissions = "KICK_MEMBERS", + aliases("kick") +)] +pub async fn kick_user( + ctx: Context<'_>, + #[description = "User to kick"] user: Member, + #[description = "Reason to kick"] reason: Option, + #[description = "If true, the reply from the bot will be ephemeral"] quiet: Option, + #[description = "If true, the affected user will be sent a DM"] dm_user: Option, +) -> Result<()> { + let action = ModAction { + reason, + data: Kick {}, + }; + + action.handle(&ctx, &user, quiet, dm_user, true).await?; + + Ok(()) +} + +/// Kick multiple users +#[poise::command( + slash_command, + prefix_command, + guild_only, + default_member_permissions = "KICK_MEMBERS", + required_permissions = "KICK_MEMBERS", + aliases("multi_kick") +)] +pub async fn mass_kick( + ctx: Context<'_>, + #[description = "Comma separated list of users to kick"] users: String, + #[description = "Reason to kick"] reason: Option, + #[description = "If true, the reply from the bot will be ephemeral"] quiet: Option, + #[description = "If true, the affected user will be sent a DM"] dm_user: Option, +) -> Result<()> { + let gid = ctx + .guild_id() + .ok_or_else(|| eyre!("Couldn't get GuildId!"))?; + let users: Vec = split_argument(&ctx, Some(gid), None, users).await?; + + for user in &users { + let action = ModAction { + reason: reason.clone(), + data: Kick {}, + }; + + action.handle(&ctx, user, quiet, dm_user, false).await?; + } + + let resp = format!("{} users kicked!", users.len()); + ctx.reply(resp).await?; + + Ok(()) +}