diff --git a/Cargo.lock b/Cargo.lock index 1604f19..2b8f454 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1123,6 +1123,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" + [[package]] name = "hyper" version = "1.6.0" @@ -2001,6 +2007,7 @@ dependencies = [ "csv", "deadpool-redis", "dotenvy", + "humantime", "image", "include_dir", "itertools 0.14.0", diff --git a/Cargo.toml b/Cargo.toml index 5b63abe..91ba026 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ colored = "3.0.0" csv = "1.3.1" deadpool-redis = { version = "0.22.0", features = ["cluster", "cluster-async"] } dotenvy = "0.15.7" +humantime = "2.2.0" image = "0.25.6" include_dir = "0.7.4" itertools = "0.14.0" diff --git a/src/framework/commands/arguments.rs b/src/framework/commands/arguments.rs index 9e86102..25b8787 100644 --- a/src/framework/commands/arguments.rs +++ b/src/framework/commands/arguments.rs @@ -106,7 +106,7 @@ impl FromArguments for NeedyString { args: &'a str, _ctx: &'a CommandContext<'_, S, E>, ) -> Result<(Self, &'a str), ArgumentParsingError> { - if !args.is_empty() { + if !args.trim().is_empty() { match args.split_once(" ") { Some((arg, rest)) => Ok((Self(arg.to_string()), rest)), None => Ok((Self(args.to_string()), "")), @@ -144,8 +144,8 @@ impl FromArguments for NeedyGreedyString { args: &'a str, _ctx: &'a CommandContext<'_, S, E>, ) -> Result<(Self, &'a str), ArgumentParsingError> { - if !args.is_empty() { - Ok((Self(args.to_string()), "")) + if !args.trim().is_empty() { + Ok((Self(args.trim().to_string()), "")) } else { Err(ArgumentParsingError::MissingArgument) } diff --git a/src/groups/moderation/arguments.rs b/src/groups/moderation/arguments.rs index e5141b2..c1a9745 100644 --- a/src/groups/moderation/arguments.rs +++ b/src/groups/moderation/arguments.rs @@ -1,4 +1,7 @@ -use std::ops::{Deref, DerefMut}; +use std::{ + ops::{Deref, DerefMut}, + time::Duration, +}; use scylla::client::session::Session; @@ -10,6 +13,7 @@ use crate::{ context::CommandContext, }, state::State, + utils::split_at_nth_from_end, }; /// Fetch a user by their ID from the database. @@ -81,12 +85,13 @@ impl FromArguments for UserArgument { args: &'a str, ctx: &'a CommandContext<'_, State, Extras>, ) -> Result<(Self, &'a str), ArgumentParsingError> { - let user: User; - if !args.is_empty() { // There's an argument, so that argument is used to find the user. + let user: User; + let (identifier, args) = NeedyString::from_args(args, ctx).await?; + let identifier = identifier.0.trim(); if identifier.starts_with("<@") && identifier.ends_with(">") { // The argument is a Discord mention. @@ -108,7 +113,9 @@ impl FromArguments for UserArgument { } else { // The argument is (probably) a username. - let Some(db_user) = User::get_by_name(identifier.0, &ctx.rt.state.db).await? else { + let Some(db_user) = + User::get_by_name(identifier.to_string(), &ctx.rt.state.db).await? + else { // Check in the message's reply, and if not use the command executor. user = user_from_reply_or_command_executor(ctx).await?; @@ -117,12 +124,16 @@ impl FromArguments for UserArgument { user = db_user; } + + // Notice that `args` is shadowed above, so this line can't be removed (or else the + // full args will be passed and not just the remaining ones as it should be). + Ok((UserArgument(user), args)) } else { // Check in the message's reply, and if not use the command executor. - user = user_from_reply_or_command_executor(ctx).await?; - } + let user = user_from_reply_or_command_executor(ctx).await?; - Ok((UserArgument(user), args)) + Ok((UserArgument(user), args)) + } } } @@ -139,3 +150,47 @@ impl DerefMut for UserArgument { &mut self.0 } } + +#[derive(Debug, Clone, Copy)] +pub struct DurationArgument(pub Duration); + +impl FromArguments for DurationArgument { + async fn from_args<'a>( + mut args: &'a str, + _ctx: &'a CommandContext<'_, State, Extras>, + ) -> Result<(Self, &'a str), ArgumentParsingError> { + let mut i = 1; + let mut finding = None; + + while let Some((substring, remaining_args)) = split_at_nth_from_end(args, " ", i) { + if let Ok(duration) = humantime::parse_duration(substring) { + finding = Some(DurationArgument(duration)); + args = remaining_args; + + break; + } + + i += 1; + } + + if let Some(duration) = finding { + Ok((duration, args)) + } else { + Err(ArgumentParsingError::InvalidArgument) + } + } +} + +impl Deref for DurationArgument { + type Target = Duration; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for DurationArgument { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} diff --git a/src/groups/moderation/commands.rs b/src/groups/moderation/commands.rs index 73f520c..ff59d73 100644 --- a/src/groups/moderation/commands.rs +++ b/src/groups/moderation/commands.rs @@ -1,23 +1,25 @@ use async_trait::async_trait; +use time::UtcDateTime; use crate::{ - database::models::{Call, Message, User}, + database::models::{UserIncident, UserIncidentType}, extras::Extras, framework::{ commands::{ Command, CommandError, - arguments::{Arg, Argument, ArgumentParsingError, ParseArguments}, + arguments::{Arg, Argument, ArgumentParsingError, NeedyGreedyString, ParseArguments}, context::CommandContext, permissions::Permission, }, runtime::Runtime, }, groups::moderation::{ - arguments::UserArgument, + arguments::{DurationArgument, UserArgument}, get_incident_page, permissions::{BotModeratorPermission, BotOwnerPermission}, }, state::State, + utils::get_random_u64, }; #[derive(Debug)] @@ -149,34 +151,101 @@ impl Command for IncidentsCommand { } #[derive(Debug)] -pub struct StatsCommand; +pub struct BanCommand; #[async_trait] -impl Command for StatsCommand { +impl Command for BanCommand { fn name(&self) -> &'static str { - "stats" + "ban" + } + + fn aliases(&self) -> &'static [&'static str] { + &["b"] + } + + fn description(&self) -> &'static str { + "Bans a user from Payphone calls.\n\nThe user can be specified via either replying to them (works on call too), mentioning them, specifying their username, specifying their ID, or not setting it to default to yourself.\n\nThe duration can be specified as defined in the [`humantime::parse_duration` documentation](https://docs.rs/humantime/latest/humantime/fn.parse_duration.html)." } fn permissions(&self) -> Vec + Send + Sync + 'static>> { - vec![Box::new(BotOwnerPermission)] + vec![Box::new(BotModeratorPermission)] } - fn show_in_help(&self, rt: &Runtime, extras: &Extras) -> bool { - extras.user.id == rt.state.bot_owner_id + fn show_in_help(&self, _rt: &Runtime, extras: &Extras) -> bool { + extras.user.is_moderator + } + + fn arguments(&self) -> &'static [Argument] { + &[ + Argument { + name: "user", + is_optional: false, + is_greedy: false, + }, + Argument { + name: "duration", + is_optional: true, + is_greedy: false, + }, + Argument { + name: "reason", + is_optional: false, + is_greedy: true, + }, + ] } async fn execute( &self, ctx: CommandContext<'_, State, Extras>, - _args: &str, + args: &str, ) -> Result<(), CommandError> { - let users = User::count(&ctx.rt.state.db).await?; - let calls = Call::count(&ctx.rt.state.db).await?; - let messages = Message::count(&ctx.rt.state.db).await?; + let (user, duration, reason) = match <( + UserArgument, + Option, + NeedyGreedyString, + )>::parse(args, &ctx) + .await + { + Ok(arg) => arg, + Err(e) => match e { + ArgumentParsingError::InvalidArgument => { + ctx.send() + .content("❌ User not found in DB or no reason was provided.") + .await?; + return Ok(()); + } + ArgumentParsingError::DatabaseFailed(e) => { + return Err(CommandError::DatabaseFailed(e)); + } + ArgumentParsingError::MissingArgument => { + ctx.send().content(format!("❌ No reason was provided. Use `{}help ban` to learn how to use this command.", ctx.prefix)).await?; + return Ok(()); + } + }, + }; + + let incident = UserIncident { + created_at: UtcDateTime::now(), + user_id: user.id, + incident_id: get_random_u64(), + report_id: None, + r#type: UserIncidentType::Ban, + message: None, + created_by_id: Some(ctx.event.author.id.get()), + reason: (*reason).clone(), + expires_at: duration.map(|d| UtcDateTime::now() + *d), + duration: duration.map(|d| humantime::format_duration(*d).to_string()), + }; + incident.insert(&ctx.rt.state.db).await?; ctx.send() .content(format!( - "- Users: {users}\n- Calls: {calls}\n- Messages: {messages}" + "✅ User `{}` has been banned from Payphone{}.", + user.name, + duration + .map(|d| format!(" for {}", humantime::format_duration(*d))) + .unwrap_or_default() )) .await?; diff --git a/src/main.rs b/src/main.rs index ee097be..876fece 100644 --- a/src/main.rs +++ b/src/main.rs @@ -141,11 +141,12 @@ use crate::{ commands::{ CallCommand, FriendRequestCommand, HangupCommand, MaskCommand, SkipCommand, }, - events::{CallMessageDeleteHandler, CallMessageEditHandler, CallMessageHandler}, tasks::EndInactiveCallsTask, + events::{CallMessageDeleteHandler, CallMessageEditHandler, CallMessageHandler}, + tasks::EndInactiveCallsTask, }, gateway::events::{ReadyHandler, ReconnectHandler}, leveling::{commands::LeaderboardCommand, events::MessageLevelingHandler}, - moderation::commands::{IncidentsCommand, ModeratorCommand, StatsCommand}, + moderation::commands::{BanCommand, IncidentsCommand, ModeratorCommand}, servers::{ commands::PrefixCommand, events::{ServerCreateHandler, ServerDeleteHandler, ServerUpdateHandler}, @@ -301,7 +302,7 @@ async fn start( .command(PrefixCommand) .command(ModeratorCommand) .command(IncidentsCommand) - .command(StatsCommand) + .command(BanCommand) .component(HelpGoBackButton) .cache_resource_type(ResourceType::GUILD) .cache_resource_type(ResourceType::CHANNEL) diff --git a/src/utils.rs b/src/utils.rs index b91546f..139305b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -100,3 +100,32 @@ pub fn i64_to_u64(i: i64) -> u64 { pub fn u64_to_i64(u: u64) -> i64 { i64::from_ne_bytes(u.to_ne_bytes()) } + +/// Splits at the `n`th occurrence of `pattern` counting from the end of the string. +/// +/// Arguments: +/// * `s` - The string to split. +/// * `pattern` - The pattern to split at. +/// * `n` - The occurrence to split at (counting from the end). +/// +/// Returns: +/// [`Option<(&str, &str)>`] - The splitted parts of the string, or [`None`] if no occurrences of +/// `pattern` were found in `s`. +pub fn split_at_nth_from_end<'a, 'b>(s: &'a str, pattern: &'b str, n: usize) -> Option<(&'a str, &'a str)> { + let mut end = s.len(); + let mut count = 0; + + while let Some(pos) = s[..end].rfind(pattern) { + count += 1; + + if count == n { + let before = &s[..pos]; + let after = &s[pos + pattern.len()..]; + return Some((before, after)); + } + + end = pos; // move left and keep searching + } + + None +} \ No newline at end of file