diff --git a/Cargo.lock b/Cargo.lock index 5347856..df5b6dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2013,7 +2013,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "payphone" -version = "4.4.42" +version = "4.4.43" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 40da7ef..68fa693 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "payphone" -version = "4.4.42" +version = "4.4.43" edition = "2024" default-run = "payphone" diff --git a/src/framework/commands/mod.rs b/src/framework/commands/mod.rs index 55437ef..3955d72 100644 --- a/src/framework/commands/mod.rs +++ b/src/framework/commands/mod.rs @@ -19,6 +19,7 @@ pub mod messages; pub mod permissions; pub mod runner; +/// An error occurred during command execution. #[derive(Debug, Error)] pub enum CommandError { #[error("A database query failed")] @@ -46,30 +47,84 @@ pub enum CommandError { DatabaseOrDiscordError(#[from] DatabaseOrDiscordError), } +/// Represents a bot command. +/// +/// Only the [`Command::name()`] and the [`Command::execute()`] methods must be implemented. The +/// rest have default implementations. They can all be overriden to tweak the command's behavior +/// and display in the help command. #[async_trait] pub trait Command: Send + Sync + Debug { + /// The command's name. + /// + /// Returns: + /// [`&'static str`] - The command's name. fn name(&self) -> &'static str; + /// The command's aliases. + /// + /// Returns: + /// [`&'static [&'static str]`] - The command's aliases. fn aliases(&self) -> &'static [&'static str] { &[] } + /// The command's description, shown in the help command. + /// + /// Returns: + /// [`&'static str`] - The command's description. fn description(&self) -> &'static str { "A Payphone command." } + /// The command's arguments, shown in the help command. + /// + /// This does not affect parsing in any way, as that's done inside [`Command::execute()`]. This + /// is for display in the help command only. + /// + /// Returns: + /// [`&'static [Argument]`] - The command's arguments. fn arguments(&self) -> &'static [Argument] { &[] } + /// Permissions that will be checked for before running the command. + /// + /// If any of the permissions fail, the command will not be run and the permission's + /// [`Permission::on_handle_denied()`] will be called. + /// + /// Returns: + /// [`Vec + Send + Sync>>`] - A vector of the command's permissions. fn permissions(&self) -> Vec + Send + Sync + 'static>> { vec![] } + /// Whether the command should be shown in the help command. + /// + /// This is useful for commands that are not meant to be used by users directly, like internal + /// commands or easter egg commands. + /// + /// Returns: + /// [`bool`] - Whether the command should be shown in the help command. fn show_in_help(&self, _rt: &Runtime, _extras: &E) -> bool { true } + /// Whether the command only runs when the message starts with the command prefix. + /// + /// When this is false, the command will be run when the command is found on any part of the + /// message. This is useful for about-group commands, like: + /// + /// ```text + /// User: You can donate $6 at p.d to get unbanned. + /// Bot: https://ko-fi.com/payphonedev + /// ``` + /// + /// Returns: + /// [`bool`] - Whether the command only runs when the message starts with the command prefix. + fn only_runs_if_startswith(&self) -> bool { + true + } + /// Returns the command name + all its aliases. /// /// Returns: @@ -80,35 +135,86 @@ pub trait Command: Send + Sync + Debug { all } + /// Executes the command. + /// + /// Arguments: + /// * `ctx` - The command context, containing information about the command invocation and + /// helpers for responding. + /// * `args` - The raw arguments passed to the command, without any parsing. This is the part + /// of the message after the command name. + /// + /// Returns: + /// [`Result<(), CommandError>`] - Ok if the command executed successfully, or an error if + /// something went wrong during execution. async fn execute(&self, ctx: CommandContext<'_, T, E>, args: &str) -> Result<(), CommandError>; } +/// The parts of a command found in a message, without processing. +/// +/// This is not guaranteed to point to a valid command, as it only finds a prefix in a message and +/// parses it without checking for command existance nor argument validity. pub struct CommandParts<'a> { + /// The name of the command. pub name: &'a str, + + /// The arguments passed to the command, raw as a string. pub args: &'a str, + + /// The index in the start-trimmed message where the command was found. + /// + /// For example, given the prefix is `p.`: + /// ```text + /// Donate $6 at p.d to get unbanned. + /// ^ + /// index = 13 + /// ``` + pub start_index: usize, } impl<'a> CommandParts<'a> { - fn new(name: &'a str, args: &'a str) -> Self { - Self { name, args } + /// Creates a new [`CommandParts`] struct. + /// + /// Arguments: + /// * `name` - The name of the command. + /// * `args` - The arguments passed to the command, raw as a string. + /// * `start_index` - The index in the start-trimmed message where the command was found. + /// + /// Returns: + /// [`CommandParts`] - The created struct. + fn new(name: &'a str, args: &'a str, start_index: usize) -> Self { + Self { + name, + args, + start_index, + } } } +/// Parses a message content to get the command name and arguments. +/// +/// Arguments: +/// * `prefix` - The prefix to look for in the message. +/// * `message` - The message content to parse. +/// +/// Returns: +/// [`Option`] - The parsed command parts, or None if the prefix was not found in the +/// message. pub fn get_command_parts<'a>(prefix: &str, message: &'a str) -> Option> { let message_trimmed_start = message.trim_ascii_start(); + let message_trimmed_start_lowercase = message_trimmed_start.to_ascii_lowercase(); - if message_trimmed_start - .to_ascii_lowercase() - .starts_with(&prefix.to_ascii_lowercase()) - { - let message = message_trimmed_start - .split_at(prefix.len()) - .1 - .trim_ascii_start(); + let prefix_lowercase = prefix.to_ascii_lowercase(); - match message.split_once(" ") { - Some((name, args)) => Some(CommandParts::new(name, args)), - None => Some(CommandParts::new(message, "")), + if let Some(index) = message_trimmed_start_lowercase.find(&prefix_lowercase) { + let command_text = &message_trimmed_start[index + prefix.len()..]; + + match command_text.trim_ascii_start().split_once(" ") { + Some((name, args)) => Some(CommandParts::new(name, args, index)), + None => Some(CommandParts::new( + command_text.trim_ascii_start(), + "", + index, + )), } } else { None diff --git a/src/framework/commands/runner.rs b/src/framework/commands/runner.rs index 5fcf427..339135d 100644 --- a/src/framework/commands/runner.rs +++ b/src/framework/commands/runner.rs @@ -40,6 +40,10 @@ where }); if let Some(command) = command { + if command.only_runs_if_startswith() && parts.start_index != 0 { + continue; + } + let extras = extras .get_extra(&event.author, &rt) .await diff --git a/src/groups/about/commands.rs b/src/groups/about/commands.rs index b46c284..fdbd08c 100644 --- a/src/groups/about/commands.rs +++ b/src/groups/about/commands.rs @@ -92,6 +92,10 @@ impl Command for CodeOfConductCommand { false } + fn only_runs_if_startswith(&self) -> bool { + false + } + async fn execute( &self, ctx: CommandContext<'_, State, Extras>, @@ -126,6 +130,10 @@ impl Command for DonateCommand { false } + fn only_runs_if_startswith(&self) -> bool { + false + } + async fn execute( &self, ctx: CommandContext<'_, State, Extras>,