Add basic commands

This commit is contained in:
Joscha 2024-12-28 00:04:09 +01:00
parent 39ab887b47
commit 9a1ba8f1ce
7 changed files with 589 additions and 41 deletions

View file

@ -7,6 +7,7 @@ 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"
cookie = "0.18.1" cookie = "0.18.1"
futures-util = "0.3.31" futures-util = "0.3.31"

View file

@ -4,6 +4,7 @@ version = { workspace = true }
edition = { workspace = true } edition = { workspace = true }
[dependencies] [dependencies]
async-trait = { workspace = true }
cookie = { workspace = true } cookie = { workspace = true }
euphoxide = { path = "../euphoxide" } euphoxide = { path = "../euphoxide" }
jiff = { workspace = true } jiff = { workspace = true }

View file

@ -1,64 +1,76 @@
use std::sync::Arc; use std::{fmt::Debug, sync::Arc};
use jiff::Timestamp; use jiff::Timestamp;
use log::error;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::{ use crate::{
command::Commands,
instance::ServerConfig, instance::ServerConfig,
instances::{Event, Instances, InstancesConfig}, instances::{Event, Instances, InstancesConfig},
}; };
#[derive(Debug, Clone)]
#[non_exhaustive] #[non_exhaustive]
pub struct BotConfig<S> { pub struct Bot<S = (), E = euphoxide::Error> {
pub server: ServerConfig,
pub instances: InstancesConfig,
pub state: S,
}
impl<S> BotConfig<S> {
pub fn with_state<S2>(self, state: S2) -> BotConfig<S2> {
BotConfig {
server: self.server,
instances: self.instances,
state,
}
}
pub fn create(self, event_tx: mpsc::Sender<Event>) -> Bot<S> {
Bot::new(self, event_tx)
}
}
impl Default for BotConfig<()> {
fn default() -> Self {
Self {
server: ServerConfig::default(),
instances: InstancesConfig::default(),
state: (),
}
}
}
#[derive(Clone)]
pub struct Bot<S> {
pub server_config: ServerConfig, pub server_config: ServerConfig,
pub commands: Arc<Commands<S, E>>,
pub state: Arc<S>, pub state: Arc<S>,
pub instances: Instances, pub instances: Instances,
pub start_time: Timestamp, pub start_time: Timestamp,
} }
impl<S> Bot<S> { impl Bot {
pub fn new(config: BotConfig<S>, event_tx: mpsc::Sender<Event>) -> Self { pub fn new_simple(commands: Commands, event_tx: mpsc::Sender<Event>) -> Self {
Self::new(
ServerConfig::default(),
InstancesConfig::default(),
commands,
(),
event_tx,
)
}
}
impl<S, E> Bot<S, E> {
pub fn new(
server_config: ServerConfig,
instances_config: InstancesConfig,
commands: Commands<S, E>,
state: S,
event_tx: mpsc::Sender<Event>,
) -> Self {
Self { Self {
server_config: config.server, server_config,
state: Arc::new(config.state), commands: Arc::new(commands),
instances: Instances::new(config.instances, event_tx), state: Arc::new(state),
instances: Instances::new(instances_config, event_tx),
start_time: Timestamp::now(), start_time: Timestamp::now(),
} }
} }
}
impl<S, E> Bot<S, E>
where
S: Send + Sync + 'static,
E: Debug + 'static,
{
pub fn handle_event(&self, event: Event) { pub fn handle_event(&self, event: Event) {
todo!() 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(),
instances: self.instances.clone(),
start_time: self.start_time,
}
} }
} }

View file

@ -0,0 +1,186 @@
pub mod bang;
pub mod basic;
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 crate::{bot::Bot, instance::Instance, instances::Event};
#[non_exhaustive]
pub struct Context {
pub instance: Instance,
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: Event, bot: &Bot<S, E>) -> Result<Propagate, E> {
let Event::Packet {
instance,
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 {
instance,
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

@ -1,3 +1,4 @@
pub mod bot; pub mod bot;
pub mod command;
pub mod instance; pub mod instance;
pub mod instances; pub mod instances;