Make Command system more flexible

This commit is contained in:
Joscha 2023-01-26 17:51:04 +01:00
parent 2719ab3301
commit ff886efd78
6 changed files with 289 additions and 261 deletions

View file

@ -3,7 +3,6 @@ use clap::Parser;
use crate::api::Message; use crate::api::Message;
use crate::bot::command::{ClapCommand, Context}; use crate::bot::command::{ClapCommand, Context};
use crate::bot::commands::CommandInfo;
use crate::conn; use crate::conn;
/// Show full bot help. /// Show full bot help.
@ -25,7 +24,7 @@ impl FullHelp {
} }
pub trait HasDescriptions { pub trait HasDescriptions {
fn descriptions(&self) -> &[CommandInfo]; fn descriptions(&self, ctx: &Context) -> Vec<String>;
} }
#[async_trait] #[async_trait]
@ -50,19 +49,9 @@ where
result.push('\n'); result.push('\n');
} }
for help in bot.descriptions() { for description in bot.descriptions(ctx) {
if !help.visible { result.push_str(&description);
continue; result.push('\n');
}
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);
} }
if !self.after.is_empty() { if !self.after.is_empty() {

View file

@ -1,4 +1,6 @@
mod bang;
mod clap; mod clap;
mod hidden;
use std::future::Future; use std::future::Future;
@ -7,45 +9,13 @@ use async_trait::async_trait;
use crate::api::{self, Message, MessageId}; use crate::api::{self, Message, MessageId};
use crate::conn::{self, ConnTx, Joined}; 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; 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 struct Context {
pub name: String,
pub kind: Kind,
pub config: InstanceConfig, pub config: InstanceConfig,
pub conn_tx: ConnTx, pub conn_tx: ConnTx,
pub joined: Joined, pub joined: Joined,
@ -75,9 +45,10 @@ impl Context {
} }
} }
#[allow(unused_variables)]
#[async_trait] #[async_trait]
pub trait Command<B, E> { pub trait Command<B, E> {
fn description(&self) -> Option<String> { fn description(&self, ctx: &Context) -> Option<String> {
None None
} }

233
src/bot/command/bang.rs Normal file
View file

@ -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<C> {
prefix: String,
name: String,
inner: C,
}
impl<C> Global<C> {
pub fn new<S: ToString>(name: S, inner: C) -> Self {
Self {
prefix: "!".to_string(),
name: name.to_string(),
inner,
}
}
pub fn prefix<S: ToString>(mut self, prefix: S) -> Self {
self.prefix = prefix.to_string();
self
}
}
#[async_trait]
impl<B, E, C> Command<B, E> for Global<C>
where
B: Send,
C: Command<B, E> + Send + Sync,
{
fn description(&self, ctx: &Context) -> Option<String> {
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<C> {
prefix: String,
name: String,
inner: C,
}
impl<C> General<C> {
pub fn new<S: ToString>(name: S, inner: C) -> Self {
Self {
prefix: "!".to_string(),
name: name.to_string(),
inner,
}
}
pub fn prefix<S: ToString>(mut self, prefix: S) -> Self {
self.prefix = prefix.to_string();
self
}
}
#[async_trait]
impl<B, E, C> Command<B, E> for General<C>
where
B: Send,
C: Command<B, E> + Send + Sync,
{
fn description(&self, ctx: &Context) -> Option<String> {
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<C> {
prefix: String,
name: String,
inner: C,
}
impl<C> Specific<C> {
pub fn new<S: ToString>(name: S, inner: C) -> Self {
Self {
prefix: "!".to_string(),
name: name.to_string(),
inner,
}
}
pub fn prefix<S: ToString>(mut self, prefix: S) -> Self {
self.prefix = prefix.to_string();
self
}
}
#[async_trait]
impl<B, E, C> Command<B, E> for Specific<C>
where
B: Send,
C: Command<B, E> + Send + Sync,
{
fn description(&self, ctx: &Context) -> Option<String> {
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);
}
}

View file

@ -106,7 +106,7 @@ where
C: ClapCommand<B, E> + Send + Sync, C: ClapCommand<B, E> + Send + Sync,
C::Args: Parser + Send, C::Args: Parser + Send,
{ {
fn description(&self) -> Option<String> { fn description(&self, _ctx: &Context) -> Option<String> {
C::Args::command().get_about().map(|s| format!("{s}")) 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("<command>").trim();
args.insert(0, usage.to_string());
let args = match C::Args::try_parse_from(args) { let args = match C::Args::try_parse_from(args) {
Ok(args) => args, Ok(args) => args,

23
src/bot/command/hidden.rs Normal file
View file

@ -0,0 +1,23 @@
use async_trait::async_trait;
use crate::api::Message;
use super::{Command, Context};
pub struct Hidden<C>(pub C);
#[async_trait]
impl<B, E, C> Command<B, E> for Hidden<C>
where
B: Send,
C: Command<B, E> + Send + Sync,
{
fn description(&self, _ctx: &Context) -> Option<String> {
// 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
}
}

View file

@ -1,153 +1,31 @@
use std::collections::HashMap;
use crate::api::packet::ParsedPacket; use crate::api::packet::ParsedPacket;
use crate::api::{Data, SendEvent}; use crate::api::{Data, SendEvent};
use crate::conn; use crate::conn;
use super::command::{Command, Context, Kind}; use super::command::{Command, Context};
use super::instance::{InstanceConfig, Snapshot}; 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<String>,
pub visible: bool,
}
struct CommandWrapper<B, E> {
command: Box<dyn Command<B, E>>,
visible: bool,
}
pub struct Commands<B, E> { pub struct Commands<B, E> {
global: HashMap<String, CommandWrapper<B, E>>, commands: Vec<Box<dyn Command<B, E> + Send + Sync>>,
general: HashMap<String, CommandWrapper<B, E>>,
specific: HashMap<String, CommandWrapper<B, E>>,
} }
impl<B, E> Commands<B, E> { impl<B, E> Commands<B, E> {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self { commands: vec![] }
global: HashMap::new(),
general: HashMap::new(),
specific: HashMap::new(),
}
} }
/// Global commands always respond. They override any specific or general pub fn add<C>(&mut self, command: C)
/// commands of the same name.
///
/// Use this if your bot "owns" the command and no other bot uses it.
pub fn global<S, C>(mut self, name: S, command: C, visible: bool) -> Self
where where
S: ToString, C: Command<B, E> + Send + Sync + 'static,
C: Command<B, E> + 'static,
{ {
let command = Box::new(command); self.commands.push(Box::new(command));
let info = CommandWrapper { command, visible };
self.global.insert(name.to_string(), info);
self
} }
/// General commands only respond if no nick is specified. pub fn descriptions(&self, ctx: &Context) -> Vec<String> {
/// self.commands
/// Use this if your or any other bot has a specific command of the same .iter()
/// name. .filter_map(|c| c.description(ctx))
pub fn general<S, C>(mut self, name: S, command: C, visible: bool) -> Self .collect::<Vec<_>>()
where
S: ToString,
C: Command<B, E> + '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<S, C>(mut self, name: S, command: C, visible: bool) -> Self
where
S: ToString,
C: Command<B, E> + 'static,
{
let command = Box::new(command);
let info = CommandWrapper { command, visible };
self.specific.insert(name.to_string(), info);
self
}
pub fn descriptions(&self) -> Vec<CommandInfo> {
let mut keys = (self.global.keys())
.chain(self.general.keys())
.chain(self.specific.keys())
.collect::<Vec<_>>();
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
} }
/// Returns `true` if a command was found and executed, `false` otherwise. /// Returns `true` if a command was found and executed, `false` otherwise.
@ -157,63 +35,28 @@ impl<B, E> Commands<B, E> {
packet: &ParsedPacket, packet: &ParsedPacket,
snapshot: &Snapshot, snapshot: &Snapshot,
bot: &mut B, bot: &mut B,
) -> Result<bool, E> { ) -> Result<(), E> {
let msg = match &packet.content { let msg = match &packet.content {
Ok(Data::SendEvent(SendEvent(msg))) => msg, Ok(Data::SendEvent(SendEvent(msg))) => msg,
_ => return Ok(false), _ => return Ok(()),
}; };
let joined = match &snapshot.state { let joined = match &snapshot.state {
conn::State::Joining(_) => return Ok(false), conn::State::Joining(_) => return Ok(()),
conn::State::Joined(joined) => joined.clone(), conn::State::Joined(joined) => joined.clone(),
}; };
let (cmd_name, rest) = match parse_command(&msg.content) { let ctx = Context {
Some(parsed) => parsed,
None => return Ok(false),
};
let mut ctx = Context {
name: cmd_name.to_string(),
kind: Kind::Global,
config: config.clone(), config: config.clone(),
conn_tx: snapshot.conn_tx.clone(), conn_tx: snapshot.conn_tx.clone(),
joined, joined,
}; };
if let Some(wrapper) = self.global.get(cmd_name) { for command in &self.commands {
ctx.kind = Kind::Global; command.execute(&msg.content, msg, &ctx, bot).await?;
wrapper.command.execute(rest, msg, &ctx, bot).await?;
return Ok(true);
} }
if let Some((cmd_nick, rest)) = parse_specific(rest) { Ok(())
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)
} }
} }
@ -222,36 +65,3 @@ impl<B, E> Default for Commands<B, E> {
Self::new() 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);
}
}