From ff886efd7892294bd1809a2307a01e33ea025458 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 26 Jan 2023 17:51:04 +0100 Subject: [PATCH] Make Command system more flexible --- src/bot/botrulez/full_help.rs | 19 +-- src/bot/command.rs | 43 +------ src/bot/command/bang.rs | 233 ++++++++++++++++++++++++++++++++++ src/bot/command/clap.rs | 6 +- src/bot/command/hidden.rs | 23 ++++ src/bot/commands.rs | 226 +++------------------------------ 6 files changed, 289 insertions(+), 261 deletions(-) create mode 100644 src/bot/command/bang.rs create mode 100644 src/bot/command/hidden.rs diff --git a/src/bot/botrulez/full_help.rs b/src/bot/botrulez/full_help.rs index 33af357..d14ae21 100644 --- a/src/bot/botrulez/full_help.rs +++ b/src/bot/botrulez/full_help.rs @@ -3,7 +3,6 @@ use clap::Parser; use crate::api::Message; use crate::bot::command::{ClapCommand, Context}; -use crate::bot::commands::CommandInfo; use crate::conn; /// Show full bot help. @@ -25,7 +24,7 @@ impl FullHelp { } pub trait HasDescriptions { - fn descriptions(&self) -> &[CommandInfo]; + fn descriptions(&self, ctx: &Context) -> Vec; } #[async_trait] @@ -50,19 +49,9 @@ where result.push('\n'); } - for help in bot.descriptions() { - if !help.visible { - continue; - } - - let usage = help.kind.usage(&help.name, &ctx.joined.session.name); - let line = if let Some(description) = &help.description { - format!("{usage} - {description}\n") - } else { - format!("{usage}\n") - }; - - result.push_str(&line); + for description in bot.descriptions(ctx) { + result.push_str(&description); + result.push('\n'); } if !self.after.is_empty() { diff --git a/src/bot/command.rs b/src/bot/command.rs index 3032f05..7f75f5e 100644 --- a/src/bot/command.rs +++ b/src/bot/command.rs @@ -1,4 +1,6 @@ +mod bang; mod clap; +mod hidden; use std::future::Future; @@ -7,45 +9,13 @@ use async_trait::async_trait; use crate::api::{self, Message, MessageId}; use crate::conn::{self, ConnTx, Joined}; -pub use self::clap::{Clap, ClapCommand}; +pub use self::bang::*; +pub use self::clap::*; +pub use self::hidden::*; use super::instance::InstanceConfig; -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Kind { - /// Global commands always respond. They override any specific or general - /// commands of the same name. - Global, - /// General commands only respond if no nick is specified. - General, - /// Specific commands only respond if the bot's current nick is specified. - Specific, -} - -impl Kind { - pub fn global_and_general_usage(cmd_name: &str) -> String { - format!("!{cmd_name}") - } - - pub fn specific_nick(nick: &str) -> String { - nick.replace(char::is_whitespace, "") - } - - pub fn specific_usage(cmd_name: &str, nick: &str) -> String { - format!("!{cmd_name} @{}", Self::specific_nick(nick)) - } - - pub fn usage(self, cmd_name: &str, nick: &str) -> String { - match self { - Self::Global | Self::General => Self::global_and_general_usage(cmd_name), - Self::Specific => Self::specific_usage(cmd_name, nick), - } - } -} - pub struct Context { - pub name: String, - pub kind: Kind, pub config: InstanceConfig, pub conn_tx: ConnTx, pub joined: Joined, @@ -75,9 +45,10 @@ impl Context { } } +#[allow(unused_variables)] #[async_trait] pub trait Command { - fn description(&self) -> Option { + fn description(&self, ctx: &Context) -> Option { None } diff --git a/src/bot/command/bang.rs b/src/bot/command/bang.rs new file mode 100644 index 0000000..589811f --- /dev/null +++ b/src/bot/command/bang.rs @@ -0,0 +1,233 @@ +use async_trait::async_trait; + +use crate::api::Message; + +use super::{Command, Context}; + +/// Parse leading whitespace followed by an `!`-initiated command. +/// +/// Returns the command name and the remaining text with one leading whitespace +/// removed. The remaining text may be the empty string. +fn parse_command<'a>(text: &'a str, prefix: &str) -> Option<(&'a str, &'a str)> { + let text = text.trim_start(); + let text = text.strip_prefix(prefix)?; + let (name, rest) = text.split_once(char::is_whitespace).unwrap_or((text, "")); + if name.is_empty() { + return None; + } + Some((name, rest)) +} + +/// Parse leading whitespace followed by an `@`-initiated nick. +/// +/// Returns the nick and the remaining text with one leading whitespace removed. +/// The remaining text may be the empty string. +fn parse_specific(text: &str) -> Option<(&str, &str)> { + let text = text.trim_start(); + let text = text.strip_prefix('@')?; + let (name, rest) = text.split_once(char::is_whitespace).unwrap_or((text, "")); + if name.is_empty() { + return None; + } + Some((name, rest)) +} + +fn specific_nick(nick: &str) -> String { + nick.replace(char::is_whitespace, "") +} + +fn normalize_specific_nick(nick: &str) -> String { + specific_nick(nick).to_lowercase() +} + +pub struct Global { + prefix: String, + name: String, + inner: C, +} + +impl Global { + pub fn new(name: S, inner: C) -> Self { + Self { + prefix: "!".to_string(), + name: name.to_string(), + inner, + } + } + + pub fn prefix(mut self, prefix: S) -> Self { + self.prefix = prefix.to_string(); + self + } +} + +#[async_trait] +impl Command for Global +where + B: Send, + C: Command + Send + Sync, +{ + fn description(&self, ctx: &Context) -> Option { + let inner = self.inner.description(ctx)?; + Some(format!("{}{} - {inner}", self.prefix, self.name)) + } + + async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) -> Result<(), E> { + // TODO Replace with let-else + let (name, rest) = match parse_command(arg, &self.prefix) { + Some(parsed) => parsed, + None => return Ok(()), + }; + + if name != self.name { + return Ok(()); + } + + self.inner.execute(rest, msg, ctx, bot).await + } +} + +pub struct General { + prefix: String, + name: String, + inner: C, +} + +impl General { + pub fn new(name: S, inner: C) -> Self { + Self { + prefix: "!".to_string(), + name: name.to_string(), + inner, + } + } + + pub fn prefix(mut self, prefix: S) -> Self { + self.prefix = prefix.to_string(); + self + } +} + +#[async_trait] +impl Command for General +where + B: Send, + C: Command + Send + Sync, +{ + fn description(&self, ctx: &Context) -> Option { + let inner = self.inner.description(ctx)?; + Some(format!("{}{} - {inner}", self.prefix, self.name)) + } + + async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) -> Result<(), E> { + // TODO Replace with let-else + let (name, rest) = match parse_command(arg, &self.prefix) { + Some(parsed) => parsed, + None => return Ok(()), + }; + + if name != self.name { + return Ok(()); + } + + if parse_specific(rest).is_some() { + // The command looks like a specific command. If we treated it like + // a general command match, we would interpret other bots' specific + // commands as general commands. + return Ok(()); + } + + self.inner.execute(rest, msg, ctx, bot).await + } +} + +pub struct Specific { + prefix: String, + name: String, + inner: C, +} + +impl Specific { + pub fn new(name: S, inner: C) -> Self { + Self { + prefix: "!".to_string(), + name: name.to_string(), + inner, + } + } + + pub fn prefix(mut self, prefix: S) -> Self { + self.prefix = prefix.to_string(); + self + } +} + +#[async_trait] +impl Command for Specific +where + B: Send, + C: Command + Send + Sync, +{ + fn description(&self, ctx: &Context) -> Option { + let inner = self.inner.description(ctx)?; + let nick = specific_nick(&ctx.joined.session.name); + Some(format!("{}{} @{nick} - {inner}", self.prefix, self.name)) + } + + async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) -> Result<(), E> { + // TODO Replace with let-else + let (name, rest) = match parse_command(arg, &self.prefix) { + Some(parsed) => parsed, + None => return Ok(()), + }; + + if name != self.name { + return Ok(()); + } + + // TODO Replace with let-else + let (nick, rest) = match parse_specific(rest) { + Some(parsed) => parsed, + None => return Ok(()), + }; + + if normalize_specific_nick(nick) != normalize_specific_nick(&ctx.joined.session.name) { + return Ok(()); + } + + self.inner.execute(rest, msg, ctx, bot).await + } +} + +#[cfg(test)] +mod test { + use super::{parse_command, parse_specific}; + + #[test] + fn test_parse_command() { + assert_eq!(parse_command("!foo", "!"), Some(("foo", ""))); + assert_eq!(parse_command(" !foo", "!"), Some(("foo", ""))); + assert_eq!(parse_command("!foo ", "!"), Some(("foo", " "))); + assert_eq!(parse_command(" !foo ", "!"), Some(("foo", " "))); + assert_eq!(parse_command("!foo @bar", "!"), Some(("foo", "@bar"))); + assert_eq!(parse_command("!foo @bar", "!"), Some(("foo", " @bar"))); + assert_eq!(parse_command("!foo @bar ", "!"), Some(("foo", "@bar "))); + assert_eq!(parse_command("! foo @bar", "!"), None); + assert_eq!(parse_command("!", "!"), None); + assert_eq!(parse_command("?foo", "!"), None); + } + + #[test] + fn test_parse_specific() { + assert_eq!(parse_specific("@foo"), Some(("foo", ""))); + assert_eq!(parse_specific(" @foo"), Some(("foo", ""))); + assert_eq!(parse_specific("@foo "), Some(("foo", " "))); + assert_eq!(parse_specific(" @foo "), Some(("foo", " "))); + assert_eq!(parse_specific("@foo !bar"), Some(("foo", "!bar"))); + assert_eq!(parse_specific("@foo !bar"), Some(("foo", " !bar"))); + assert_eq!(parse_specific("@foo !bar "), Some(("foo", "!bar "))); + assert_eq!(parse_specific("@ foo !bar"), None); + assert_eq!(parse_specific("@"), None); + assert_eq!(parse_specific("?foo"), None); + } +} diff --git a/src/bot/command/clap.rs b/src/bot/command/clap.rs index b828e96..efb7fce 100644 --- a/src/bot/command/clap.rs +++ b/src/bot/command/clap.rs @@ -106,7 +106,7 @@ where C: ClapCommand + Send + Sync, C::Args: Parser + Send, { - fn description(&self) -> Option { + fn description(&self, _ctx: &Context) -> Option { C::Args::command().get_about().map(|s| format!("{s}")) } @@ -119,7 +119,9 @@ where } }; - args.insert(0, ctx.kind.usage(&ctx.name, &ctx.joined.session.name)); + // Hacky, but it should work fine in most cases + let usage = msg.content.strip_suffix(arg).unwrap_or("").trim(); + args.insert(0, usage.to_string()); let args = match C::Args::try_parse_from(args) { Ok(args) => args, diff --git a/src/bot/command/hidden.rs b/src/bot/command/hidden.rs new file mode 100644 index 0000000..c3f6bf2 --- /dev/null +++ b/src/bot/command/hidden.rs @@ -0,0 +1,23 @@ +use async_trait::async_trait; + +use crate::api::Message; + +use super::{Command, Context}; + +pub struct Hidden(pub C); + +#[async_trait] +impl Command for Hidden +where + B: Send, + C: Command + Send + Sync, +{ + fn description(&self, _ctx: &Context) -> Option { + // Default implementation, repeated here for emphasis. + None + } + + async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) -> Result<(), E> { + self.0.execute(arg, msg, ctx, bot).await + } +} diff --git a/src/bot/commands.rs b/src/bot/commands.rs index 0e40509..b9e6993 100644 --- a/src/bot/commands.rs +++ b/src/bot/commands.rs @@ -1,153 +1,31 @@ -use std::collections::HashMap; - use crate::api::packet::ParsedPacket; use crate::api::{Data, SendEvent}; use crate::conn; -use super::command::{Command, Context, Kind}; +use super::command::{Command, Context}; use super::instance::{InstanceConfig, Snapshot}; -fn normalize_specific_nick(nick: &str) -> String { - Kind::specific_nick(nick).to_lowercase() -} - -/// Parse leading whitespace followed by an `!`-initiated command. -/// -/// Returns the command name and the remaining text with one leading whitespace -/// removed. The remaining text may be the empty string. -fn parse_command(text: &str) -> Option<(&str, &str)> { - let text = text.trim_start(); - let text = text.strip_prefix('!')?; - let (name, rest) = text.split_once(char::is_whitespace).unwrap_or((text, "")); - if name.is_empty() { - return None; - } - Some((name, rest)) -} - -/// Parse leading whitespace followed by an `@`-initiated nick. -/// -/// Returns the nick and the remaining text with one leading whitespace removed. -/// The remaining text may be the empty string. -fn parse_specific(text: &str) -> Option<(&str, &str)> { - let text = text.trim_start(); - let text = text.strip_prefix('@')?; - let (name, rest) = text.split_once(char::is_whitespace).unwrap_or((text, "")); - if name.is_empty() { - return None; - } - Some((name, rest)) -} - -pub struct CommandInfo { - pub kind: Kind, - pub name: String, - pub description: Option, - pub visible: bool, -} - -struct CommandWrapper { - command: Box>, - visible: bool, -} - pub struct Commands { - global: HashMap>, - general: HashMap>, - specific: HashMap>, + commands: Vec + Send + Sync>>, } impl Commands { pub fn new() -> Self { - Self { - global: HashMap::new(), - general: HashMap::new(), - specific: HashMap::new(), - } + Self { commands: vec![] } } - /// Global commands always respond. They override any specific or general - /// commands of the same name. - /// - /// Use this if your bot "owns" the command and no other bot uses it. - pub fn global(mut self, name: S, command: C, visible: bool) -> Self + pub fn add(&mut self, command: C) where - S: ToString, - C: Command + 'static, + C: Command + Send + Sync + 'static, { - let command = Box::new(command); - let info = CommandWrapper { command, visible }; - self.global.insert(name.to_string(), info); - self + self.commands.push(Box::new(command)); } - /// General commands only respond if no nick is specified. - /// - /// Use this if your or any other bot has a specific command of the same - /// name. - pub fn general(mut self, name: S, command: C, visible: bool) -> Self - where - S: ToString, - C: Command + 'static, - { - let command = Box::new(command); - let info = CommandWrapper { command, visible }; - self.general.insert(name.to_string(), info); - self - } - - /// Specific commands only respond if the bot's current nick is specified. - pub fn specific(mut self, name: S, command: C, visible: bool) -> Self - where - S: ToString, - C: Command + 'static, - { - let command = Box::new(command); - let info = CommandWrapper { command, visible }; - self.specific.insert(name.to_string(), info); - self - } - - pub fn descriptions(&self) -> Vec { - let mut keys = (self.global.keys()) - .chain(self.general.keys()) - .chain(self.specific.keys()) - .collect::>(); - keys.sort_unstable(); - keys.dedup(); - - let mut result = vec![]; - for name in keys { - if let Some(wrapper) = self.global.get(name) { - result.push(CommandInfo { - name: name.clone(), - kind: Kind::Global, - visible: wrapper.visible, - description: wrapper.command.description(), - }); - continue; // Shadows general and specific commands - } - - if let Some(wrapper) = self.general.get(name) { - result.push(CommandInfo { - name: name.clone(), - kind: Kind::General, - visible: wrapper.visible, - description: wrapper.command.description(), - }); - } - - if let Some(wrapper) = self.specific.get(name) { - result.push(CommandInfo { - name: name.clone(), - kind: Kind::Specific, - visible: wrapper.visible, - description: wrapper.command.description(), - }); - } - } - - result + pub fn descriptions(&self, ctx: &Context) -> Vec { + self.commands + .iter() + .filter_map(|c| c.description(ctx)) + .collect::>() } /// Returns `true` if a command was found and executed, `false` otherwise. @@ -157,63 +35,28 @@ impl Commands { packet: &ParsedPacket, snapshot: &Snapshot, bot: &mut B, - ) -> Result { + ) -> Result<(), E> { let msg = match &packet.content { Ok(Data::SendEvent(SendEvent(msg))) => msg, - _ => return Ok(false), + _ => return Ok(()), }; let joined = match &snapshot.state { - conn::State::Joining(_) => return Ok(false), + conn::State::Joining(_) => return Ok(()), conn::State::Joined(joined) => joined.clone(), }; - let (cmd_name, rest) = match parse_command(&msg.content) { - Some(parsed) => parsed, - None => return Ok(false), - }; - - let mut ctx = Context { - name: cmd_name.to_string(), - kind: Kind::Global, + let ctx = Context { config: config.clone(), conn_tx: snapshot.conn_tx.clone(), joined, }; - if let Some(wrapper) = self.global.get(cmd_name) { - ctx.kind = Kind::Global; - wrapper.command.execute(rest, msg, &ctx, bot).await?; - return Ok(true); + for command in &self.commands { + command.execute(&msg.content, msg, &ctx, bot).await?; } - if let Some((cmd_nick, rest)) = parse_specific(rest) { - if let Some(wrapper) = self.specific.get(cmd_name) { - let nick_norm = normalize_specific_nick(&ctx.joined.session.name); - let cmd_nick_norm = normalize_specific_nick(cmd_nick); - if nick_norm == cmd_nick_norm { - ctx.kind = Kind::Specific; - wrapper.command.execute(rest, msg, &ctx, bot).await?; - return Ok(true); - } - } - - // The command looks like a specific command. If we treated it like - // a general command just because the nick doesn't match, we would - // interpret other bots' specific commands as general commands. - // - // To call a specific command with a mention as its first positional - // argument, -- can be used. - return Ok(false); - } - - if let Some(wrapper) = self.general.get(cmd_name) { - ctx.kind = Kind::General; - wrapper.command.execute(rest, msg, &ctx, bot).await?; - return Ok(true); - } - - Ok(false) + Ok(()) } } @@ -222,36 +65,3 @@ impl Default for Commands { Self::new() } } - -#[cfg(test)] -mod test { - use super::{parse_command, parse_specific}; - - #[test] - fn test_parse_command() { - assert_eq!(parse_command("!foo"), Some(("foo", ""))); - assert_eq!(parse_command(" !foo"), Some(("foo", ""))); - assert_eq!(parse_command("!foo "), Some(("foo", " "))); - assert_eq!(parse_command(" !foo "), Some(("foo", " "))); - assert_eq!(parse_command("!foo @bar"), Some(("foo", "@bar"))); - assert_eq!(parse_command("!foo @bar"), Some(("foo", " @bar"))); - assert_eq!(parse_command("!foo @bar "), Some(("foo", "@bar "))); - assert_eq!(parse_command("! foo @bar"), None); - assert_eq!(parse_command("!"), None); - assert_eq!(parse_command("?foo"), None); - } - - #[test] - fn test_parse_specific() { - assert_eq!(parse_specific("@foo"), Some(("foo", ""))); - assert_eq!(parse_specific(" @foo"), Some(("foo", ""))); - assert_eq!(parse_specific("@foo "), Some(("foo", " "))); - assert_eq!(parse_specific(" @foo "), Some(("foo", " "))); - assert_eq!(parse_specific("@foo !bar"), Some(("foo", "!bar"))); - assert_eq!(parse_specific("@foo !bar"), Some(("foo", " !bar"))); - assert_eq!(parse_specific("@foo !bar "), Some(("foo", "!bar "))); - assert_eq!(parse_specific("@ foo !bar"), None); - assert_eq!(parse_specific("@"), None); - assert_eq!(parse_specific("?foo"), None); - } -}