diff --git a/xmpp/Cargo.toml b/xmpp/Cargo.toml index a022151..9adde95 100644 --- a/xmpp/Cargo.toml +++ b/xmpp/Cargo.toml @@ -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" ] \ No newline at end of file diff --git a/xmpp/ChangeLog b/xmpp/ChangeLog index dc538dc..605d10c 100644 --- a/xmpp/ChangeLog +++ b/xmpp/ChangeLog @@ -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 , Emmanuel Gil Peyrot ] diff --git a/xmpp/examples/actions_bot.rs b/xmpp/examples/actions_bot.rs new file mode 100644 index 0000000..6fb8fd6 --- /dev/null +++ b/xmpp/examples/actions_bot.rs @@ -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 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, 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: {} ", 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(()) +} diff --git a/xmpp/src/action/message.rs b/xmpp/src/action/message.rs new file mode 100644 index 0000000..a9a574a --- /dev/null +++ b/xmpp/src/action/message.rs @@ -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 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, U: AsRef>(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, U: AsRef>( + jid: T, + msg: U, + ) -> Result { + 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 + } +} diff --git a/xmpp/src/action/mod.rs b/xmpp/src/action/mod.rs new file mode 100644 index 0000000..f1fb8e7 --- /dev/null +++ b/xmpp/src/action/mod.rs @@ -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, U: AsRef>(room: T, nick: U) -> RoomJoinAction { + RoomJoinAction::new(room, nick) +} + +/// Starts building a [`RoomMessageAction`] +pub fn room_message, U: AsRef>(room: T, msg: U) -> RoomMessageAction { + RoomMessageAction::new(room, msg) +} + +/// Starts building a [`RoomPrivateMessageAction`] +pub fn room_private_message, U: AsRef>( + room: T, + nick: RoomNick, + msg: U, +) -> RoomPrivateMessageAction { + RoomPrivateMessageAction::new(room, nick, msg) +} + +/// Starts building a [`MessageAction`] +pub fn message, U: AsRef>(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 { + /// The method called by the [`Agent`] to perform the action. + async fn act(self, client: &mut Agent) -> Result; +} diff --git a/xmpp/src/action/room/join.rs b/xmpp/src/action/room/join.rs new file mode 100644 index 0000000..0e4d499 --- /dev/null +++ b/xmpp/src/action/room/join.rs @@ -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, + pub lang: Option, + pub status: Option, + pub history: Option, +} + +#[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 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, U: AsRef>(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, U: AsRef>( + room: T, + nick: U, + ) -> Result { + 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, 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 + } +} diff --git a/xmpp/src/action/room/message.rs b/xmpp/src/action/room/message.rs new file mode 100644 index 0000000..ce89de8 --- /dev/null +++ b/xmpp/src/action/room/message.rs @@ -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 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, U: AsRef>(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, U: AsRef>( + room: T, + msg: U, + ) -> Result { + 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 + } +} diff --git a/xmpp/src/action/room/mod.rs b/xmpp/src/action/room/mod.rs new file mode 100644 index 0000000..f0ba391 --- /dev/null +++ b/xmpp/src/action/room/mod.rs @@ -0,0 +1,8 @@ +mod join; +pub use join::RoomJoinAction; + +mod message; +pub use message::RoomMessageAction; + +mod private_message; +pub use private_message::RoomPrivateMessageAction; diff --git a/xmpp/src/action/room/private_message.rs b/xmpp/src/action/room/private_message.rs new file mode 100644 index 0000000..d538ca5 --- /dev/null +++ b/xmpp/src/action/room/private_message.rs @@ -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 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, U: AsRef>( + 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, U: AsRef>( + room: T, + recipient: RoomNick, + msg: U, + ) -> Result { + 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 + } +} diff --git a/xmpp/src/lib.rs b/xmpp/src/lib.rs index d9096e6..2b941e9 100644 --- a/xmpp/src/lib.rs +++ b/xmpp/src/lib.rs @@ -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>(&mut self, action: T) -> Result { + action.act(self).await + } + pub async fn join_room( &mut self, room: BareJid,