agent_twitter_client/
tweets.rs

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    // Check if media is video
332    let is_video = media_type.starts_with("video/");
333
334    if is_video {
335        // Handle video upload using chunked upload
336        upload_video_in_chunks(client, file_data, media_type, headers).await
337    } else {
338        // Handle image upload directly
339        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    // INIT command
360    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    // APPEND command - upload in chunks
379    let chunk_size = 5 * 1024 * 1024; // 5MB chunks
380    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    // FINALIZE command
395    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    // Check processing status for videos
405    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        // Maximum 20 attempts
417        tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; // Wait 5 seconds
418
419        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
537// Helper function for long tweet features
538fn 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    // Prepare variables
572    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    // Add reply information if provided
583    if let Some(reply_id) = reply_to {
584        variables["reply"] = json!({
585            "in_reply_to_tweet_id": reply_id
586        });
587    }
588
589    // Handle media uploads if provided
590    if let Some(media_files) = media_data {
591        let mut media_entities = Vec::new();
592
593        // Upload each media file and collect media IDs
594        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    // Make the create tweet request
606    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}