diff --git a/euphoxide-bot/src/command.rs b/euphoxide-bot/src/command.rs index a36bc4f..a630b8d 100644 --- a/euphoxide-bot/src/command.rs +++ b/euphoxide-bot/src/command.rs @@ -1,3 +1,7 @@ +mod bang; +mod hidden; +mod prefixed; + use std::future::Future; use async_trait::async_trait; @@ -9,6 +13,8 @@ use euphoxide::{ }, }; +pub use self::{bang::*, hidden::*, prefixed::*}; + #[non_exhaustive] pub struct Context { pub conn: ClientConnHandle, diff --git a/euphoxide-bot/src/command/bang.rs b/euphoxide-bot/src/command/bang.rs new file mode 100644 index 0000000..99dc814 --- /dev/null +++ b/euphoxide-bot/src/command/bang.rs @@ -0,0 +1,228 @@ +use async_trait::async_trait; +use euphoxide::{api::Message, nick}; + +use super::{Command, Context, Info, Propagate}; + +// TODO Don't ignore leading whitespace? +// I'm not entirely happy with how commands handle whitespace, and on euphoria, +// prefixing commands with whitespace is traditionally used to not trigger them. + +/// Parse leading whitespace followed by an prefix-initiated command. +/// +/// Returns the command name and the remaining text with one leading whitespace +/// removed. The remaining text may be the empty string. +pub fn parse_prefix_initiated<'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)) +} + +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 with_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 info(&self, ctx: &Context) -> Info { + self.inner + .info(ctx) + .with_prepended_trigger(format!("{}{}", self.prefix, self.name)) + } + + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &mut B, + ) -> Result { + let Some((name, rest)) = parse_prefix_initiated(arg, &self.prefix) else { + return Ok(Propagate::Yes); + }; + + if name != self.name { + return Ok(Propagate::Yes); + } + + 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 with_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 info(&self, ctx: &Context) -> Info { + self.inner + .info(ctx) + .with_prepended_trigger(format!("{}{}", self.prefix, self.name)) + } + + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &mut B, + ) -> Result { + let Some((name, rest)) = parse_prefix_initiated(arg, &self.prefix) else { + return Ok(Propagate::Yes); + }; + + if name != self.name { + return Ok(Propagate::Yes); + } + + if parse_prefix_initiated(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(Propagate::Yes); + } + + 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 with_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 info(&self, ctx: &Context) -> Info { + let nick = nick::mention(&ctx.joined.session.name); + self.inner + .info(ctx) + .with_prepended_trigger(format!("{}{} @{nick}", self.prefix, self.name)) + } + + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &mut B, + ) -> Result { + let Some((name, rest)) = parse_prefix_initiated(arg, &self.prefix) else { + return Ok(Propagate::Yes); + }; + + if name != self.name { + return Ok(Propagate::Yes); + } + + let Some((nick, rest)) = parse_prefix_initiated(rest, "@") else { + return Ok(Propagate::Yes); + }; + + if nick::normalize(nick) != nick::normalize(&ctx.joined.session.name) { + return Ok(Propagate::Yes); + } + + self.inner.execute(rest, msg, ctx, bot).await + } +} + +#[cfg(test)] +mod test { + use super::parse_prefix_initiated; + + #[test] + fn test_parse_prefixed() { + assert_eq!(parse_prefix_initiated("!foo", "!"), Some(("foo", ""))); + assert_eq!(parse_prefix_initiated(" !foo", "!"), Some(("foo", ""))); + assert_eq!( + parse_prefix_initiated("!foo ", "!"), + Some(("foo", " ")) + ); + assert_eq!( + parse_prefix_initiated(" !foo ", "!"), + Some(("foo", " ")) + ); + assert_eq!( + parse_prefix_initiated("!foo @bar", "!"), + Some(("foo", "@bar")) + ); + assert_eq!( + parse_prefix_initiated("!foo @bar", "!"), + Some(("foo", " @bar")) + ); + assert_eq!( + parse_prefix_initiated("!foo @bar ", "!"), + Some(("foo", "@bar ")) + ); + assert_eq!(parse_prefix_initiated("! foo @bar", "!"), None); + assert_eq!(parse_prefix_initiated("!", "!"), None); + assert_eq!(parse_prefix_initiated("?foo", "!"), None); + } +} diff --git a/euphoxide-bot/src/command/hidden.rs b/euphoxide-bot/src/command/hidden.rs new file mode 100644 index 0000000..4c6ee12 --- /dev/null +++ b/euphoxide-bot/src/command/hidden.rs @@ -0,0 +1,55 @@ +use async_trait::async_trait; +use euphoxide::api::Message; + +use super::{Command, Context, Info, Propagate}; + +pub struct Hidden { + pub inner: C, + pub allow_trigger: bool, + pub allow_description: bool, +} + +impl Hidden { + pub fn new(inner: C) -> Self { + Self { + inner, + allow_trigger: false, + allow_description: false, + } + } + + pub fn with_allow_trigger(mut self, allow: bool) -> Self { + self.allow_trigger = allow; + self + } + + pub fn with_allow_description(mut self, allow: bool) -> Self { + self.allow_description = allow; + self + } +} + +#[async_trait] +impl Command for Hidden +where + B: Send, + C: Command + Send + Sync, +{ + fn info(&self, ctx: &Context) -> Info { + let info = self.inner.info(ctx); + Info { + trigger: info.trigger.filter(|_| self.allow_trigger), + description: info.description.filter(|_| self.allow_description), + } + } + + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &mut B, + ) -> Result { + self.inner.execute(arg, msg, ctx, bot).await + } +} diff --git a/euphoxide-bot/src/command/prefixed.rs b/euphoxide-bot/src/command/prefixed.rs new file mode 100644 index 0000000..f989a26 --- /dev/null +++ b/euphoxide-bot/src/command/prefixed.rs @@ -0,0 +1,43 @@ +use async_trait::async_trait; +use euphoxide::api::Message; + +use super::{Command, Context, Info, Propagate}; + +pub struct Prefixed { + prefix: String, + inner: C, +} + +impl Prefixed { + pub fn new(prefix: S, inner: C) -> Self { + Self { + prefix: prefix.to_string(), + inner, + } + } +} + +#[async_trait] +impl Command for Prefixed +where + B: Send, + C: Command + Send + Sync, +{ + fn info(&self, ctx: &Context) -> Info { + self.inner.info(ctx).with_prepended_trigger(&self.prefix) + } + + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &mut B, + ) -> Result { + if let Some(rest) = arg.trim_start().strip_prefix(&self.prefix) { + self.inner.execute(rest, msg, ctx, bot).await + } else { + Ok(Propagate::Yes) + } + } +}