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}