diff --git a/Cargo.toml b/Cargo.toml index b79053d..b0807c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" [workspace.dependencies] async-trait = "0.1.83" caseless = "0.2.1" -clap = { version = "4.5.23", default-features = false, features = ["std"] } +clap = { version = "4.5.23", default-features = false, features = ["std", "derive"] } cookie = "0.18.1" futures-util = "0.3.31" jiff = { version = "0.1.15", default-features = false, features = ["std"] } diff --git a/euphoxide-bot/Cargo.toml b/euphoxide-bot/Cargo.toml index c54c22b..bd160d2 100644 --- a/euphoxide-bot/Cargo.toml +++ b/euphoxide-bot/Cargo.toml @@ -11,6 +11,7 @@ async-trait = { workspace = true } clap = { workspace = true, optional = true } cookie = { workspace = true } euphoxide = { path = "../euphoxide" } +jiff = { workspace = true } log = { workspace = true } tokio = { workspace = true, features = ["rt"] } tokio-tungstenite = { workspace = true } diff --git a/euphoxide-bot/src/command.rs b/euphoxide-bot/src/command.rs index 789c2d9..3866384 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..ea44f45 --- /dev/null +++ b/euphoxide-bot/src/command/botrulez/full_help.rs @@ -0,0 +1,111 @@ +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::command::{Command, Context, Info, Propagate}; + +pub trait HasCommandInfos { + fn command_infos(&self, ctx: &Context) -> Vec; +} + +#[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: &B) -> String { + let mut result = String::new(); + + if !self.before.is_empty() { + result.push_str(&self.before); + result.push('\n'); + } + + for info in bot.command_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 + B: HasCommandInfos + Send, + E: From, +{ + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &mut B, + ) -> 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 + B: HasCommandInfos + Send, + E: From, +{ + type Args = FullHelpArgs; + + async fn execute( + &self, + _args: Self::Args, + msg: &Message, + ctx: &Context, + bot: &mut B, + ) -> 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..a90c913 --- /dev/null +++ b/euphoxide-bot/src/command/botrulez/ping.rs @@ -0,0 +1,68 @@ +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::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: &mut B, + ) -> 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: &mut B, + ) -> 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..badb4eb --- /dev/null +++ b/euphoxide-bot/src/command/botrulez/short_help.rs @@ -0,0 +1,62 @@ +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::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: &mut B, + ) -> 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: &mut B, + ) -> 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..2fa63d4 --- /dev/null +++ b/euphoxide-bot/src/command/botrulez/uptime.rs @@ -0,0 +1,137 @@ +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::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: &B, 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 + B: HasStartTime + Send, + E: From, +{ + async fn execute( + &self, + arg: &str, + msg: &Message, + ctx: &Context, + bot: &mut B, + ) -> 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 + B: HasStartTime + Send, + E: From, +{ + type Args = UptimeArgs; + + async fn execute( + &self, + args: Self::Args, + msg: &Message, + ctx: &Context, + bot: &mut B, + ) -> Result { + let reply = self.formulate_reply(ctx, bot, args.connected); + ctx.reply_only(msg.id, reply).await?; + Ok(Propagate::No) + } +}