lib.rs

  1// Copyright (c) 2019 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
  2//
  3// This Source Code Form is subject to the terms of the Mozilla Public
  4// License, v. 2.0. If a copy of the MPL was not distributed with this
  5// file, You can obtain one at http://mozilla.org/MPL/2.0/.
  6
  7#![deny(bare_trait_objects)]
  8
  9use futures::stream::StreamExt;
 10use std::cell::RefCell;
 11use std::convert::TryFrom;
 12use std::rc::Rc;
 13use tokio_xmpp::{AsyncClient as TokioXmppClient, Event as TokioXmppEvent};
 14use xmpp_parsers::{
 15    bookmarks2::Conference,
 16    caps::{compute_disco, hash_caps, Caps},
 17    disco::{DiscoInfoQuery, DiscoInfoResult, Feature, Identity},
 18    hashes::Algo,
 19    iq::{Iq, IqType},
 20    message::{Body, Message, MessageType},
 21    muc::{
 22        user::{MucUser, Status},
 23        Muc,
 24    },
 25    ns,
 26    presence::{Presence, Type as PresenceType},
 27    pubsub::pubsub::{Items, PubSub},
 28    roster::{Item as RosterItem, Roster},
 29    stanza_error::{DefinedCondition, ErrorType, StanzaError},
 30    BareJid, FullJid, Jid,
 31};
 32#[macro_use]
 33extern crate log;
 34
 35mod pubsub;
 36
 37pub type Error = tokio_xmpp::Error;
 38
 39#[derive(Debug)]
 40pub enum ClientType {
 41    Bot,
 42    Pc,
 43}
 44
 45impl Default for ClientType {
 46    fn default() -> Self {
 47        ClientType::Bot
 48    }
 49}
 50
 51impl ToString for ClientType {
 52    fn to_string(&self) -> String {
 53        String::from(match self {
 54            ClientType::Bot => "bot",
 55            ClientType::Pc => "pc",
 56        })
 57    }
 58}
 59
 60#[derive(PartialEq)]
 61pub enum ClientFeature {
 62    #[cfg(feature = "avatars")]
 63    Avatars,
 64    ContactList,
 65    JoinRooms,
 66}
 67
 68pub type RoomNick = String;
 69
 70#[derive(Debug)]
 71pub enum Event {
 72    Online,
 73    Disconnected,
 74    ContactAdded(RosterItem),
 75    ContactRemoved(RosterItem),
 76    ContactChanged(RosterItem),
 77    #[cfg(feature = "avatars")]
 78    AvatarRetrieved(Jid, String),
 79    ChatMessage(BareJid, Body),
 80    JoinRoom(BareJid, Conference),
 81    LeaveRoom(BareJid),
 82    LeaveAllRooms,
 83    RoomJoined(BareJid),
 84    RoomLeft(BareJid),
 85    RoomMessage(BareJid, RoomNick, Body),
 86}
 87
 88#[derive(Default)]
 89pub struct ClientBuilder<'a> {
 90    jid: &'a str,
 91    password: &'a str,
 92    website: String,
 93    default_nick: String,
 94    lang: Vec<String>,
 95    disco: (ClientType, String),
 96    features: Vec<ClientFeature>,
 97}
 98
 99impl ClientBuilder<'_> {
