diff --git a/Cargo.toml b/Cargo.toml index 97995e8..be45ac0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ version = "0.5.1" edition = "2021" [workspace.dependencies] +async-trait = "0.1.83" caseless = "0.2.1" cookie = "0.18.1" futures-util = "0.3.31" diff --git a/euphoxide-bot/Cargo.toml b/euphoxide-bot/Cargo.toml index c1f80c3..47642f0 100644 --- a/euphoxide-bot/Cargo.toml +++ b/euphoxide-bot/Cargo.toml @@ -4,6 +4,7 @@ version = { workspace = true } edition = { workspace = true } [dependencies] +async-trait = { workspace = true } cookie = { workspace = true } euphoxide = { path = "../euphoxide" } jiff = { workspace = true } diff --git a/euphoxide-bot/src/bot.rs b/euphoxide-bot/src/bot.rs index 0e24906..6dea4b4 100644 --- a/euphoxide-bot/src/bot.rs +++ b/euphoxide-bot/src/bot.rs @@ -1,64 +1,76 @@ -use std::sync::Arc; +use std::{fmt::Debug, sync::Arc}; use jiff::Timestamp; +use log::error; use tokio::sync::mpsc; use crate::{ + command::Commands, instance::ServerConfig, instances::{Event, Instances, InstancesConfig}, }; -#[derive(Debug, Clone)] #[non_exhaustive] -pub struct BotConfig { - pub server: ServerConfig, - pub instances: InstancesConfig, - pub state: S, -} - -impl BotConfig { - pub fn with_state(self, state: S2) -> BotConfig { - BotConfig { - server: self.server, - instances: self.instances, - state, - } - } - - pub fn create(self, event_tx: mpsc::Sender) -> Bot { - Bot::new(self, event_tx) - } -} - -impl Default for BotConfig<()> { - fn default() -> Self { - Self { - server: ServerConfig::default(), - instances: InstancesConfig::default(), - state: (), - } - } -} - -#[derive(Clone)] -pub struct Bot { +pub struct Bot { pub server_config: ServerConfig, + pub commands: Arc>, pub state: Arc, pub instances: Instances, pub start_time: Timestamp, } -impl Bot { - pub fn new(config: BotConfig, event_tx: mpsc::Sender) -> Self { +impl Bot { + pub fn new_simple(commands: Commands, event_tx: mpsc::Sender) -> Self { + Self::new( + ServerConfig::default(), + InstancesConfig::default(), + commands, + (), + event_tx, + ) + } +} + +impl Bot { + pub fn new( + server_config: ServerConfig, + instances_config: InstancesConfig, + commands: Commands, + state: S, + event_tx: mpsc::Sender, + ) -> Self { Self { - server_config: config.server, - state: Arc::new(config.state), - instances: Instances::new(config.instances, event_tx), + server_config, + commands: Arc::new(commands), + state: Arc::new(state), + instances: Instances::new(instances_config, event_tx), start_time: Timestamp::now(), } } - +} +impl Bot +where + S: Send + Sync + 'static, + E: Debug + 'static, +{ pub fn handle_event(&self, event: Event) { - todo!() + let bot = self.clone(); + tokio::task::spawn(async move { + if let Err(err) = bot.commands.on_event(event, &bot).await { + error!("while handling event: {err:#?}"); + } + }); + } +} + +impl Clone for Bot { + fn clone(&self) -> Self { + Self { + server_config: self.server_config.clone(), + commands: self.commands.clone(), + state: self.state.clone(), + instances: self.instances.clone(), + start_time: self.start_time, + } } } diff --git a/euphoxide-bot/src/command.rs b/euphoxide-bot/src/command.rs new file mode 100644 index 0000000..c16f837 --- /dev/null +++ b/euphoxide-bot/src/command.rs @@ -0,0 +1,186 @@ +pub mod bang; +pub mod basic; + +use std::future::Future; + +use async_trait::async_trait; +use euphoxide::{ + api::{self, Data, Message, MessageId, SendEvent, SendReply}, + client::{ + conn::ClientConnHandle, + state::{Joined, State}, + }, +}; + +use crate::{bot::Bot, instance::Instance, instances::Event}; + +#[non_exhaustive] +pub struct Context { + pub instance: Instance, + pub conn: ClientConnHandle, + pub joined: Joined, +} + +impl Context { + pub async fn send( + &self, + content: impl ToString, + ) -> euphoxide::Result>> { + self.conn + .send(api::Send { + content: content.to_string(), + parent: None, + }) + .await + } + + pub async fn send_only(&self, content: impl ToString) -> euphoxide::Result<()> { + let _ignore = self.send(content).await?; + Ok(()) + } + + pub async fn reply( + &self, + parent: MessageId, + content: impl ToString, + ) -> euphoxide::Result>> { + self.conn + .send(api::Send { + content: content.to_string(), + parent: Some(parent), + }) + .await + } + + pub async fn reply_only( + &self, + parent: MessageId, + content: impl ToString, + ) -> euphoxide::Result<()> { + let _ignore = self.reply(parent, content).await?; + Ok(()) + } +} + +#[derive(Default)] +pub struct Info { + pub trigger: Option, + pub description: Option, +} + +impl Info { + pub fn new() -> Self { + Self::default() + } + + pub fn with_trigger(mut self, trigger: impl ToString) -> Self { + self.trigger = Some(trigger.to_string()); + self + } + + pub fn with_description(mut self, description: impl ToString) -> Self { + self.description = Some(description.to_string()); + self + } + + pub fn prepend_trigger(&mut self, trigger: impl ToString) { + let cur_trigger = self.trigger.get_or_insert_default(); + if !cur_trigger.is_empty() { + cur_trigger.insert(0, ' '); + } + cur_trigger.insert_str(0, &trigger.to_string()); + } + + pub fn with_prepended_trigger(mut self, trigger: impl ToString) -> Self { + self.prepend_trigger(trigger); + self + } +} + +/// Whether a message should propagate to subsequent commands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Propagate { + No, + Yes, +} + +#[allow(unused_variables)] +#[async_trait] +pub trait Command { + fn info(&self, ctx: &Context) -> Info { + Info::default() + } + + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &Bot, + ) -> Result; +} + +pub struct Commands { + commands: Vec + Sync + Send>>, +} + +impl Commands { + pub fn new() -> Self { + Self { commands: vec![] } + } + + pub fn add(&mut self, command: impl Command + Sync + Send + 'static) { + self.commands.push(Box::new(command)); + } + + pub fn then(mut self, command: impl Command + Sync + Send + 'static) -> Self { + self.add(command); + self + } + + pub fn infos(&self, ctx: &Context) -> Vec { + self.commands.iter().map(|c| c.info(ctx)).collect() + } + + pub(crate) async fn on_event(&self, event: Event, bot: &Bot) -> Result { + let Event::Packet { + instance, + conn, + state, + packet, + } = event + else { + return Ok(Propagate::Yes); + }; + + let Ok(Data::SendEvent(SendEvent(msg))) = &packet.content else { + return Ok(Propagate::Yes); + }; + + let State::Joined(joined) = state else { + return Ok(Propagate::Yes); + }; + + let ctx = Context { + instance, + conn, + joined, + }; + + for command in &self.commands { + let propagate = command.execute(&msg.content, msg, &ctx, bot).await?; + if propagate == Propagate::No { + return Ok(Propagate::No); + } + } + + Ok(Propagate::Yes) + } +} + +// Has fewer restrictions on generic types than #[derive(Default)]. +impl Default for Commands { + fn default() -> Self { + Self::new() + } +} diff --git a/euphoxide-bot/src/command/bang.rs b/euphoxide-bot/src/command/bang.rs new file mode 100644 index 0000000..8e87952 --- /dev/null +++ b/euphoxide-bot/src/command/bang.rs @@ -0,0 +1,232 @@ +//! Euphoria-style `!foo` and `!foo @bar` command wrappers. + +use async_trait::async_trait; +use euphoxide::{api::Message, nick}; + +use crate::bot::Bot; + +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 + S: Send + Sync, + C: Command + 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: &Bot, + ) -> 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 + S: Send + Sync, + C: Command + 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: &Bot, + ) -> 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 + S: Send + Sync, + C: Command + 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: &Bot, + ) -> 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/basic.rs b/euphoxide-bot/src/command/basic.rs new file mode 100644 index 0000000..4ec78e9 --- /dev/null +++ b/euphoxide-bot/src/command/basic.rs @@ -0,0 +1,115 @@ +//! Basic command wrappers. + +use async_trait::async_trait; +use euphoxide::api::Message; + +use crate::bot::Bot; + +use super::{Command, Context, Info, Propagate}; + +/// Rewrite or hide command info. +pub struct Described { + pub inner: C, + pub trigger: Option>, + pub description: Option>, +} + +impl Described { + pub fn new(inner: C) -> Self { + Self { + inner, + trigger: None, + description: None, + } + } + + pub fn hidden(inner: C) -> Self { + Self::new(inner) + .with_trigger_hidden() + .with_description_hidden() + } + + pub fn with_trigger(mut self, trigger: impl ToString) -> Self { + self.trigger = Some(Some(trigger.to_string())); + self + } + + pub fn with_trigger_hidden(mut self) -> Self { + self.trigger = Some(None); + self + } + + pub fn with_description(mut self, description: impl ToString) -> Self { + self.description = Some(Some(description.to_string())); + self + } + + pub fn with_description_hidden(mut self) -> Self { + self.description = Some(None); + self + } +} + +#[async_trait] +impl Command for Described +where + S: Send + Sync, + C: Command + Sync, +{ + fn info(&self, ctx: &Context) -> Info { + let info = self.inner.info(ctx); + Info { + trigger: self.trigger.clone().unwrap_or(info.trigger), + description: self.description.clone().unwrap_or(info.description), + } + } + + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &Bot, + ) -> Result { + self.inner.execute(arg, msg, ctx, bot).await + } +} + +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 + S: Send + Sync, + C: Command + 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: &Bot, + ) -> Result { + if let Some(rest) = arg.trim_start().strip_prefix(&self.prefix) { + self.inner.execute(rest, msg, ctx, bot).await + } else { + Ok(Propagate::Yes) + } + } +} diff --git a/euphoxide-bot/src/lib.rs b/euphoxide-bot/src/lib.rs index 4ecf479..c0bf08e 100644 --- a/euphoxide-bot/src/lib.rs +++ b/euphoxide-bot/src/lib.rs @@ -1,3 +1,4 @@ pub mod bot; +pub mod command; pub mod instance; pub mod instances;