egg_mode/tweet/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 functions for working with statuses and timelines.
6//!
7//! In this module, you can find various structs and methods to load and interact with tweets and
8//! their metadata. This also includes loading a user's timeline, posting a new tweet, or liking or
9//! retweeting another tweet. However, this does *not* include searching for tweets; that
10//! functionality is in the [`search`][] module.
11//!
12//! [`search`]: ../search/index.html
13//!
14//! ## Types
15//!
16//! - `Tweet`/`TweetEntities`/`ExtendedTweetEntities`: At the bottom of it all, this is the struct
17//! that represents a single tweet. The `*Entities` structs contain information about media,
18//! links, and hashtags within their parent tweet.
19//! - `DraftTweet`: This is what you use to post a new tweet. At present, not all available options
20//! are supported, but basics like marking the tweet as a reply and attaching a location
21//! coordinate are available.
22//! - `Timeline`: Returned by several functions in this module, this is how you cursor through a
23//! collection of tweets. See the struct-level documentation for details.
24//!
25//! ## Functions
26//!
27//! ### User actions
28//!
29//! These functions perform actions on their given tweets. They require write access to the
30//! authenticated user's account.
31//!
32//! - `delete` (for creating a tweet, see `DraftTweet`)
33//! - `like`/`unlike`
34//! - `retweet`/`unretweet`
35//!
36//! ### Metadata lookup
37//!
38//! These functions either perform some direct lookup of specific tweets, or provide some metadata
39//! about the given tweet in a direct (non-`Timeline`) fashion.
40//!
41//! - `show`
42//! - `lookup`/`lookup_map` (for the differences between these functions, see their respective
43//! documentations.)
44//! - `retweeters_of`
45//! - `retweets_of`
46//!
47//! ### `Timeline` cursors
48//!
49//! These functions return `Timeline`s and can be cursored around in the same way. See the
50//! documentation for `Timeline` to learn how to navigate these return values. This correspond to a
51//! user's own view of Twitter, or with feeds you might see attached to a user's profile page.
52//!
53//! - `home_timeline`/`mentions_timeline`/`retweets_of_me`
54//! - `user_timeline`/`liked_by`
55
56use std::borrow::Cow;
57use std::convert::TryFrom;
58use std::future::Future;
59use std::pin::Pin;
60use std::str::FromStr;
61use std::task::{Context, Poll};
62
63use chrono;
64use hyper::{Body, Request};
65use regex::Regex;
66use serde::{Deserialize, Deserializer, Serialize};
67
68use crate::common::*;
69use crate::error::{Error::InvalidResponse, Result};
70use crate::stream::FilterLevel;
71use crate::{auth, entities, error, links, media, place, user};
72
73mod fun;
74mod raw;
75
76pub use self::fun::*;
77
78round_trip! { raw::RawTweet,
79 ///Represents a single status update.
80 ///
81 ///The fields present in this struct can be mainly split up based on the context they're present
82 ///for.
83 ///
84 ///## Base Tweet Info
85 ///
86 ///This information is the basic information inherent to all tweets, regardless of context.
87 ///
88 ///* `text`
89 ///* `id`
90 ///* `created_at`
91 ///* `user`
92 ///* `source`
93 ///* `favorite_count`/`retweet_count`
94 ///* `lang`, though third-party clients usually don't surface this at a user-interface level.
95 /// Twitter Web uses this to create machine-translations of the tweet.
96 ///* `coordinates`/`place`
97 ///* `display_text_range`
98 ///* `truncated`
99 ///
100 ///## Perspective-based data
101 ///
102 ///This information depends on the authenticated user who called the data. These are left as
103 ///Options because certain contexts where the information is pulled either don't have an
104 ///authenticated user to compare with, or don't have to opportunity to poll the user's interactions
105 ///with the tweet.
106 ///
107 ///* `favorited`
108 ///* `retweeted`
109 ///* `current_user_retweet`
110 ///
111 ///## Replies
112 ///
113 ///This information is only present when the tweet in question is marked as being a reply to
114 ///another tweet, or when it's threaded into a chain from the same user.
115 ///
116 ///* `in_reply_to_user_id`/`in_reply_to_screen_name`
117 ///* `in_reply_to_status_id`
118 ///
119 ///## Retweets and Quote Tweets
120 ///
121 ///This information is only present when the tweet in question is a native retweet or is a "quote
122 ///tweet" that references another tweet by linking to it. These fields allow you to reference the
123 ///parent tweet without having to make another call to `show`.
124 ///
125 ///* `retweeted_status`
126 ///* `quoted_status`/`quoted_status_id`
127 ///
128 ///## Media
129 ///
130 ///As a tweet can attach an image, GIF, or video, these fields allow you to access information
131 ///about the attached media. Note that polls are not surfaced to the Public API at the time of this
132 ///writing (2016-09-01). For more information about how to use attached media, see the
133 ///documentation for [`MediaEntity`][].
134 ///
135 ///[`MediaEntity`]: ../entities/struct.MediaEntity.html
136 ///
137 ///* `entities` (note that this also contains information about hyperlinks, user mentions, and
138 /// hashtags in addition to a picture/thumbnail)
139 ///* `extended_entities`: This field is only present for tweets with attached media, and houses
140 /// more complete media information, in the case of a photo set, video, or GIF. For videos and
141 /// GIFs, note that `entities` will only contain a thumbnail, and the actual video links will be
142 /// in this field. For tweets with more than one photo attached, `entities` will only contain the
143 /// first photo, and this field will contain all of them.
144 ///* `possibly_sensitive`
145 ///* `withheld_copyright`
146 ///* `withheld_in_countries`
147 ///* `withheld_scope`
148 #[derive(Debug, Clone)]
149 pub struct Tweet {
150 //If the user has contributors enabled, this will show which accounts contributed to this
151 //tweet.
152 //pub contributors: Option<Contributors>,
153 ///If present, the location coordinate attached to the tweet, as a (latitude, longitude) pair.
154 pub coordinates: Option<(f64, f64)>,
155 ///UTC timestamp from when the tweet was posted.
156 #[serde(with = "serde_datetime")]
157 pub created_at: chrono::DateTime<chrono::Utc>,
158 ///If the authenticated user has retweeted this tweet, contains the ID of the retweet.
159 pub current_user_retweet: Option<u64>,
160 ///If this tweet is an extended tweet with "hidden" metadata and entities, contains the byte
161 ///offsets between which the "displayable" tweet text is.
162 pub display_text_range: Option<(usize, usize)>,
163 ///Link, hashtag, and user mention information extracted from the tweet text.
164 pub entities: TweetEntities,
165 ///Extended media information attached to the tweet, if media is available.
166 ///
167 ///If a tweet has a photo, set of photos, gif, or video attached to it, this field will be
168 ///present and contain the real media information. The information available in the `media`
169 ///field of `entities` will only contain the first photo of a set, or a thumbnail of a gif or
170 ///video.
171 pub extended_entities: Option<ExtendedTweetEntities>,
172 ///"Approximately" how many times this tweet has been liked by users.
173 pub favorite_count: i32,
174 ///Indicates whether the authenticated user has liked this tweet.
175 pub favorited: Option<bool>,
176 ///Indicates the maximum `FilterLevel` parameter that can be applied to a stream and still show
177 ///this tweet.
178 pub filter_level: Option<FilterLevel>,
179 ///Numeric ID for this tweet.
180 pub id: u64,
181 ///If the tweet is a reply, contains the ID of the user that was replied to.
182 pub in_reply_to_user_id: Option<u64>,
183 ///If the tweet is a reply, contains the screen name of the user that was replied to.
184 pub in_reply_to_screen_name: Option<String>,
185 ///If the tweet is a reply, contains the ID of the tweet that was replied to.
186 pub in_reply_to_status_id: Option<u64>,
187 ///Can contain a language ID indicating the machine-detected language of the text, or "und" if
188 ///no language could be detected.
189 pub lang: Option<String>,
190 ///When present, the `Place` that this tweet is associated with (but not necessarily where it
191 ///originated from).
192 pub place: Option<place::Place>,
193 ///If the tweet has a link, indicates whether the link may contain content that could be
194 ///identified as sensitive.
195 pub possibly_sensitive: Option<bool>,
196 ///If this tweet is quoting another by link, contains the ID of the quoted tweet.
197 pub quoted_status_id: Option<u64>,
198 ///If this tweet is quoting another by link, contains the quoted tweet.
199 pub quoted_status: Option<Box<Tweet>>,
200 //"A set of key-value pairs indicating the intended contextual delivery of the containing
201 //Tweet. Currently used by Twitter’s Promoted Products."
202 //pub scopes: Option<Scopes>,
203 ///The number of times this tweet has been retweeted (with native retweets).
204 pub retweet_count: i32,
205 ///Indicates whether the authenticated user has retweeted this tweet.
206 pub retweeted: Option<bool>,
207 ///If this tweet is a retweet, then this field contains the original status information.
208 ///
209 ///The separation between retweet and original is so that retweets can be recalled by deleting
210 ///the retweet, and so that liking a retweet results in an additional notification to the user
211 ///who retweeted the status, as well as the original poster.
212 pub retweeted_status: Option<Box<Tweet>>,
213 ///The application used to post the tweet.
214 pub source: Option<TweetSource>,
215 ///The text of the tweet. For "extended" tweets, opening reply mentions and/or attached media
216 ///or quoted tweet links do not count against character count, so this could be longer than 280
217 ///characters in those situations.
218 pub text: String,
219 ///Indicates whether this tweet is a truncated "compatibility" form of an extended tweet whose
220 ///full text is longer than 280 characters.
221 pub truncated: bool,
222 ///The user who posted this tweet. This field will be absent on tweets included as part of a
223 ///`TwitterUser`.
224 pub user: Option<Box<user::TwitterUser>>,
225 ///If present and `true`, indicates that this tweet has been withheld due to a DMCA complaint.
226 pub withheld_copyright: bool,
227 ///If present, contains two-letter country codes indicating where this tweet is being withheld.
228 ///
229 ///The following special codes exist:
230 ///
231 ///- `XX`: Withheld in all countries
232 ///- `XY`: Withheld due to DMCA complaint.
233 pub withheld_in_countries: Option<Vec<String>>,
234 ///If present, indicates whether the content being withheld is the `status` or the `user`.
235 pub withheld_scope: Option<String>,
236 }
237}
238
239impl TryFrom<raw::RawTweet> for Tweet {
240 type Error = error::Error;
241
242 fn try_from(mut raw: raw::RawTweet) -> Result<Tweet> {
243 let extended_full_text = raw.extended_tweet.map(|xt| xt.full_text);
244 let text = raw
245 .full_text
246 .or(extended_full_text)
247 .or(raw.text)
248 .ok_or(error::Error::MissingValue("text"))?;
249 let current_user_retweet = raw.current_user_retweet.map(|cur| cur.id);
250
251 if let Some(ref mut range) = raw.display_text_range {
252 codepoints_to_bytes(range, &text);
253 }
254 for entity in &mut raw.entities.hashtags {
255 codepoints_to_bytes(&mut entity.range, &text);
256 }
257 for entity in &mut raw.entities.symbols {
258 codepoints_to_bytes(&mut entity.range, &text);
259 }
260 for entity in &mut raw.entities.urls {
261 codepoints_to_bytes(&mut entity.range, &text);
262 }
263 for entity in &mut raw.entities.user_mentions {
264 codepoints_to_bytes(&mut entity.range, &text);
265 }
266 if let Some(ref mut media) = raw.entities.media {
267 for entity in media.iter_mut() {
268 codepoints_to_bytes(&mut entity.range, &text);
269 }
270 }
271 if let Some(ref mut entities) = raw.extended_entities {
272 for entity in entities.media.iter_mut() {
273 codepoints_to_bytes(&mut entity.range, &text);
274 }
275 }
276
277 Ok(Tweet {
278 coordinates: raw.coordinates.map(|coords| coords.coordinates),
279 created_at: raw.created_at,
280 display_text_range: raw.display_text_range,
281 entities: raw.entities,
282 extended_entities: raw.extended_entities,
283 favorite_count: raw.favorite_count,
284 favorited: raw.favorited,
285 filter_level: raw.filter_level,
286 id: raw.id,
287 in_reply_to_user_id: raw.in_reply_to_user_id,
288 in_reply_to_screen_name: raw.in_reply_to_screen_name,
289 in_reply_to_status_id: raw.in_reply_to_status_id,
290 lang: raw.lang,
291 place: raw.place,
292 possibly_sensitive: raw.possibly_sensitive,
293 quoted_status_id: raw.quoted_status_id,
294 quoted_status: raw.quoted_status,
295 retweet_count: raw.retweet_count,
296 retweeted: raw.retweeted,
297 retweeted_status: raw.retweeted_status,
298 source: raw.source,
299 truncated: raw.truncated,
300 user: raw.user,
301 withheld_copyright: raw.withheld_copyright,
302 withheld_in_countries: raw.withheld_in_countries,
303 withheld_scope: raw.withheld_scope,
304 text,
305 current_user_retweet,
306 })
307 }
308}
309
310///Represents the app from which a specific tweet was posted.
311///
312///This struct is parsed out of the HTML anchor tag that Twitter returns as part of each tweet.
313///This way you can format the source link however you like without having to parse the values out
314///yourself.
315///
316///Note that if you're going to reconstruct a link from this, the source URL has `rel="nofollow"`
317///in the anchor tag.
318#[derive(Debug, Clone, Deserialize, Serialize)]
319pub struct TweetSource {
320 ///The name of the app, given by its developer.
321 pub name: String,
322 ///The URL for the app, given by its developer.
323 pub url: String,
324}
325
326impl FromStr for TweetSource {
327 type Err = error::Error;
328
329 fn from_str(full: &str) -> Result<TweetSource> {
330 use lazy_static::lazy_static;
331 lazy_static! {
332 static ref RE_URL: Regex = Regex::new("href=\"(.*?)\"").unwrap();
333 static ref RE_NAME: Regex = Regex::new(">(.*)</a>").unwrap();
334 }
335
336 if full == "web" {
337 return Ok(TweetSource {
338 name: "Twitter Web Client".to_string(),
339 url: "https://twitter.com".to_string(),
340 });
341 }
342
343 let url = RE_URL
344 .captures(full)
345 .and_then(|cap| cap.get(1))
346 .map(|m| m.as_str().to_string())
347 .ok_or_else(|| {
348 InvalidResponse("TweetSource had no link href", Some(full.to_string()))
349 })?;
350
351 let name = RE_NAME
352 .captures(full)
353 .and_then(|cap| cap.get(1))
354 .map(|m| m.as_str().to_string())
355 .ok_or_else(|| {
356 InvalidResponse("TweetSource had no link text", Some(full.to_string()))
357 })?;
358
359 Ok(TweetSource { name, url })
360 }
361}
362
363fn deserialize_tweet_source<'de, D>(ser: D) -> std::result::Result<Option<TweetSource>, D::Error>
364where
365 D: Deserializer<'de>,
366{
367 let s = String::deserialize(ser)?;
368 Ok(TweetSource::from_str(&s).ok())
369}
370
371///Container for URL, hashtag, mention, and media information associated with a tweet.
372///
373///If a tweet has no hashtags, financial symbols ("cashtags"), links, or mentions, those respective
374///Vecs will be empty. If there is no media attached to the tweet, that field will be `None`.
375///
376///Note that for media attached to a tweet, this struct will only contain the first image of a
377///photo set, or a thumbnail of a video or GIF. Full media information is available in the tweet's
378///`extended_entities` field.
379#[derive(Debug, Clone, Deserialize, Serialize)]
380pub struct TweetEntities {
381 ///Collection of hashtags parsed from the tweet.
382 pub hashtags: Vec<entities::HashtagEntity>,
383 ///Collection of financial symbols, or "cashtags", parsed from the tweet.
384 pub symbols: Vec<entities::HashtagEntity>,
385 ///Collection of URLs parsed from the tweet.
386 pub urls: Vec<entities::UrlEntity>,
387 ///Collection of user mentions parsed from the tweet.
388 pub user_mentions: Vec<entities::MentionEntity>,
389 ///If the tweet contains any attached media, this contains a collection of media information
390 ///from the tweet.
391 pub media: Option<Vec<entities::MediaEntity>>,
392}
393
394///Container for extended media information for a tweet.
395///
396///If a tweet has a photo, set of photos, gif, or video attached to it, this field will be present
397///and contain the real media information. The information available in the `media` field of
398///`entities` will only contain the first photo of a set, or a thumbnail of a gif or video.
399#[derive(Debug, Clone, Deserialize, Serialize)]
400pub struct ExtendedTweetEntities {
401 ///Collection of extended media information attached to the tweet.
402 pub media: Vec<entities::MediaEntity>,
403}
404
405/// Helper struct to navigate collections of tweets by requesting tweets older or newer than certain
406/// IDs.
407///
408/// Using a Timeline to navigate collections of tweets (like a user's timeline, their list of likes,
409/// etc) allows you to efficiently cursor through a collection and only load in tweets you need.
410///
411/// To begin, call a method that returns a `Timeline`, optionally set the page size, and call
412/// `start` to load the first page of results:
413///
414/// ```rust,no_run
415/// # use egg_mode::Token;
416/// # #[tokio::main]
417/// # async fn main() {
418/// # let token: Token = unimplemented!();
419/// let timeline = egg_mode::tweet::home_timeline(&token).with_page_size(10);
420///
421/// let (timeline, feed) = timeline.start().await.unwrap();
422/// for tweet in &*feed {
423/// println!("<@{}> {}", tweet.user.as_ref().unwrap().screen_name, tweet.text);
424/// }
425/// # }
426/// ```
427///
428/// If you need to load the next set of tweets, call `older`, which will automatically update the
429/// tweet IDs it tracks:
430///
431/// ```rust,no_run
432/// # use egg_mode::Token;
433/// # #[tokio::main]
434/// # async fn main() {
435/// # let token: Token = unimplemented!();
436/// # let timeline = egg_mode::tweet::home_timeline(&token);
437/// # let (timeline, _) = timeline.start().await.unwrap();
438/// let (timeline, feed) = timeline.older(None).await.unwrap();
439/// for tweet in &*feed {
440/// println!("<@{}> {}", tweet.user.as_ref().unwrap().screen_name, tweet.text);
441/// }
442/// # }
443/// ```
444///
445/// ...and similarly for `newer`, which operates in a similar fashion.
446///
447/// If you want to start afresh and reload the newest set of tweets again, you can call `start`
448/// again, which will clear the tracked tweet IDs before loading the newest set of tweets. However,
449/// if you've been storing these tweets as you go, and already know the newest tweet ID you have on
450/// hand, you can load only those tweets you need like this:
451///
452/// ```rust,no_run
453/// # use egg_mode::Token;
454/// # #[tokio::main]
455/// # async fn main() {
456/// # let token: Token = unimplemented!();
457/// let timeline = egg_mode::tweet::home_timeline(&token)
458/// .with_page_size(10);
459///
460/// let (timeline, _feed) = timeline.start().await.unwrap();
461///
462/// //keep the max_id for later
463/// let reload_id = timeline.max_id.unwrap();
464///
465/// //simulate scrolling down a little bit
466/// let (timeline, _feed) = timeline.older(None).await.unwrap();
467/// let (mut timeline, _feed) = timeline.older(None).await.unwrap();
468///
469/// //reload the timeline with only what's new
470/// timeline.reset();
471/// let (timeline, _new_posts) = timeline.older(Some(reload_id)).await.unwrap();
472/// # }
473/// ```
474///
475/// Here, the argument to `older` means "older than what I just returned, but newer than the given
476/// ID". Since we cleared the tracked IDs with `reset`, that turns into "the newest tweets
477/// available that were posted after the given ID". The earlier invocations of `older` with `None`
478/// do not place a bound on the tweets it loads. `newer` operates in a similar fashion with its
479/// argument, saying "newer than what I just returned, but not newer than this given ID". When
480/// called like this, it's possible for these methods to return nothing, which will also clear the
481/// `Timeline`'s tracked IDs.
482///
483/// If you want to manually pull tweets between certain IDs, the baseline `call` function can do
484/// that for you. Keep in mind, though, that `call` doesn't update the `min_id` or `max_id` fields,
485/// so you'll have to set those yourself if you want to follow up with `older` or `newer`.
486pub struct Timeline {
487 ///The URL to request tweets from.
488 link: &'static str,
489 ///The token to authorize requests with.
490 token: auth::Token,
491 ///Optional set of params to include prior to adding timeline navigation parameters.
492 params_base: Option<ParamList>,
493 ///The maximum number of tweets to return in a single call. Twitter doesn't guarantee returning
494 ///exactly this number, as suspended or deleted content is removed after retrieving the initial
495 ///collection of tweets.
496 pub count: i32,
497 ///The largest/most recent tweet ID returned in the last call to `start`, `older`, or `newer`.
498 pub max_id: Option<u64>,
499 ///The smallest/oldest tweet ID returned in the last call to `start`, `older`, or `newer`.
500 pub min_id: Option<u64>,
501}
502
503impl Timeline {
504 ///Clear the saved IDs on this timeline.
505 pub fn reset(&mut self) {
506 self.max_id = None;
507 self.min_id = None;
508 }
509
510 ///Clear the saved IDs on this timeline, and return the most recent set of tweets.
511 pub fn start(mut self) -> TimelineFuture {
512 self.reset();
513
514 self.older(None)
515 }
516
517 ///Return the set of tweets older than the last set pulled, optionally placing a minimum tweet
518 ///ID to bound with.
519 pub fn older(self, since_id: Option<u64>) -> TimelineFuture {
520 let req = self.request(since_id, self.min_id.map(|id| id - 1));
521 let loader = Box::pin(request_with_json_response(req));
522
523 TimelineFuture {
524 timeline: Some(self),
525 loader,
526 }
527 }
528
529 ///Return the set of tweets newer than the last set pulled, optionall placing a maximum tweet
530 ///ID to bound with.
531 pub fn newer(self, max_id: Option<u64>) -> TimelineFuture {
532 let req = self.request(self.max_id, max_id);
533 let loader = Box::pin(request_with_json_response(req));
534
535 TimelineFuture {
536 timeline: Some(self),
537 loader,
538 }
539 }
540
541 ///Return the set of tweets between the IDs given.
542 ///
543 ///Note that the range is not fully inclusive; the tweet ID given by `since_id` will not be
544 ///returned, but the tweet ID in `max_id` will be returned.
545 ///
546 ///If the range of tweets given by the IDs would return more than `self.count`, the newest set
547 ///of tweets will be returned.
548 pub async fn call(
549 &self,
550 since_id: Option<u64>,
551 max_id: Option<u64>,
552 ) -> Result<Response<Vec<Tweet>>> {
553 request_with_json_response(self.request(since_id, max_id)).await
554 }
555
556 ///Helper function to construct a `Request` from the current state.
557 fn request(&self, since_id: Option<u64>, max_id: Option<u64>) -> Request<Body> {
558 let params = self
559 .params_base
560 .as_ref()
561 .cloned()
562 .unwrap_or_default()
563 .add_param("count", self.count.to_string())
564 .add_param("tweet_mode", "extended")
565 .add_param("include_ext_alt_text", "true")
566 .add_opt_param("since_id", since_id.map(|v| v.to_string()))
567 .add_opt_param("max_id", max_id.map(|v| v.to_string()));
568
569 get(self.link, &self.token, Some(¶ms))
570 }
571
572 ///Helper builder function to set the page size.
573 pub fn with_page_size(self, page_size: i32) -> Self {
574 Timeline {
575 count: page_size,
576 ..self
577 }
578 }
579
580 ///With the returned slice of Tweets, set the min_id and max_id on self.
581 fn map_ids(&mut self, resp: &[Tweet]) {
582 self.max_id = resp.first().map(|status| status.id);
583 self.min_id = resp.last().map(|status| status.id);
584 }
585
586 ///Create an instance of `Timeline` with the given link and tokens.
587 pub(crate) fn new(
588 link: &'static str,
589 params_base: Option<ParamList>,
590 token: &auth::Token,
591 ) -> Self {
592 Timeline {
593 link,
594 token: token.clone(),
595 params_base,
596 count: 20,
597 max_id: None,
598 min_id: None,
599 }
600 }
601}
602
603/// `Future` which represents loading from a `Timeline`.
604///
605/// When this future completes, it will either return the tweets given by Twitter (after having
606/// updated the IDs in the parent `Timeline`) or the error encountered when loading or parsing the
607/// response.
608#[must_use = "futures do nothing unless polled"]
609pub struct TimelineFuture {
610 timeline: Option<Timeline>,
611 loader: FutureResponse<Vec<Tweet>>,
612}
613
614impl Future for TimelineFuture {
615 type Output = Result<(Timeline, Response<Vec<Tweet>>)>;
616
617 fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
618 match Pin::new(&mut self.loader).poll(cx) {
619 Poll::Pending => Poll::Pending,
620 Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
621 Poll::Ready(Ok(resp)) => {
622 if let Some(mut timeline) = self.timeline.take() {
623 timeline.map_ids(&resp.response);
624 Poll::Ready(Ok((timeline, resp)))
625 } else {
626 Poll::Ready(Err(error::Error::FutureAlreadyCompleted))
627 }
628 }
629 }
630 }
631}
632
633/// Represents an in-progress tweet before it is sent.
634///
635/// This is your entry point to posting new tweets to Twitter. To begin, make a new `DraftTweet` by
636/// calling `new` with your desired status text:
637///
638/// ```rust,no_run
639/// use egg_mode::tweet::DraftTweet;
640///
641/// let draft = DraftTweet::new("This is an example status!");
642/// ```
643///
644/// As-is, the draft won't do anything until you call `send` to post it:
645///
646/// ```rust,no_run
647/// # use egg_mode::Token;
648/// # #[tokio::main]
649/// # async fn main() {
650/// # let token: Token = unimplemented!();
651/// # use egg_mode::tweet::DraftTweet;
652/// # let draft = DraftTweet::new("This is an example status!");
653///
654/// draft.send(&token).await.unwrap();
655/// # }
656/// ```
657///
658/// Right now, the options for adding metadata to a post are pretty sparse. See the adaptor
659/// functions below to see what metadata can be set. For example, you can use `in_reply_to` to
660/// create a reply-chain like this:
661///
662/// ```rust,no_run
663/// # use egg_mode::Token;
664/// # #[tokio::main]
665/// # async fn main() {
666/// # let token: Token = unimplemented!();
667/// use egg_mode::tweet::DraftTweet;
668///
669/// let draft = DraftTweet::new("I'd like to start a thread here.");
670/// let tweet = draft.send(&token).await.unwrap();
671///
672/// let draft = DraftTweet::new("You see, I have a lot of things to say.")
673/// .in_reply_to(tweet.id);
674/// let tweet = draft.send(&token).await.unwrap();
675///
676/// let draft = DraftTweet::new("Thank you for your time.")
677/// .in_reply_to(tweet.id);
678/// let tweet = draft.send(&token).await.unwrap();
679/// # }
680/// ```
681#[derive(Debug, Clone)]
682pub struct DraftTweet {
683 ///The text of the draft tweet.
684 pub text: Cow<'static, str>,
685 ///If present, the ID of the tweet this draft is replying to.
686 pub in_reply_to: Option<u64>,
687 ///If present, whether to automatically fill reply mentions from the metadata of the
688 ///`in_reply_to` tweet.
689 pub auto_populate_reply_metadata: Option<bool>,
690 ///If present, the list of user IDs to exclude from the automatically-populated metadata pulled
691 ///when `auto_populate_reply_metadata` is true.
692 pub exclude_reply_user_ids: Option<Cow<'static, [u64]>>,
693 ///If present, the tweet link to quote or a [DM deep link][] to include in the tweet's
694 ///attachment metadata.
695 ///
696 ///Note that if this link is not a tweet link or a [DM deep link][], Twitter will return an
697 ///error when the draft is sent.
698 ///
699 ///[DM deep link]: https://business.twitter.com/en/help/campaign-editing-and-optimization/public-to-private-conversation.html
700 pub attachment_url: Option<CowStr>,
701 ///If present, the latitude/longitude coordinates to attach to the draft.
702 pub coordinates: Option<(f64, f64)>,
703 ///If present (and if `coordinates` is present), indicates whether to display a pin on the
704 ///exact coordinate when the eventual tweet is displayed.
705 pub display_coordinates: Option<bool>,
706 ///If present the Place to attach to this draft.
707 pub place_id: Option<CowStr>,
708 ///List of media entities associated with tweet.
709 ///
710 ///A tweet can have one video, one GIF, or up to four images attached to it. When attaching
711 ///them to a tweet, they're represented by a media ID, given through the upload process. (See
712 ///[the `media` module] for more information on how to upload media.)
713 ///
714 ///[the `media` module]: ../media/index.html
715 ///
716 ///`DraftTweet` treats zeros in this array as if the media were not present.
717 pub media_ids: Vec<media::MediaId>,
718 ///States whether the media attached with `media_ids` should be labeled as "possibly
719 ///sensitive", to mask the media by default.
720 pub possibly_sensitive: Option<bool>,
721}
722
723impl DraftTweet {
724 ///Creates a new `DraftTweet` with the given status text.
725 pub fn new<S: Into<Cow<'static, str>>>(text: S) -> Self {
726 DraftTweet {
727 text: text.into(),
728 in_reply_to: None,
729 auto_populate_reply_metadata: None,
730 exclude_reply_user_ids: None,
731 attachment_url: None,
732 coordinates: None,
733 display_coordinates: None,
734 place_id: None,
735 media_ids: Vec::new(),
736 possibly_sensitive: None,
737 }
738 }
739
740 ///Marks this draft tweet as replying to the given status ID.
741 ///
742 ///Note that this will only properly take effect if the user who posted the given status is
743 ///@mentioned in the status text, or if the given status was posted by the authenticated user.
744 pub fn in_reply_to(self, in_reply_to: u64) -> Self {
745 DraftTweet {
746 in_reply_to: Some(in_reply_to),
747 ..self
748 }
749 }
750
751 ///Tells Twitter whether or not to automatically fill reply mentions from the tweet linked in
752 ///`in_reply_to`.
753 ///
754 ///This parameter will have no effect if `in_reply_to` is absent.
755 ///
756 ///If you set this to true, you can strip out the mentions from the beginning of the tweet text
757 ///if they were also in the reply mentions of the parent tweet. To remove handles from the list
758 ///of reply mentions, hand their user IDs to `exclude_reply_user_ids`.
759 pub fn auto_populate_reply_metadata(self, auto_populate: bool) -> Self {
760 DraftTweet {
761 auto_populate_reply_metadata: Some(auto_populate),
762 ..self
763 }
764 }
765
766 ///Tells Twitter to exclude the given list of user IDs from the automatically-populated reply
767 ///mentions.
768 ///
769 ///This parameter will have no effect if `auto_populate_reply_metadata` is absent or false.
770 ///
771 ///Note that you cannot use this parameter to remove the author of the parent tweet from the
772 ///reply list. Twitter will silently ignore the author's ID in that scenario.
773 pub fn exclude_reply_user_ids<V: Into<Cow<'static, [u64]>>>(self, user_ids: V) -> Self {
774 DraftTweet {
775 exclude_reply_user_ids: Some(user_ids.into()),
776 ..self
777 }
778 }
779
780 ///Attaches the given tweet URL or [DM deep link][] to the tweet draft, which lets it be used
781 ///outside the 280 character text limit.
782 ///
783 ///Note that if this link is not a tweet URL or a DM deep link, then Twitter will return an
784 ///error when this draft is sent.
785 ///
786 ///[DM deep link]: https://business.twitter.com/en/help/campaign-editing-and-optimization/public-to-private-conversation.html
787 pub fn attachment_url<S: Into<Cow<'static, str>>>(self, url: S) -> Self {
788 DraftTweet {
789 attachment_url: Some(url.into()),
790 ..self
791 }
792 }
793
794 ///Attach a lat/lon coordinate to this tweet, and mark whether a pin should be placed on the
795 ///exact coordinate when the tweet is displayed.
796 ///
797 ///If coordinates are given through this method and no `place_id` is attached, Twitter will
798 ///effectively call `place::reverse_geocode` with the given coordinate and attach that Place to
799 ///the eventual tweet.
800 ///
801 ///Location fields will be ignored unless the user has enabled geolocation from their profile.
802 pub fn coordinates(self, latitude: f64, longitude: f64, display: bool) -> Self {
803 DraftTweet {
804 coordinates: Some((latitude, longitude)),
805 display_coordinates: Some(display),
806 ..self
807 }
808 }
809
810 ///Attach a Place to this tweet. This field will take precedence over `coordinates` in terms of
811 ///what location is displayed with the tweet.
812 ///
813 ///Location fields will be ignored unless the user has enabled geolocation from their profile.
814 pub fn place_id<S: Into<CowStr>>(self, place_id: S) -> Self {
815 DraftTweet {
816 place_id: Some(place_id.into()),
817 ..self
818 }
819 }
820
821 ///Attaches the given media ID(s) to this tweet. If more than four IDs are in this slice, only
822 ///the first four will be attached. Note that Twitter will only allow one GIF, one video, or up
823 ///to four images to be attached to a single tweet.
824 ///
825 /// Note that if this is called multiple times, only the last four IDs will be kept.
826 pub fn add_media(&mut self, media_id: media::MediaId) {
827 if self.media_ids.len() == 4 {
828 self.media_ids.remove(0);
829 }
830 self.media_ids.push(media_id);
831 }
832
833 ///Marks the media attached with `media_ids` as being sensitive, so it can be hidden by
834 ///default.
835 pub fn possibly_sensitive(self, sensitive: bool) -> Self {
836 DraftTweet {
837 possibly_sensitive: Some(sensitive),
838 ..self
839 }
840 }
841
842 ///Send the assembled tweet as the authenticated user.
843 pub async fn send(&self, token: &auth::Token) -> Result<Response<Tweet>> {
844 let mut params = ParamList::new()
845 .add_param("status", self.text.clone())
846 .add_opt_param("in_reply_to_status_id", self.in_reply_to.map_string())
847 .add_opt_param(
848 "auto_populate_reply_metadata",
849 self.auto_populate_reply_metadata.map_string(),
850 )
851 .add_opt_param("attachment_url", self.attachment_url.as_ref().cloned())
852 .add_opt_param("display_coordinates", self.display_coordinates.map_string())
853 .add_opt_param("place_id", self.place_id.as_ref().cloned())
854 .add_opt_param("possible_sensitive", self.possibly_sensitive.map_string());
855
856 if let Some(ref exclude) = self.exclude_reply_user_ids {
857 let list = exclude
858 .iter()
859 .map(|id| id.to_string())
860 .collect::<Vec<_>>()
861 .join(",");
862 params.add_param_ref("exclude_reply_user_ids", list);
863 }
864
865 if let Some((lat, long)) = self.coordinates {
866 params.add_param_ref("lat", lat.to_string());
867 params.add_param_ref("long", long.to_string());
868 }
869
870 let media = {
871 let media = self
872 .media_ids
873 .iter()
874 .map(|x| x.0.as_str())
875 .collect::<Vec<_>>();
876 media.join(",")
877 };
878
879 if !media.is_empty() {
880 params.add_param_ref("media_ids", media);
881 }
882
883 let req = post(links::statuses::UPDATE, token, Some(¶ms));
884 request_with_json_response(req).await
885 }
886}
887
888#[cfg(test)]
889mod tests {
890 use super::Tweet;
891 use crate::common::tests::load_file;
892
893 use chrono::{Datelike, Timelike, Weekday};
894
895 fn load_tweet(path: &str) -> Tweet {
896 let sample = load_file(path);
897 ::serde_json::from_str(&sample).unwrap()
898 }
899
900 #[test]
901 fn parse_basic() {
902 let sample = load_tweet("sample_payloads/sample-extended-onepic.json");
903
904 assert_eq!(sample.text,
905 ".@Serrayak said he’d use what-ev-er I came up with as his Halloween avatar so I’m just making sure you all know he said that https://t.co/MvgxCwDwSa");
906 assert!(sample.user.is_some());
907 assert_eq!(sample.user.unwrap().screen_name, "0xabad1dea");
908 assert_eq!(sample.id, 782349500404862976);
909 let source = sample.source.as_ref().unwrap();
910 assert_eq!(source.name, "Tweetbot for iΟS"); //note that's an omicron, not an O
911 assert_eq!(source.url, "http://tapbots.com/tweetbot");
912 assert_eq!(sample.created_at.weekday(), Weekday::Sat);
913 assert_eq!(sample.created_at.year(), 2016);
914 assert_eq!(sample.created_at.month(), 10);
915 assert_eq!(sample.created_at.day(), 1);
916 assert_eq!(sample.created_at.hour(), 22);
917 assert_eq!(sample.created_at.minute(), 40);
918 assert_eq!(sample.created_at.second(), 30);
919 assert_eq!(sample.favorite_count, 20);
920 assert_eq!(sample.retweet_count, 0);
921 assert_eq!(sample.lang, Some("en".into()));
922 assert_eq!(sample.coordinates, None);
923 assert!(sample.place.is_none());
924
925 assert_eq!(sample.favorited, Some(false));
926 assert_eq!(sample.retweeted, Some(false));
927 assert!(sample.current_user_retweet.is_none());
928
929 assert!(sample
930 .entities
931 .user_mentions
932 .iter()
933 .any(|m| m.screen_name == "Serrayak"));
934 assert!(sample.extended_entities.is_some());
935 assert_eq!(sample.extended_entities.unwrap().media.len(), 1);
936
937 //text contains extended link, which is outside of display_text_range
938 let range = sample.display_text_range.unwrap();
939 assert_eq!(&sample.text[range.0..range.1],
940 ".@Serrayak said he’d use what-ev-er I came up with as his Halloween avatar so I’m just making sure you all know he said that"
941 );
942 assert_eq!(sample.truncated, false);
943 }
944
945 #[test]
946 fn parse_samples() {
947 // Just check we can parse them without error, taken from
948 // https://github.com/twitterdev/tweet-updates/tree/686982b586dcc87d669151e89532ffea7e29e0d8/samples/initial
949 load_tweet("sample_payloads/compatibilityplus_classic_13994.json");
950 load_tweet("sample_payloads/compatibilityplus_classic_hidden_13797.json");
951 load_tweet("sample_payloads/compatibilityplus_extended_13997.json");
952 load_tweet("sample_payloads/extended_classic_14002.json");
953 load_tweet("sample_payloads/extended_classic_hidden_13761.json");
954 load_tweet("sample_payloads/extended_extended_14001.json");
955 load_tweet("sample_payloads/nullable_user_mention.json");
956 }
957
958 #[test]
959 fn parse_reply() {
960 let sample = load_tweet("sample_payloads/sample-reply.json");
961
962 assert_eq!(
963 sample.in_reply_to_screen_name,
964 Some("QuietMisdreavus".to_string())
965 );
966 assert_eq!(sample.in_reply_to_user_id, Some(2977334326));
967 assert_eq!(sample.in_reply_to_status_id, Some(782643731665080322));
968 }
969
970 #[test]
971 fn parse_quote() {
972 let sample = load_tweet("sample_payloads/sample-quote.json");
973
974 assert_eq!(sample.quoted_status_id, Some(783004145485840384));
975 assert!(sample.quoted_status.is_some());
976 assert_eq!(sample.quoted_status.unwrap().text,
977 "@chalkboardsband hot damn i should call up my friends in austin, i might actually be able to make one of these now :D");
978 }
979
980 #[test]
981 fn parse_retweet() {
982 let sample = load_tweet("sample_payloads/sample-retweet.json");
983
984 assert!(sample.retweeted_status.is_some());
985 assert_eq!(sample.retweeted_status.unwrap().text,
986 "it's working: follow @andrewhuangbot for a random lyric of mine every hour. we'll call this version 0.1.0. wanna get line breaks in there");
987 }
988
989 #[test]
990 fn parse_image_alt_text() {
991 let sample = load_tweet("sample_payloads/sample-image-alt-text.json");
992 let extended_entities = sample.extended_entities.unwrap();
993
994 assert_eq!(
995 extended_entities.media[0].ext_alt_text,
996 Some("test alt text for the image".to_string())
997 );
998 }
999
1000 #[test]
1001 fn roundtrip_deser() {
1002 let sample = load_file("sample_payloads/tweet_array.json");
1003 let tweets_src: Vec<Tweet> = serde_json::from_str(&sample).unwrap();
1004 let json1 = serde_json::to_value(tweets_src).unwrap();
1005 let tweets_roundtrip: Vec<Tweet> = serde_json::from_value(json1.clone()).unwrap();
1006 let json2 = serde_json::to_value(tweets_roundtrip).unwrap();
1007
1008 assert_eq!(json1, json2);
1009 }
1010}