Create euphoxide-bot crate

This commit is contained in:
Joscha 2024-12-11 01:01:11 +01:00
parent 01561db93c
commit 7dbf041b69
13 changed files with 1222 additions and 1 deletions

View file

@ -1,13 +1,15 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["euphoxide", "euphoxide-client"] members = ["euphoxide", "euphoxide-bot", "euphoxide-client"]
[workspace.package] [workspace.package]
version = "0.5.1" version = "0.5.1"
edition = "2021" edition = "2021"
[workspace.dependencies] [workspace.dependencies]
async-trait = "0.1.83"
caseless = "0.2.1" caseless = "0.2.1"
clap = { version = "4.5.23", default-features = false, features = ["std"] }
cookie = "0.18.1" cookie = "0.18.1"
futures-util = "0.3.31" futures-util = "0.3.31"
jiff = { version = "0.1.15", default-features = false, features = ["std"] } jiff = { version = "0.1.15", default-features = false, features = ["std"] }
@ -22,6 +24,7 @@ anyhow = "1.0.94"
rustls = "0.23.19" rustls = "0.23.19"
# In this workspace # In this workspace
euphoxide = { path = "./euphoxide" } euphoxide = { path = "./euphoxide" }
euphoxide-bot = { path = "./euphoxide-bot" }
euphoxide-client = { path = "./euphoxide-client" } euphoxide-client = { path = "./euphoxide-client" }
[workspace.lints] [workspace.lints]

21
euphoxide-bot/Cargo.toml Normal file
View file

@ -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

73
euphoxide-bot/src/bot.rs Normal file
View file

@ -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<S = (), E = euphoxide::Error> {
pub server_config: ServerConfig,
pub commands: Arc<Commands<S, E>>,
pub state: Arc<S>,
pub clients: MultiClient,
pub start_time: Timestamp,
}
impl Bot {
pub fn new_simple(commands: Commands, event_tx: mpsc::Sender<MultiClientEvent>) -> Self {
Self::new(
ServerConfig::default(),
MultiClientConfig::default(),
commands,
(),
event_tx,
)
}
}
impl<S, E> Bot<S, E> {
pub fn new(
server_config: ServerConfig,
clients_config: MultiClientConfig,
commands: Commands<S, E>,
state: S,
event_tx: mpsc::Sender<MultiClientEvent>,
) -> 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<S, E> Bot<S, E>
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<S, E> Clone for Bot<S, E> {
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,
}
}
}

View file

@ -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<impl Future<Output = euphoxide::Result<SendReply>>> {
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<impl Future<Output = euphoxide::Result<SendReply>>> {
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<String>,
pub description: Option<String>,
}
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<S = (), E = euphoxide::Error> {
fn info(&self, ctx: &Context) -> Info {
Info::default()
}
async fn execute(
&self,
arg: &str,
msg: &Message,
ctx: &Context,
bot: &Bot<S, E>,
) -> Result<Propagate, E>;
}
pub struct Commands<B = (), E = euphoxide::Error> {
commands: Vec<Box<dyn Command<B, E> + Sync + Send>>,
}
impl<S, E> Commands<S, E> {
pub fn new() -> Self {
Self { commands: vec![] }
}
pub fn add(&mut self, command: impl Command<S, E> + Sync + Send + 'static) {
self.commands.push(Box::new(command));
}
pub fn then(mut self, command: impl Command<S, E> + Sync + Send + 'static) -> Self {
self.add(command);
self
}
pub fn infos(&self, ctx: &Context) -> Vec<Info> {
self.commands.iter().map(|c| c.info(ctx)).collect()
}
pub(crate) async fn on_event(
&self,
event: MultiClientEvent,
bot: &Bot<S, E>,
) -> Result<Propagate, E> {
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<S, E> Default for Commands<S, E> {
fn default() -> Self {
Self::new()
}
}

View 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);
}
}

View 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)
}
}
}

View 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::*};

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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());
}
}

2
euphoxide-bot/src/lib.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod bot;
pub mod command;