diff --git a/Cargo.lock b/Cargo.lock index 2fdb103..a7fa08f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,11 +958,13 @@ dependencies = [ [[package]] name = "kon" -version = "0.2.7" +version = "0.3.2" dependencies = [ "cargo_toml", "gamedig", + "lazy_static", "once_cell", + "parse_duration", "poise", "reqwest 0.12.3", "serde", @@ -1123,12 +1125,79 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090c7f9998ee0ff65aa5b723e4009f7b217707f1fb5ea551329cc4d6231fb304" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d869c01cc0c455284163fd0092f1f93835385ccab5a98a0dcc497b2f8bf055a9" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef" +dependencies = [ + "autocfg", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.18" @@ -1230,6 +1299,17 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "parse_duration" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7037e5e93e0172a5a96874380bf73bc6ecef022e26fa25f2be26864d6b3ba95d" +dependencies = [ + "lazy_static", + "num", + "regex", +] + [[package]] name = "percent-encoding" version = "2.3.1" diff --git a/Cargo.toml b/Cargo.toml index bef7f9e..638efb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,14 @@ [package] name = "kon" -version = "0.2.7" +version = "0.3.2" edition = "2021" [dependencies] cargo_toml = "0.19.2" gamedig = "0.5.0" +lazy_static = "1.4.0" once_cell = "1.19.0" +parse_duration = "2.1.1" poise = "0.6.1" reqwest = { version = "0.12.3", features = ["json"] } serde = "1.0.197" 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..da2811e --- /dev/null +++ b/src/commands/moderation.rs @@ -0,0 +1,203 @@ +use crate::{ + Error, + internals::utils::capitalize_first, + models::moderation_events::{ + Moderations, + ActionTypes + } +}; + +use poise::CreateReply; +use poise::serenity_prelude::Member; +use parse_duration::parse; +use std::time::SystemTime; + +static FALLBACK_REASON: &str = "Reason unknown"; + +fn duration2epoch(duration: &str) -> Result { + match parse(duration) { + Ok(dur) => { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|_| Error::from("System time before Unix Epoch"))?; + Ok((now + dur).as_secs() as i64) + } + Err(_) => Err(Error::from("Invalid duration format")) + } +} + +/// 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(FALLBACK_REASON.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(()) +} + +/// Ban a member +#[poise::command( + slash_command, + guild_only, + default_member_permissions = "BAN_MEMBERS", + required_bot_permissions = "BAN_MEMBERS" +)] +pub async fn ban( + ctx: poise::Context<'_, (), Error>, + #[description = "Member to be banned"] member: Member, + #[description = "Reason for the ban"] reason: Option, + #[description = "Ban duration"] duration: Option +) -> Result<(), Error> { + let reason = reason.unwrap_or(FALLBACK_REASON.to_string()); + let duration = match duration { + Some(d) => Some(duration2epoch(&d)?), + None => None + }; + let is_case_active = duration.is_some(); + + let case = Moderations::create_case( + i64::from(ctx.guild_id().unwrap()), + ActionTypes::Ban, + is_case_active, + i64::from(member.user.id), + member.user.tag(), + reason.clone(), + i64::from(ctx.author().id), + ctx.author().tag(), + ctx.created_at().timestamp(), + duration + ).await?; + Moderations::generate_modlog(case.clone(), &ctx.http(), ctx.channel_id().into()).await?; + + member.ban_with_reason(&ctx.http(), 0, &reason).await?; + ctx.send(CreateReply::default().content(format!("Member: {}\nReason: `{}`\nType: {}\nModerator: {}\nDuration: `{}`", member.user.tag(), reason, capitalize_first(case.action_type.as_str()), ctx.author().tag(), duration.unwrap()))).await?; + + Ok(()) +} + +/// Timeout a member +#[poise::command( + slash_command, + guild_only, + default_member_permissions = "MODERATE_MEMBERS", + required_bot_permissions = "MODERATE_MEMBERS" +)] +pub async fn mute( + ctx: poise::Context<'_, (), Error>, + #[description = "Member to be muted"] mut member: Member, + #[description = "Mute duration"] duration: String, + #[description = "Reason for the mute"] reason: Option +) -> Result<(), Error> { + let reason = reason.unwrap_or(FALLBACK_REASON.to_string()); + let duration = Some(duration2epoch(&duration)?); + let is_case_active = duration.is_some(); + + let case = Moderations::create_case( + i64::from(ctx.guild_id().unwrap()), + ActionTypes::Mute, + is_case_active, + i64::from(member.user.id), + member.user.tag(), + reason.clone(), + i64::from(ctx.author().id), + ctx.author().tag(), + ctx.created_at().timestamp(), + duration + ).await?; + + println!("case.duration: {}", case.duration.unwrap().to_string().as_str()); + + let mute_time = poise::serenity_prelude::Timestamp::from_unix_timestamp(case.duration.unwrap()).expect("Failed to format timestamp"); + member.disable_communication_until_datetime(&ctx.http(), mute_time).await?; + + ctx.send(CreateReply::default().content(format!("Member: {}\nReason: `{}`\nType: {}\nModerator: {}\nDuration: `{}`", member.user.tag(), reason, capitalize_first(case.action_type.as_str()), ctx.author().tag(), mute_time))).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(FALLBACK_REASON.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/controllers/mod.rs b/src/controllers/mod.rs index 8fd0a6b..84dba30 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1 +1,2 @@ pub mod database; +pub mod timers; diff --git a/src/controllers/timers.rs b/src/controllers/timers.rs new file mode 100644 index 0000000..ff70991 --- /dev/null +++ b/src/controllers/timers.rs @@ -0,0 +1,95 @@ +use crate::{ + Error, + models::moderation_events::{ + Moderations, + ActionTypes + } +}; + +use std::time::SystemTime; +use poise::serenity_prelude::{ + Context, + model::{ + user::CurrentUser, + id::{ + UserId, + GuildId + } + }, +}; +use tokio::time::{ + interval, + Duration +}; + +fn timer_failed(name: &str) -> String { + format!("Failed to start timer for {}", name) +} + +pub async fn start_timers(discord_: &Context, bot_: CurrentUser) -> Result<(), Error> { + let ctx_clone = discord_.clone(); + tokio::spawn(async move { + check_modlog_cases(&ctx_clone, bot_).await.expect(&timer_failed("moderation events")) + }); + + Ok(()) +} + +async fn check_modlog_cases(discord_: &Context, bot_: CurrentUser) -> Result<(), Error> { + let mut interval = interval(Duration::from_secs(6)); + + loop { + interval.tick().await; + let events = Moderations::get_active_events().await?; + + for event in events { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|_| Error::from("System time before Unix Epoch"))?; + + let check_action_type = match event.action_type { + ActionTypes::Ban => ActionTypes::Unban, + ActionTypes::Mute => ActionTypes::Unmute, + _ => continue // Skip if not a timed action + }; + + if let Some(duration) = event.duration { + let duration = Duration::from_secs(duration as u64); + if now > duration { + Moderations::update_case( + event.guild_id, + event.case_id, + false, + None + ).await?; + Moderations::generate_modlog(Moderations::create_case( + event.guild_id, + check_action_type, + false, + event.user_id, + event.user_tag.clone(), + format!("Duration for Case #{} has expired", event.case_id), + bot_.id.into(), + bot_.tag(), + SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs() as i64, + None + ).await?, &discord_.http, 865673694184996888).await?; + + match check_action_type { + ActionTypes::Unban => { + let guild_id = GuildId::new(event.guild_id as u64); + let user_id = UserId::new(event.user_id as u64); + discord_.http.remove_ban(guild_id, user_id, Some(format!("Duration for Case #{} has expired", event.case_id).as_str())).await?; + }, + _ => {} + } + + let guild_id = GuildId::new(event.guild_id as u64); + let cached_guild_data = discord_.cache.guild(guild_id); + + println!("ModerationTimer[CaseExpired]: {}:#{}:{}:{}", cached_guild_data.unwrap().name.to_owned(), event.case_id, event.user_tag, event.reason) + } + } + } + } +} 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..bad9c0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,10 +3,6 @@ mod controllers; mod models; mod internals; -use std::{ - env::var, - error -}; use poise::serenity_prelude::{ builder::{ CreateMessage, @@ -21,10 +17,12 @@ use poise::serenity_prelude::{ GatewayIntents }; -type Error = Box; +type Error = Box; static BOT_READY_NOTIFY: u64 = 865673694184996888; +static CAN_DEPLOY_COMMANDS: bool = false; + async fn on_ready( ctx: &Context, ready: &Ready, @@ -32,6 +30,8 @@ async fn on_ready( ) -> Result<(), Error> { println!("Connected to API as {}", ready.user.name); + controllers::timers::start_timers(&ctx, ready.user.to_owned()).await.expect("Failed to start timers"); + let message = CreateMessage::new(); let ready_embed = CreateEmbed::new() .color(internals::utils::EMBED_COLOR) @@ -40,15 +40,14 @@ async fn on_ready( ChannelId::new(BOT_READY_NOTIFY).send_message(&ctx.http, message.add_embed(ready_embed)).await?; - let register_commands = var("REGISTER_CMDS").unwrap_or_else(|_| String::from("true")).parse::().unwrap_or(true); - - if register_commands { + if CAN_DEPLOY_COMMANDS { let builder = poise::builtins::create_application_commands(&framework.options().commands); 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 +66,14 @@ 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::ban(), + commands::moderation::kick(), + commands::moderation::mute(), + 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..7a84cc9 --- /dev/null +++ b/src/models/moderation_events.rs @@ -0,0 +1,215 @@ +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 +} + +#[derive(Clone, Copy)] +pub enum ActionTypes { + Ban, + Kick, + Mute, + Warn, + Unban, + Unmute, + Unknown +} + +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", + ActionTypes::Unknown => "unknown" + } + } +} + +impl Moderations { + pub async fn get_active_events() -> Result, tokio_postgres::Error> { + let _db = DatabaseController::new().await?.client; + + _db.execute("BEGIN", &[]).await.expect("Failed to start transaction!"); + let stmt = _db.prepare(" + SELECT * FROM moderation_events + WHERE is_active = true + ORDER BY duration DESC, time_created DESC; + ").await?; + + _db.execute("COMMIT", &[]).await.expect("Failed to commit transaction!"); + let rows = _db.query(&stmt, &[]).await?; + + let mut moderations = Vec::new(); + for row in rows { + moderations.push(Moderations { + guild_id: row.get("guild_id"), + case_id: row.get("case_id"), + action_type: match row.get::<_, &str>("action_type") { + "ban" => ActionTypes::Ban, + "kick" => ActionTypes::Kick, + "mute" => ActionTypes::Mute, + "warn" => ActionTypes::Warn, + "unban" => ActionTypes::Unban, + "unmute" => ActionTypes::Unmute, + _ => ActionTypes::Unknown + }, + is_active: row.get("is_active"), + user_id: row.get("user_id"), + user_tag: row.get("user_tag"), + reason: row.get("reason"), + moderator_id: row.get("moderator_id"), + moderator_tag: row.get("moderator_tag"), + time_created: row.get("time_created"), + duration: row.get("duration") + }); + } + + Ok(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(()) + } +}