Add bot::command::clap

This commit is contained in:
Joscha 2023-01-24 14:12:50 +01:00
parent 84b742ee6e
commit 4dcc021f73
4 changed files with 189 additions and 7 deletions

View file

@ -4,10 +4,10 @@ version = "0.2.0"
edition = "2021" edition = "2021"
[features] [features]
bot = ["dep:cookie"] bot = ["dep:async-trait", "dep:clap", "dep:cookie"]
[dependencies] [dependencies]
async-trait = "0.1.63" async-trait = { version = "0.1.63", optional = true }
cookie = { version = "0.16.2", optional = true } cookie = { version = "0.16.2", optional = true }
futures-util = { version = "0.3.25", default-features = false, features = ["sink"] } futures-util = { version = "0.3.25", default-features = false, features = ["sink"] }
log = "0.4.17" log = "0.4.17"
@ -18,6 +18,12 @@ tokio = { version = "1.23.0", features = ["time", "sync", "macros", "rt"] }
tokio-stream = "0.1.11" tokio-stream = "0.1.11"
tokio-tungstenite = { version = "0.18.0", features = ["rustls-tls-native-roots"] } tokio-tungstenite = { version = "0.18.0", features = ["rustls-tls-native-roots"] }
[dependencies.clap]
version = "4.1.3"
optional = true
default-features = false
features = ["std"]
[dev-dependencies] # For example bot [dev-dependencies] # For example bot
tokio = { version = "1.23.0", features = ["rt-multi-thread"] } tokio = { version = "1.23.0", features = ["rt-multi-thread"] }

View file

