From 7dbf041b694e484f56dec728be7293907aad9b68 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 11 Dec 2024 01:01:11 +0100 Subject: [PATCH] Create euphoxide-bot crate --- Cargo.toml | 5 +- euphoxide-bot/Cargo.toml | 21 ++ euphoxide-bot/src/bot.rs | 73 ++++++ euphoxide-bot/src/command.rs | 194 +++++++++++++++ euphoxide-bot/src/command/bang.rs | 232 ++++++++++++++++++ euphoxide-bot/src/command/basic.rs | 115 +++++++++ euphoxide-bot/src/command/botrulez.rs | 8 + .../src/command/botrulez/full_help.rs | 110 +++++++++ euphoxide-bot/src/command/botrulez/ping.rs | 71 ++++++ .../src/command/botrulez/short_help.rs | 65 +++++ euphoxide-bot/src/command/botrulez/uptime.rs | 140 +++++++++++ euphoxide-bot/src/command/clap.rs | 187 ++++++++++++++ euphoxide-bot/src/lib.rs | 2 + 13 files changed, 1222 insertions(+), 1 deletion(-) create mode 100644 euphoxide-bot/Cargo.toml create mode 100644 euphoxide-bot/src/bot.rs create mode 100644 euphoxide-bot/src/command.rs create mode 100644 euphoxide-bot/src/command/bang.rs create mode 100644 euphoxide-bot/src/command/basic.rs create mode 100644 euphoxide-bot/src/command/botrulez.rs create mode 100644 euphoxide-bot/src/command/botrulez/full_help.rs create mode 100644 euphoxide-bot/src/command/botrulez/ping.rs create mode 100644 euphoxide-bot/src/command/botrulez/short_help.rs create mode 100644 euphoxide-bot/src/command/botrulez/uptime.rs create mode 100644 euphoxide-bot/src/command/clap.rs create mode 100644 euphoxide-bot/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 56db451..69f5a23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,15 @@ [workspace] resolver = "2" -members = ["euphoxide", "euphoxide-client"] +members = ["euphoxide", "euphoxide-bot", "euphoxide-client"] [workspace.package] version = "0.5.1" edition = "2021" [workspace.dependencies] +async-trait = "0.1.83" caseless = "0.2.1" +clap = { version = "4.5.23", default-features = false, features = ["std"] } cookie = "0.18.1" futures-util = "0.3.31" jiff = { version = "0.1.15", default-features = false, features = ["std"] } @@ -22,6 +24,7 @@ anyhow = "1.0.94" rustls = "0.23.19" # In this workspace euphoxide = { path = "./euphoxide" } +euphoxide-bot = { path = "./euphoxide-bot" } euphoxide-client = { path = "./euphoxide-client" } [workspace.lints] diff --git a/euphoxide-bot/Cargo.toml b/euphoxide-bot/Cargo.toml new file mode 100644 index 0000000..a152f93 --- /dev/null +++ b/euphoxide-bot/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "euphoxide-bot" +version = { workspace = true } +edition = { workspace = true } + +[features] +clap = ["dep:clap"] + +[dependencies] +async-trait = { workspace = true } +clap = { workspace = true, optional = true, features = ["derive"] } +cookie = { workspace = true } +euphoxide = { workspace = true } +euphoxide-client = { workspace = true } +jiff = { workspace = true } +log = { workspace = true } +tokio = { workspace = true, features = ["rt"] } +tokio-tungstenite = { workspace = true } + +[lints] +workspace = true diff --git a/euphoxide-bot/src/bot.rs b/euphoxide-bot/src/bot.rs new file mode 100644 index 0000000..ecbf777 --- /dev/null +++ b/euphoxide-bot/src/bot.rs @@ -0,0 +1,73 @@ +use std::{fmt::Debug, sync::Arc}; + +use euphoxide_client::{MultiClient, MultiClientConfig, MultiClientEvent, ServerConfig}; +use jiff::Timestamp; +use log::error; +use tokio::sync::mpsc; + +use crate::command::Commands; + +#[non_exhaustive] +pub struct Bot { + pub server_config: ServerConfig, + pub commands: Arc>, + pub state: Arc, + pub clients: MultiClient, + pub start_time: Timestamp, +} + +impl Bot { + pub fn new_simple(commands: Commands, event_tx: mpsc::Sender) -> Self { + Self::new( + ServerConfig::default(), + MultiClientConfig::default(), + commands, + (), + event_tx, + ) + } +} + +impl Bot { + pub fn new( + server_config: ServerConfig, + clients_config: MultiClientConfig, + commands: Commands, + state: S, + event_tx: mpsc::Sender, + ) -> Self { + Self { + server_config, + commands: Arc::new(commands), + state: Arc::new(state), + clients: MultiClient::new_with_config(clients_config, event_tx), + start_time: Timestamp::now(), + } + } +} +impl Bot +where + S: Send + Sync + 'static, + E: Debug + 'static, +{ + pub fn handle_event(&self, event: MultiClientEvent) { + 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(), + clients: self.clients.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..e993aac --- /dev/null +++ b/euphoxide-bot/src/command.rs @@ -0,0 +1,194 @@ +pub mod bang; +pub mod basic; +pub mod botrulez; +#[cfg(feature = "clap")] +pub mod clap; + +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 euphoxide_client::{Client, MultiClientEvent}; + +use crate::bot::Bot; + +#[non_exhaustive] +pub struct Context { + pub client: Client, + 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: MultiClientEvent, + bot: &Bot, + ) -> Result { + let MultiClientEvent::Packet { + client, + 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 { + client, + 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/command/botrulez.rs b/euphoxide-bot/src/command/botrulez.rs new file mode 100644 index 0000000..94e7875 --- /dev/null +++ b/euphoxide-bot/src/command/botrulez.rs @@ -0,0 +1,8 @@ +//! The main [botrulez](https://github.com/jedevc/botrulez) commands. + +mod full_help; +mod ping; +mod short_help; +mod uptime; + +pub use self::{full_help::*, ping::*, short_help::*, uptime::*}; diff --git a/euphoxide-bot/src/command/botrulez/full_help.rs b/euphoxide-bot/src/command/botrulez/full_help.rs new file mode 100644 index 0000000..868da06 --- /dev/null +++ b/euphoxide-bot/src/command/botrulez/full_help.rs @@ -0,0 +1,110 @@ +use async_trait::async_trait; +#[cfg(feature = "clap")] +use clap::Parser; +use euphoxide::api::Message; + +#[cfg(feature = "clap")] +use crate::command::clap::ClapCommand; +use crate::{ + bot::Bot, + command::{Command, Context, Propagate}, +}; + +#[derive(Default)] +pub struct FullHelp { + pub before: String, + pub after: String, +} + +impl FullHelp { + pub fn new() -> Self { + Self::default() + } + + pub fn with_before(mut self, before: impl ToString) -> Self { + self.before = before.to_string(); + self + } + + pub fn with_after(mut self, after: impl ToString) -> Self { + self.after = after.to_string(); + self + } + + fn formulate_reply(&self, ctx: &Context, bot: &Bot) -> String { + let mut result = String::new(); + + if !self.before.is_empty() { + result.push_str(&self.before); + result.push('\n'); + } + + for info in bot.commands.infos(ctx) { + if let Some(trigger) = &info.trigger { + result.push_str(trigger); + if let Some(description) = &info.description { + result.push_str(" - "); + result.push_str(description); + } + result.push('\n'); + } + } + + if !self.after.is_empty() { + result.push_str(&self.after); + result.push('\n'); + } + + result + } +} + +#[async_trait] +impl Command for FullHelp +where + S: Send + Sync, + E: From, +{ + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &Bot, + ) -> Result { + if arg.trim().is_empty() { + let reply = self.formulate_reply(ctx, bot); + ctx.reply_only(msg.id, reply).await?; + Ok(Propagate::No) + } else { + Ok(Propagate::Yes) + } + } +} + +/// Show full bot help. +#[cfg(feature = "clap")] +#[derive(Parser)] +pub struct FullHelpArgs {} + +#[cfg(feature = "clap")] +#[async_trait] +impl ClapCommand for FullHelp +where + S: Send + Sync, + E: From, +{ + type Args = FullHelpArgs; + + async fn execute( + &self, + _args: Self::Args, + msg: &Message, + ctx: &Context, + bot: &Bot, + ) -> Result { + let reply = self.formulate_reply(ctx, bot); + ctx.reply_only(msg.id, reply).await?; + Ok(Propagate::No) + } +} diff --git a/euphoxide-bot/src/command/botrulez/ping.rs b/euphoxide-bot/src/command/botrulez/ping.rs new file mode 100644 index 0000000..f419ba5 --- /dev/null +++ b/euphoxide-bot/src/command/botrulez/ping.rs @@ -0,0 +1,71 @@ +use async_trait::async_trait; +#[cfg(feature = "clap")] +use clap::Parser; +use euphoxide::api::Message; + +#[cfg(feature = "clap")] +use crate::command::clap::ClapCommand; +use crate::{ + bot::Bot, + command::{Command, Context, Propagate}, +}; + +pub struct Ping(pub String); + +impl Ping { + pub fn new(reply: S) -> Self { + Self(reply.to_string()) + } +} + +impl Default for Ping { + fn default() -> Self { + Self::new("Pong!") + } +} + +#[async_trait] +impl Command for Ping +where + E: From, +{ + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + _bot: &Bot, + ) -> Result { + if arg.trim().is_empty() { + ctx.reply_only(msg.id, &self.0).await?; + Ok(Propagate::No) + } else { + Ok(Propagate::Yes) + } + } +} + +/// Trigger a short reply. +#[cfg(feature = "clap")] +#[derive(Parser)] +pub struct PingArgs {} + +#[cfg(feature = "clap")] +#[async_trait] +impl ClapCommand for Ping +where + E: From, +{ + type Args = PingArgs; + + async fn execute( + &self, + _args: Self::Args, + msg: &Message, + ctx: &Context, + _bot: &Bot, + ) -> Result { + ctx.reply_only(msg.id, &self.0).await?; + Ok(Propagate::No) + } +} diff --git a/euphoxide-bot/src/command/botrulez/short_help.rs b/euphoxide-bot/src/command/botrulez/short_help.rs new file mode 100644 index 0000000..6ce5680 --- /dev/null +++ b/euphoxide-bot/src/command/botrulez/short_help.rs @@ -0,0 +1,65 @@ +use async_trait::async_trait; +#[cfg(feature = "clap")] +use clap::Parser; +use euphoxide::api::Message; + +#[cfg(feature = "clap")] +use crate::command::clap::ClapCommand; +use crate::{ + bot::Bot, + command::{Command, Context, Propagate}, +}; + +pub struct ShortHelp(pub String); + +impl ShortHelp { + pub fn new(text: S) -> Self { + Self(text.to_string()) + } +} + +#[async_trait] +impl Command for ShortHelp +where + E: From, +{ + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + _bot: &Bot, + ) -> Result { + if arg.trim().is_empty() { + ctx.reply_only(msg.id, &self.0).await?; + Ok(Propagate::No) + } else { + Ok(Propagate::Yes) + } + } +} + +/// Show short bot help. +#[cfg(feature = "clap")] +#[derive(Parser)] +pub struct ShortHelpArgs {} + +#[cfg(feature = "clap")] +#[async_trait] +impl ClapCommand for ShortHelp +where + E: From, +{ + type Args = ShortHelpArgs; + + async fn execute( + &self, + _args: Self::Args, + msg: &Message, + ctx: &Context, + _bot: &Bot, + ) -> Result { + ctx.reply_only(msg.id, &self.0).await?; + Ok(Propagate::No) + } +} diff --git a/euphoxide-bot/src/command/botrulez/uptime.rs b/euphoxide-bot/src/command/botrulez/uptime.rs new file mode 100644 index 0000000..614a01e --- /dev/null +++ b/euphoxide-bot/src/command/botrulez/uptime.rs @@ -0,0 +1,140 @@ +use async_trait::async_trait; +#[cfg(feature = "clap")] +use clap::Parser; +use euphoxide::api::Message; +use jiff::{Span, Timestamp, Unit}; + +#[cfg(feature = "clap")] +use crate::command::clap::ClapCommand; +use crate::{ + bot::Bot, + command::{Command, Context, Propagate}, +}; + +pub fn format_time(t: Timestamp) -> String { + t.strftime("%Y-%m-%d %H:%M:%S UTC").to_string() +} + +pub fn format_relative_time(d: Span) -> String { + if d.is_positive() { + format!("in {}", format_duration(d.abs())) + } else { + format!("{} ago", format_duration(d.abs())) + } +} + +pub fn format_duration(d: Span) -> String { + let total = d.abs().total(Unit::Second).unwrap() as i64; + let secs = total % 60; + let mins = (total / 60) % 60; + let hours = (total / 60 / 60) % 24; + let days = total / 60 / 60 / 24; + + let mut segments = vec![]; + if days > 0 { + segments.push(format!("{days}d")); + } + if hours > 0 { + segments.push(format!("{hours}h")); + } + if mins > 0 { + segments.push(format!("{mins}m")); + } + if secs > 0 { + segments.push(format!("{secs}s")); + } + if segments.is_empty() { + segments.push("0s".to_string()); + } + + let segments = segments.join(" "); + if d.is_positive() { + segments + } else { + format!("-{segments}") + } +} + +pub struct Uptime; + +pub trait HasStartTime { + fn start_time(&self) -> Timestamp; +} + +impl Uptime { + fn formulate_reply(&self, ctx: &Context, bot: &Bot, connected: bool) -> String { + let start = bot.start_time; + let now = Timestamp::now(); + + let mut reply = format!( + "/me has been up since {} ({})", + format_time(start), + format_relative_time(start - now), + ); + + if connected { + let since = ctx.joined.since; + reply.push_str(&format!( + ", connected since {} ({})", + format_time(since), + format_relative_time(since - now), + )); + } + + reply + } +} + +#[async_trait] +impl Command for Uptime +where + S: Send + Sync, + E: From, +{ + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &Bot, + ) -> Result { + if arg.trim().is_empty() { + let reply = self.formulate_reply(ctx, bot, false); + ctx.reply_only(msg.id, reply).await?; + Ok(Propagate::No) + } else { + Ok(Propagate::Yes) + } + } +} + +/// Show how long the bot has been online. +#[cfg(feature = "clap")] +#[derive(Parser)] +pub struct UptimeArgs { + /// Show how long the bot has been connected without interruption. + #[arg(long, short)] + pub connected: bool, +} + +#[cfg(feature = "clap")] +#[async_trait] +impl ClapCommand for Uptime +where + S: Send + Sync, + E: From, +{ + type Args = UptimeArgs; + + async fn execute( + &self, + args: Self::Args, + msg: &Message, + ctx: &Context, + bot: &Bot, + ) -> Result { + let reply = self.formulate_reply(ctx, bot, args.connected); + ctx.reply_only(msg.id, reply).await?; + Ok(Propagate::No) + } +} diff --git a/euphoxide-bot/src/command/clap.rs b/euphoxide-bot/src/command/clap.rs new file mode 100644 index 0000000..fb03974 --- /dev/null +++ b/euphoxide-bot/src/command/clap.rs @@ -0,0 +1,187 @@ +//! [`clap`]-based commands. + +use async_trait::async_trait; +use clap::{CommandFactory, Parser}; +use euphoxide::api::Message; + +use crate::bot::Bot; + +use super::{Command, Context, Info, Propagate}; + +#[async_trait] +pub trait ClapCommand { + type Args; + + async fn execute( + &self, + args: Self::Args, + msg: &Message, + ctx: &Context, + bot: &Bot, + ) -> Result; +} + +/// 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, &'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(pub C); + +#[async_trait] +impl Command for Clap +where + S: Send + Sync, + E: From, + C: ClapCommand + Sync, + C::Args: Parser + Send, +{ + fn info(&self, _ctx: &Context) -> Info { + Info { + description: C::Args::command().get_about().map(|s| s.to_string()), + ..Info::default() + } + } + + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &Bot, + ) -> Result { + let mut args = match parse_quoted_args(arg) { + Ok(args) => args, + Err(err) => { + ctx.reply_only(msg.id, err).await?; + return Ok(Propagate::No); + } + }; + + // 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, + Err(err) => { + ctx.reply_only(msg.id, format!("{}", err.render())).await?; + return Ok(Propagate::No); + } + }; + + 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()); + } +} diff --git a/euphoxide-bot/src/lib.rs b/euphoxide-bot/src/lib.rs new file mode 100644 index 0000000..975f881 --- /dev/null +++ b/euphoxide-bot/src/lib.rs @@ -0,0 +1,2 @@ +pub mod bot; +pub mod command;