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 async fn handle_iq(&mut self, iq: Iq) -> Vec<Event> {
240 let mut events = vec![];
241 let from = iq
242 .from
243 .clone()
244 .unwrap_or_else(|| self.client.bound_jid().unwrap().clone());
245 if let IqType::Get(payload) = iq.payload {
246 if payload.is("query", ns::DISCO_INFO) {
247 let query = DiscoInfoQuery::try_from(payload);
248 match query {
249 Ok(query) => {
250 let mut disco_info = self.disco.clone();
251 disco_info.node = query.node;
252 let iq = Iq::from_result(iq.id, Some(disco_info))
253 .with_to(iq.from.unwrap())
254 .into();
255 let _ = self.client.send_stanza(iq).await;
256 }
257 Err(err) => {
258 let error = StanzaError::new(
259 ErrorType::Modify,
260 DefinedCondition::BadRequest,
261 "en",
262 &format!("{}", err),
263 );
264 let iq = Iq::from_error(iq.id, error)
265 .with_to(iq.from.unwrap())
266 .into();
267 let _ = self.client.send_stanza(iq).await;
268 }
269 }
270 } else {
271 // We MUST answer unhandled get iqs with a service-unavailable error.
272 let error = StanzaError::new(
273 ErrorType::Cancel,
274 DefinedCondition::ServiceUnavailable,
275 "en",
276 "No handler defined for this kind of iq.",
277 );
278 let iq = Iq::from_error(iq.id, error)
279 .with_to(iq.from.unwrap())
280 .into();
281 let _ = self.client.send_stanza(iq).await;
282 }
283 } else if let IqType::Result(Some(payload)) = iq.payload {
284 // TODO: move private iqs like this one somewhere else, for
285 // security reasons.
286 if payload.is("query", ns::ROSTER) && iq.from.is_none() {
287 let roster = Roster::try_from(payload).unwrap();
288 for item in roster.items.into_iter() {
289 events.push(Event::ContactAdded(item));
290 }
291 } else if payload.is("pubsub", ns::PUBSUB) {
292 let new_events = pubsub::handle_iq_result(&from, payload);
293 events.extend(new_events);
294 }
295 } else if let IqType::Set(_) = iq.payload {
296 // We MUST answer unhandled set iqs with a service-unavailable error.
297 let error = StanzaError::new(
298 ErrorType::Cancel,
299 DefinedCondition::ServiceUnavailable,
300 "en",
301 "No handler defined for this kind of iq.",
302 );
303 let iq = Iq::from_error(iq.id, error)
304 .with_to(iq.from.unwrap())
305 .into();
306 let _ = self.client.send_stanza(iq).await;
307 }
308
309 events
310 }
311
312 async fn handle_message(&mut self, message: Message) -> Vec<Event> {
313 let mut events = vec![];
314 let from = message.from.clone().unwrap();
315 let langs: Vec<&str> = self.lang.iter().map(String::as_str).collect();
316 match message.get_best_body(langs) {
317 Some((_lang, body)) => match message.type_ {
318 MessageType::Groupchat => {
319 let event = Event::RoomMessage(
320 from.clone().into(),
321 FullJid::try_from(from.clone()).unwrap().resource,
322 body.clone(),
323 );
324 events.push(event)
325 }
326 MessageType::Chat | MessageType::Normal => {
327 let event = Event::ChatMessage(from.clone().into(), body.clone());
328 events.push(event)
329 }
330 _ => (),
331 },
332 None => (),
333 }
334 for child in message.payloads {
335 if child.is("event", ns::PUBSUB_EVENT) {
336 let new_events = pubsub::handle_event(&from, child, self).await;
337 events.extend(new_events);
338 }
339 }
340
341 events
342 }
343
344 async fn handle_presence(&mut self, presence: Presence) -> Vec<Event> {
345 let mut events = vec![];
346 let from: BareJid = match presence.from.clone().unwrap() {
347 Jid::Full(FullJid { node, domain, .. }) => BareJid { node, domain },
348 Jid::Bare(bare) => bare,
349 };
350 for payload in presence.payloads.into_iter() {
351 let muc_user = match MucUser::try_from(payload) {
352 Ok(muc_user) => muc_user,
353 _ => continue,
354 };
355 for status in muc_user.status.into_iter() {
356 if status == Status::SelfPresence {
357 events.push(Event::RoomJoined(from.clone()));
358 break;
359 }
360 }
361 }
362
363 events
364 }
365
366 pub async fn wait_for_events(&mut self) -> Option<Vec<Event>> {
367 if let Some(event) = self.client.next().await {
368 let mut events = Vec::new();
369
370 match event {
371 TokioXmppEvent::Online { resumed: false, .. } => {
372 let presence = Self::make_initial_presence(&self.disco, &self.node).into();
373 let _ = self.client.send_stanza(presence).await;
374 events.push(Event::Online);
375 // TODO: only send this when the ContactList feature is enabled.
376 let iq = Iq::from_get(
377 "roster",
378 Roster {
379 ver: None,
380 items: vec![],
381 },
382 )
383 .into();
384 let _ = self.client.send_stanza(iq).await;
385 // TODO: only send this when the JoinRooms feature is enabled.
386 let iq =
387 Iq::from_get("bookmarks", PubSub::Items(Items::new(ns::BOOKMARKS2))).into();
388 let _ = self.client.send_stanza(iq).await;
389 }
390 TokioXmppEvent::Online { resumed: true, .. } => {}
391 TokioXmppEvent::Disconnected(_) => {
392 events.push(Event::Disconnected);
393 }
394 TokioXmppEvent::Stanza(elem) => {
395 if elem.is("iq", "jabber:client") {
396 let iq = Iq::try_from(elem).unwrap();
397 let new_events = self.handle_iq(iq).await;
398 events.extend(new_events);
399 } else if elem.is("message", "jabber:client") {
400 let message = Message::try_from(elem).unwrap();
401 let new_events = self.handle_message(message).await;
402 events.extend(new_events);
403 } else if elem.is("presence", "jabber:client") {
404 let presence = Presence::try_from(elem).unwrap();
405 let new_events = self.handle_presence(presence).await;
406 events.extend(new_events);
407 } else if elem.is("error", "http://etherx.jabber.org/streams") {
408 println!("Received a fatal stream error: {}", String::from(&elem));
409 } else {
410 panic!("Unknown stanza: {}", String::from(&elem));
411 }
412 }
413 }
414
415 Some(events)
416 } else {
417 None
418 }
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::{Agent, ClientBuilder, ClientFeature, ClientType, Event};
425 use tokio_xmpp::AsyncClient as TokioXmppClient;
426
427 #[tokio::test]
428 async fn test_simple() {
429 let client = TokioXmppClient::new("foo@bar", "meh").unwrap();
430
431 // Client instance
432 let client_builder = ClientBuilder::new("foo@bar", "meh")
433 .set_client(ClientType::Bot, "xmpp-rs")
434 .set_website("https://gitlab.com/xmpp-rs/xmpp-rs")
435 .set_default_nick("bot")
436 .enable_feature(ClientFeature::Avatars)
437 .enable_feature(ClientFeature::ContactList);
438
439 let mut agent: Agent = client_builder.build_impl(client).unwrap();
440
441 while let Some(events) = agent.wait_for_events().await {
442 assert!(match events[0] {
443 Event::Disconnected => true,
444 _ => false,
445 });
446 assert_eq!(events.len(), 1);
447 break;
448 }
449 }
450}