diff --git a/euphoxide-bot/Cargo.toml b/euphoxide-bot/Cargo.toml index afcb7c4..58bcbcf 100644 --- a/euphoxide-bot/Cargo.toml +++ b/euphoxide-bot/Cargo.toml @@ -8,7 +8,7 @@ clap = ["dep:clap"] [dependencies] async-trait = { workspace = true } -clap = { workspace = true, optional = true } +clap = { workspace = true, optional = true, features = ["derive"] } cookie = { workspace = true } euphoxide = { path = "../euphoxide" } jiff = { workspace = true } diff --git a/euphoxide-bot/src/command.rs b/euphoxide-bot/src/command.rs index 72fb4e9..ce654f5 100644 --- a/euphoxide-bot/src/command.rs +++ b/euphoxide-bot/src/command.rs @@ -1,5 +1,6 @@ pub mod bang; pub mod basic; +pub mod botrulez; #[cfg(feature = "clap")] pub mod clap; 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) + } +}