Create euphoxide-bot crate
This commit is contained in:
parent
42f5c9cb21
commit
39b6c341fa
13 changed files with 1222 additions and 1 deletions
232
euphoxide-bot/src/command/bang.rs
Normal file
232
euphoxide-bot/src/command/bang.rs
Normal file
|
|
@ -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<C> {
|
||||
prefix: String,
|
||||
name: String,
|
||||
inner: C,
|
||||
}
|
||||
|
||||
impl<C> Global<C> {
|
||||
pub fn new<S: ToString>(name: S, inner: C) -> Self {
|
||||
Self {
|
||||
prefix: "!".to_string(),
|
||||
name: name.to_string(),
|
||||
inner,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_prefix<S: ToString>(mut self, prefix: S) -> Self {
|
||||
self.prefix = prefix.to_string();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S, E, C> Command<S, E> for Global<C>
|
||||
where
|
||||
S: Send + Sync,
|
||||
C: Command<S, E> + 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<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
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<C> {
|
||||
prefix: String,
|
||||
name: String,
|
||||
inner: C,
|
||||
}
|
||||
|
||||
impl<C> General<C> {
|
||||
pub fn new<S: ToString>(name: S, inner: C) -> Self {
|
||||
Self {
|
||||
prefix: "!".to_string(),
|
||||
name: name.to_string(),
|
||||
inner,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_prefix<S: ToString>(mut self, prefix: S) -> Self {
|
||||
self.prefix = prefix.to_string();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S, E, C> Command<S, E> for General<C>
|
||||
where
|
||||
S: Send + Sync,
|
||||
C: Command<S, E> + 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<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
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<C> {
|
||||
prefix: String,
|
||||
name: String,
|
||||
inner: C,
|
||||
}
|
||||
|
||||
impl<C> Specific<C> {
|
||||
pub fn new<S: ToString>(name: S, inner: C) -> Self {
|
||||
Self {
|
||||
prefix: "!".to_string(),
|
||||
name: name.to_string(),
|
||||
inner,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_prefix<S: ToString>(mut self, prefix: S) -> Self {
|
||||
self.prefix = prefix.to_string();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S, E, C> Command<S, E> for Specific<C>
|
||||
where
|
||||
S: Send + Sync,
|
||||
C: Command<S, E> + 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<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
115
euphoxide-bot/src/command/basic.rs
Normal file
115
euphoxide-bot/src/command/basic.rs
Normal file
|
|
@ -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<C> {
|
||||
pub inner: C,
|
||||
pub trigger: Option<Option<String>>,
|
||||
pub description: Option<Option<String>>,
|
||||
}
|
||||
|
||||
impl<C> Described<C> {
|
||||
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<S, E, C> Command<S, E> for Described<C>
|
||||
where
|
||||
S: Send + Sync,
|
||||
C: Command<S, E> + 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<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
self.inner.execute(arg, msg, ctx, bot).await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Prefixed<C> {
|
||||
prefix: String,
|
||||
inner: C,
|
||||
}
|
||||
|
||||
impl<C> Prefixed<C> {
|
||||
pub fn new<S: ToString>(prefix: S, inner: C) -> Self {
|
||||
Self {
|
||||
prefix: prefix.to_string(),
|
||||
inner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S, E, C> Command<S, E> for Prefixed<C>
|
||||
where
|
||||
S: Send + Sync,
|
||||
C: Command<S, E> + 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<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
if let Some(rest) = arg.trim_start().strip_prefix(&self.prefix) {
|
||||
self.inner.execute(rest, msg, ctx, bot).await
|
||||
} else {
|
||||
Ok(Propagate::Yes)
|
||||
}
|
||||
}
|
||||
}
|
||||
8
euphoxide-bot/src/command/botrulez.rs
Normal file
8
euphoxide-bot/src/command/botrulez.rs
Normal file
|
|
@ -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::*};
|
||||
110
euphoxide-bot/src/command/botrulez/full_help.rs
Normal file
110
euphoxide-bot/src/command/botrulez/full_help.rs
Normal file
|
|
@ -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<S, E>(&self, ctx: &Context, bot: &Bot<S, E>) -> 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<S, E> Command<S, E> for FullHelp
|
||||
where
|
||||
S: Send + Sync,
|
||||
E: From<euphoxide::Error>,
|
||||
{
|
||||
async fn execute(
|
||||
&self,
|
||||
arg: &str,
|
||||
msg: &Message,
|
||||
ctx: &Context,
|
||||
bot: &Bot<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
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<S, E> ClapCommand<S, E> for FullHelp
|
||||
where
|
||||
S: Send + Sync,
|
||||
E: From<euphoxide::Error>,
|
||||
{
|
||||
type Args = FullHelpArgs;
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: Self::Args,
|
||||
msg: &Message,
|
||||
ctx: &Context,
|
||||
bot: &Bot<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
let reply = self.formulate_reply(ctx, bot);
|
||||
ctx.reply_only(msg.id, reply).await?;
|
||||
Ok(Propagate::No)
|
||||
}
|
||||
}
|
||||
71
euphoxide-bot/src/command/botrulez/ping.rs
Normal file
71
euphoxide-bot/src/command/botrulez/ping.rs
Normal file
|
|
@ -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<S: ToString>(reply: S) -> Self {
|
||||
Self(reply.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Ping {
|
||||
fn default() -> Self {
|
||||
Self::new("Pong!")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S, E> Command<S, E> for Ping
|
||||
where
|
||||
E: From<euphoxide::Error>,
|
||||
{
|
||||
async fn execute(
|
||||
&self,
|
||||
arg: &str,
|
||||
msg: &Message,
|
||||
ctx: &Context,
|
||||
_bot: &Bot<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
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<S, E> ClapCommand<S, E> for Ping
|
||||
where
|
||||
E: From<euphoxide::Error>,
|
||||
{
|
||||
type Args = PingArgs;
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: Self::Args,
|
||||
msg: &Message,
|
||||
ctx: &Context,
|
||||
_bot: &Bot<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
ctx.reply_only(msg.id, &self.0).await?;
|
||||
Ok(Propagate::No)
|
||||
}
|
||||
}
|
||||
65
euphoxide-bot/src/command/botrulez/short_help.rs
Normal file
65
euphoxide-bot/src/command/botrulez/short_help.rs
Normal file
|
|
@ -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<S: ToString>(text: S) -> Self {
|
||||
Self(text.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S, E> Command<S, E> for ShortHelp
|
||||
where
|
||||
E: From<euphoxide::Error>,
|
||||
{
|
||||
async fn execute(
|
||||
&self,
|
||||
arg: &str,
|
||||
msg: &Message,
|
||||
ctx: &Context,
|
||||
_bot: &Bot<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
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<S, E> ClapCommand<S, E> for ShortHelp
|
||||
where
|
||||
E: From<euphoxide::Error>,
|
||||
{
|
||||
type Args = ShortHelpArgs;
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: Self::Args,
|
||||
msg: &Message,
|
||||
ctx: &Context,
|
||||
_bot: &Bot<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
ctx.reply_only(msg.id, &self.0).await?;
|
||||
Ok(Propagate::No)
|
||||
}
|
||||
}
|
||||
140
euphoxide-bot/src/command/botrulez/uptime.rs
Normal file
140
euphoxide-bot/src/command/botrulez/uptime.rs
Normal file
|
|
@ -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<S, E>(&self, ctx: &Context, bot: &Bot<S, E>, 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<S, E> Command<S, E> for Uptime
|
||||
where
|
||||
S: Send + Sync,
|
||||
E: From<euphoxide::Error>,
|
||||
{
|
||||
async fn execute(
|
||||
&self,
|
||||
arg: &str,
|
||||
msg: &Message,
|
||||
ctx: &Context,
|
||||
bot: &Bot<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
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<S, E> ClapCommand<S, E> for Uptime
|
||||
where
|
||||
S: Send + Sync,
|
||||
E: From<euphoxide::Error>,
|
||||
{
|
||||
type Args = UptimeArgs;
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: Self::Args,
|
||||
msg: &Message,
|
||||
ctx: &Context,
|
||||
bot: &Bot<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
let reply = self.formulate_reply(ctx, bot, args.connected);
|
||||
ctx.reply_only(msg.id, reply).await?;
|
||||
Ok(Propagate::No)
|
||||
}
|
||||
}
|
||||
187
euphoxide-bot/src/command/clap.rs
Normal file
187
euphoxide-bot/src/command/clap.rs
Normal file
|
|
@ -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<S, E> {
|
||||
type Args;
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: Self::Args,
|
||||
msg: &Message,
|
||||
ctx: &Context,
|
||||
bot: &Bot<S, E>,
|
||||
) -> Result<Propagate, E>;
|
||||
}
|
||||
|
||||
/// 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<Vec<String>, &'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<C>(pub C);
|
||||
|
||||
#[async_trait]
|
||||
impl<S, E, C> Command<S, E> for Clap<C>
|
||||
where
|
||||
S: Send + Sync,
|
||||
E: From<euphoxide::Error>,
|
||||
C: ClapCommand<S, E> + 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<S, E>,
|
||||
) -> Result<Propagate, E> {
|
||||
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("<command>").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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue