1use crate::api::endpoints::Endpoints;
2use crate::api::requests::{request_api, request_multipart_api};
3use crate::error::{Result, TwitterError};
4use crate::models::tweets::Tweet;
5use crate::profile::get_user_id_by_screen_name;
6use crate::timeline::v2::parse_threaded_conversation;
7use crate::timeline::v2::parse_timeline_tweets_v2;
8use crate::timeline::v2::QueryTweetsResponse;
9use crate::timeline::v2::ThreadedConversation;
10use reqwest::header::HeaderMap;
11use reqwest::Method;
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use crate::api::client::TwitterClient;
15
16pub const DEFAULT_EXPANSIONS: &[&str] = &[
17 "attachments.poll_ids",
18 "attachments.media_keys",
19 "author_id",
20 "referenced_tweets.id",
21 "in_reply_to_user_id",
22 "edit_history_tweet_ids",
23 "geo.place_id",
24 "entities.mentions.username",
25 "referenced_tweets.id.author_id",
26];
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Mention {
30 pub id: String,
31 pub username: Option<String>,
32 pub name: Option<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Photo {
37 pub id: String,
38 pub url: String,
39 pub alt_text: Option<String>,
40}
41
42pub async fn fetch_tweets(
43 client: &TwitterClient,
44 user_id: &str,
45 max_tweets: i32,
46 cursor: Option<&str>,
47) -> Result<Value> {
48 let mut headers = HeaderMap::new();
49 client.auth.install_headers(&mut headers).await?;
50
51 let mut variables = json!({
52 "userId": user_id,
53 "count": max_tweets.min(200),
54 "includePromotedContent": false
55 });
56
57 if let Some(cursor_val) = cursor {
58 variables["cursor"] = json!(cursor_val);
59 }
60
61 let (value, _headers) = request_api(
62 &client.client,
63 "https://twitter.com/i/api/graphql/YNXM2DGuE2Sff6a2JD3Ztw/UserTweets",
64 headers,
65 Method::GET,
66 Some(json!({
67 "variables": variables,
68 "features": get_default_features()
69 })),
70 )
71 .await?;
72
73 Ok(value)
74}
75
76pub async fn fetch_tweets_and_replies(
77 client: &TwitterClient,
78 username: &str,
79 max_tweets: i32,
80 cursor: Option<&str>,
81) -> Result<QueryTweetsResponse> {
82 let mut headers = HeaderMap::new();
83 client.auth.install_headers(&mut headers).await?;
84
85 let user_id = get_user_id_by_screen_name(client, username).await?;
86
87 let endpoint = Endpoints::user_tweets_and_replies(&user_id, max_tweets.min(40), cursor);
88
89 let (value, _headers) =
90 request_api(&client.client, &endpoint.to_request_url(), headers, Method::GET, None).await?;
91
92 let parsed_response = parse_timeline_tweets_v2(&value);
93 Ok(parsed_response)
94}
95
96pub async fn fetch_tweets_and_replies_by_user_id(
97 client: &TwitterClient,
98 user_id: &str,
99 max_tweets: i32,
100 cursor: Option<&str>,
101) -> Result<QueryTweetsResponse> {
102 let mut headers = HeaderMap::new();
103 client.auth.install_headers(&mut headers).await?;
104
105 let endpoint = Endpoints::user_tweets_and_replies(user_id, max_tweets.min(40), cursor);
106
107 let (value, _headers) =
108 request_api(&client.client, &endpoint.to_request_url(), headers, Method::GET, None).await?;
109
110 let parsed_response = parse_timeline_tweets_v2(&value);
111 Ok(parsed_response)
112}
113
114pub async fn fetch_list_tweets(
115 client: &TwitterClient,
116 list_id: &str,
117 max_tweets: i32,
118 cursor: Option<&str>,
119) -> Result<Value> {
120 let mut headers = HeaderMap::new();
121 client.auth.install_headers(&mut headers).await?;
122
123 let mut variables = json!({
124 "listId": list_id,
125 "count": max_tweets.min(200)
126 });
127
128 if let Some(cursor_val) = cursor {
129 variables["cursor"] = json!(cursor_val);
130 }
131
132 let (value, _headers) = request_api(
133 &client.client,
134 "https://twitter.com/i/api/graphql/LFKj1wqHNTsEJ4Oq7TzaNA/ListLatestTweetsTimeline",
135 headers,
136 Method::GET,
137 Some(json!({
138 "variables": variables,
139 "features": get_default_features()
140 })),
141 )
142 .await?;
143
144 Ok(value)
145}
146
147pub async fn create_quote_tweet(
148 client: &TwitterClient,
149 text: &str,
150 quoted_tweet_id: &str,
151 media_data: Option<Vec<(Vec<u8>, String)>>,
152) -> Result<Value> {
153 let mut headers = HeaderMap::new();
154 client.auth.install_headers(&mut headers).await?;
155
156 let mut variables = json!({
157 "tweet_text": text,
158 "dark_request": false,
159 "attachment_url": format!("https://twitter.com/twitter/status/{}", quoted_tweet_id),
160 "media": {
161 "media_entities": [],
162 "possibly_sensitive": false
163 },
164 "semantic_annotation_ids": []
165 });
166
167 if let Some(media_files) = media_data {
168 let mut media_entities = Vec::new();
169
170 for (file_data, media_type) in media_files {
171 let media_id = upload_media(client, file_data, &media_type).await?;
172 media_entities.push(json!({
173 "media_id": media_id,
174 "tagged_users": []
175 }));
176 }
177
178 variables["media"]["media_entities"] = json!(media_entities);
179 }
180
181 let (value, _headers) = request_api(
182 &client.client,
183 "https://twitter.com/i/api/graphql/a1p9RWpkYKBjWv_I3WzS-A/CreateTweet",
184 headers,
185 Method::POST,
186 Some(json!({
187 "variables": variables,
188 "features": create_quote_tweet_features()
189 })),
190 )
191 .await?;
192
193 Ok(value)
194}
195
196pub async fn like_tweet(client: &TwitterClient, tweet_id: &str) -> Result<Value> {
197 let mut headers = HeaderMap::new();
198 client.auth.install_headers(&mut headers).await?;
199
200 let (value, _headers) = request_api(
201 &client.client,
202 "https://twitter.com/i/api/graphql/lI07N6Otwv1PhnEgXILM7A/FavoriteTweet",
203 headers,
204 Method::POST,
205 Some(json!({
206 "variables": {
207 "tweet_id": tweet_id
208 }
209 })),
210 )
211 .await?;
212
213 Ok(value)
214}
215
216pub async fn retweet(client: &TwitterClient, tweet_id: &str) -> Result<Value> {
217 let mut headers = HeaderMap::new();
218 client.auth.install_headers(&mut headers).await?;
219
220 let (value, _headers) = request_api(
221 &client.client,
222 "https://twitter.com/i/api/graphql/ojPdsZsimiJrUGLR1sjUtA/CreateRetweet",
223 headers,
224 Method::POST,
225 Some(json!({
226 "variables": {
227 "tweet_id": tweet_id,
228 "dark_request": false
229 }
230 })),
231 )
232 .await?;
233
234 Ok(value)
235}
236
237pub async fn create_long_tweet(
238 client: &TwitterClient,
239 text: &str,
240 reply_to: Option<&str>,
241 media_ids: Option<Vec<String>>,
242) -> Result<Value> {
243 let mut headers = HeaderMap::new();
244 client.auth.install_headers(&mut headers).await?;
245
246 let mut variables = json!({
247 "tweet_text": text,
248 "dark_request": false,
249 "media": {
250 "media_entities": [],
251 "possibly_sensitive": false
252 },
253 "semantic_annotation_ids": []
254 });
255
256 if let Some(reply_id) = reply_to {
257 variables["reply"] = json!({
258 "in_reply_to_tweet_id": reply_id
259 });
260 }
261
262 if let Some(media) = media_ids {
263 variables["media"]["media_entities"] = json!(media
264 .iter()
265 .map(|id| json!({
266 "media_id": id,
267 "tagged_users": []
268 }))
269 .collect::<Vec<_>>());
270 }
271
272 let (value, _headers) = request_api(
273 &client.client,
274 "https://twitter.com/i/api/graphql/YNXM2DGuE2Sff6a2JD3Ztw/CreateNoteTweet",
275 headers,
276 Method::POST,
277 Some(json!({
278 "variables": variables,
279 "features": get_long_tweet_features()
280 })),
281 )
282 .await?;
283
284 Ok(value)
285}
286
287pub async fn fetch_liked_tweets(
288 client: &TwitterClient,
289 user_id: &str,
290 max_tweets: i32,
291 cursor: Option<&str>,
292) -> Result<Value> {
293 let mut headers = HeaderMap::new();
294 client.auth.install_headers(&mut headers).await?;
295
296 let mut variables = json!({
297 "userId": user_id,
298 "count": max_tweets.min(200),
299 "includePromotedContent": false
300 });
301
302 if let Some(cursor_val) = cursor {
303 variables["cursor"] = json!(cursor_val);
304 }
305
306 let (value, _headers) = request_api(
307 &client.client,
308 "https://twitter.com/i/api/graphql/YlkSUg4Czo2Zx7yRqpwDow/Likes",
309 headers,
310 Method::GET,
311 Some(json!({
312 "variables": variables,
313 "features": get_default_features()
314 })),
315 )
316 .await?;
317
318 Ok(value)
319}
320
321pub async fn upload_media(
322 client: &TwitterClient,
323 file_data: Vec<u8>,
324 media_type: &str,
325) -> Result<String> {
326 let mut headers = HeaderMap::new();
327 client.auth.install_headers(&mut headers).await?;
328
329 let upload_url = "https://upload.twitter.com/1.1/media/upload.json";
330
331 let is_video = media_type.starts_with("video/");
333
334 if is_video {
335 upload_video_in_chunks(client, file_data, media_type, headers).await
337 } else {
338 let form = reqwest::multipart::Form::new()
340 .part("media", reqwest::multipart::Part::bytes(file_data));
341
342 let (response, _) = request_multipart_api::<Value>(&client.client, upload_url, headers, form).await?;
343
344 response["media_id_string"]
345 .as_str()
346 .map(String::from)
347 .ok_or_else(|| TwitterError::Api("Failed to get media_id".into()))
348 }
349}
350
351async fn upload_video_in_chunks(
352 client: &TwitterClient,
353 file_data: Vec<u8>,
354 media_type: &str,
355 headers: HeaderMap,
356) -> Result<String> {
357 let upload_url = "https://upload.twitter.com/1.1/media/upload.json";
358
359 let (init_response, _) = request_api::<Value>(
361 &client.client,
362 upload_url,
363 headers.clone(),
364 Method::POST,
365 Some(json!({
366 "command": "INIT",
367 "total_bytes": file_data.len(),
368 "media_type": media_type
369 })),
370 )
371 .await?;
372
373 let media_id = init_response["media_id_string"]
374 .as_str()
375 .ok_or_else(|| TwitterError::Api("Failed to get media_id".into()))?
376 .to_string();
377
378 let chunk_size = 5 * 1024 * 1024; let mut segment_index = 0;
381
382 for chunk in file_data.chunks(chunk_size) {
383 let form = reqwest::multipart::Form::new()
384 .text("command", "APPEND")
385 .text("media_id", media_id.clone())
386 .text("segment_index", segment_index.to_string())
387 .part("media", reqwest::multipart::Part::bytes(chunk.to_vec()));
388
389 let (_, _) = request_multipart_api::<Value>(&client.client, upload_url, headers.clone(), form).await?;
390
391 segment_index += 1;
392 }
393
394 let (finalize_response, _) = request_api::<Value>(
396 &client.client,
397 &format!("{}?command=FINALIZE&media_id={}", upload_url, media_id),
398 headers.clone(),
399 Method::POST,
400 None,
401 )
402 .await?;
403
404 if finalize_response.get("processing_info").is_some() {
406 check_upload_status(client, &media_id, &headers).await?;
407 }
408
409 Ok(media_id)
410}
411
412async fn check_upload_status(client: &TwitterClient, media_id: &str, headers: &HeaderMap) -> Result<()> {
413 let upload_url = "https://upload.twitter.com/1.1/media/upload.json";
414
415 for _ in 0..20 {
416 tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; let (status_response, _) = request_api::<Value>(
420 &client.client,
421 &format!("{}?command=STATUS&media_id={}", upload_url, media_id),
422 headers.clone(),
423 Method::GET,
424 None,
425 )
426 .await?;
427
428 if let Some(processing_info) = status_response.get("processing_info") {
429 match processing_info["state"].as_str() {
430 Some("succeeded") => return Ok(()),
431 Some("failed") => return Err(TwitterError::Api("Video processing failed".into())),
432 _ => continue,
433 }
434 }
435 }
436
437 Err(TwitterError::Api("Video processing timeout".into()))
438}
439
440pub async fn get_tweet(client: &TwitterClient, id: &str) -> Result<Tweet> {
441 let mut headers = HeaderMap::new();
442 client.auth.install_headers(&mut headers).await?;
443 let tweet_detail_request = Endpoints::tweet_detail(id);
444 let url = tweet_detail_request.to_request_url();
445
446 let (response, _) = request_api::<Value>(&client.client, &url, headers, Method::GET, None).await?;
447 let data = response.clone();
448 let conversation: ThreadedConversation = serde_json::from_value(data)?;
449 let tweets = parse_threaded_conversation(&conversation);
450 tweets.into_iter().next().ok_or_else(|| TwitterError::Api("No tweets found".into()))
451}
452
453fn create_tweet_features() -> Value {
454 json!({
455 "interactive_text_enabled": true,
456 "longform_notetweets_inline_media_enabled": false,
457 "responsive_web_text_conversations_enabled": false,
458 "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
459 "vibe_api_enabled": false,
460 "rweb_lists_timeline_redesign_enabled": true,
461 "responsive_web_graphql_exclude_directive_enabled": true,
462 "verified_phone_label_enabled": false,
463 "creator_subscriptions_tweet_preview_api_enabled": true,
464 "responsive_web_graphql_timeline_navigation_enabled": true,
465 "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
466 "tweetypie_unmention_optimization_enabled": true,
467 "responsive_web_edit_tweet_api_enabled": true,
468 "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
469 "view_counts_everywhere_api_enabled": true,
470 "longform_notetweets_consumption_enabled": true,
471 "tweet_awards_web_tipping_enabled": false,
472 "freedom_of_speech_not_reach_fetch_enabled": true,
473 "standardized_nudges_misinfo": true,
474 "longform_notetweets_rich_text_read_enabled": true,
475 "responsive_web_enhance_cards_enabled": false,
476 "subscriptions_verification_info_enabled": true,
477 "subscriptions_verification_info_reason_enabled": true,
478 "subscriptions_verification_info_verified_since_enabled": true,
479 "super_follow_badge_privacy_enabled": false,
480 "super_follow_exclusive_tweet_notifications_enabled": false,
481 "super_follow_tweet_api_enabled": false,
482 "super_follow_user_api_enabled": false,
483 "android_graphql_skip_api_media_color_palette": false,
484 "creator_subscriptions_subscription_count_enabled": false,
485 "blue_business_profile_image_shape_enabled": false,
486 "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
487 "rweb_video_timestamps_enabled": false,
488 "c9s_tweet_anatomy_moderator_badge_enabled": false,
489 "responsive_web_twitter_article_tweet_consumption_enabled": false
490 })
491}
492
493fn get_default_features() -> Value {
494 json!({
495 "interactive_text_enabled": true,
496 "longform_notetweets_inline_media_enabled": false,
497 "responsive_web_text_conversations_enabled": false,
498 "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
499 "vibe_api_enabled": false,
500 "rweb_lists_timeline_redesign_enabled": true,
501 "responsive_web_graphql_exclude_directive_enabled": true,
502 "verified_phone_label_enabled": false,
503 "creator_subscriptions_tweet_preview_api_enabled": true,
504 "responsive_web_graphql_timeline_navigation_enabled": true,
505 "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
506 "tweetypie_unmention_optimization_enabled": true,
507 "responsive_web_edit_tweet_api_enabled": true,
508 "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
509 "view_counts_everywhere_api_enabled": true,
510 "longform_notetweets_consumption_enabled": true,
511 "tweet_awards_web_tipping_enabled": false,
512 "freedom_of_speech_not_reach_fetch_enabled": true,
513 "standardized_nudges_misinfo": true,
514 "longform_notetweets_rich_text_read_enabled": true,
515 "responsive_web_enhance_cards_enabled": false,
516 "subscriptions_verification_info_enabled": true,
517 "subscriptions_verification_info_reason_enabled": true,
518 "subscriptions_verification_info_verified_since_enabled": true,
519 "super_follow_badge_privacy_enabled": false,
520 "super_follow_exclusive_tweet_notifications_enabled": false,
521 "super_follow_tweet_api_enabled": false,
522 "super_follow_user_api_enabled": false,
523 "android_graphql_skip_api_media_color_palette": false,
524 "creator_subscriptions_subscription_count_enabled": false,
525 "blue_business_profile_image_shape_enabled": false,
526 "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
527 "rweb_video_timestamps_enabled": true,
528 "c9s_tweet_anatomy_moderator_badge_enabled": true,
529 "responsive_web_twitter_article_tweet_consumption_enabled": false,
530 "creator_subscriptions_quote_tweet_preview_enabled": false,
531 "profile_label_improvements_pcf_label_in_post_enabled": false,
532 "rweb_tipjar_consumption_enabled": true,
533 "articles_preview_enabled": true
534 })
535}
536
537fn get_long_tweet_features() -> Value {
539 json!({
540 "premium_content_api_read_enabled": false,
541 "communities_web_enable_tweet_community_results_fetch": true,
542 "c9s_tweet_anatomy_moderator_badge_enabled": true,
543 "responsive_web_grok_analyze_button_fetch_trends_enabled": true,
544 "responsive_web_edit_tweet_api_enabled": true,
545 "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
546 "view_counts_everywhere_api_enabled": true,
547 "longform_notetweets_consumption_enabled": true,
548 "responsive_web_twitter_article_tweet_consumption_enabled": true,
549 "tweet_awards_web_tipping_enabled": false,
550 "longform_notetweets_rich_text_read_enabled": true,
551 "longform_notetweets_inline_media_enabled": true,
552 "responsive_web_graphql_exclude_directive_enabled": true,
553 "verified_phone_label_enabled": false,
554 "freedom_of_speech_not_reach_fetch_enabled": true,
555 "standardized_nudges_misinfo": true,
556 "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
557 "responsive_web_graphql_timeline_navigation_enabled": true,
558 "responsive_web_enhance_cards_enabled": false
559 })
560}
561
562pub async fn create_tweet_request(
563 client: &TwitterClient,
564 text: &str,
565 reply_to: Option<&str>,
566 media_data: Option<Vec<(Vec<u8>, String)>>,
567) -> Result<Value> {
568 let mut headers = HeaderMap::new();
569 client.auth.install_headers(&mut headers).await?;
570
571 let mut variables = json!({
573 "tweet_text": text,
574 "dark_request": false,
575 "media": {
576 "media_entities": [],
577 "possibly_sensitive": false
578 },
579 "semantic_annotation_ids": []
580 });
581
582 if let Some(reply_id) = reply_to {
584 variables["reply"] = json!({
585 "in_reply_to_tweet_id": reply_id
586 });
587 }
588
589 if let Some(media_files) = media_data {
591 let mut media_entities = Vec::new();
592
593 for (file_data, media_type) in media_files {
595 let media_id = upload_media(client, file_data, &media_type).await?;
596 media_entities.push(json!({
597 "media_id": media_id,
598 "tagged_users": []
599 }));
600 }
601
602 variables["media"]["media_entities"] = json!(media_entities);
603 }
604 let features = create_tweet_features();
605 let (value, _headers) = request_api(
607 &client.client,
608 "https://twitter.com/i/api/graphql/a1p9RWpkYKBjWv_I3WzS-A/CreateTweet",
609 headers,
610 Method::POST,
611 Some(json!({
612 "variables": variables,
613 "features": features,
614 "fieldToggles": {}
615 })),
616 )
617 .await?;
618
619 Ok(value)
620}
621
622fn create_quote_tweet_features() -> Value {
623 json!({
624 "interactive_text_enabled": true,
625 "longform_notetweets_inline_media_enabled": false,
626 "responsive_web_text_conversations_enabled": false,
627 "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
628 "vibe_api_enabled": false,
629 "rweb_lists_timeline_redesign_enabled": true,
630 "responsive_web_graphql_exclude_directive_enabled": true,
631 "verified_phone_label_enabled": false,
632 "creator_subscriptions_tweet_preview_api_enabled": true,
633 "responsive_web_graphql_timeline_navigation_enabled": true,
634 "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
635 "tweetypie_unmention_optimization_enabled": true,
636 "responsive_web_edit_tweet_api_enabled": true,
637 "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
638 "view_counts_everywhere_api_enabled": true,
639 "longform_notetweets_consumption_enabled": true,
640 "tweet_awards_web_tipping_enabled": false,
641 "freedom_of_speech_not_reach_fetch_enabled": true,
642 "standardized_nudges_misinfo": true,
643 "longform_notetweets_rich_text_read_enabled": true,
644 "responsive_web_enhance_cards_enabled": false,
645 "subscriptions_verification_info_enabled": true,
646 "subscriptions_verification_info_reason_enabled": true,
647 "subscriptions_verification_info_verified_since_enabled": true,
648 "super_follow_badge_privacy_enabled": false,
649 "super_follow_exclusive_tweet_notifications_enabled": false,
650 "super_follow_tweet_api_enabled": false,
651 "super_follow_user_api_enabled": false,
652 "android_graphql_skip_api_media_color_palette": false,
653 "creator_subscriptions_subscription_count_enabled": false,
654 "blue_business_profile_image_shape_enabled": false,
655 "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
656 "rweb_video_timestamps_enabled": true,
657 "c9s_tweet_anatomy_moderator_badge_enabled": true,
658 "responsive_web_twitter_article_tweet_consumption_enabled": false
659 })
660}
661
662pub async fn fetch_user_tweets(
663 client: &TwitterClient,
664 user_id: &str,
665 max_tweets: i32,
666 cursor: Option<&str>,
667
668) -> Result<QueryTweetsResponse> {
669 let mut headers = HeaderMap::new();
670 client.auth.install_headers(&mut headers).await?;
671
672 let endpoint = Endpoints::user_tweets(user_id, max_tweets.min(200), cursor);
673
674 let (value, _headers) =
675 request_api(&client.client, &endpoint.to_request_url(), headers, Method::GET, None).await?;
676
677 let parsed_response = parse_timeline_tweets_v2(&value);
678 Ok(parsed_response)
679}