@ -1,8 +1,14 @@
mod clap;
use std::future::Future;
use async_trait::async_trait; 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};
use super::instance::InstanceConfig; use super::instance::InstanceConfig;
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@ -38,6 +44,7 @@ impl Kind {
} }
pub struct Context { pub struct Context {
pub name: String,
pub kind: Kind, pub kind: Kind,
pub config: InstanceConfig, pub config: InstanceConfig,
pub conn_tx: ConnTx, pub conn_tx: ConnTx,
@ -45,24 +52,26 @@ pub struct Context {
} }
impl Context { impl Context {
pub async fn send<S: ToString>(&self, content: S) -> Result<Message, conn::Error> { pub fn send<S: ToString>(&self, content: S) -> impl Future<Output = conn::Result<Message>> {
let cmd = api::Send { let cmd = api::Send {
content: content.to_string(), content: content.to_string(),
parent: None, parent: None,
}; };
Ok(self.conn_tx.send(cmd).await?.0) let reply = self.conn_tx.send(cmd);
async move { reply.await.map(|r| r.0) }
} }
pub async fn reply<S: ToString>( pub fn reply<S: ToString>(
&self, &self,
parent: MessageId, parent: MessageId,
content: S, content: S,
) -> Result<Message, conn::Error> { ) -> impl Future<Output = conn::Result<Message>> {
let cmd = api::Send { let cmd = api::Send {
content: content.to_string(), content: content.to_string(),
parent: Some(parent), parent: Some(parent),
}; };
Ok(self.conn_tx.send(cmd).await?.0) let reply = self.conn_tx.send(cmd);
async move { reply.await.map(|r| r.0) }
} }
} }

166
src/bot/command/clap.rs Normal file
View file

@ -0,0 +1,166 @@
use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use crate::api::Message;
use super::{Command, Context};
#[async_trait]
pub trait ClapCommand<B> {
type Args;
async fn execute(&self, args: Self::Args, msg: &Message, ctx: &Context, bot: &mut B);
}
/// Parse bash-like quoted arguments separated by whitespace.
///
/// Outside of quotes, the backslash either escapes the next character or forms
/// an escape sequence. \n is a newline, \r a carriage return and \t a tab.
/// TODO Escape sequences
///
/// Special characters like the backslash and whitespace can also be quoted
/// using double quotes. Within double quotes, \" escapes a double quote and \\
/// escapes a backslash. Other occurrences of \ have no special meaning.
fn parse_quoted_args(text: &str) -> Result<Vec<String>, &'static str> {
let mut args = vec![];
let mut arg = String::new();
let mut arg_exists = false;
let mut quoted = false;
let mut escaped = false;
for c in text.chars() {
if quoted {
match c {
'\\' if escaped => {
arg.push('\\');
escaped = false;
}
'"' if escaped => {
arg.push('"');
escaped = false;
}
c if escaped => {
arg.push('\\');
arg.push(c);
escaped = false;
}
'\\' => escaped = true,
'"' => quoted = false,
c => arg.push(c),
}
} else {
match c {
c if escaped => {
arg.push(c);
arg_exists = true;
escaped = false;
}
c if c.is_whitespace() => {
if arg_exists {
args.push(arg);
arg = String::new();
arg_exists = false;
}
}
'\\' => escaped = true,
'"' => {
quoted = true;
arg_exists = true;
}
c => {
arg.push(c);
arg_exists = true;
}
}
}
}
if quoted {
return Err("Unclosed trailing quote");
}
if escaped {
return Err("Unfinished trailing escape");
}
if arg_exists {
args.push(arg);
}
Ok(args)
}
pub struct Clap<C>(pub C);
#[async_trait]
impl<B, C> Command<B> for Clap<C>
where
B: Send,
C: ClapCommand<B> + Send + Sync,
C::Args: Parser + Send,
{
fn description(&self) -> Option<String> {
C::Args::command().get_about().map(|s| format!("{s}"))
}
async fn execute(&self, arg: &str, msg: &Message, ctx: &Context, bot: &mut B) {
let mut args = match parse_quoted_args(arg) {
Ok(args) => args,
Err(err) => {
let _ = ctx.reply(msg.id, err);
return;
}
};
args.insert(0, ctx.kind.usage(&ctx.name, &ctx.joined.session.name));
let args = match C::Args::try_parse_from(args) {
Ok(args) => args,
Err(err) => {
let _ = ctx.reply(msg.id, format!("{}", err.render()));
return;
}
};
self.0.execute(args, msg, ctx, bot).await
}
}
#[cfg(test)]
mod test {
use super::parse_quoted_args;
fn assert_quoted(raw: &str, parsed: &[&str]) {
let parsed = parsed.iter().map(|s| s.to_string()).collect();
assert_eq!(parse_quoted_args(raw), Ok(parsed))
}
#[test]
fn test_parse_quoted_args() {
assert_quoted("foo bar baz", &["foo", "bar", "baz"]);
assert_quoted(" foo bar baz ", &["foo", "bar", "baz"]);
assert_quoted("foo\\ ba\"r ba\"z", &["foo bar baz"]);
assert_quoted(
"It's a nice day, isn't it?",
&["It's", "a", "nice", "day,", "isn't", "it?"],
);
// Trailing whitespace
assert_quoted("a ", &["a"]);
assert_quoted("a\\ ", &["a "]);
assert_quoted("a\\ ", &["a "]);
// Zero-length arguments
assert_quoted("a \"\" b \"\"", &["a", "", "b", ""]);
assert_quoted("a \"\" b \"\" ", &["a", "", "b", ""]);
// Backslashes in quotes
assert_quoted("\"a \\b \\\" \\\\\"", &["a \\b \" \\"]);
// Unclosed quotes and unfinished escapes
assert!(parse_quoted_args("foo 'bar \"baz").is_err());
assert!(parse_quoted_args("foo \"bar baz").is_err());
assert!(parse_quoted_args("foo \"bar 'baz").is_err());
assert!(parse_quoted_args("foo \\").is_err());
assert!(parse_quoted_args("foo 'bar\\").is_err());
}
}

View file

@ -166,6 +166,7 @@ impl<B> Commands<B> {
}; };
let mut ctx = Context { let mut ctx = Context {
name: cmd_name.to_string(),
kind: Kind::Global, kind: Kind::Global,
config: config.clone(), config: config.clone(),
conn_tx: snapshot.conn_tx.clone(), conn_tx: snapshot.conn_tx.clone(),