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(¶ms))
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}