diff --git a/src/commands/moderation.rs b/src/commands/moderation.rs index 8b13789..81f290b 100644 --- a/src/commands/moderation.rs +++ b/src/commands/moderation.rs @@ -1 +1,119 @@ +use crate::{ + utilities::{ + messages, models, + modlog::{self, ModType}, + }, + Context, Error, +}; +use chrono::Utc; +use poise::serenity_prelude::UserId; +use tracing::{error, info}; + +#[poise::command( + prefix_command, + slash_command, + category = "Moderator", + required_permissions = "BAN_MEMBERS", + required_bot_permissions = "BAN_MEMBERS | SEND_MESSAGES", + guild_only, + ephemeral +)] +pub async fn ban( + context: Context<'_>, + #[description = "The user to ban."] + #[rename = "user"] + user_id: UserId, + #[description = "Reason for the ban."] + #[max_length = 80] + reason: Option, +) -> Result<(), Error> { + let database = &context.data().sqlite; + + let user = models::user(context, user_id).await?; + + let moderator = context.author(); + let moderator_id = moderator.id; + + if user.system { + let reply = messages::error_reply("Cannot ban a system user.", false); + context.send(reply).await?; + return Ok(()); + } + + if user_id == moderator_id { + let reply = messages::error_reply("Sorry, but you cannot ban yourself.", true); + context.send(reply).await?; + + return Ok(()); + } + + let reason = reason.unwrap_or_else(|| "No reason provided.".to_string()); + + let reason_char_count = reason.chars().count(); + if reason_char_count > 80 { + let reply = messages::info_reply("Reason must be no more than 80 characters long.", true); + context.send(reply).await?; + + return Ok(()); + } + + let result = { + let (user_name, user_mention) = (&user.name, models::user_mention(context, user_id).await?); + + let (moderator_name, moderator_mention) = + (&moderator.name, models::author_mention(context)?); + + let (guild_id, guild_name) = { + let guild_id = context.guild_id().unwrap(); + let guild = context.guild().unwrap(); + (guild_id, guild.name.clone()) + }; + + let created_at = Utc::now().naive_utc(); + + let mut user_mod_history = modlog::select_modlog_from_users(&user_id, database).await?; + + let message = messages::info_message(format!( + "You've been banned from {guild_name} by {moderator_mention} for {reason}.", + )); + let dm = user.direct_message(context, message).await; + + if let Err(why) = dm { + error!("Couldn't send DM to @{user_name}: {why:?}"); + } + + match guild_id.ban_with_reason(context, user_id, 0, &reason).await { + Ok(_) => { + modlog::insert_modlog( + ModType::Ban, + &guild_id, + &user_id, + &moderator_id, + &reason, + created_at, + database, + ) + .await?; + + user_mod_history += 1; + + modlog::update_users_set_modlog(&user_id, user_mod_history, database).await?; + + info!("@{moderator_name} banned @{user_name} from {guild_name}: {reason}"); + Ok(format!("{user_mention} has been banned.")) + } + Err(why) => { + error!("Couldn't ban @{user_name}: {why:?}"); + Err(format!("Sorry, but I couldn't ban {user_mention}.")) + } + } + }; + + if let Err(why) = result { + let reply = messages::error_reply(&why, true); + context.send(reply).await?; + } + + Ok(()) +} diff --git a/src/commands/setup.rs b/src/commands/setup.rs index 860da97..da441a7 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -57,7 +57,12 @@ pub async fn prefix(context: Context<'_>) -> Result<(), Error> { required_bot_permissions = "SEND_MESSAGES", guild_only = true )] -pub async fn set(context: Context<'_>, prefix: Option) -> Result<(), Error> { +pub async fn set( + context: Context<'_>, + #[description = "The new prefix."] + #[max_length = 5] + prefix: Option, +) -> Result<(), Error> { if let Some(guild_id) = context.guild_id() { let prefix = prefix.unwrap_or_else(|| "+".to_string()); diff --git a/src/main.rs b/src/main.rs index cf3b11b..9d5c472 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ use utilities::types::GuildSettings; mod commands; mod utilities; -use crate::commands::{info::*, math::*, owner::*, setup::*, utilities::*}; +use crate::commands::{info::*, math::*, owner::*, setup::*, utilities::*, moderation::*}; use sqlx::SqlitePool; @@ -181,6 +181,9 @@ async fn main() { bot_stat(), multiply(), add(), + divide(), + subtract(), + ban(), help(), ping(), servers(), diff --git a/src/utilities/embeds.rs b/src/utilities/embeds.rs index 9d9ab1d..09e6c45 100644 --- a/src/utilities/embeds.rs +++ b/src/utilities/embeds.rs @@ -1,7 +1,11 @@ use chrono::NaiveDateTime; use serenity::{ - all::User, + all::{ + colours::css, + User, + }, builder::{CreateEmbed, CreateEmbedAuthor}, + model::Colour, }; use std::fmt::Write; @@ -61,3 +65,15 @@ pub fn warnings_command_embed( .author(embed_author) .fields(embed_fields) } + +pub fn error_message_embed(message: &String) -> CreateEmbed { + CreateEmbed::default() + .description(format!("{message}")) + .colour(css::DANGER) +} + +pub fn info_message_embed(message: &String) -> CreateEmbed { + CreateEmbed::default() + .description(format!("{message}")) + .colour(Colour::BLUE) +} diff --git a/src/utilities/messages.rs b/src/utilities/messages.rs new file mode 100644 index 0000000..64324c1 --- /dev/null +++ b/src/utilities/messages.rs @@ -0,0 +1,36 @@ +use poise::CreateReply; +use serenity::builder::{ + CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, +}; + +use super::embeds; + +pub async fn error_response( + message: impl Into, + ephemeral: bool, +) -> CreateInteractionResponse { + let embed = embeds::error_message_embed(&message.into()); + + let response_message = CreateInteractionResponseMessage::new() + .embed(embed) + .ephemeral(ephemeral); + CreateInteractionResponse::Message(response_message) +} + +pub fn info_message(message: impl Into) -> CreateMessage { + let embed = embeds::info_message_embed(&message.into()); + + CreateMessage::default().embed(embed) +} + +pub fn error_reply(message: impl Into, ephemeral: bool) -> CreateReply { + let embed = embeds::error_message_embed(&message.into()); + + CreateReply::default().embed(embed).ephemeral(ephemeral) +} + +pub fn info_reply(message: impl Into, ephemeral: bool) -> CreateReply { + let embed = embeds::info_message_embed(&message.into()); + + CreateReply::default().embed(embed).ephemeral(ephemeral) +} diff --git a/src/utilities/mod.rs b/src/utilities/mod.rs index f2bfc87..34dfd72 100644 --- a/src/utilities/mod.rs +++ b/src/utilities/mod.rs @@ -1,5 +1,8 @@ pub mod embeds; pub mod event_handler; pub mod git; +pub mod messages; +pub mod models; pub mod modlog; +pub mod paginate; pub mod types; diff --git a/src/utilities/models.rs b/src/utilities/models.rs new file mode 100644 index 0000000..12a8818 --- /dev/null +++ b/src/utilities/models.rs @@ -0,0 +1,27 @@ +use crate::Context; +use poise::serenity_prelude::{model::ModelError, User, UserId}; +use serenity::all::{Mention, Mentionable}; +use tracing::error; + +pub fn author(context: Context<'_>) -> Result<&User, ModelError> { + Ok(context.author()) +} + +pub fn author_mention(context: Context<'_>) -> Result { + let author = author(context)?; + Ok(author.mention()) +} + +pub async fn user(context: Context<'_>, user_id: UserId) -> Result { + match user_id.to_user(context).await { + Ok(user) => Ok(user), + Err(why) => { + error!("Couldn't get user: {why:?}"); + return Err(ModelError::MemberNotFound); + } + } +} + +pub async fn user_mention(context: Context<'_>, user_id: UserId) -> Result { + Ok(user(context, user_id).await?.mention()) +} diff --git a/src/utilities/modlog.rs b/src/utilities/modlog.rs index 609c983..5567be8 100644 --- a/src/utilities/modlog.rs +++ b/src/utilities/modlog.rs @@ -3,7 +3,7 @@ use poise::serenity_prelude as serenity; use serenity::all::{GuildId, UserId}; use sqlx::{Row, SqlitePool}; use tokio::time::Instant; -use tracing::{error, info}; +use tracing::{debug, error, info}; use uuid::Uuid; pub enum ModType { @@ -100,7 +100,7 @@ pub async fn delete_mod_log( Ok(()) } -pub async fn insert_mod_log( +pub async fn insert_modlog( action_type: ModType, guild_id: &GuildId, user_id: &UserId, @@ -135,3 +135,54 @@ pub async fn insert_mod_log( Ok(()) } + +pub async fn select_modlog_from_users( + user_id: &UserId, + pool: &SqlitePool, +) -> Result { + let start_time = Instant::now(); + + let query = + sqlx::query("SELECT infractions FROM users WHERE user_id = ?").bind(i64::from(*user_id)); + let row = match query.fetch_one(pool).await { + Ok(infractions) => infractions, + Err(why) => { + error!("Couldn't select infractions from Users: {why:?}"); + return Err(why); + } + }; + + let infractions = match row.try_get::("infractions") { + Ok(infractions) => infractions, + Err(why) => { + error!("Couldn't get infractions: {why:?}"); + return Err(why); + } + }; + + let elapsed_time = start_time.elapsed(); + debug!("Selected infractions from Users in {elapsed_time:.2?}"); + + Ok(infractions) +} + +pub async fn update_users_set_modlog( + user_id: &UserId, + infractions: i32, + pool: &SqlitePool, +) -> Result<(), sqlx::Error> { + let start_time = Instant::now(); + + let query = sqlx::query("UPDATE users SET infractions = ? WHERE user_id = ?") + .bind(infractions) + .bind(i64::from(*user_id)); + if let Err(why) = query.execute(pool).await { + error!("Couldn't update infractions for user(s) in Users: {why:?}"); + return Err(why); + } + + let elapsed_time = start_time.elapsed(); + debug!("Updated infractions for user(s) within Users in {elapsed_time:.2?}"); + + Ok(()) +} diff --git a/src/utilities/paginate.rs b/src/utilities/paginate.rs new file mode 100644 index 0000000..08dac5e --- /dev/null +++ b/src/utilities/paginate.rs @@ -0,0 +1,66 @@ +use poise::serenity_prelude as serenity; +use uuid::Uuid; + +use crate::Context; + +/// Paginates a list of embeds using UUID as custom ID to identify the buttons. +pub async fn paginate( + ctx: Context<'_>, + pages: Vec, +) -> Result<(), serenity::Error> { + // Define some unique identifiers for the navigation buttons + let ctx_id = Uuid::new_v4(); + let prev_button_id = format!("{}prev", ctx_id); + let next_button_id = format!("{}next", ctx_id); + + // Send the embed with the first page as content + let reply = { + let components = serenity::CreateActionRow::Buttons(vec![ + serenity::CreateButton::new(&prev_button_id).emoji('◀'), + serenity::CreateButton::new(&next_button_id).emoji('▶'), + ]); + + poise::CreateReply::default() + .embed(pages[0].clone()) + .components(vec![components]) + }; + + ctx.send(reply).await?; + + // Loop through incoming interactions with the navigation buttons + let mut current_page = 0; + while let Some(press) = serenity::collector::ComponentInteractionCollector::new(ctx) + // We defined our button IDs to start with `ctx_id`. If they don't, some other command's + // button was pressed + .filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string())) + // Timeout when no navigation button has been pressed for 24 hours + .timeout(std::time::Duration::from_secs(3600 * 24)) + .await + { + // Depending on which button was pressed, go to next or previous page + if press.data.custom_id == next_button_id { + current_page += 1; + if current_page >= pages.len() { + current_page = 0; + } + } else if press.data.custom_id == prev_button_id { + current_page = current_page.checked_sub(1).unwrap_or(pages.len() - 1); + } else { + // This is an unrelated button interaction + continue; + } + + // Update the message with the new page contents + press + .create_response( + ctx.serenity_context(), + serenity::CreateInteractionResponse::UpdateMessage( + serenity::CreateInteractionResponseMessage::new() + .embed(pages[current_page].clone()), + ), + ) + .await?; + } + + Ok(()) +}