From bd2afed839140552ffee8b7705d6faf634790e42 Mon Sep 17 00:00:00 2001 From: toast Date: Fri, 29 Mar 2024 21:28:04 +1100 Subject: [PATCH] Add moderation to Kon and refactor unrelated parts --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/commands/mod.rs | 1 + src/commands/moderation.rs | 110 ++++++++++++++++++++ src/controllers/database.rs | 27 +++++ src/internals/utils.rs | 8 ++ src/main.rs | 12 ++- src/models/mod.rs | 1 + src/models/moderation_events.rs | 173 ++++++++++++++++++++++++++++++++ 9 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 src/commands/moderation.rs create mode 100644 src/models/moderation_events.rs diff --git a/Cargo.lock b/Cargo.lock index c94ef62..766978b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -952,7 +952,7 @@ dependencies = [ [[package]] name = "kon" -version = "0.2.7" +version = "0.3.0" dependencies = [ "cargo_toml", "gamedig", diff --git a/Cargo.toml b/Cargo.toml index df47be1..325bf9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kon" -version = "0.2.7" +version = "0.3.0" edition = "2021" [dependencies] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3c4d858..66ba4ec 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,3 +2,4 @@ pub mod ping; pub mod status; pub mod uptime; pub mod gameserver; +pub mod moderation; diff --git a/src/commands/moderation.rs b/src/commands/moderation.rs new file mode 100644 index 0000000..29762ed --- /dev/null +++ b/src/commands/moderation.rs @@ -0,0 +1,110 @@ +use crate::{ + Error, + internals::utils::capitalize_first, + models::moderation_events::{ + Moderations, + ActionTypes + } +}; + +use poise::CreateReply; +use poise::serenity_prelude::Member; + +/// Subcommands collection for /case command +#[poise::command( + slash_command, + guild_only, + subcommands("update"), + default_member_permissions = "KICK_MEMBERS | BAN_MEMBERS | MODERATE_MEMBERS" +)] +pub async fn case(_: poise::Context<'_, (), Error>) -> Result<(), Error> { + Ok(()) +} + +/// Update a case with new reason +#[poise::command( + slash_command, + guild_only +)] +pub async fn update( + ctx: poise::Context<'_, (), Error>, + #[description = "Case ID to update"] case_id: i32, + #[description = "New reason for the case"] reason: String +) -> Result<(), Error> { + match Moderations::update_case( + i64::from(ctx.guild_id().unwrap()), + case_id, + false, + Some(reason.clone()) + ).await { + Ok(_) => ctx.send(CreateReply::default().content(format!("Case #{} updated with new reason:\n`{}`", case_id, reason))).await?, + Err(e) => ctx.send(CreateReply::default().content(format!("Error updating case ID: {}\nError: {}", case_id, e))).await? + }; + + Ok(()) +} + +/// Kick a member +#[poise::command( + slash_command, + guild_only, + default_member_permissions = "KICK_MEMBERS", + required_bot_permissions = "KICK_MEMBERS" +)] +pub async fn kick( + ctx: poise::Context<'_, (), Error>, + #[description = "Member to be kicked"] member: Member, + #[description = "Reason for the kick"] reason: Option +) -> Result<(), Error> { + let reason = reason.unwrap_or_else(|| "Reason unknown".to_string()); + let case = Moderations::create_case( + i64::from(ctx.guild_id().unwrap()), + ActionTypes::Kick, + false, + i64::from(member.user.id), + member.user.tag(), + reason.clone(), + i64::from(ctx.author().id), + ctx.author().tag(), + ctx.created_at().timestamp(), + None + ).await?; + Moderations::generate_modlog(case.clone(), &ctx.http(), ctx.channel_id().into()).await?; + + member.kick_with_reason(&ctx.http(), &reason).await?; + ctx.send(CreateReply::default().content(format!("Member: {}\nReason: `{}`\nType: {}\nModerator: {}", member.user.tag(), reason, capitalize_first(case.action_type.as_str()), ctx.author().tag()))).await?; + + Ok(()) +} + +/// Warn a member +#[poise::command( + slash_command, + guild_only, + default_member_permissions = "MODERATE_MEMBERS", + required_bot_permissions = "MODERATE_MEMBERS" +)] +pub async fn warn( + ctx: poise::Context<'_, (), Error>, + #[description = "Member to be warned"] member: Member, + #[description = "Reason for the warn"] reason: Option +) -> Result<(), Error> { + let reason = reason.unwrap_or_else(|| "Reason unknown".to_string()); + let case = Moderations::create_case( + i64::from(ctx.guild_id().unwrap()), + ActionTypes::Warn, + false, + i64::from(member.user.id), + member.user.tag(), + reason.clone(), + i64::from(ctx.author().id), + ctx.author().tag(), + ctx.created_at().timestamp(), + None + ).await?; + Moderations::generate_modlog(case.clone(), &ctx.http(), ctx.channel_id().into()).await?; + + ctx.send(CreateReply::default().content(format!("Member: {}\nReason: `{}`\nType: {}\nModerator: {}", member.user.tag(), reason, capitalize_first(case.action_type.as_str()), ctx.author().tag()))).await?; + + Ok(()) +} diff --git a/src/controllers/database.rs b/src/controllers/database.rs index 0ad0cff..05c3a6f 100644 --- a/src/controllers/database.rs +++ b/src/controllers/database.rs @@ -32,6 +32,33 @@ impl DatabaseController { ); ").await?; + // Guild Case IDs + client.batch_execute(" + CREATE TABLE IF NOT EXISTS guild_case_ids ( + guild_id BIGINT NOT NULL, + max_case_id INT NOT NULL DEFAULT 0, + PRIMARY KEY (guild_id) + ); + ").await?; + + // ModerationEvents + client.batch_execute(" + CREATE TABLE IF NOT EXISTS moderation_events ( + guild_id BIGINT NOT NULL, + case_id INT NOT NULL, + action_type VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT FALSE, + user_id BIGINT NOT NULL, + user_tag VARCHAR(255) NOT NULL, + reason VARCHAR(1024) NOT NULL, + moderator_id BIGINT NOT NULL, + moderator_tag VARCHAR(255) NOT NULL, + time_created BIGINT NOT NULL, + duration BIGINT, + PRIMARY KEY (guild_id, case_id) + ); + ").await?; + Ok(DatabaseController { client }) } } diff --git a/src/internals/utils.rs b/src/internals/utils.rs index cf6cd72..7a1d134 100644 --- a/src/internals/utils.rs +++ b/src/internals/utils.rs @@ -17,6 +17,14 @@ pub fn concat_message(messages: Vec) -> String { messages.join("\n") } +pub fn capitalize_first(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + chars.as_str(), + } +} + pub fn format_duration(secs: u64) -> String { let days = secs / 86400; let hours = (secs % 86400) / 3600; diff --git a/src/main.rs b/src/main.rs index 8376117..8647b26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,8 +47,9 @@ async fn on_ready( let commands = Command::set_global_commands(&ctx.http, builder).await; match commands { - Ok(cmdmap) => for command in cmdmap.iter() { - println!("Registered command globally: {}", command.name); + Ok(cmdmap) => { + let command_box: Vec<_> = cmdmap.iter().map(|cmd| cmd.name.clone()).collect(); + println!("Registered commands globally: {}", command_box.join("\n- ")); }, Err(why) => println!("Error registering commands: {:?}", why) } @@ -67,7 +68,12 @@ async fn main() { commands::ping::ping(), commands::uptime::uptime(), commands::status::status(), - commands::gameserver::gameserver() + commands::gameserver::gameserver(), + // Separator here to make it easier to read and update moderation stuff below + commands::moderation::case(), + commands::moderation::update(), + commands::moderation::kick(), + commands::moderation::warn(), ], pre_command: |ctx| Box::pin(async move { let get_guild_name = match ctx.guild() { diff --git a/src/models/mod.rs b/src/models/mod.rs index ca6994a..e7add35 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1 +1,2 @@ pub mod gameservers; +pub mod moderation_events; diff --git a/src/models/moderation_events.rs b/src/models/moderation_events.rs new file mode 100644 index 0000000..b6a1c27 --- /dev/null +++ b/src/models/moderation_events.rs @@ -0,0 +1,173 @@ +use crate::{ + controllers::database::DatabaseController, + internals::utils::{ + EMBED_COLOR, + capitalize_first + } +}; + +use poise::serenity_prelude::{ + Http, + Error, + Timestamp, + ChannelId, + CreateMessage, + CreateEmbed +}; + +#[derive(Clone)] +pub struct Moderations { + pub guild_id: i64, + pub case_id: i32, + pub action_type: ActionTypes, + pub is_active: bool, + pub user_id: i64, + pub user_tag: String, + pub reason: String, + pub moderator_id: i64, + pub moderator_tag: String, + pub time_created: i64, + pub duration: Option +} + +#[allow(dead_code)] // For temporary suppression until we use all of enums. +#[derive(Clone)] +pub enum ActionTypes { + Ban, + Kick, + Mute, + Warn, + Unban, + Unmute +} + +impl ActionTypes { + pub fn as_str(&self) -> &'static str { + match *self { + ActionTypes::Ban => "ban", + ActionTypes::Kick => "kick", + ActionTypes::Mute => "mute", + ActionTypes::Warn => "warn", + ActionTypes::Unban => "unban", + ActionTypes::Unmute => "unmute" + } + } +} + +impl Moderations { + pub async fn generate_modlog(case: Moderations, http: &Http, channel_id: u64) -> Result<(), Error> { + let time_created_formatted = Timestamp::from_unix_timestamp(case.time_created).expect(" Failed to format timestamp!"); + let modlog_embed = CreateEmbed::default() + .color(EMBED_COLOR) + .title(format!("{} • Case #{}", capitalize_first(case.action_type.as_str()), case.case_id)) + .fields(vec![ + ("User", format!("{}\n<@{}>", case.user_tag, case.user_id), true), + ("Moderator", format!("{}\n<@{}>", case.moderator_tag, case.moderator_id), true), + ("\u{200B}", "\u{200B}".to_string(), true), + ("Reason", format!("`{}`", case.reason), false) + ]) + .timestamp(time_created_formatted); + + ChannelId::new(channel_id).send_message(http, CreateMessage::new().embed(modlog_embed)).await?; + + Ok(()) + } + + pub async fn create_case( + guild_id: i64, + action_type: ActionTypes, + is_active: bool, + user_id: i64, + user_tag: String, + reason: String, + moderator_id: i64, + moderator_tag: String, + time_created: i64, + duration: Option + ) -> Result { + let _db = DatabaseController::new().await?.client; + + // Get the current max case_id for the guild + let stmt = _db.prepare(" + SELECT max_case_id FROM guild_case_ids WHERE guild_id = $1; + ").await?; + let rows = _db.query(&stmt, &[&guild_id]).await?; + let mut max_case_id = if let Some(row) = rows.get(0) { + row.get::<_, i32>("max_case_id") + } else { + 0 + }; + + // Increment the max case_id for the guild + max_case_id += 1; + let stmt = _db.prepare(" + INSERT INTO guild_case_ids (guild_id, max_case_id) VALUES ($1, $2) + ON CONFLICT (guild_id) DO UPDATE SET max_case_id = $2; + ").await?; + _db.execute(&stmt, &[&guild_id, &max_case_id]).await?; + + // Create a new case in database and return the case_id + let stmt = _db.prepare(" + INSERT INTO moderation_events ( + guild_id, case_id, action_type, is_active, user_id, user_tag, reason, moderator_id, moderator_tag, time_created, duration + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING case_id; + ").await?; + + let row = _db.query(&stmt, &[ + &guild_id, + &max_case_id, + &action_type.as_str(), + &is_active, + &user_id, + &user_tag, + &reason, + &moderator_id, + &moderator_tag, + &time_created, + &duration + ]).await?; + + let moderations = Moderations { + guild_id, + case_id: row[0].get("case_id"), + action_type, + is_active, + user_id, + user_tag, + reason, + moderator_id, + moderator_tag, + time_created, + duration + }; + + Ok(moderations) + } + + pub async fn update_case( + guild_id: i64, + case_id: i32, + is_active: bool, + reason: Option + ) -> Result<(), tokio_postgres::Error> { + let _db = DatabaseController::new().await?.client; + + match reason { + Some(reason) => { + _db.execute(" + UPDATE moderation_events + SET is_active = $3, reason = $4 WHERE guild_id = $1 AND case_id = $2; + ", &[&guild_id, &case_id, &is_active, &reason]).await?; + }, + None => { + _db.execute(" + UPDATE moderation_events + SET is_active = $3 WHERE guild_id = $1 AND case_id = $2; + ", &[&guild_id, &case_id, &is_active]).await?; + } + } + + Ok(()) + } +}