diff --git a/Cargo.toml b/Cargo.toml index 564f045..2c6a77d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,4 +29,5 @@ sqlx = { version = "0.7.3", "features" = [ reqwest = { version = "0.11.23", features = ["json"] } lazy_static = "1.4.0" dashmap = "^5.5.3" -uuid = { version = "1.7.0", features = ["v4"] } \ No newline at end of file +uuid = { version = "1.7.0", features = ["v4"] } +duration-str = "0.7.1" \ No newline at end of file diff --git a/migrations/202402071852_wish_simulator_genshin.sql b/migrations/202402071852_wish_simulator_genshin.sql index ae678c3..ee529f1 100644 --- a/migrations/202402071852_wish_simulator_genshin.sql +++ b/migrations/202402071852_wish_simulator_genshin.sql @@ -74,26 +74,3 @@ WHERE availability = 'standard' -- AND added_in < current_release() ; - --- music settings schema (TBD) -/* -CREATE TABLE IF NOT EXISTS music_settings ( - guild_id BIGINT NOT NULL, - volume INTEGER NOT NULL DEFAULT 59, -- max volume is 100, also, add 10 to DEFAULT to get funny number - loop_mode TEXT NOT NULL DEFAULT "off", - autoplay INTEGER NOT NULL DEFAULT 0, - DEFAULT_search TEXT NOT NULL DEFAULT "youtube", - PRIMARY KEY (guild_id) -) - - --- snipes schema -CREATE TABLE IF NOT EXISTS snipes ( - guild_id BIGINT NOT NULL, - channel_id BIGINT NOT NULL, - id INTEGER NOT NULL UNIQUE DEFAULT 0, - message_content TEXT NOT NULL DEFAULT "No message found", - message_attachment TEXT NOT NULL DEFAULT "No attachment found" -); - -*/ diff --git a/resources/little_bis.png b/resources/little_bis.png new file mode 100644 index 0000000..80320ab Binary files /dev/null and b/resources/little_bis.png differ diff --git a/src/commands/moderation.rs b/src/commands/moderation.rs index 81f290b..75c4465 100644 --- a/src/commands/moderation.rs +++ b/src/commands/moderation.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use crate::{ utilities::{ messages, models, @@ -6,8 +8,10 @@ use crate::{ Context, Error, }; -use chrono::Utc; +use chrono::{Days, Utc}; +use duration_str::parse; use poise::serenity_prelude::UserId; +use serenity::model::Timestamp; use tracing::{error, info}; #[poise::command( @@ -117,3 +121,489 @@ pub async fn ban( Ok(()) } + +#[poise::command( + prefix_command, + slash_command, + category = "Moderator", + required_permissions = "KICK_MEMBERS", + required_bot_permissions = "KICK_MEMBERS | SEND_MESSAGES", + guild_only, + ephemeral +)] +pub async fn kick( + context: Context<'_>, + #[description = "The user to kick."] + #[rename = "user"] + user_id: UserId, + #[description = "Reason for the kick."] + #[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 kick a system user.", false); + context.send(reply).await?; + return Ok(()); + } + + if user_id == moderator_id { + let reply = messages::error_reply("Sorry, but you cannot kick 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 kicked 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.kick_with_reason(context, user_id, &reason).await { + Ok(_) => { + modlog::insert_modlog( + ModType::Kick, + &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} kicked @{user_name} from {guild_name}: {reason}"); + Ok(format!("{user_mention} has been kicked.")) + } + Err(why) => { + error!("Couldn't kick @{user_name}: {why:?}"); + Err(format!("Sorry, but I couldn't kick {user_mention}.")) + } + } + }; + + if let Err(why) = result { + let reply = messages::error_reply(&why, true); + context.send(reply).await?; + } + + Ok(()) +} + +#[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 unban( + context: Context<'_>, + #[description = "The user to unban."] + #[rename = "user"] + user_id: UserId, + #[description = "Reason for the unban."] + #[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 unban a system user.", false); + context.send(reply).await?; + return Ok(()); + } + + if user_id == moderator_id { + let reply = messages::error_reply("Sorry, but you cannot unban 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.name; + + 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?; + + match guild_id.unban(context, user_id).await { + Ok(_) => { + modlog::insert_modlog( + ModType::Unban, + &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} unbanned @{user_name} from {guild_name}: {reason}"); + Ok(format!("{user_mention} has been unbanned.")) + } + Err(why) => { + error!("Couldn't unban @{user_name}: {why:?}"); + Err(format!("Sorry, but I couldn't unban {user_mention}.")) + } + } + }; + + if let Err(why) = result { + let reply = messages::error_reply(&why, true); + context.send(reply).await?; + } + + Ok(()) +} + +#[poise::command( + prefix_command, + slash_command, + category = "Moderator", + required_permissions = "MODERATE_MEMBERS", + required_bot_permissions = "MODERATE_MEMBERS | SEND_MESSAGES", + guild_only, + ephemeral +)] +pub async fn timeout( + context: Context<'_>, + #[description = "The user to timeout."] + #[rename = "user"] + user_id: UserId, + #[description = "Duration of the timeout."] duration: String, + #[description = "Reason for the timeout."] + #[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 timeout a system user.", false); + context.send(reply).await?; + return Ok(()); + } + + if user_id == moderator_id { + let reply = messages::error_reply("Sorry, but you cannot timeout 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 duration = match parse(&duration) { + Ok(duration) => duration, + Err(why) => { + let reply = messages::error_reply(why.to_string(), true); + context.send(reply).await?; + return Ok(()); + } + }; + + let time = Timestamp::from(Utc::now() + duration); + + if time > Timestamp::from(Utc::now() + Days::new(28)) { + let reply = messages::error_reply("Cannot timeout for longer than 28 days.", true); + context.send(reply).await?; + return Ok(()); + } + + if time < Timestamp::from(Utc::now() + Duration::from_secs(0)) { + let reply = messages::error_reply("Cannot timeout for less than 0 seconds.", true); + context.send(reply).await?; + return Ok(()); + } + + let result = { + let (user_name, user_mention) = (&user.name, models::user_mention(context, user_id).await?); + + let mut member = models::member(context, context.guild_id().unwrap(), user_id).await?; + + let moderator_name = &moderator.name; + + 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?; + + match member + .disable_communication_until_datetime(context, time) + .await + { + Ok(_) => { + modlog::insert_modlog( + ModType::Timeout, + &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} timed out @{user_name} from {guild_name}: {reason}"); + Ok(format!("{user_mention} has been timed out.")) + } + Err(why) => { + error!("Couldn't timeout @{user_name}: {why:?}"); + Err(format!("Sorry, but I couldn't timeout {user_mention}.")) + } + } + }; + + if let Err(why) = result { + let reply = messages::error_reply(&why, true); + context.send(reply).await?; + } + + Ok(()) +} + +#[poise::command( + prefix_command, + slash_command, + category = "Moderator", + required_permissions = "MODERATE_MEMBERS", + required_bot_permissions = "MODERATE_MEMBERS | SEND_MESSAGES", + guild_only, + ephemeral +)] +pub async fn untimeout( + context: Context<'_>, + #[description = "The user to untimeout."] + #[rename = "user"] + user_id: UserId, +) -> Result<(), Error> { + let database = &context.data().sqlite; + + let user = models::user(context, user_id).await?; + + if user.system { + let reply = messages::error_reply("Cannot untimeout a system user.", false); + context.send(reply).await?; + return Ok(()); + } + + let result = { + let (user_name, user_mention) = (&user.name, models::user_mention(context, user_id).await?); + + let mut member = models::member(context, context.guild_id().unwrap(), user_id).await?; + + let moderator_id = context.author().id; + + 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?; + + match member.enable_communication(context).await { + Ok(_) => { + modlog::insert_modlog( + ModType::Untimeout, + &guild_id, + &user_id, + &moderator_id, + "", + created_at, + database, + ) + .await?; + + user_mod_history += 1; + + modlog::update_users_set_modlog(&user_id, user_mod_history, database).await?; + + info!("@{moderator_id} untimed out @{user_name} from {guild_name}"); + Ok(format!("{user_mention} has been untimed out.")) + } + Err(why) => { + error!("Couldn't untimeout @{user_name}: {why:?}"); + Err(format!("Sorry, but I couldn't untimeout {user_mention}.")) + } + } + }; + + if let Err(why) = result { + let reply = messages::error_reply(&why, true); + context.send(reply).await?; + } + + Ok(()) +} + +#[poise::command( + prefix_command, + slash_command, + category = "Moderator", + required_permissions = "MODERATE_MEMBERS", + required_bot_permissions = "MODERATE_MEMBERS | SEND_MESSAGES", + guild_only, + ephemeral +)] +pub async fn warn( + context: Context<'_>, + #[description = "The user to warn."] + #[rename = "user"] + user_id: UserId, + #[description = "The reason for the warning."] reason: String, +) -> Result<(), Error> { + let database = &context.data().sqlite; + + let user = models::user(context, user_id).await?; + + if user.system { + let reply = messages::error_reply("Cannot warn a system user.", false); + context.send(reply).await?; + return Ok(()); + } + + let result = { + let (user_name, user_mention) = (&user.name, models::user_mention(context, user_id).await?); + + let moderator_id = context.author().id; + + 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?; + + match modlog::insert_modlog( + ModType::Warn, + &guild_id, + &user_id, + &moderator_id, + &reason, + created_at, + database, + ) + .await + { + Ok(_) => { + user_mod_history += 1; + + modlog::update_users_set_modlog(&user_id, user_mod_history, database).await?; + + info!("@{moderator_id} warned @{user_name} from {guild_name}"); + Ok(format!("{user_mention} has been warned.")) + } + + Err(why) => { + error!("Couldn't warn @{user_name}: {why:?}"); + Err(format!("Sorry, but I couldn't warn {user_mention}.")) + } + } + }; + + if let Err(why) = result { + let reply = messages::error_reply(&why, true); + context.send(reply).await?; + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 9d5c472..a721f6b 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::*, moderation::*}; +use crate::commands::{info::*, math::*, moderation::*, owner::*, setup::*, utilities::*}; use sqlx::SqlitePool; @@ -175,19 +175,29 @@ async fn main() { ..Default::default() }, commands: vec![ + // Info commands about(), user_info(), user_avatars(), bot_stat(), + // Math commands multiply(), add(), divide(), subtract(), + // Moderation commands ban(), + kick(), + unban(), + timeout(), + untimeout(), + warn(), + // Utility commands help(), ping(), servers(), prefix(), + // Owner commands shutdown(), ], skip_checks_for_owners: true, diff --git a/src/utilities/embeds.rs b/src/utilities/embeds.rs index 09e6c45..9a3cee1 100644 --- a/src/utilities/embeds.rs +++ b/src/utilities/embeds.rs @@ -1,9 +1,6 @@ use chrono::NaiveDateTime; use serenity::{ - all::{ - colours::css, - User, - }, + all::{colours::css, User}, builder::{CreateEmbed, CreateEmbedAuthor}, model::Colour, }; @@ -53,13 +50,14 @@ pub fn warnings_command_embed( writeln!(date_field, "{date}").unwrap(); } - let mut embed_fields = Vec::new(); - embed_fields.push(("ID", id_field.clone(), true)); - embed_fields.push(("Moderator", moderator_field, true)); - embed_fields.push(("Reason", reason_field, true)); - embed_fields.push(("\u{200B}", "\u{200B}".to_owned(), false)); - embed_fields.push(("ID", id_field, true)); - embed_fields.push(("Date", date_field, true)); + let embed_fields = vec![ + ("ID", id_field.clone(), true), + ("Moderator", moderator_field, true), + ("Reason", reason_field, true), + ("\u{200B}", "\u{200B}".to_owned(), false), + ("ID", id_field, true), + ("Date", date_field, true) + ]; CreateEmbed::default() .author(embed_author) @@ -68,12 +66,12 @@ pub fn warnings_command_embed( pub fn error_message_embed(message: &String) -> CreateEmbed { CreateEmbed::default() - .description(format!("{message}")) + .description(message.to_string()) .colour(css::DANGER) } pub fn info_message_embed(message: &String) -> CreateEmbed { CreateEmbed::default() - .description(format!("{message}")) + .description(message.to_string()) .colour(Colour::BLUE) } diff --git a/src/utilities/models.rs b/src/utilities/models.rs index 12a8818..1a8e19c 100644 --- a/src/utilities/models.rs +++ b/src/utilities/models.rs @@ -1,6 +1,6 @@ use crate::Context; use poise::serenity_prelude::{model::ModelError, User, UserId}; -use serenity::all::{Mention, Mentionable}; +use serenity::all::{GuildId, Member, Mention, Mentionable}; use tracing::error; pub fn author(context: Context<'_>) -> Result<&User, ModelError> { @@ -17,7 +17,7 @@ pub async fn user(context: Context<'_>, user_id: UserId) -> Result Ok(user), Err(why) => { error!("Couldn't get user: {why:?}"); - return Err(ModelError::MemberNotFound); + Err(ModelError::MemberNotFound) } } } @@ -25,3 +25,17 @@ pub async fn user(context: Context<'_>, user_id: UserId) -> Result, user_id: UserId) -> Result { Ok(user(context, user_id).await?.mention()) } + +pub async fn member( + ctx: Context<'_>, + guild_id: GuildId, + user_id: UserId, +) -> Result { + match guild_id.member(&ctx, user_id).await { + Ok(member) => Ok(member), + Err(why) => { + error!("Couldn't get member: {why:?}"); + Err(ModelError::MemberNotFound) + } + } +} diff --git a/src/utilities/modlog.rs b/src/utilities/modlog.rs index 5567be8..3be3452 100644 --- a/src/utilities/modlog.rs +++ b/src/utilities/modlog.rs @@ -9,10 +9,10 @@ use uuid::Uuid; pub enum ModType { Warn, Timeout, + Untimeout, Kick, Ban, - Deafen, - Mute, + Unban, } impl ModType { @@ -20,10 +20,10 @@ impl ModType { match self { ModType::Warn => "warn", ModType::Timeout => "timeout", + ModType::Untimeout => "untimeout", ModType::Kick => "kick", ModType::Ban => "ban", - ModType::Deafen => "deafen", - ModType::Mute => "mute", + ModType::Unban => "unban", } } } diff --git a/src/utilities/paginate.rs b/src/utilities/paginate.rs index 08dac5e..694524a 100644 --- a/src/utilities/paginate.rs +++ b/src/utilities/paginate.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use crate::Context; /// Paginates a list of embeds using UUID as custom ID to identify the buttons. -pub async fn paginate( +pub async fn paginate( ctx: Context<'_>, pages: Vec, ) -> Result<(), serenity::Error> {