100    pub fn new<'a>(jid: &'a str, password: &'a str) -> ClientBuilder<'a> {
101        ClientBuilder {
102            jid,
103            password,
104            website: String::from("https://gitlab.com/xmpp-rs/tokio-xmpp"),
105            default_nick: String::from("xmpp-rs"),
106            lang: vec![String::from("en")],
107            disco: (ClientType::default(), String::from("tokio-xmpp")),
108            features: vec![],
109        }
110    }
111
112    pub fn set_client(mut self, type_: ClientType, name: &str) -> Self {
113        self.disco = (type_, String::from(name));
114        self
115    }
116
117    pub fn set_website(mut self, url: &str) -> Self {
118        self.website = String::from(url);
119        self
120    }
121
122    pub fn set_default_nick(mut self, nick: &str) -> Self {
123        self.default_nick = String::from(nick);
124        self
125    }
126
127    pub fn set_lang(mut self, lang: Vec<String>) -> Self {
128        self.lang = lang;
129        self
130    }
131
132    pub fn enable_feature(mut self, feature: ClientFeature) -> Self {
133        self.features.push(feature);
134        self
135    }
136
137    fn make_disco(&self) -> DiscoInfoResult {
138        let identities = vec![Identity::new(
139            "client",
140            self.disco.0.to_string(),
141            "en",
142            self.disco.1.to_string(),
143        )];
144        let mut features = vec![Feature::new(ns::DISCO_INFO)];
145        #[cfg(feature = "avatars")]
146        {
147            if self.features.contains(&ClientFeature::Avatars) {
148                features.push(Feature::new(format!("{}+notify", ns::AVATAR_METADATA)));
149            }
150        }
151        if self.features.contains(&ClientFeature::JoinRooms) {
152            features.push(Feature::new(format!("{}+notify", ns::BOOKMARKS2)));
153        }
154        DiscoInfoResult {
155            node: None,
156            identities,
157            features,
158            extensions: vec![],
159        }
160    }
161
162    pub fn build(self) -> Result<Agent, Error> {
163        let client = TokioXmppClient::new(self.jid, self.password)?;
164        Ok(self.build_impl(client)?)
165    }
166
167    // This function is meant to be used for testing build
168    pub(crate) fn build_impl(self, client: TokioXmppClient) -> Result<Agent, Error> {
169        let disco = self.make_disco();
170        let node = self.website;
171
172        let agent = Agent {
173            client,
174            default_nick: Rc::new(RefCell::new(self.default_nick)),
175            lang: Rc::new(self.lang),
176            disco,
177            node,
178        };
179
180        Ok(agent)
181    }
182}
183
184pub struct Agent {
185    client: TokioXmppClient,
186    default_nick: Rc<RefCell<String>>,
187    lang: Rc<Vec<String>>,
188    disco: DiscoInfoResult,
189    node: String,
190}
191
192impl Agent {
193    pub async fn join_room(
194        &mut self,
195        room: BareJid,
196        nick: Option<String>,
197        password: Option<String>,
198        lang: &str,
199        status: &str,
200    ) {
201        let mut muc = Muc::new();
202        if let Some(password) = password {
203            muc = muc.with_password(password);
204        }
205
206        let nick = nick.unwrap_or_else(|| self.default_nick.borrow().clone());
207        let room_jid = room.with_resource(nick);
208        let mut presence = Presence::new(PresenceType::None).with_to(Jid::Full(room_jid));
209        presence.add_payload(muc);
210        presence.set_status(String::from(lang), String::from(status));
211        let _ = self.client.send_stanza(presence.into()).await;
212    }
213
214    pub async fn send_message(
215        &mut self,
216        recipient: Jid,
217        type_: MessageType,
218        lang: &str,
219        text: &str,
220    ) {
221        let mut message = Message::new(Some(recipient));
222        message.type_ = type_;
223        message
224            .bodies
225            .insert(String::from(lang), Body(String::from(text)));
226        let _ = self.client.send_stanza(message.into()).await;
227    }
228
229    fn make_initial_presence(disco: &DiscoInfoResult, node: &str) -> Presence {
230        let caps_data = compute_disco(disco);
231        let hash = hash_caps(&caps_data, Algo::Sha_1).unwrap();
232        let caps = Caps::new(node, hash);
233
234        let mut presence = Presence::new(PresenceType::None);
235        presence.add_payload(caps);
236        presence
237    }
238
239    pub async fn wait_for_events(&mut self) -> Option<Vec<Event>> {
240        if let Some(event) = self.client.next().await {
241            let mut events = Vec::new();
242
243            match event {
244                TokioXmppEvent::Online { resumed: false, .. } => {
245                    let presence = Self::make_initial_presence(&self.disco, &self.node).into();
246                    let _ = self.client.send_stanza(presence).await;
247                    events.push(Event::Online);
248                    // TODO: only send this when the ContactList feature is enabled.
249                    let iq = Iq::from_get(
250                        "roster",
251                        Roster {
252                            ver: None,
253                            items: vec![],
254                        },
255                    )
256                    .into();
257                    let _ = self.client.send_stanza(iq).await;
258                    // TODO: only send this when the JoinRooms feature is enabled.
259                    let iq =
260                        Iq::from_get("bookmarks", PubSub::Items(Items::new(ns::BOOKMARKS2))).into();
261                    let _ = self.client.send_stanza(iq).await;
262                }
263                TokioXmppEvent::Online { resumed: true, .. } => {}
264                TokioXmppEvent::Disconnected(_) => {
265                    events.push(Event::Disconnected);
266                }
267                TokioXmppEvent::Stanza(stanza) => {
268                    if stanza.is("iq", "jabber:client") {
269                        let iq = Iq::try_from(stanza).unwrap();
270                        let from = iq
271                            .from
272                            .clone()
273                            .unwrap_or_else(|| self.client.bound_jid().unwrap().clone());
274                        if let IqType::Get(payload) = iq.payload {
275                            if payload.is("query", ns::DISCO_INFO) {
276                                let query = DiscoInfoQuery::try_from(payload);
277                                match query {
278                                    Ok(query) => {
279                                        let mut disco_info = self.disco.clone();
280                                        disco_info.node = query.node;
281                                        let iq = Iq::from_result(iq.id, Some(disco_info))
282                                            .with_to(iq.from.unwrap())
283                                            .into();
284                                        let _ = self.client.send_stanza(iq).await;
285                                    }
286                                    Err(err) => {
287                                        let error = StanzaError::new(
288                                            ErrorType::Modify,
289                                            DefinedCondition::BadRequest,
290                                            "en",
291                                            &format!("{}", err),
292                                        );
293                                        let iq = Iq::from_error(iq.id, error)
294                                            .with_to(iq.from.unwrap())
295                                            .into();
296                                        let _ = self.client.send_stanza(iq).await;
297                                    }
298                                }
299                            } else {
300                                // We MUST answer unhandled get iqs with a service-unavailable error.
301                                let error = StanzaError::new(
302                                    ErrorType::Cancel,
303                                    DefinedCondition::ServiceUnavailable,
304                                    "en",
305                                    "No handler defined for this kind of iq.",
306                                );
307                                let iq = Iq::from_error(iq.id, error)
308                                    .with_to(iq.from.unwrap())
309                                    .into();
310                                let _ = self.client.send_stanza(iq).await;
311                            }
312                        } else if let IqType::Result(Some(payload)) = iq.payload {
313                            // TODO: move private iqs like this one somewhere else, for
314                            // security reasons.
315                            if payload.is("query", ns::ROSTER) && iq.from.is_none() {
316                                let roster = Roster::try_from(payload).unwrap();
317                                for item in roster.items.into_iter() {
318                                    events.push(Event::ContactAdded(item));
319                                }
320                            } else if payload.is("pubsub", ns::PUBSUB) {
321                                let new_events = pubsub::handle_iq_result(&from, payload);
322                                events.extend(new_events);
323                            }
324                        } else if let IqType::Set(_) = iq.payload {
325                            // We MUST answer unhandled set iqs with a service-unavailable error.
326                            let error = StanzaError::new(
327                                ErrorType::Cancel,
328                                DefinedCondition::ServiceUnavailable,
329                                "en",
330                                "No handler defined for this kind of iq.",
331                            );
332                            let iq = Iq::from_error(iq.id, error)
333                                .with_to(iq.from.unwrap())
334                                .into();
335                            let _ = self.client.send_stanza(iq).await;
336                        }
337                    } else if stanza.is("message", "jabber:client") {
338                        let message = Message::try_from(stanza).unwrap();
339                        let from = message.from.clone().unwrap();
340                        let langs: Vec<&str> = self.lang.iter().map(String::as_str).collect();
341                        match message.get_best_body(langs) {
342                            Some((_lang, body)) => match message.type_ {
343                                MessageType::Groupchat => {
344                                    let event = Event::RoomMessage(
345                                        from.clone().into(),
346                                        FullJid::try_from(from.clone()).unwrap().resource,
347                                        body.clone(),
348                                    );
349                                    events.push(event)
350                                }
351                                MessageType::Chat | MessageType::Normal => {
352                                    let event =
353                                        Event::ChatMessage(from.clone().into(), body.clone());
354                                    events.push(event)
355                                }
356                                _ => (),
357                            },
358                            None => (),
359                        }
360                        for child in message.payloads {
361                            if child.is("event", ns::PUBSUB_EVENT) {
362                                let new_events = pubsub::handle_event(&from, child, self).await;
363                                events.extend(new_events);
364                            }
365                        }
366                    } else if stanza.is("presence", "jabber:client") {
367                        let presence = Presence::try_from(stanza).unwrap();
368                        let from: BareJid = match presence.from.clone().unwrap() {
369                            Jid::Full(FullJid { node, domain, .. }) => BareJid { node, domain },
370                            Jid::Bare(bare) => bare,
371                        };
372                        for payload in presence.payloads.into_iter() {
373                            let muc_user = match MucUser::try_from(payload) {
374                                Ok(muc_user) => muc_user,
375                                _ => continue,
376                            };
377                            for status in muc_user.status.into_iter() {
378                                if status == Status::SelfPresence {
379                                    events.push(Event::RoomJoined(from.clone()));
380                                    break;
381                                }
382                            }
383                        }
384                    } else if stanza.is("error", "http://etherx.jabber.org/streams") {
385                        println!("Received a fatal stream error: {}", String::from(&stanza));
386                    } else {
387                        panic!("Unknown stanza: {}", String::from(&stanza));
388                    }
389                }
390            }
391
392            Some(events)
393        } else {
394            None
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::{Agent, ClientBuilder, ClientFeature, ClientType, Event};
402    use tokio_xmpp::AsyncClient as TokioXmppClient;
403
404    #[tokio::test]
405    async fn test_simple() {
406        let client = TokioXmppClient::new("foo@bar", "meh").unwrap();
407
408        // Client instance
409        let client_builder = ClientBuilder::new("foo@bar", "meh")
410            .set_client(ClientType::Bot, "xmpp-rs")
411            .set_website("https://gitlab.com/xmpp-rs/xmpp-rs")
412            .set_default_nick("bot")
413            .enable_feature(ClientFeature::Avatars)
414            .enable_feature(ClientFeature::ContactList);
415
416        let mut agent: Agent = client_builder.build_impl(client).unwrap();
417
418        while let Some(events) = agent.wait_for_events().await {
419            assert!(match events[0] {
420                Event::Disconnected => true,
421                _ => false,
422            });
423            assert_eq!(events.len(), 1);
424            break;
425        }
426    }
427}