egg_mode/direct/
mod.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5//! Structs and methods for working with direct messages.
6//!
7//! Note that direct message access requires a special permissions level above regular read/write
8//! access. Your app must be configured to have "read, write, and direct message" access to use any
9//! function in this module, even the read-only ones.
10//!
11//! In some sense, DMs are simpler than Tweets, because there are fewer ways to interact with them
12//! and less metadata stored with them. However, there are also separate DM-specific capabilities
13//! that are available, to allow users to create a structured conversation for things like
14//! customer-service, interactive storytelling, etc. The extra DM-specific facilities are
15//! documented in their respective builder functions on `DraftMessage`.
16//!
17//! ## Types
18//!
19//! * `DirectMessage`: The primary representation of a DM as retrieved from Twitter. Contains the
20//!   types `DMEntities`/`Cta`/`QuickReply` as fields.
21//! * `Timeline`: Returned by `list`, this is how you load a user's Direct Messages. Contains
22//!   adapters to consume the collection as a `Stream` or to load it into a `DMConversations`
23//!   collection.
24//! * `DraftMessage`: As DMs have many optional parameters when creating them, this builder struct
25//!   allows you to build up a DM before sending it.
26//!
27//! ## Functions
28//!
29//! * `list`: This creates a `Timeline` struct to load a user's Direct Messages.
30//! * `show`: This allows you to load a single DM from its ID.
31//! * `delete`: This allows you to delete a DM from a user's own views. Note that it will not
32//!   delete it entirely from the system; the recipient will still have a copy of the message.
33//! * `mark_read`: This sends a read receipt for a given message to a given user. This also has the
34//!   effect of clearing the message's "unread" status for the authenticated user.
35//! * `indicate_typing`: This sends a typing indicator to a given user, to indicate that the
36//!   authenticated user is typing or thinking of a response.
37
38use std::borrow::Cow;
39use std::collections::{HashMap, VecDeque};
40use std::future::Future;
41
42use chrono;
43use futures::stream::{self, Stream, StreamExt, TryStreamExt};
44use futures::FutureExt;
45use hyper::{Body, Request};
46use serde::{Deserialize, Serialize};
47
48use crate::common::*;
49use crate::tweet::TweetSource;
50use crate::user::{self, UserID};
51use crate::{auth, entities, error, links, media};
52
53mod fun;
54pub(crate) mod raw;
55
56pub use self::fun::*;
57
58// TODO is this enough? i'm not sure if i want a field-by-field breakdown like with Tweet
59/// Represents a single direct message.
60#[derive(Debug)]
61pub struct DirectMessage {
62    /// Numeric ID for this DM.
63    pub id: u64,
64    /// UTC timestamp from when this DM was created.
65    pub created_at: chrono::DateTime<chrono::Utc>,
66    /// The text of the DM.
67    pub text: String,
68    /// Link, hashtag, and user mention information parsed out of the DM.
69    pub entities: DMEntities,
70    /// An image, gif, or video attachment, if present.
71    pub attachment: Option<entities::MediaEntity>,
72    /// A list of "call to action" buttons attached to the DM, if present.
73    pub ctas: Option<Vec<Cta>>,
74    /// A list of "Quick Replies" sent with this message to request structured input from the
75    /// recipient.
76    ///
77    /// Note that there is no way to select a Quick Reply as a response in the public API; a
78    /// `quick_reply_response` can only be populated if the Quick Reply was selected in the Twitter
79    /// Web Client, or Twitter for iOS/Android.
80    pub quick_replies: Option<Vec<QuickReply>>,
81    /// The `metadata` accompanying a Quick Reply, if the sender selected a Quick Reply for their
82    /// response.
83    pub quick_reply_response: Option<String>,
84    /// The ID of the user who sent the DM.
85    ///
86    /// To load full user information for the sender or recipient, use `user::show`. Note that
87    /// Twitter may show a message with a user that doesn't exist if that user has been suspended
88    /// or has deleted their account.
89    pub sender_id: u64,
90    /// The app that sent this direct message.
91    ///
92    /// Source app information is only available for messages sent by the authorized user. For
93    /// received messages written by other users, this field will be `None`.
94    pub source_app: Option<TweetSource>,
95    /// The ID of the user who received the DM.
96    ///
97    /// To load full user information for the sender or recipient, use `user::show`. Note that
98    /// Twitter may show a message with a user that doesn't exist if that user has been suspended
99    /// or has deleted their account.
100    pub recipient_id: u64,
101}
102
103impl From<raw::SingleEvent> for DirectMessage {
104    fn from(ev: raw::SingleEvent) -> DirectMessage {
105        let raw::SingleEvent { event, apps } = ev;
106        let raw: raw::RawDirectMessage = event.as_raw_dm();
107        raw.into_dm(&apps)
108    }
109}
110
111impl From<raw::EventCursor> for Vec<DirectMessage> {
112    fn from(evs: raw::EventCursor) -> Vec<DirectMessage> {
113        let raw::EventCursor { events, apps, .. } = evs;
114        let mut ret = vec![];
115
116        for ev in events {
117            let raw: raw::RawDirectMessage = ev.as_raw_dm();
118            ret.push(raw.into_dm(&apps));
119        }
120
121        ret
122    }
123}
124
125/// Container for URL, hashtag, and mention information associated with a direct message.
126///
127/// As far as entities are concerned, a DM can contain nearly everything a tweet can. The only
128/// thing that isn't present here is the "extended media" that would be on the tweet's
129/// `extended_entities` field. A user can attach a single picture to a DM, but if that is present,
130/// it will be available in the `attachments` field of the original `DirectMessage` struct and not
131/// in the entities.
132///
133/// For all other fields, if the message contains no hashtags, financial symbols ("cashtags"),
134/// links, or mentions, those corresponding fields will be empty.
135#[derive(Debug, Deserialize)]
136pub struct DMEntities {
137    /// Collection of hashtags parsed from the DM.
138    pub hashtags: Vec<entities::HashtagEntity>,
139    /// Collection of financial symbols, or "cashtags", parsed from the DM.
140    pub symbols: Vec<entities::HashtagEntity>,
141    /// Collection of URLs parsed from the DM.
142    pub urls: Vec<entities::UrlEntity>,
143    /// Collection of user mentions parsed from the DM.
144    pub user_mentions: Vec<entities::MentionEntity>,
145}
146
147/// A "call to action" added as a button to a direct message.
148///
149/// Buttons allow you to attach additional URLs as "calls to action" for the recipient of the
150/// message. For more information, see the `cta_button` function on [`DraftMessage`].
151///
152/// [`DraftMessage`]: struct.DraftMessage.html
153#[derive(Debug, Deserialize)]
154pub struct Cta {
155    /// The label shown to the user for the CTA.
156    pub label: String,
157    /// The `t.co` URL that the user should navigate to if they click this CTA.
158    pub tco_url: String,
159    /// The URL given for the CTA, that could be displayed if needed.
160    pub url: String,
161}
162
163/// A version of `Cta` without `tco_url` to be used in `DraftMessage`.
164struct DraftCta {
165    label: String,
166    url: String,
167}
168
169/// A Quick Reply attached to a message to request structured input from a user.
170///
171/// For more information about Quick Replies, see the `quick_reply_option` function on
172/// [`DraftMessage`].
173///
174/// [`DraftMessage`]: struct.DraftMessage.html
175#[derive(Debug, Serialize, Deserialize)]
176pub struct QuickReply {
177    /// The label shown to the user. When the user selects this Quick Reply, the label will be sent
178    /// as the `text` of the reply message.
179    pub label: String,
180    /// An optional description that accompanies a Quick Reply.
181    pub description: Option<String>,
182    /// Metadata that accompanies this Quick Reply. Metadata is not shown to the user, but is
183    /// available in the `quick_reply_response` when the user selects it.
184    pub metadata: String,
185}
186
187/// Helper struct to navigate collections of direct messages by tracking the status of Twitter's
188/// cursor references.
189///
190/// The API of the Direct Message `Timeline` differs from the Tweet `Timeline`, in that Twitter
191/// returns a "cursor" ID instead of paging through results by asking for messages before or after
192/// a certain ID. It's not a strict `CursorIter`, though, in that there is no "previous cursor"
193/// ID given by Twitter; messages are loaded one-way, from newest to oldest.
194///
195/// To start using a `Timeline`, call `list` to set one up. Before starting, you can call
196/// `with_page_size` to set how many messages to ask for at once. Then use `start` and `next_page`
197/// to load messages one page at a time.
198///
199/// ```no_run
200/// # #[tokio::main]
201/// # async fn main() {
202/// # let token: egg_mode::Token = unimplemented!();
203/// let timeline = egg_mode::direct::list(&token).with_page_size(50);
204/// let mut messages = timeline.start().await.unwrap();
205///
206/// while timeline.next_cursor.is_some() {
207///     let next_page = timeline.next_page().await.unwrap();
208///     messages.extend(next_page.response);
209/// }
210/// # }
211/// ```
212///
213/// An adapter is provided which converts a `Timeline` into a `futures::stream::Stream` which
214/// yields one message at a time and lazily loads each page as needed. As the stream's `Item` is a
215/// `Result` which can express the error caused by loading the next page, it also implements
216/// `futures::stream::TryStream` as well. The previous example can also be expressed like this:
217///
218/// ```no_run
219/// use egg_mode::Response;
220/// use egg_mode::direct::DirectMessage;
221/// use futures::stream::TryStreamExt;
222/// # #[tokio::main]
223/// # async fn main() {
224/// # let token: egg_mode::Token = unimplemented!();
225/// let timeline = egg_mode::direct::list(&token).with_page_size(50);
226/// let messages = timeline.into_stream()
227///                        .try_collect::<Vec<Response<DirectMessage>>>()
228///                        .await
229///                        .unwrap();
230/// # }
231/// ```
232///
233/// In addition, an adapter is available which loads all available messages and sorts them into
234/// "conversations" between the authenticated user and other users. The `into_conversations`
235/// adapter loads all available messages and returns a [`DMConversations`] map after sorting them.
236///
237/// [`DMConversations`]: type.DMConversations.html
238pub struct Timeline {
239    link: &'static str,
240    token: auth::Token,
241    /// The number of messages to request in a single page. The default is 20; the maximum is 50.
242    pub count: u32,
243    /// The string ID that can be used to load the next page of results. A value of `None`
244    /// indicates that either no messages have been loaded yet, or that the most recently loaded
245    /// page is the last page of messages available.
246    pub next_cursor: Option<String>,
247    /// Whether this `Timeline` has been called yet.
248    pub loaded: bool,
249}
250
251impl Timeline {
252    pub(crate) fn new(link: &'static str, token: auth::Token) -> Timeline {
253        Timeline {
254            link,
255            token,
256            count: 20,
257            next_cursor: None,
258            loaded: false,
259        }
260    }
261
262    /// Builder function to set the page size. The default value for the page size is 20; the
263    /// maximum allowed is 50.
264    pub fn with_page_size(self, count: u32) -> Self {
265        Timeline { count, ..self }
266    }
267
268    /// Clears the saved cursor information on this `Timeline`.
269    pub fn reset(&mut self) {
270        self.next_cursor = None;
271        self.loaded = false;
272    }
273
274    fn request(&self, cursor: Option<String>) -> Request<Body> {
275        let params = ParamList::new()
276            .add_param("count", self.count.to_string())
277            .add_opt_param("cursor", cursor);
278
279        get(self.link, &self.token, Some(&params))
280    }
281
282    /// Clear the saved cursor information on this timeline, then return the most recent set of
283    /// messages.
284    pub fn start(
285        &mut self,
286    ) -> impl Future<Output = Result<Response<Vec<DirectMessage>>, error::Error>> + '_ {
287        self.reset();
288        self.next_page()
289    }
290
291    /// Loads the next page of messages, setting the `next_cursor` to the one received from
292    /// Twitter.
293    pub fn next_page(
294        &mut self,
295    ) -> impl Future<Output = Result<Response<Vec<DirectMessage>>, error::Error>> + '_ {
296        let next_cursor = self.next_cursor.take();
297        let req = self.request(next_cursor);
298        let loader = request_with_json_response(req);
299        loader.map(
300            move |resp: Result<Response<raw::EventCursor>, error::Error>| {
301                let mut resp = resp?;
302                self.loaded = true;
303                self.next_cursor = resp.next_cursor.take();
304                Ok(Response::into(resp))
305            },
306        )
307    }
308
309    /// Converts this `Timeline` into a `Stream` of direct messages, which automatically loads the
310    /// next page as needed.
311    pub fn into_stream(self) -> impl Stream<Item = Result<Response<DirectMessage>, error::Error>> {
312        stream::try_unfold(self, |mut timeline| async move {
313            if timeline.loaded && timeline.next_cursor.is_none() {
314                Ok::<_, error::Error>(None)
315            } else {
316                let page = timeline.next_page().await?;
317                Ok(Some((page, timeline)))
318            }
319        })
320        .map_ok(|page| stream::iter(page).map(Ok::<_, error::Error>))
321        .try_flatten()
322    }
323
324    /// Loads all the direct messages from this `Timeline` and sorts them into a `DMConversations`
325    /// map.
326    ///
327    /// This adapter is a convenient way to sort all of a user's messages (from the last 30 days)
328    /// into a familiar user-interface pattern of a list of conversations between the authenticated
329    /// user and a specific other user. This function first pulls all the available messages, then
330    /// sorts them into a set of threads by matching them against which user the authenticated user
331    /// is messaging.
332    ///
333    /// If there are more messages available than can be loaded without hitting the rate limit (15
334    /// calls to the `list` endpoint per 15 minutes), then this function will stop once it receives
335    /// a rate-limit error and sort the messages it received.
336    pub async fn into_conversations(mut self) -> Result<DMConversations, error::Error> {
337        let mut dms: Vec<DirectMessage> = vec![];
338        while !self.loaded || self.next_cursor.is_some() {
339            match self.next_page().await {
340                Ok(page) => dms.extend(page.into_iter().map(|r| r.response)),
341                Err(error::Error::RateLimit(_)) => break,
342                Err(e) => return Err(e),
343            }
344        }
345        let mut conversations = HashMap::new();
346        let me_id = if let Some(dm) = dms.first() {
347            if dm.source_app.is_some() {
348                // since the source app info is only populated when the authenticated user sent the
349                // message, we know that this message was sent by the authenticated user
350                dm.sender_id
351            } else {
352                dm.recipient_id
353            }
354        } else {
355            // no messages, nothing to sort
356            return Ok(conversations);
357        };
358
359        for dm in dms {
360            let entry = match (dm.sender_id == me_id, dm.recipient_id == me_id) {
361                (true, true) => {
362                    // if the sender and recipient are the same - and they match the authenticated
363                    // user - then it's the listing of "messages to self"
364                    conversations.entry(me_id).or_default()
365                }
366                (true, false) => conversations.entry(dm.recipient_id).or_default(),
367                (false, true) => conversations.entry(dm.sender_id).or_default(),
368                (false, false) => {
369                    return Err(error::Error::InvalidResponse(
370                        "messages activity contains disjoint conversations",
371                        None,
372                    ));
373                }
374            };
375            entry.push(dm);
376        }
377
378        Ok(conversations)
379    }
380}
381
382/// Wrapper around a collection of direct messages, sorted by their recipient.
383///
384/// The mapping exposed here is from a User ID to a listing of direct messages between the
385/// authenticated user and that user. Messages sent from the authenticated user to themself are
386/// sorted under the user's own ID. This map is returned by the `into_conversations` adapter on
387/// [`Timeline`].
388///
389/// [`Timeline`]: struct.Timeline.html
390pub type DMConversations = HashMap<u64, Vec<DirectMessage>>;
391
392/// Represents a direct message before it is sent.
393///
394/// Because there are several optional items you can add to a DM, this struct allows you to add or
395/// skip them using a builder-style struct, much like with `DraftTweet`.
396///
397/// To begin drafting a direct message, start by calling `new` with the message text and the User
398/// ID of the recipient:
399///
400/// ```no_run
401/// use egg_mode::direct::DraftMessage;
402///
403/// # let recipient: egg_mode::user::TwitterUser = unimplemented!();
404/// let message = DraftMessage::new("hey, what's up?", recipient.id);
405/// ```
406///
407/// As-is, the draft won't do anything until you call `send` to send it:
408///
409/// ```no_run
410/// # #[tokio::main]
411/// # async fn main() {
412/// # let message: egg_mode::direct::DraftMessage = unimplemented!();
413/// # let token: egg_mode::Token = unimplemented!();
414/// message.send(&token).await.unwrap();
415/// # }
416/// ```
417///
418/// In between creating the draft and sending it, you can use any of the other adapter functions to
419/// add other information to the message. See the documentation for those functions for details.
420pub struct DraftMessage {
421    text: Cow<'static, str>,
422    recipient: UserID,
423    quick_reply_options: VecDeque<QuickReply>,
424    cta_buttons: VecDeque<DraftCta>,
425    media_attachment: Option<media::MediaId>,
426}
427
428impl DraftMessage {
429    /// Creates a new `DraftMessage` with the given text, to be sent to the given recipient.
430    ///
431    /// Note that while this accepts a `UserID`, Twitter only accepts a numeric ID to denote the
432    /// recipient. If you pass this function a string Screen Name, a separate user lookup will
433    /// occur when you `send` this message. To avoid this extra lookup, use a numeric ID (or the
434    /// `UserID::ID` variant of `UserID`) when creating a `DraftMessage`.
435    pub fn new(text: impl Into<Cow<'static, str>>, recipient: impl Into<UserID>) -> DraftMessage {
436        DraftMessage {
437            text: text.into(),
438            recipient: recipient.into(),
439            quick_reply_options: VecDeque::new(),
440            cta_buttons: VecDeque::new(),
441            media_attachment: None,
442        }
443    }
444
445    /// Adds an Option-type Quick Reply to this draft message.
446    ///
447    /// Quick Replies allow you to request structured input from the other user. They'll have the
448    /// opportunity to select from the options you add to the message when you send it. If they
449    /// select one of the given options, its `metadata` will be given in the response in the
450    /// `quick_reply_response` field.
451    ///
452    /// Note that while `description` is optional in this call, Twitter will not send the message
453    /// if only some of the given Quick Replies have `description` fields.
454    ///
455    /// The fields here have the following length restrictions:
456    ///
457    /// * `label` has a maximum of 36 characters, including spaces.
458    /// * `metadata` has a maximum of 1000 characters, including spaces.
459    /// * `description` has a maximum of 72 characters, including spaces.
460    ///
461    /// There is a maximum of 20 Quick Reply Options on a single Direct Message. If you try to add
462    /// more, the oldest one will be removed.
463    ///
464    /// Users can only respond to Quick Replies in the Twitter Web Client, and Twitter for
465    /// iOS/Android.
466    ///
467    /// It is not possible to respond to a Quick Reply sent to yourself, though Twitter will
468    /// register the options in the message it returns.
469    pub fn quick_reply_option(
470        mut self,
471        label: impl Into<String>,
472        metadata: impl Into<String>,
473        description: Option<String>,
474    ) -> Self {
475        if self.quick_reply_options.len() == 20 {
476            self.quick_reply_options.pop_front();
477        }
478        self.quick_reply_options.push_back(QuickReply {
479            label: label.into(),
480            metadata: metadata.into(),
481            description,
482        });
483        self
484    }
485
486    /// Adds a "Call To Action" button to the message.
487    ///
488    /// Buttons allow you to add up to three links to a message. These links act as an extension to
489    /// the message rather than embedding the URLs into the message text itself. If a [Web Intent
490    /// link] is used as the URL, they can also be used to bounce users back into the Twitter UI to
491    /// perform some action.
492    ///
493    /// [Web Intent link]: https://developer.twitter.com/en/docs/twitter-for-websites/web-intents/overview
494    ///
495    /// The `label` has a length limit of 36 characters.
496    ///
497    /// There is a maximum of 3 CTA Buttons on a single Direct Message. If you try to add more, the
498    /// oldest one will be removed.
499    pub fn cta_button(mut self, label: impl Into<String>, url: impl Into<String>) -> Self {
500        if self.cta_buttons.is_empty() {
501            self.cta_buttons.reserve_exact(3);
502        } else if self.cta_buttons.len() == 3 {
503            self.cta_buttons.pop_front();
504        }
505        self.cta_buttons.push_back(DraftCta {
506            label: label.into(),
507            url: url.into(),
508        });
509        self
510    }
511
512    /// Add the given media to this message.
513    ///
514    /// The `MediaId` needs to have been uploaded via [`media::upload_media_for_dm`]. Twitter
515    /// requires DM-specific media categories for media that will be attached to Direct Messages.
516    /// In addition, there's an extra setting available for media attached to Direct Messages. For
517    /// more information, see the documentation for `upload_media_for_dm`.
518    ///
519    /// [`media::upload_media_for_dm`]: ../media/fn.upload_media_for_dm.html
520    pub fn attach_media(self, media_id: media::MediaId) -> Self {
521        DraftMessage {
522            media_attachment: Some(media_id),
523            ..self
524        }
525    }
526
527    /// Sends this direct message using the given `Token`.
528    ///
529    /// The recipient must allow DMs from the authenticated user for this to be successful. In
530    /// practice, this means that the recipient must either follow the authenticated user, or they must
531    /// have the "allow DMs from anyone" setting enabled. As the latter setting has no visibility on
532    /// the API, there may be situations where you can't verify the recipient's ability to receive the
533    /// requested DM beforehand.
534    ///
535    /// If the message was successfully sent, this function will return the `DirectMessage` that
536    /// was just sent.
537    pub async fn send(self, token: &auth::Token) -> Result<Response<DirectMessage>, error::Error> {
538        let recipient_id = match self.recipient {
539            UserID::ID(id) => id,
540            UserID::ScreenName(name) => {
541                let user = user::show(name, token).await?;
542                user.id
543            }
544        };
545        let mut message_data = serde_json::json!({
546            "text": self.text
547        });
548        if !self.quick_reply_options.is_empty() {
549            message_data.as_object_mut().unwrap().insert(
550                "quick_reply".into(),
551                serde_json::json!({
552                    "type": "options",
553                    "options": self.quick_reply_options
554                }),
555            );
556        }
557        if !self.cta_buttons.is_empty() {
558            message_data.as_object_mut().unwrap().insert(
559                "ctas".into(),
560                self.cta_buttons
561                    .into_iter()
562                    .map(|b| {
563                        serde_json::json!({
564                            "type": "web_url",
565                            "label": b.label,
566                            "url": b.url,
567                        })
568                    })
569                    .collect::<Vec<_>>()
570                    .into(),
571            );
572        }
573        if let Some(media_id) = self.media_attachment {
574            message_data.as_object_mut().unwrap().insert(
575                "attachment".into(),
576                serde_json::json!({
577                    "type": "media",
578                    "media": {
579                        "id": media_id.0
580                    }
581                }),
582            );
583        }
584
585        let message = serde_json::json!({
586            "event": {
587                "type": "message_create",
588                "message_create": {
589                    "target": {
590                        "recipient_id": recipient_id
591                    },
592                    "message_data": message_data
593                }
594            }
595        });
596        let req = post_json(links::direct::SEND, token, message);
597        let resp: Response<raw::SingleEvent> = request_with_json_response(req).await?;
598        Ok(Response::into(resp))
599    }
600}