use std::borrow::Cow;
use std::future::Future;
use std::pin::Pin;
use std::str::FromStr;
use std::task::{Context, Poll};
use chrono;
use hyper::{Body, Request};
use regex::Regex;
use serde::de::Error;
use serde::{Deserialize, Deserializer};
use crate::common::*;
use crate::error::{Error::InvalidResponse, Result};
use crate::stream::FilterLevel;
use crate::{auth, entities, error, links, media, place, user};
mod fun;
mod raw;
pub use self::fun::*;
#[derive(Debug, Clone)]
pub struct Tweet {
pub coordinates: Option<(f64, f64)>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub current_user_retweet: Option<u64>,
pub display_text_range: Option<(usize, usize)>,
pub entities: TweetEntities,
pub extended_entities: Option<ExtendedTweetEntities>,
pub favorite_count: i32,
pub favorited: Option<bool>,
pub filter_level: Option<FilterLevel>,
pub id: u64,
pub in_reply_to_user_id: Option<u64>,
pub in_reply_to_screen_name: Option<String>,
pub in_reply_to_status_id: Option<u64>,
pub lang: Option<String>,
pub place: Option<place::Place>,
pub possibly_sensitive: Option<bool>,
pub quoted_status_id: Option<u64>,
pub quoted_status: Option<Box<Tweet>>,
pub retweet_count: i32,
pub retweeted: Option<bool>,
pub retweeted_status: Option<Box<Tweet>>,
pub source: Option<TweetSource>,
pub text: String,
pub truncated: bool,
pub user: Option<Box<user::TwitterUser>>,
pub withheld_copyright: bool,
pub withheld_in_countries: Option<Vec<String>>,
pub withheld_scope: Option<String>,
}
impl<'de> Deserialize<'de> for Tweet {
fn deserialize<D>(deser: D) -> std::result::Result<Tweet, D::Error>
where
D: Deserializer<'de>,
{
let mut raw = raw::RawTweet::deserialize(deser)?;
let text = raw
.full_text
.or(raw.extended_tweet.map(|xt| xt.full_text))
.or(raw.text)
.ok_or_else(|| D::Error::custom("Tweet missing text field"))?;
let current_user_retweet = raw.current_user_retweet.map(|cur| cur.id);
if let Some(ref mut range) = raw.display_text_range {
codepoints_to_bytes(range, &text);
}
for entity in &mut raw.entities.hashtags {
codepoints_to_bytes(&mut entity.range, &text);
}
for entity in &mut raw.entities.symbols {
codepoints_to_bytes(&mut entity.range, &text);
}
for entity in &mut raw.entities.urls {
codepoints_to_bytes(&mut entity.range, &text);
}
for entity in &mut raw.entities.user_mentions {
codepoints_to_bytes(&mut entity.range, &text);
}
if let Some(ref mut media) = raw.entities.media {
for entity in media.iter_mut() {
codepoints_to_bytes(&mut entity.range, &text);
}
}
if let Some(ref mut entities) = raw.extended_entities {
for entity in entities.media.iter_mut() {
codepoints_to_bytes(&mut entity.range, &text);
}
}
Ok(Tweet {
coordinates: raw.coordinates.map(|coords| coords.coordinates),
created_at: raw.created_at,
display_text_range: raw.display_text_range,
entities: raw.entities,
extended_entities: raw.extended_entities,
favorite_count: raw.favorite_count,
favorited: raw.favorited,
filter_level: raw.filter_level,
id: raw.id,
in_reply_to_user_id: raw.in_reply_to_user_id,
in_reply_to_screen_name: raw.in_reply_to_screen_name,
in_reply_to_status_id: raw.in_reply_to_status_id,
lang: raw.lang,
place: raw.place,
possibly_sensitive: raw.possibly_sensitive,
quoted_status_id: raw.quoted_status_id,
quoted_status: raw.quoted_status,
retweet_count: raw.retweet_count,
retweeted: raw.retweeted,
retweeted_status: raw.retweeted_status,
source: raw.source,
truncated: raw.truncated,
user: raw.user,
withheld_copyright: raw.withheld_copyright,
withheld_in_countries: raw.withheld_in_countries,
withheld_scope: raw.withheld_scope,
text,
current_user_retweet,
})
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct TweetSource {
pub name: String,
pub url: String,
}
impl FromStr for TweetSource {
type Err = error::Error;
fn from_str(full: &str) -> Result<TweetSource> {
use lazy_static::lazy_static;
lazy_static! {
static ref RE_URL: Regex = Regex::new("href=\"(.*?)\"").unwrap();
static ref RE_NAME: Regex = Regex::new(">(.*)</a>").unwrap();
}
if full == "web" {
return Ok(TweetSource {
name: "Twitter Web Client".to_string(),
url: "https://twitter.com".to_string(),
});
}
let url = RE_URL
.captures(full)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())
.ok_or(InvalidResponse(
"TweetSource had no link href",
Some(full.to_string()),
))?;
let name = RE_NAME
.captures(full)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())
.ok_or(InvalidResponse(
"TweetSource had no link text",
Some(full.to_string()),
))?;
Ok(TweetSource { name, url })
}
}
fn deserialize_tweet_source<'de, D>(ser: D) -> std::result::Result<Option<TweetSource>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(ser)?;
Ok(TweetSource::from_str(&s).ok())
}
#[derive(Debug, Clone, Deserialize)]
pub struct TweetEntities {
pub hashtags: Vec<entities::HashtagEntity>,
pub symbols: Vec<entities::HashtagEntity>,
pub urls: Vec<entities::UrlEntity>,
pub user_mentions: Vec<entities::MentionEntity>,
pub media: Option<Vec<entities::MediaEntity>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ExtendedTweetEntities {
pub media: Vec<entities::MediaEntity>,
}
pub struct Timeline {
link: &'static str,
token: auth::Token,
params_base: Option<ParamList>,
pub count: i32,
pub max_id: Option<u64>,
pub min_id: Option<u64>,
}
impl Timeline {
pub fn reset(&mut self) {
self.max_id = None;
self.min_id = None;
}
pub fn start(mut self) -> TimelineFuture {
self.reset();
self.older(None)
}
pub fn older(self, since_id: Option<u64>) -> TimelineFuture {
let req = self.request(since_id, self.min_id.map(|id| id - 1));
let loader = Box::pin(request_with_json_response(req));
TimelineFuture {
timeline: Some(self),
loader: loader,
}
}
pub fn newer(self, max_id: Option<u64>) -> TimelineFuture {
let req = self.request(self.max_id, max_id);
let loader = Box::pin(request_with_json_response(req));
TimelineFuture {
timeline: Some(self),
loader: loader,
}
}
pub async fn call(
&self,
since_id: Option<u64>,
max_id: Option<u64>,
) -> Result<Response<Vec<Tweet>>> {
request_with_json_response(self.request(since_id, max_id)).await
}
fn request(&self, since_id: Option<u64>, max_id: Option<u64>) -> Request<Body> {
let params = ParamList::from(self.params_base.as_ref().cloned().unwrap_or_default())
.add_param("count", self.count.to_string())
.add_param("tweet_mode", "extended")
.add_param("include_ext_alt_text", "true")
.add_opt_param("since_id", since_id.map(|v| v.to_string()))
.add_opt_param("max_id", max_id.map(|v| v.to_string()));
get(self.link, &self.token, Some(¶ms))
}
pub fn with_page_size(self, page_size: i32) -> Self {
Timeline {
count: page_size,
..self
}
}
fn map_ids(&mut self, resp: &[Tweet]) {
self.max_id = resp.first().map(|status| status.id);
self.min_id = resp.last().map(|status| status.id);
}
pub(crate) fn new(
link: &'static str,
params_base: Option<ParamList>,
token: &auth::Token,
) -> Self {
Timeline {
link: link,
token: token.clone(),
params_base: params_base,
count: 20,
max_id: None,
min_id: None,
}
}
}
#[must_use = "futures do nothing unless polled"]
pub struct TimelineFuture {
timeline: Option<Timeline>,
loader: FutureResponse<Vec<Tweet>>,
}
impl Future for TimelineFuture {
type Output = Result<(Timeline, Response<Vec<Tweet>>)>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
match Pin::new(&mut self.loader).poll(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
Poll::Ready(Ok(resp)) => {
if let Some(mut timeline) = self.timeline.take() {
timeline.map_ids(&resp.response);
Poll::Ready(Ok((timeline, resp)))
} else {
Poll::Ready(Err(error::Error::FutureAlreadyCompleted))
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct DraftTweet {
pub text: Cow<'static, str>,
pub in_reply_to: Option<u64>,
pub auto_populate_reply_metadata: Option<bool>,
pub exclude_reply_user_ids: Option<Cow<'static, [u64]>>,
pub attachment_url: Option<CowStr>,
pub coordinates: Option<(f64, f64)>,
pub display_coordinates: Option<bool>,
pub place_id: Option<CowStr>,
pub media_ids: Vec<media::MediaId>,
pub possibly_sensitive: Option<bool>,
}
impl DraftTweet {
pub fn new<S: Into<Cow<'static, str>>>(text: S) -> Self {
DraftTweet {
text: text.into(),
in_reply_to: None,
auto_populate_reply_metadata: None,
exclude_reply_user_ids: None,
attachment_url: None,
coordinates: None,
display_coordinates: None,
place_id: None,
media_ids: Vec::new(),
possibly_sensitive: None,
}
}
pub fn in_reply_to(self, in_reply_to: u64) -> Self {
DraftTweet {
in_reply_to: Some(in_reply_to),
..self
}
}
pub fn auto_populate_reply_metadata(self, auto_populate: bool) -> Self {
DraftTweet {
auto_populate_reply_metadata: Some(auto_populate),
..self
}
}
pub fn exclude_reply_user_ids<V: Into<Cow<'static, [u64]>>>(self, user_ids: V) -> Self {
DraftTweet {
exclude_reply_user_ids: Some(user_ids.into()),
..self
}
}
pub fn attachment_url<S: Into<Cow<'static, str>>>(self, url: S) -> Self {
DraftTweet {
attachment_url: Some(url.into()),
..self
}
}
pub fn coordinates(self, latitude: f64, longitude: f64, display: bool) -> Self {
DraftTweet {
coordinates: Some((latitude, longitude)),
display_coordinates: Some(display),
..self
}
}
pub fn place_id<S: Into<CowStr>>(self, place_id: S) -> Self {
DraftTweet {
place_id: Some(place_id.into()),
..self
}
}
pub fn add_media(&mut self, media_id: media::MediaId) {
if self.media_ids.len() == 4 {
self.media_ids.remove(0);
}
self.media_ids.push(media_id);
}
pub fn possibly_sensitive(self, sensitive: bool) -> Self {
DraftTweet {
possibly_sensitive: Some(sensitive),
..self
}
}
pub async fn send(&self, token: &auth::Token) -> Result<Response<Tweet>> {
let mut params = ParamList::new()
.add_param("status", self.text.clone())
.add_opt_param("in_reply_to_status_id", self.in_reply_to.map_string())
.add_opt_param(
"auto_populate_reply_metadata",
self.auto_populate_reply_metadata.map_string(),
)
.add_opt_param(
"attachment_url",
self.attachment_url.as_ref().map(|v| v.clone()),
)
.add_opt_param("display_coordinates", self.display_coordinates.map_string())
.add_opt_param("place_id", self.place_id.as_ref().map(|v| v.clone()))
.add_opt_param("possible_sensitive", self.possibly_sensitive.map_string());
if let Some(ref exclude) = self.exclude_reply_user_ids {
let list = exclude
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
params.add_param_ref("exclude_reply_user_ids", list);
}
if let Some((lat, long)) = self.coordinates {
params.add_param_ref("lat", lat.to_string());
params.add_param_ref("long", long.to_string());
}
let media = {
let media = self
.media_ids
.iter()
.map(|x| x.0.as_str())
.collect::<Vec<_>>();
media.join(",")
};
if !media.is_empty() {
params.add_param_ref("media_ids", media);
}
let req = post(links::statuses::UPDATE, token, Some(¶ms));
request_with_json_response(req).await
}
}
#[cfg(test)]
mod tests {
use super::Tweet;
use crate::common::tests::load_file;
use chrono::{Datelike, Timelike, Weekday};
fn load_tweet(path: &str) -> Tweet {
let sample = load_file(path);
::serde_json::from_str(&sample).unwrap()
}
#[test]
fn parse_basic() {
let sample = load_tweet("sample_payloads/sample-extended-onepic.json");
assert_eq!(sample.text,
".@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");
assert!(sample.user.is_some());
assert_eq!(sample.user.unwrap().screen_name, "0xabad1dea");
assert_eq!(sample.id, 782349500404862976);
let source = sample.source.as_ref().unwrap();
assert_eq!(source.name, "Tweetbot for iΟS"); assert_eq!(source.url, "http://tapbots.com/tweetbot");
assert_eq!(sample.created_at.weekday(), Weekday::Sat);
assert_eq!(sample.created_at.year(), 2016);
assert_eq!(sample.created_at.month(), 10);
assert_eq!(sample.created_at.day(), 1);
assert_eq!(sample.created_at.hour(), 22);
assert_eq!(sample.created_at.minute(), 40);
assert_eq!(sample.created_at.second(), 30);
assert_eq!(sample.favorite_count, 20);
assert_eq!(sample.retweet_count, 0);
assert_eq!(sample.lang, Some("en".into()));
assert_eq!(sample.coordinates, None);
assert!(sample.place.is_none());
assert_eq!(sample.favorited, Some(false));
assert_eq!(sample.retweeted, Some(false));
assert!(sample.current_user_retweet.is_none());
assert!(sample
.entities
.user_mentions
.iter()
.any(|m| m.screen_name == "Serrayak"));
assert!(sample.extended_entities.is_some());
assert_eq!(sample.extended_entities.unwrap().media.len(), 1);
let range = sample.display_text_range.unwrap();
assert_eq!(&sample.text[range.0..range.1],
".@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"
);
assert_eq!(sample.truncated, false);
}
#[test]
fn parse_samples() {
load_tweet("sample_payloads/compatibilityplus_classic_13994.json");
load_tweet("sample_payloads/compatibilityplus_classic_hidden_13797.json");
load_tweet("sample_payloads/compatibilityplus_extended_13997.json");
load_tweet("sample_payloads/extended_classic_14002.json");
load_tweet("sample_payloads/extended_classic_hidden_13761.json");
load_tweet("sample_payloads/extended_extended_14001.json");
load_tweet("sample_payloads/nullable_user_mention.json");
}
#[test]
fn parse_reply() {
let sample = load_tweet("sample_payloads/sample-reply.json");
assert_eq!(
sample.in_reply_to_screen_name,
Some("QuietMisdreavus".to_string())
);
assert_eq!(sample.in_reply_to_user_id, Some(2977334326));
assert_eq!(sample.in_reply_to_status_id, Some(782643731665080322));
}
#[test]
fn parse_quote() {
let sample = load_tweet("sample_payloads/sample-quote.json");
assert_eq!(sample.quoted_status_id, Some(783004145485840384));
assert!(sample.quoted_status.is_some());
assert_eq!(sample.quoted_status.unwrap().text,
"@chalkboardsband hot damn i should call up my friends in austin, i might actually be able to make one of these now :D");
}
#[test]
fn parse_retweet() {
let sample = load_tweet("sample_payloads/sample-retweet.json");
assert!(sample.retweeted_status.is_some());
assert_eq!(sample.retweeted_status.unwrap().text,
"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");
}
#[test]
fn parse_image_alt_text() {
let sample = load_tweet("sample_payloads/sample-image-alt-text.json");
let extended_entities = sample.extended_entities.unwrap();
assert_eq!(
extended_entities.media[0].ext_alt_text,
Some("test alt text for the image".to_string())
);
}
}