Introduce a typed Action API

This commit is contained in:
xmppftw 2023-06-06 18:20:22 +02:00
parent 76b68e932a
commit 47092e6b9a
10 changed files with 543 additions and 0 deletions

View file

@ -21,10 +21,18 @@ tokio = { version = "1", features = ["fs"] }
log = "0.4"
reqwest = { version = "0.11.8", features = ["stream"] }
tokio-util = { version = "0.7", features = ["codec"] }
# For actions API
async-trait = { version = "0.1", optional = true }
[dev-dependencies]
env_logger = "0.10"
xmpp = { path = ".", features = [ "actions" ] }
[features]
default = ["avatars"]
avatars = []
actions = [ "dep:async-trait" ]
[[example]]
name = "actions_bot"
required-features = [ "actions" ]

View file

@ -6,6 +6,7 @@ xxxxxxxxxx
* Improvements:
- Agent is now Send, by replacing Rc with Arc and RefCell with RwLock (#64)
- ClientBuilder now has a set_resource method for manual resource management (#72)
- the action module is enabled by the `actions` feature (#75) and providers a more robust and ergonomic API
Version 0.4.0:
2023-05-18 [ Maxime “pep” Buquet <pep@bouah.net>, Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> ]

View file

@ -0,0 +1,143 @@
use env_logger;
use std::str::FromStr;
use xmpp::{action, ClientBuilder, ClientFeature, ClientType, Event};
use xmpp_parsers::{BareJid, JidParseError};
#[derive(Debug)]
enum Error {
Syntax(String),
Jid(String, JidParseError),
XMPP(tokio_xmpp::Error),
}
impl From<tokio_xmpp::Error> for Error {
fn from(e: tokio_xmpp::Error) -> Error {
Error::XMPP(e)
}
}
struct Args {
jid: BareJid,
password: String,
room: BareJid,
}
impl Args {
fn from_cli() -> Result<Option<Args>, Error> {
let mut args = std::env::args();
let binary_name = args.next().unwrap();
let jid = args
.next()
.ok_or(Error::Syntax("Missing argument: jid".into()))?;
match jid.to_lowercase().as_str() {
"help" | "-h" | "--help" => {
help(&binary_name);
return Ok(None);
}
_ => {}
}
let jid = BareJid::from_str(&jid).map_err(|e| Error::Jid(jid, e))?;
let password = args
.next()
.ok_or(Error::Syntax("Missing argument: password".into()))?;
let room = args
.next()
.ok_or(Error::Syntax("Missing argument: room".into()))?;
let room = BareJid::from_str(&room).map_err(|e| Error::Jid(room, e))?;
Ok(Some(Args {
jid,
password,
room,
}))
}
}
fn help(name: &str) {
println!("A bot that welcomes you when you say hello");
println!("Usage: {} <jid> <password> <room>", name);
println!("NOTE: Run with RUST_LOG=debug for debug logs");
}
#[tokio::main]
async fn main() -> Result<(), Error> {
env_logger::init();
let args = if let Some(args) = Args::from_cli()? {
args
} else {
return Ok(());
};
let nick = String::from("actions_bot");
// Client instance
let mut client = ClientBuilder::new(args.jid.clone(), &args.password)
.set_client(ClientType::Bot, "xmpp-rs")
.set_website("https://gitlab.com/xmpp-rs/xmpp-rs")
.set_default_nick(&nick)
.enable_feature(ClientFeature::JoinRooms)
.build();
while let Some(events) = client.wait_for_events().await {
for event in events {
match event {
Event::Online => {
println!("Online.");
client
.action(action::room_join(args.room.clone(), &nick))
.await?;
}
Event::Disconnected => {
println!("Disconnected");
}
Event::RoomMessage(_id, room_jid, author, body) => {
println!(
"Message in room {} from {}: {}",
&room_jid, &author, &body.0
);
let msg = body.0.trim().to_lowercase();
if msg == "hello" {
client
.action(action::room_message(
room_jid,
format!("Hello, {}", &author),
))
.await?;
}
}
Event::RoomPrivateMessage(_id, room_jid, nick, body) => {
println!(
"Private Message from room {} from {}: {}",
&room_jid, &nick, body.0
);
client
.action(action::room_private_message(
room_jid,
nick,
format!("You said: {}", body.0),
))
.await?;
}
Event::ChatMessage(_id, jid, body) => {
println!("Message from {}: {}", jid, body.0);
client
.action(action::message(jid, format!("You said: {}", body.0)))
.await?;
}
Event::RoomJoined(jid) => {
println!("Joined room {}.", jid);
client
.action(action::room_message(jid, "Hello, world!"))
.await?;
}
_ => (),
}
}
}
Ok(())
}

View file

@ -0,0 +1,57 @@
use crate::{Act, Agent, Error};
use std::str::FromStr;
use xmpp_parsers::{message::Message, Element, Jid, JidParseError};
pub struct MessageAction {
pub jid: Jid,
pub msg: String,
pub lang: String,
}
#[async_trait::async_trait]
impl Act<()> for MessageAction {
async fn act(self, agent: &mut Agent) -> Result<(), Error> {
agent.client.send_stanza(self.into()).await?;
Ok(())
}
}
impl From<MessageAction> for Element {
fn from(action: MessageAction) -> Element {
let MessageAction { jid, msg, lang } = action;
Message::chat(Some(jid.into())).with_body(lang, msg).into()
}
}
impl MessageAction {
/// Generates a new action to send a private message to a parsed [`Jid`]. By default,
/// the language used in the message is considered unknown.
pub fn new<T: Into<Jid>, U: AsRef<str>>(jid: T, msg: U) -> MessageAction {
MessageAction {
jid: jid.into(),
msg: msg.as_ref().to_string(),
lang: "".to_string(),
}
}
/// Generates a new action to send a private message to a stringy JID. By default,
/// the language used in the message is considered unknown. Errors if the recipient Jid cannot be
/// parsed into a [`Jid`].
pub fn new_str<T: AsRef<str>, U: AsRef<str>>(
jid: T,
msg: U,
) -> Result<MessageAction, JidParseError> {
Ok(MessageAction {
jid: Jid::from_str(jid.as_ref())?,
msg: msg.as_ref().to_string(),
lang: "".to_string(),
})
}
/// Specify the language your message is written in, if you know it.
pub fn lang(mut self, lang: &str) -> MessageAction {
self.lang = lang.to_string();
self
}
}

51
xmpp/src/action/mod.rs Normal file
View file

@ -0,0 +1,51 @@
//! The actions API allows to create type-safe actions for an [`Agent`]. You need to enable the
//! `actions` feature in the `xmpp` crate to use it!
//!
//! You can build an action manually by generating one of the types exposed in this module,
//! or directly call one of the helper methods:
//!
//! - [`action::message`]
//! - [`action::room_join`]
//! - [`action::room_message`]
//! - [`action::room_private_message`]
use crate::{Agent, Error, RoomNick};
use xmpp_parsers::{BareJid, Jid};
mod room;
pub use room::{RoomJoinAction, RoomMessageAction, RoomPrivateMessageAction};
mod message;
pub use message::MessageAction;
/// Starts building a [`RoomJoinAction`]
pub fn room_join<T: Into<BareJid>, U: AsRef<str>>(room: T, nick: U) -> RoomJoinAction {
RoomJoinAction::new(room, nick)
}
/// Starts building a [`RoomMessageAction`]
pub fn room_message<T: Into<BareJid>, U: AsRef<str>>(room: T, msg: U) -> RoomMessageAction {
RoomMessageAction::new(room, msg)
}
/// Starts building a [`RoomPrivateMessageAction`]
pub fn room_private_message<T: Into<BareJid>, U: AsRef<str>>(
room: T,
nick: RoomNick,
msg: U,
) -> RoomPrivateMessageAction {
RoomPrivateMessageAction::new(room, nick, msg)
}
/// Starts building a [`MessageAction`]
pub fn message<T: Into<Jid>, U: AsRef<str>>(room: T, msg: U) -> MessageAction {
MessageAction::new(room, msg)
}
/// The trait to implement for typed actions to be consumed by an [`Agent`].
/// It contains a single method for the action logic.
#[async_trait::async_trait]
pub trait Act<T> {
/// The method called by the [`Agent`] to perform the action.
async fn act(self, client: &mut Agent) -> Result<T, Error>;
}

View file

@ -0,0 +1,128 @@
use crate::{Act, Agent, Error};
use std::str::FromStr;
use xmpp_parsers::{
muc::muc::{History, Muc},
presence::Presence,
BareJid, Element, JidParseError,
};
#[derive(Clone, Debug)]
pub struct RoomJoinAction {
pub room: BareJid,
pub nick: String,
pub password: Option<String>,
pub lang: Option<String>,
pub status: Option<String>,
pub history: Option<History>,
}
#[async_trait::async_trait]
impl Act<()> for RoomJoinAction {
async fn act(self, agent: &mut Agent) -> Result<(), Error> {
agent.client.send_stanza(self.clone().into()).await?;
Ok(())
}
}
impl From<RoomJoinAction> for Element {
fn from(action: RoomJoinAction) -> Element {
let RoomJoinAction {
room,
nick,
password,
lang,
status,
history,
} = action;
let mut muc = Muc::new();
if let Some(history) = history {
muc = muc.with_history(history);
}
if let Some(password) = password {
muc = muc.with_password(password);
}
// TODO: type-safe
let room_jid = room.with_resource_str(&nick).unwrap();
let mut presence = Presence::available()
.with_to(room_jid.clone())
.with_payload(muc);
if let Some(status) = status {
// lang None is treated as unknown language (empty string)
presence.set_status(lang.unwrap_or("".to_string()), status);
}
presence.into()
}
}
impl RoomJoinAction {
/// Creates an action to join a MUC chatroom from a parsed [`BareJid`]. By default,
/// legacy history management is disabled.
/// If you really need history from the MUC, you can use [`JoinRoomAction::history_default`], but be careful
/// because there will be no way to distinguish them from normal live messages, see also:
/// https://gitlab.com/xmpp-rs/xmpp-rs/-/issues/77
pub fn new<T: Into<BareJid>, U: AsRef<str>>(room: T, nick: U) -> RoomJoinAction {
RoomJoinAction {
room: room.into(),
nick: nick.as_ref().to_string(),
password: None,
lang: None,
status: None,
history: Some(History::new().with_maxchars(0)),
}
}
/// Creates an action to join a MUC chatroom from a stringy bare JID. By default,
/// legacy history management is disabled. Errors if the room JID is not parseable into a [`BareJid`].
/// If you really need history from the MUC, you can use [`JoinRoomAction::history_default`], but be careful
/// because there will be no way to distinguish them from normal live messages, see also:
/// https://gitlab.com/xmpp-rs/xmpp-rs/-/issues/77
pub fn new_str<T: AsRef<str>, U: AsRef<str>>(
room: T,
nick: U,
) -> Result<RoomJoinAction, JidParseError> {
Ok(RoomJoinAction {
room: BareJid::from_str(room.as_ref())?,
nick: nick.as_ref().to_string(),
password: None,
lang: None,
status: None,
history: Some(History::new().with_maxchars(0)),
})
}
/// Sets the nick associated with the client on the chatroom to be joined.
pub fn nick(mut self, nick: &str) -> RoomJoinAction {
self.nick = nick.to_string();
self
}
/// Sets the password required to enter the MUC chatroom.
pub fn password(mut self, password: &str) -> RoomJoinAction {
self.password = Some(password.to_string());
self
}
/// Sets a status associated with the client, when joining the room. The status
/// should define a language associated with it, but None will be treated as explicitly
/// not knowing the language, i.e. `xml:lang=""` as explained [here](https://gitlab.com/xmpp-rs/xmpp-rs/-/issues/80).
pub fn status(mut self, lang: Option<String>, status: &str) -> RoomJoinAction {
if let Some(lang) = lang {
self.lang = Some(lang);
}
self.status = Some(status.to_string());
self
}
/// Request the default history (as set by the server) from legacy MUC history.
pub fn history_default(mut self) -> RoomJoinAction {
self.history = None;
self
}
}

View file

@ -0,0 +1,59 @@
use crate::{Act, Agent, Error};
use std::str::FromStr;
use xmpp_parsers::{message::Message, BareJid, Element, JidParseError};
pub struct RoomMessageAction {
pub room: BareJid,
pub msg: String,
pub lang: String,
}
#[async_trait::async_trait]
impl Act<()> for RoomMessageAction {
async fn act(self, agent: &mut Agent) -> Result<(), Error> {
agent.client.send_stanza(self.into()).await?;
Ok(())
}
}
impl From<RoomMessageAction> for Element {
fn from(action: RoomMessageAction) -> Element {
let RoomMessageAction { room, msg, lang } = action;
Message::groupchat(Some(room.into()))
.with_body(lang, msg)
.into()
}
}
impl RoomMessageAction {
/// Generates a new action to send a group message to a parsed MUC chatroom [`BareJid`]. By default,
/// the language used in the message is considered unknown.
pub fn new<T: Into<BareJid>, U: AsRef<str>>(room: T, msg: U) -> RoomMessageAction {
RoomMessageAction {
room: room.into(),
msg: msg.as_ref().to_string(),
lang: "".to_string(),
}
}
/// Generates a new action to send a group message to a stringy MUC chatroom. By default,
/// the language used in the message is considered unknown. Errors if the chatroom Jid cannot be
/// parsed into a [`BareJid`].
pub fn new_str<T: AsRef<str>, U: AsRef<str>>(
room: T,
msg: U,
) -> Result<RoomMessageAction, JidParseError> {
Ok(RoomMessageAction {
room: BareJid::from_str(room.as_ref())?,
msg: msg.as_ref().to_string(),
lang: "".to_string(),
})
}
/// Specify the language your message is written in, if you know it.
pub fn lang(mut self, lang: &str) -> RoomMessageAction {
self.lang = lang.to_string();
self
}
}

View file

@ -0,0 +1,8 @@
mod join;
pub use join::RoomJoinAction;
mod message;
pub use message::RoomMessageAction;
mod private_message;
pub use private_message::RoomPrivateMessageAction;

View file

@ -0,0 +1,78 @@
use crate::{Act, Agent, Error, RoomNick};
use std::str::FromStr;
use xmpp_parsers::{message::Message, muc::MucUser, BareJid, Element, JidParseError};
pub struct RoomPrivateMessageAction {
pub room: BareJid,
pub recipient: RoomNick,
pub msg: String,
pub lang: String,
}
#[async_trait::async_trait]
impl Act<()> for RoomPrivateMessageAction {
async fn act(self, agent: &mut Agent) -> Result<(), Error> {
agent.client.send_stanza(self.into()).await?;
Ok(())
}
}
impl From<RoomPrivateMessageAction> for Element {
fn from(action: RoomPrivateMessageAction) -> Element {
let RoomPrivateMessageAction {
room,
recipient,
msg,
lang,
} = action;
// TODO: type-safe
let recipient = room.with_resource_str(&recipient).unwrap();
Message::chat(Some(recipient.into()))
.with_body(lang, msg)
.with_payload(MucUser::new())
.into()
}
}
impl RoomPrivateMessageAction {
/// Generates a new action to send a private a parsed MUC participant
/// (room [`BareJid`] + participant [`RoomNick`].
/// By default, the language used in the message is considered unknown.
pub fn new<T: Into<BareJid>, U: AsRef<str>>(
room: T,
recipient: RoomNick,
msg: U,
) -> RoomPrivateMessageAction {
RoomPrivateMessageAction {
room: room.into(),
recipient,
msg: msg.as_ref().to_string(),
lang: "".to_string(),
}
}
/// Generates a new action to send a private message to a stringy MUC participant
/// (stringy room + stringy participant).
/// By default, the language used in the message is considered unknown.
/// Errors if the chatroom Jid cannot be parsed into a [`BareJid`].
pub fn new_str<T: AsRef<str>, U: AsRef<str>>(
room: T,
recipient: RoomNick,
msg: U,
) -> Result<RoomPrivateMessageAction, JidParseError> {
Ok(RoomPrivateMessageAction {
room: BareJid::from_str(room.as_ref())?,
recipient,
msg: msg.as_ref().to_string(),
lang: "".to_string(),
})
}
/// Specify the language your message is written in, if you know it.
pub fn lang(mut self, lang: &str) -> RoomPrivateMessageAction {
self.lang = lang.to_string();
self
}
}

View file

@ -40,6 +40,11 @@ extern crate log;
mod pubsub;
#[cfg(feature = "actions")]
pub mod action;
#[cfg(feature = "actions")]
pub use action::Act;
pub type Error = tokio_xmpp::Error;
#[derive(Debug)]
@ -219,6 +224,11 @@ impl Agent {
self.client.send_end().await
}
#[cfg(feature = "actions")]
pub async fn action<Success, T: Act<Success>>(&mut self, action: T) -> Result<Success, Error> {
action.act(self).await
}
pub async fn join_room(
&mut self,
room: BareJid,