From 92bad0038d3a8a0b25339fc95d17d6368373bc2b Mon Sep 17 00:00:00 2001 From: xmppftw Date: Tue, 6 Jun 2023 18:20:22 +0200 Subject: [PATCH] WIP: Actions API --- jid/src/lib.rs | 8 ++ xmpp/Cargo.toml | 10 +- xmpp/examples/actions_bot.rs | 152 +++++++++++++++++++++++ xmpp/src/action/group_message.rs | 65 ++++++++++ xmpp/src/action/group_private_message.rs | 80 ++++++++++++ xmpp/src/action/join_room.rs | 151 ++++++++++++++++++++++ xmpp/src/action/mod.rs | 62 +++++++++ xmpp/src/action/private_message.rs | 63 ++++++++++ xmpp/src/lib.rs | 13 ++ 9 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 xmpp/examples/actions_bot.rs create mode 100644 xmpp/src/action/group_message.rs create mode 100644 xmpp/src/action/group_private_message.rs create mode 100644 xmpp/src/action/join_room.rs create mode 100644 xmpp/src/action/mod.rs create mode 100644 xmpp/src/action/private_message.rs diff --git a/jid/src/lib.rs b/jid/src/lib.rs index 35df53d..9a9891d 100644 --- a/jid/src/lib.rs +++ b/jid/src/lib.rs @@ -154,6 +154,14 @@ impl Jid { Jid::Bare(BareJid { domain, .. }) | Jid::Full(FullJid { domain, .. }) => domain, } } + + /// The resource part of the Jabber ID. + pub fn resource(self) -> Option { + match self { + Jid::Bare(_) => None, + Jid::Full(full_jid) => Some(full_jid.resource), + } + } } impl From for BareJid { diff --git a/xmpp/Cargo.toml b/xmpp/Cargo.toml index a022151..f9b06e7 100644 --- a/xmpp/Cargo.toml +++ b/xmpp/Cargo.toml @@ -17,14 +17,22 @@ edition = "2018" tokio-xmpp = "3.2" xmpp-parsers = "0.19" futures = "0.3" -tokio = { version = "1", features = ["fs"] } +tokio = { version = "1", features = ["fs", "sync" ] } 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/examples/actions_bot.rs b/xmpp/examples/actions_bot.rs new file mode 100644 index 0000000..b3b6586 --- /dev/null +++ b/xmpp/examples/actions_bot.rs @@ -0,0 +1,152 @@ +// Copyright (c) 2019 Emmanuel Gil Peyrot +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +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::join_room(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::group_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::group_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::private_message( + jid, + format!("You said: {}", body.0), + )) + .await?; + } + Event::RoomJoined(jid) => { + println!("Joined room {}.", jid); + client + .action(Action::group_message(jid, "Hello, world!")) + .await?; + } + _ => (), + } + } + } + + Ok(()) +} diff --git a/xmpp/src/action/group_message.rs b/xmpp/src/action/group_message.rs new file mode 100644 index 0000000..d0aeab1 --- /dev/null +++ b/xmpp/src/action/group_message.rs @@ -0,0 +1,65 @@ +use crate::{Act, Action, Agent, Error}; +use std::str::FromStr; +use xmpp_parsers::{message::Message, BareJid, Element, JidParseError}; + +pub struct GroupMessageAction { + pub room: BareJid, + pub msg: String, + pub lang: String, +} + +#[async_trait] +impl Act<()> for GroupMessageAction { + async fn act(self, agent: &mut Agent) -> Result<(), Error> { + agent.client.send_stanza(self.into()).await?; + Ok(()) + } +} + +impl From for Action { + fn from(action: GroupMessageAction) -> Action { + Action::GroupMessage(action) + } +} + +impl From for Element { + fn from(action: GroupMessageAction) -> Element { + let GroupMessageAction { room, msg, lang } = action; + + Message::groupchat(Some(room.into())) + .with_body(lang, msg) + .into() + } +} + +impl GroupMessageAction { + /// 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) -> GroupMessageAction { + GroupMessageAction { + 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(GroupMessageAction { + 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) -> GroupMessageAction { + self.lang = lang.to_string(); + self + } +} diff --git a/xmpp/src/action/group_private_message.rs b/xmpp/src/action/group_private_message.rs new file mode 100644 index 0000000..cadeed9 --- /dev/null +++ b/xmpp/src/action/group_private_message.rs @@ -0,0 +1,80 @@ +use crate::{Act, Action, Agent, Error, RoomNick}; +use std::str::FromStr; +use xmpp_parsers::{message::Message, muc::MucUser, BareJid, Element, JidParseError}; + +pub struct GroupPrivateMessageAction { + pub room: BareJid, + pub recipient: RoomNick, + pub msg: String, + pub lang: String, +} + +#[async_trait] +impl Act<()> for GroupPrivateMessageAction { + async fn act(self, agent: &mut Agent) -> Result<(), Error> { + agent.client.send_stanza(self.into()).await?; + Ok(()) + } +} + +impl From for Action { + fn from(action: GroupPrivateMessageAction) -> Action { + Action::GroupPrivateMessage(action) + } +} + +impl From for Element { + fn from(action: GroupPrivateMessageAction) -> Element { + let GroupPrivateMessageAction { + room, + recipient, + msg, + lang, + } = action; + + let recipient = room.with_resource(recipient); + + Message::chat(Some(recipient.into())) + .with_body(lang, msg) + .with_payload(MucUser::new()) + .into() + } +} + +impl GroupPrivateMessageAction { + /// 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, + ) -> GroupPrivateMessageAction { + GroupPrivateMessageAction { + 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(GroupPrivateMessageAction { + 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) -> GroupPrivateMessageAction { + self.lang = lang.to_string(); + self + } +} diff --git a/xmpp/src/action/join_room.rs b/xmpp/src/action/join_room.rs new file mode 100644 index 0000000..64b2a83 --- /dev/null +++ b/xmpp/src/action/join_room.rs @@ -0,0 +1,151 @@ +use crate::{Act, Action, Agent, Error}; +use std::str::FromStr; +use xmpp_parsers::{ + date::DateTime, + muc::muc::{History, Muc}, + presence::Presence, + BareJid, Element, JidParseError, +}; + +#[derive(Clone, Debug)] +pub struct JoinRoomAction { + pub room: BareJid, + pub nick: String, + pub password: Option, + pub lang: Option, + pub status: Option, + pub history: History, +} + +#[async_trait] +impl Act<()> for JoinRoomAction { + async fn act(self, agent: &mut Agent) -> Result<(), Error> { + agent.client.send_stanza(self.clone().into()).await?; + Ok(()) + } +} + +impl From for Action { + fn from(action: JoinRoomAction) -> Action { + Action::JoinRoom(action) + } +} + +impl From for Element { + fn from(action: JoinRoomAction) -> Element { + let JoinRoomAction { + room, + nick, + password, + lang, + status, + history, + } = action; + + let mut muc = Muc::new().with_history(history); + + if let Some(password) = password { + muc = muc.with_password(password); + } + + let room_jid = room.with_resource(nick); + + 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 JoinRoomAction { + /// Creates an [`Action`] to join a MUC chatroom from a parsed [`BareJid`]. By default, + /// legacy history management is disabled. In the future, it should be replaced with MAM, but if + /// you really need legacy history right now we still provide helpers for it. + pub fn new, U: AsRef>(room: T, nick: U) -> JoinRoomAction { + JoinRoomAction { + room: room.into(), + nick: nick.as_ref().to_string(), + password: None, + lang: None, + status: None, + history: 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`]. + /// In the future, it should be replaced with MAM, but if you really need legacy history right now we still + /// provide helpers for it. + pub fn new_str, U: AsRef>( + room: T, + nick: U, + ) -> Result { + Ok(JoinRoomAction { + room: BareJid::from_str(room.as_ref())?, + nick: nick.as_ref().to_string(), + password: None, + lang: None, + status: None, + history: 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) -> JoinRoomAction { + self.nick = nick.to_string(); + self + } + + /// Sets the password required to enter the MUC chatroom. + pub fn password(mut self, password: &str) -> JoinRoomAction { + 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) -> JoinRoomAction { + if let Some(lang) = lang { + self.lang = Some(lang); + } + self.status = Some(status.to_string()); + self + } + + /// Request specific [`History`] settings from legacy MUC history. + pub fn history(mut self, history: History) -> JoinRoomAction { + self.history = history; + self + } + + /// Request a maximum of `maxchars` characters from legacy MUC history. + pub fn history_maxchars(mut self, maxchars: u32) -> JoinRoomAction { + self.history = History::new().with_maxchars(maxchars); + self + } + + /// Request a maximum of `maxstanzas` stanzas from legacy MUC history. + pub fn history_maxstanzas(mut self, maxstanzas: u32) -> JoinRoomAction { + self.history = History::new().with_maxstanzas(maxstanzas); + self + } + + /// Request all messages since `sec` seconds from legacy MUC history. + pub fn history_since_sec(mut self, sec: u32) -> JoinRoomAction { + self.history = History::new().with_seconds(sec); + self + } + + /// Request all messages since `since` [`DateTime`] from legacy MUC history. + pub fn history_since(mut self, since: DateTime) -> JoinRoomAction { + self.history = History::new().with_since(since); + self + } +} diff --git a/xmpp/src/action/mod.rs b/xmpp/src/action/mod.rs new file mode 100644 index 0000000..074f518 --- /dev/null +++ b/xmpp/src/action/mod.rs @@ -0,0 +1,62 @@ +//! The [`Action`] API allows to create type-safe actions for an [`Agent`]. +//! +//! You can build an Action manually by generating one of the enum variants, or use one of the associated builders: +//! +//! - [`Action::join_room`] +//! - [`Action::private_message`] +//! - [`Action::group_message`] +//! - [`Action::group_private_message`] + +use crate::{Agent, Error, RoomNick}; +use xmpp_parsers::{BareJid, Jid}; + +pub mod join_room; +pub use join_room::JoinRoomAction; +pub mod group_message; +pub use group_message::GroupMessageAction; +pub mod group_private_message; +pub use group_private_message::GroupPrivateMessageAction; +pub mod private_message; +pub use private_message::PrivateMessageAction; + +pub enum Action { + /// + JoinRoom(JoinRoomAction), + GroupMessage(GroupMessageAction), + GroupPrivateMessage(GroupPrivateMessageAction), + PrivateMessage(PrivateMessageAction), +} + +impl Action { + /// Starts building a [`JoinRoomAction`] + pub fn join_room, U: AsRef>(room: T, nick: U) -> JoinRoomAction { + JoinRoomAction::new(room, nick) + } + + /// Starts building a [`GroupMessageAction`] + pub fn group_message, U: AsRef>(room: T, msg: U) -> GroupMessageAction { + GroupMessageAction::new(room, msg) + } + + /// Starts building a [`GroupPrivateMessageAction`] + pub fn group_private_message, U: AsRef>( + room: T, + nick: RoomNick, + msg: U, + ) -> GroupPrivateMessageAction { + GroupPrivateMessageAction::new(room, nick, msg) + } + + /// Starts building a [`PrivateMessageAction`] + pub fn private_message, U: AsRef>(room: T, msg: U) -> PrivateMessageAction { + PrivateMessageAction::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] +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/private_message.rs b/xmpp/src/action/private_message.rs new file mode 100644 index 0000000..a2d2a19 --- /dev/null +++ b/xmpp/src/action/private_message.rs @@ -0,0 +1,63 @@ +use crate::{Act, Action, Agent, Error}; +use std::str::FromStr; +use xmpp_parsers::{message::Message, Element, Jid, JidParseError}; + +pub struct PrivateMessageAction { + pub jid: Jid, + pub msg: String, + pub lang: String, +} + +#[async_trait] +impl Act<()> for PrivateMessageAction { + async fn act(self, agent: &mut Agent) -> Result<(), Error> { + agent.client.send_stanza(self.into()).await?; + Ok(()) + } +} + +impl From for Action { + fn from(action: PrivateMessageAction) -> Action { + Action::PrivateMessage(action) + } +} + +impl From for Element { + fn from(action: PrivateMessageAction) -> Element { + let PrivateMessageAction { jid, msg, lang } = action; + + Message::chat(Some(jid.into())).with_body(lang, msg).into() + } +} + +impl PrivateMessageAction { + /// 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) -> PrivateMessageAction { + PrivateMessageAction { + 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(PrivateMessageAction { + 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) -> PrivateMessageAction { + self.lang = lang.to_string(); + self + } +} diff --git a/xmpp/src/lib.rs b/xmpp/src/lib.rs index 02f2da7..f90d9cc 100644 --- a/xmpp/src/lib.rs +++ b/xmpp/src/lib.rs @@ -40,6 +40,15 @@ extern crate log; mod pubsub; +#[cfg(feature = "actions")] +#[macro_use] +extern crate async_trait; + +#[cfg(feature = "actions")] +pub mod action; +#[cfg(feature = "actions")] +pub use action::{Act, Action}; + pub type Error = tokio_xmpp::Error; #[derive(Debug)] @@ -219,6 +228,10 @@ impl Agent { self.client.send_end().await } + pub async fn action>(&mut self, action: T) -> Result { + action.act(self).await + } + pub async fn join_room( &mut self, room: BareJid,