xplore/
tweets.rs

1use {
2    crate::{
3        api,
4        endpoints::Endpoints,
5        timeline_v2::{
6            parse_threaded_conversation, parse_timeline_tweets_v2, QueryTweetsResponse, ThreadedConversation,
7        },
8        Result, Xplore, XploreError,
9    },
10    chrono::{DateTime, Utc},
11    reqwest::Method,
12    serde::{Deserialize, Serialize},
13    serde_json::{json, Value},
14};
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, Deserialize, Serialize)]
29pub struct Tweet {
30    pub ext_views: Option<i32>,
31    pub created_at: Option<String>,
32    pub bookmark_count: Option<i32>,
33    pub conversation_id: Option<String>,
34    pub hashtags: Vec<String>,
35    pub html: Option<String>,
36    pub id: Option<String>,
37    pub in_reply_to_status: Option<Box<Tweet>>,
38    pub in_reply_to_status_id: Option<String>,
39    pub is_quoted: Option<bool>,
40    pub is_pin: Option<bool>,
41    pub is_reply: Option<bool>,
42    pub is_retweet: Option<bool>,
43    pub is_self_thread: Option<bool>,
44    pub likes: Option<i32>,
45    pub name: Option<String>,
46    pub mentions: Vec<Mention>,
47    pub permanent_url: Option<String>,
48    pub photos: Vec<Photo>,
49    pub place: Option<PlaceRaw>,
50    pub quoted_status: Option<Box<Tweet>>,
51    pub quoted_status_id: Option<String>,
52    pub replies: Option<i32>,
53    pub retweets: Option<i32>,
54    pub retweeted_status: Option<Box<Tweet>>,
55    pub retweeted_status_id: Option<String>,
56    pub text: Option<String>,
57    pub thread: Vec<Tweet>,
58    pub time_parsed: Option<DateTime<Utc>>,
59    pub timestamp: Option<i64>,
60    pub urls: Vec<String>,
61    pub user_id: Option<String>,
62    pub username: Option<String>,
63    pub videos: Vec<Video>,
64    pub views: Option<i32>,
65    pub sensitive_content: Option<bool>,
66    pub poll: Option<PollV2>,
67    pub quote_count: Option<i32>,
68    pub reply_count: Option<i32>,
69    pub retweet_count: Option<i32>,
70    pub screen_name: Option<String>,
71    pub thread_id: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Mention {
76    pub id: String,
77    pub username: Option<String>,
78    pub name: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Photo {
83    pub id: String,
84    pub url: String,
85    pub alt_text: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Video {
90    pub id: String,
91    pub preview: String,
92    pub url: Option<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct PlaceRaw {
97    pub id: Option<String>,
98    pub place_type: Option<String>,
99    pub name: Option<String>,
100    pub full_name: Option<String>,
101    pub country_code: Option<String>,
102    pub country: Option<String>,
103    pub bounding_box: Option<BoundingBox>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct BoundingBox {
108    #[serde(rename = "type")]
109    pub type_: Option<String>,
110    pub coordinates: Option<Vec<Vec<Vec<f64>>>>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct PollV2 {
115    pub id: Option<String>,
116    pub end_datetime: Option<String>,
117    pub voting_status: Option<String>,
118    pub options: Vec<PollOption>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct PollOption {
123    pub position: Option<i32>,
124    pub label: String,
125    pub votes: Option<i32>,
126}
127
128#[derive(Debug, Serialize, Deserialize)]
129struct TweetResponse {
130    data: TweetData,
131}
132
133#[derive(Debug, Serialize, Deserialize)]
134struct TweetData {
135    create_tweet: TweetCreateResult,
136}
137
138#[derive(Debug, Serialize, Deserialize)]
139struct TweetCreateResult {
140    tweet_results: TweetResultWrapper,
141}
142
143#[derive(Debug, Serialize, Deserialize)]
144struct TweetResultWrapper {
145    result: TweetResult,
146}
147
148#[derive(Debug, Serialize, Deserialize)]
149struct TweetResult {
150    core: TweetCore,
151    edit_control: TweetEditControl,
152    is_translatable: bool,
153    legacy: TweetLegacy,
154    rest_id: String,
155    source: String,
156    unmention_data: serde_json::Value,
157    unmention_info: serde_json::Value,
158    views: TweetViews,
159}
160
161#[derive(Debug, Serialize, Deserialize)]
162struct TweetCore {
163    user_results: UserResultWrapper,
164}
165
166#[derive(Debug, Serialize, Deserialize)]
167struct UserResultWrapper {
168    result: UserResult,
169}
170
171#[derive(Debug, Serialize, Deserialize)]
172struct UserResult {
173    __typename: String,
174    affiliates_highlighted_label: serde_json::Value,
175    has_graduated_access: bool,
176    id: String,
177    is_blue_verified: bool,
178    legacy: UserLegacy,
179    profile_image_shape: String,
180    rest_id: String,
181}
182
183#[derive(Debug, Serialize, Deserialize)]
184struct UserLegacy {
185    can_dm: bool,
186    can_media_tag: bool,
187    created_at: String,
188    default_profile: bool,
189    default_profile_image: bool,
190    description: String,
191    entities: UserEntities,
192    fast_followers_count: u64,
193    favourites_count: u64,
194    followers_count: u64,
195    friends_count: u64,
196    has_custom_timelines: bool,
197    is_translator: bool,
198    listed_count: u64,
199    location: String,
200    media_count: u64,
201    name: String,
202    needs_phone_verification: bool,
203    normal_followers_count: u64,
204    pinned_tweet_ids_str: Vec<String>,
205    possibly_sensitive: bool,
206    profile_image_url_https: String,
207    profile_interstitial_type: String,
208    screen_name: String,
209    statuses_count: u64,
210    translator_type: String,
211    verified: bool,
212    want_retweets: bool,
213    withheld_in_countries: Vec<String>,
214}
215
216#[derive(Debug, Serialize, Deserialize)]
217struct UserEntities {
218    description: UserDescriptionEntities,
219}
220
221#[derive(Debug, Serialize, Deserialize)]
222struct UserDescriptionEntities {
223    urls: Vec<serde_json::Value>,
224}
225
226#[derive(Debug, Serialize, Deserialize)]
227struct TweetEditControl {
228    edit_tweet_ids: Vec<String>,
229    editable_until_msecs: String,
230    edits_remaining: String,
231    is_edit_eligible: bool,
232}
233
234#[derive(Debug, Serialize, Deserialize)]
235struct TweetLegacy {
236    bookmark_count: u64,
237    bookmarked: bool,
238    conversation_id_str: String,
239    created_at: String,
240    display_text_range: Vec<u64>,
241    entities: TweetEntities,
242    favorite_count: u64,
243    favorited: bool,
244    full_text: String,
245    id_str: String,
246    is_quote_status: bool,
247    lang: String,
248    quote_count: u64,
249    reply_count: u64,
250    retweet_count: u64,
251    retweeted: bool,
252    user_id_str: String,
253}
254
255#[derive(Debug, Serialize, Deserialize)]
256struct TweetEntities {
257    hashtags: Vec<serde_json::Value>,
258    symbols: Vec<serde_json::Value>,
259    urls: Vec<serde_json::Value>,
260    user_mentions: Vec<serde_json::Value>,
261}
262
263#[derive(Debug, Serialize, Deserialize)]
264struct TweetViews {
265    state: String,
266}
267
268/// Retweet
269#[derive(Debug, Serialize, Deserialize)]
270pub struct TweetRetweetResponse {
271    pub data: TweetRetweetData,
272}
273
274#[derive(Debug, Serialize, Deserialize)]
275pub struct TweetRetweetData {
276    pub create_retweet: TweetRetweetCreateResult,
277}
278
279#[derive(Debug, Serialize, Deserialize)]
280pub struct TweetRetweetCreateResult {
281    pub retweet_results: TweetRetweetResultWrapper,
282}
283
284#[derive(Debug, Serialize, Deserialize)]
285pub struct TweetRetweetResultWrapper {
286    pub result: TweetRetweetResult,
287}
288
289#[derive(Debug, Serialize, Deserialize)]
290pub struct TweetRetweetResult {
291    pub legacy: TweetRetweetLegacy,
292    pub rest_id: String,
293}
294
295#[derive(Debug, Serialize, Deserialize)]
296pub struct TweetRetweetLegacy {
297    pub full_text: String,
298}
299
300pub async fn post_tweet(
301    xplore: &mut Xplore,
302    text: &str,
303    reply_to: Option<&str>,
304    media_data: Option<Vec<(Vec<u8>, String)>>,
305) -> Result<Value> {
306    create_tweet_request(xplore, text, reply_to, media_data).await
307}
308
309pub async fn read_tweet(xplore: &mut Xplore, tweet_id: &str) -> Result<Tweet> {
310    get_tweet(xplore, tweet_id).await
311}
312
313pub async fn retweet(xplore: &mut Xplore, tweet_id: &str) -> Result<TweetRetweetResponse> {
314    let value = retweet_(xplore, tweet_id).await?;
315    let res = serde_json::from_value(value)?;
316
317    Ok(res)
318}
319
320pub async fn like_tweet(xplore: &mut Xplore, tweet_id: &str) -> Result<Value> {
321    let value = like_tweet_(xplore, tweet_id).await?;
322    Ok(value)
323}
324
325pub async fn get_user_tweets(xplore: &mut Xplore, user_id: &str, limit: usize) -> Result<Vec<Tweet>> {
326    let url = format!("https://api.twitter.com/2/users/{}/tweets", user_id);
327    let body = json!({
328        "max_results": limit,
329        "tweet.fields": "created_at,author_id,conversation_id,public_metrics"
330    });
331
332    let (v, _) = api::send_request::<Vec<Tweet>>(&mut xplore.auth, &url, Method::GET, Some(body)).await?;
333    Ok(v)
334}
335
336pub async fn send_quote_tweet(
337    xplore: &mut Xplore,
338    text: &str,
339    quoted_tweet_id: &str,
340    media_data: Option<Vec<(Vec<u8>, String)>>,
341) -> Result<Value> {
342    create_quote_tweet(xplore, text, quoted_tweet_id, media_data).await
343}
344
345pub async fn fetch_tweets_and_replies(
346    xplore: &mut Xplore,
347    username: &str,
348    max_tweets: i32,
349    cursor: Option<&str>,
350) -> Result<QueryTweetsResponse> {
351    fetch_tweets_and_replies_(xplore, username, max_tweets, cursor).await
352}
353
354pub async fn fetch_tweets_and_replies_by_user_id(
355    xplore: &mut Xplore,
356    user_id: &str,
357    max_tweets: i32,
358    cursor: Option<&str>,
359) -> Result<QueryTweetsResponse> {
360    fetch_tweets_and_replies_by_user_id_(xplore, user_id, max_tweets, cursor).await
361}
362
363pub async fn fetch_list_tweets(
364    xplore: &mut Xplore,
365    list_id: &str,
366    max_tweets: i32,
367    cursor: Option<&str>,
368) -> Result<Value> {
369    fetch_list_tweets_(xplore, list_id, max_tweets, cursor).await
370}
371
372pub async fn create_long_tweet(
373    xplore: &mut Xplore,
374    text: &str,
375    reply_to: Option<&str>,
376    media_ids: Option<Vec<String>>,
377) -> Result<Value> {
378    create_long_tweet_(xplore, text, reply_to, media_ids).await
379}
380
381////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
382///
383
384pub async fn fetch_tweets(xplore: &mut Xplore, user_id: &str, max_tweets: i32, cursor: Option<&str>) -> Result<Value> {
385    let mut variables = json!({
386        "userId": user_id,
387        "count": max_tweets.min(200),
388        "includePromotedContent": false
389    });
390
391    if let Some(cursor_val) = cursor {
392        variables["cursor"] = json!(cursor_val);
393    }
394
395    let url = "https://twitter.com/i/api/graphql/YNXM2DGuE2Sff6a2JD3Ztw/UserTweets";
396    let method = Method::GET;
397    let body = Some(json!({
398        "variables": variables,
399        "features": get_default_features()
400    }));
401    let (value, _) = api::send_request(&mut xplore.auth, url, method, body).await?;
402
403    Ok(value)
404}
405
406pub async fn fetch_tweets_and_replies_(
407    xplore: &mut Xplore,
408    username: &str,
409    max_tweets: i32,
410    cursor: Option<&str>,
411) -> Result<QueryTweetsResponse> {
412    let user_id = xplore.get_user_id(username).await?;
413
414    let endpoint = Endpoints::user_tweets_and_replies(&user_id, max_tweets.min(40), cursor);
415    let url = &endpoint.to_request_url();
416    let (value, _) = api::send_request(&mut xplore.auth, url, Method::GET, None).await?;
417
418    let parsed_response = parse_timeline_tweets_v2(&value);
419    Ok(parsed_response)
420}
421
422pub async fn fetch_tweets_and_replies_by_user_id_(
423    xplore: &mut Xplore,
424    user_id: &str,
425    max_tweets: i32,
426    cursor: Option<&str>,
427) -> Result<QueryTweetsResponse> {
428    let endpoint = Endpoints::user_tweets_and_replies(user_id, max_tweets.min(40), cursor);
429    let url = &endpoint.to_request_url();
430    let method = Method::GET;
431
432    let (value, _headers) = api::send_request(&mut xplore.auth, url, method, None).await?;
433
434    let parsed_response = parse_timeline_tweets_v2(&value);
435    Ok(parsed_response)
436}
437
438pub async fn fetch_list_tweets_(
439    xplore: &mut Xplore,
440    list_id: &str,
441    max_tweets: i32,
442    cursor: Option<&str>,
443) -> Result<Value> {
444    let mut variables = json!({
445        "listId": list_id,
446        "count": max_tweets.min(200)
447    });
448
449    if let Some(cursor_val) = cursor {
450        variables["cursor"] = json!(cursor_val);
451    }
452
453    let url = "https://twitter.com/i/api/graphql/LFKj1wqHNTsEJ4Oq7TzaNA/ListLatestTweetsTimeline";
454    let body = Some(json!({
455        "variables": variables,
456        "features": get_default_features()
457    }));
458
459    let (value, _) = api::send_request(&mut xplore.auth, url, Method::GET, body).await?;
460
461    Ok(value)
462}
463
464pub async fn create_quote_tweet(
465    xplore: &mut Xplore,
466    text: &str,
467    quoted_tweet_id: &str,
468    media_data: Option<Vec<(Vec<u8>, String)>>,
469) -> Result<Value> {
470    let mut variables = json!({
471        "tweet_text": text,
472        "dark_request": false,
473        "attachment_url": format!("https://twitter.com/twitter/status/{}", quoted_tweet_id),
474        "media": {
475            "media_entities": [],
476            "possibly_sensitive": false
477        },
478        "semantic_annotation_ids": []
479    });
480
481    if let Some(media_files) = media_data {
482        let mut media_entities = Vec::new();
483
484        for (file_data, media_type) in media_files {
485            let media_id = upload_media(xplore, file_data, &media_type).await?;
486            media_entities.push(json!({
487                "media_id": media_id,
488                "tagged_users": []
489            }));
490        }
491
492        variables["media"]["media_entities"] = json!(media_entities);
493    }
494
495    let url = "https://twitter.com/i/api/graphql/a1p9RWpkYKBjWv_I3WzS-A/CreateTweet";
496    let body = Some(json!({
497        "variables": variables,
498        "features": create_quote_tweet_features()
499    }));
500    let (v, _) = api::send_request(&mut xplore.auth, url, Method::POST, body).await?;
501
502    Ok(v)
503}
504
505pub async fn like_tweet_(xplore: &mut Xplore, tweet_id: &str) -> Result<Value> {
506    let url = "https://twitter.com/i/api/graphql/lI07N6Otwv1PhnEgXILM7A/FavoriteTweet";
507    let body = Some(json!({
508        "variables": {
509            "tweet_id": tweet_id
510        }
511    }));
512
513    let (value, _) = api::send_request(&mut xplore.auth, url, Method::POST, body).await?;
514    Ok(value)
515}
516
517pub async fn retweet_(xplore: &mut Xplore, tweet_id: &str) -> Result<Value> {
518    let url = "https://twitter.com/i/api/graphql/ojPdsZsimiJrUGLR1sjUtA/CreateRetweet";
519    let body = Some(json!({
520        "variables": {
521            "tweet_id": tweet_id,
522            "dark_request": false
523        }
524    }));
525    let (value, _) = api::send_request(&mut xplore.auth, url, Method::POST, body).await?;
526    Ok(value)
527}
528
529pub async fn create_long_tweet_(
530    xplore: &mut Xplore,
531    text: &str,
532    reply_to: Option<&str>,
533    media_ids: Option<Vec<String>>,
534) -> Result<Value> {
535    let mut variables = json!({
536        "tweet_text": text,
537        "dark_request": false,
538        "media": {
539            "media_entities": [],
540            "possibly_sensitive": false
541        },
542        "semantic_annotation_ids": []
543    });
544
545    if let Some(reply_id) = reply_to {
546        variables["reply"] = json!({
547            "in_reply_to_tweet_id": reply_id
548        });
549    }
550
551    if let Some(media) = media_ids {
552        variables["media"]["media_entities"] = json!(media
553            .iter()
554            .map(|id| json!({
555                "media_id": id,
556                "tagged_users": []
557            }))
558            .collect::<Vec<_>>());
559    }
560
561    let url = "https://twitter.com/i/api/graphql/YNXM2DGuE2Sff6a2JD3Ztw/CreateNoteTweet";
562    let body = Some(json!({
563        "variables": variables,
564        "features": get_long_tweet_features()
565    }));
566    let (value, _) = api::send_request(&mut xplore.auth, url, Method::POST, body).await?;
567
568    Ok(value)
569}
570
571pub async fn fetch_liked_tweets(
572    xplore: &mut Xplore,
573    user_id: &str,
574    max_tweets: i32,
575    cursor: Option<&str>,
576) -> Result<Value> {
577    let mut variables = json!({
578        "userId": user_id,
579        "count": max_tweets.min(200),
580        "includePromotedContent": false
581    });
582
583    if let Some(cursor_val) = cursor {
584        variables["cursor"] = json!(cursor_val);
585    }
586
587    let url = "https://twitter.com/i/api/graphql/YlkSUg4Czo2Zx7yRqpwDow/Likes";
588    let body = Some(json!({
589        "variables": variables,
590        "features": get_default_features()
591    }));
592    let (value, _) = api::send_request(&mut xplore.auth, url, Method::POST, body).await?;
593    Ok(value)
594}
595
596pub async fn upload_media(xplore: &mut Xplore, file_data: Vec<u8>, media_type: &str) -> Result<String> {
597    let upload_url = "https://upload.twitter.com/1.1/media/upload.json";
598
599    // Check if media is video
600    let is_video = media_type.starts_with("video/");
601
602    if is_video {
603        // Handle video upload using chunked upload
604        upload_video_in_chunks(xplore, file_data, media_type).await
605    } else {
606        // Handle image upload directly
607        let form = reqwest::multipart::Form::new().part("media", reqwest::multipart::Part::bytes(file_data));
608
609        let (response, _) = api::request_multipart::<Value>(&mut xplore.auth, upload_url, form).await?;
610
611        response["media_id_string"]
612            .as_str()
613            .map(String::from)
614            .ok_or_else(|| XploreError::Api("Failed to get media_id".into()))
615    }
616}
617
618async fn upload_video_in_chunks(xplore: &mut Xplore, file_data: Vec<u8>, media_type: &str) -> Result<String> {
619    let upload_url = "https://upload.twitter.com/1.1/media/upload.json";
620
621    let body = Some(json!({
622        "command": "INIT",
623        "total_bytes": file_data.len(),
624        "media_type": media_type
625    }));
626    let (init_response, _) = api::send_request::<Value>(&mut xplore.auth, upload_url, Method::POST, body).await?;
627
628    let media_id = init_response["media_id_string"]
629        .as_str()
630        .ok_or_else(|| XploreError::Api("Failed to get media_id".into()))?
631        .to_string();
632
633    // APPEND command - upload in chunks
634    let chunk_size = 5 * 1024 * 1024; // 5MB chunks
635
636    for (segment_index, chunk) in file_data.chunks(chunk_size).enumerate() {
637        let form = reqwest::multipart::Form::new()
638            .text("command", "APPEND")
639            .text("media_id", media_id.clone())
640            .text("segment_index", segment_index.to_string())
641            .part("media", reqwest::multipart::Part::bytes(chunk.to_vec()));
642
643        let _ = api::request_multipart::<Value>(&mut xplore.auth, upload_url, form).await?;
644    }
645
646    // FINALIZE command
647    let (finalize_response, _) = api::send_request::<Value>(
648        &mut xplore.auth,
649        &format!("{}?command=FINALIZE&media_id={}", upload_url, media_id),
650        Method::POST,
651        None,
652    )
653    .await?;
654
655    // Check processing status for videos
656    if finalize_response.get("processing_info").is_some() {
657        check_upload_status(xplore, &media_id).await?;
658    }
659
660    Ok(media_id)
661}
662
663async fn check_upload_status(xplore: &mut Xplore, media_id: &str) -> Result<()> {
664    let upload_url = "https://upload.twitter.com/1.1/media/upload.json";
665
666    for _ in 0..20 {
667        // Maximum 20 attempts
668        tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; // Wait 5 seconds
669
670        let url = &format!("{}?command=STATUS&media_id={}", upload_url, media_id);
671        let method = Method::GET;
672        let body = None;
673        let (status_response, _) = api::send_request::<Value>(&mut xplore.auth, url, method, body).await?;
674
675        if let Some(processing_info) = status_response.get("processing_info") {
676            match processing_info["state"].as_str() {
677                Some("succeeded") => return Ok(()),
678                Some("failed") => return Err(XploreError::Api("Video processing failed".into())),
679                _ => continue,
680            }
681        }
682    }
683
684    Err(XploreError::Api("Video processing timeout".into()))
685}
686
687pub async fn get_tweet(xplore: &mut Xplore, id: &str) -> Result<Tweet> {
688    let tweet_detail_request = Endpoints::tweet_detail(id);
689    let url = tweet_detail_request.to_request_url();
690
691    let (response, _) = api::send_request::<Value>(&mut xplore.auth, &url, Method::GET, None).await?;
692    let data = response.clone();
693    let conversation: ThreadedConversation = serde_json::from_value(data)?;
694    let tweets = parse_threaded_conversation(&conversation);
695    tweets.into_iter().next().ok_or_else(|| XploreError::Api("No tweets found".into()))
696}
697
698fn create_tweet_features() -> Value {
699    json!({
700        "interactive_text_enabled": true,
701        "longform_notetweets_inline_media_enabled": false,
702        "responsive_web_text_conversations_enabled": false,
703        "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
704        "vibe_api_enabled": false,
705        "rweb_lists_timeline_redesign_enabled": true,
706        "responsive_web_graphql_exclude_directive_enabled": true,
707        "verified_phone_label_enabled": false,
708        "creator_subscriptions_tweet_preview_api_enabled": true,
709        "responsive_web_graphql_timeline_navigation_enabled": true,
710        "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
711        "tweetypie_unmention_optimization_enabled": true,
712        "responsive_web_edit_tweet_api_enabled": true,
713        "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
714        "view_counts_everywhere_api_enabled": true,
715        "longform_notetweets_consumption_enabled": true,
716        "tweet_awards_web_tipping_enabled": false,
717        "freedom_of_speech_not_reach_fetch_enabled": true,
718        "standardized_nudges_misinfo": true,
719        "longform_notetweets_rich_text_read_enabled": true,
720        "responsive_web_enhance_cards_enabled": false,
721        "subscriptions_verification_info_enabled": true,
722        "subscriptions_verification_info_reason_enabled": true,
723        "subscriptions_verification_info_verified_since_enabled": true,
724        "super_follow_badge_privacy_enabled": false,
725        "super_follow_exclusive_tweet_notifications_enabled": false,
726        "super_follow_tweet_api_enabled": false,
727        "super_follow_user_api_enabled": false,
728        "android_graphql_skip_api_media_color_palette": false,
729        "creator_subscriptions_subscription_count_enabled": false,
730        "blue_business_profile_image_shape_enabled": false,
731        "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
732        "rweb_video_timestamps_enabled": false,
733        "c9s_tweet_anatomy_moderator_badge_enabled": false,
734        "responsive_web_twitter_article_tweet_consumption_enabled": false
735    })
736}
737
738fn get_default_features() -> Value {
739    json!({
740        "interactive_text_enabled": true,
741        "longform_notetweets_inline_media_enabled": false,
742        "responsive_web_text_conversations_enabled": false,
743        "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
744        "vibe_api_enabled": false,
745        "rweb_lists_timeline_redesign_enabled": true,
746        "responsive_web_graphql_exclude_directive_enabled": true,
747        "verified_phone_label_enabled": false,
748        "creator_subscriptions_tweet_preview_api_enabled": true,
749        "responsive_web_graphql_timeline_navigation_enabled": true,
750        "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
751        "tweetypie_unmention_optimization_enabled": true,
752        "responsive_web_edit_tweet_api_enabled": true,
753        "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
754        "view_counts_everywhere_api_enabled": true,
755        "longform_notetweets_consumption_enabled": true,
756        "tweet_awards_web_tipping_enabled": false,
757        "freedom_of_speech_not_reach_fetch_enabled": true,
758        "standardized_nudges_misinfo": true,
759        "longform_notetweets_rich_text_read_enabled": true,
760        "responsive_web_enhance_cards_enabled": false,
761        "subscriptions_verification_info_enabled": true,
762        "subscriptions_verification_info_reason_enabled": true,
763        "subscriptions_verification_info_verified_since_enabled": true,
764        "super_follow_badge_privacy_enabled": false,
765        "super_follow_exclusive_tweet_notifications_enabled": false,
766        "super_follow_tweet_api_enabled": false,
767        "super_follow_user_api_enabled": false,
768        "android_graphql_skip_api_media_color_palette": false,
769        "creator_subscriptions_subscription_count_enabled": false,
770        "blue_business_profile_image_shape_enabled": false,
771        "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
772        "rweb_video_timestamps_enabled": true,
773        "c9s_tweet_anatomy_moderator_badge_enabled": true,
774        "responsive_web_twitter_article_tweet_consumption_enabled": false,
775        "creator_subscriptions_quote_tweet_preview_enabled": false,
776        "profile_label_improvements_pcf_label_in_post_enabled": false,
777        "rweb_tipjar_consumption_enabled": true,
778        "articles_preview_enabled": true
779    })
780}
781
782// Helper function for long tweet features
783fn get_long_tweet_features() -> Value {
784    json!({
785        "premium_content_api_read_enabled": false,
786        "communities_web_enable_tweet_community_results_fetch": true,
787        "c9s_tweet_anatomy_moderator_badge_enabled": true,
788        "responsive_web_grok_analyze_button_fetch_trends_enabled": true,
789        "responsive_web_edit_tweet_api_enabled": true,
790        "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
791        "view_counts_everywhere_api_enabled": true,
792        "longform_notetweets_consumption_enabled": true,
793        "responsive_web_twitter_article_tweet_consumption_enabled": true,
794        "tweet_awards_web_tipping_enabled": false,
795        "longform_notetweets_rich_text_read_enabled": true,
796        "longform_notetweets_inline_media_enabled": true,
797        "responsive_web_graphql_exclude_directive_enabled": true,
798        "verified_phone_label_enabled": false,
799        "freedom_of_speech_not_reach_fetch_enabled": true,
800        "standardized_nudges_misinfo": true,
801        "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
802        "responsive_web_graphql_timeline_navigation_enabled": true,
803        "responsive_web_enhance_cards_enabled": false
804    })
805}
806
807pub async fn create_tweet_request(
808    xplore: &mut Xplore,
809    text: &str,
810    reply_to: Option<&str>,
811    media_data: Option<Vec<(Vec<u8>, String)>>,
812) -> Result<Value> {
813    // Prepare variables
814    let mut variables = json!({
815        "tweet_text": text,
816        "dark_request": false,
817        "media": {
818            "media_entities": [],
819            "possibly_sensitive": false
820        },
821        "semantic_annotation_ids": []
822    });
823
824    // Add reply information if provided
825    if let Some(reply_id) = reply_to {
826        variables["reply"] = json!({
827            "in_reply_to_tweet_id": reply_id
828        });
829    }
830
831    // Handle media uploads if provided
832    if let Some(media_files) = media_data {
833        let mut media_entities = Vec::new();
834
835        // Upload each media file and collect media IDs
836        for (file_data, media_type) in media_files {
837            let media_id = upload_media(xplore, file_data, &media_type).await?;
838            media_entities.push(json!({
839                "media_id": media_id,
840                "tagged_users": []
841            }));
842        }
843
844        variables["media"]["media_entities"] = json!(media_entities);
845    }
846    let features = create_tweet_features();
847
848    let url = "https://twitter.com/i/api/graphql/a1p9RWpkYKBjWv_I3WzS-A/CreateTweet";
849    let body = Some(json!({
850        "variables": variables,
851        "features": features,
852        "fieldToggles": {}
853    }));
854    let (value, _) = api::send_request(&mut xplore.auth, url, Method::POST, body).await?;
855    Ok(value)
856}
857
858fn create_quote_tweet_features() -> Value {
859    json!({
860        "interactive_text_enabled": true,
861        "longform_notetweets_inline_media_enabled": false,
862        "responsive_web_text_conversations_enabled": false,
863        "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
864        "vibe_api_enabled": false,
865        "rweb_lists_timeline_redesign_enabled": true,
866        "responsive_web_graphql_exclude_directive_enabled": true,
867        "verified_phone_label_enabled": false,
868        "creator_subscriptions_tweet_preview_api_enabled": true,
869        "responsive_web_graphql_timeline_navigation_enabled": true,
870        "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
871        "tweetypie_unmention_optimization_enabled": true,
872        "responsive_web_edit_tweet_api_enabled": true,
873        "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
874        "view_counts_everywhere_api_enabled": true,
875        "longform_notetweets_consumption_enabled": true,
876        "tweet_awards_web_tipping_enabled": false,
877        "freedom_of_speech_not_reach_fetch_enabled": true,
878        "standardized_nudges_misinfo": true,
879        "longform_notetweets_rich_text_read_enabled": true,
880        "responsive_web_enhance_cards_enabled": false,
881        "subscriptions_verification_info_enabled": true,
882        "subscriptions_verification_info_reason_enabled": true,
883        "subscriptions_verification_info_verified_since_enabled": true,
884        "super_follow_badge_privacy_enabled": false,
885        "super_follow_exclusive_tweet_notifications_enabled": false,
886        "super_follow_tweet_api_enabled": false,
887        "super_follow_user_api_enabled": false,
888        "android_graphql_skip_api_media_color_palette": false,
889        "creator_subscriptions_subscription_count_enabled": false,
890        "blue_business_profile_image_shape_enabled": false,
891        "unified_cards_ad_metadata_container_dynamic_card_content_query_enabled": false,
892        "rweb_video_timestamps_enabled": true,
893        "c9s_tweet_anatomy_moderator_badge_enabled": true,
894        "responsive_web_twitter_article_tweet_consumption_enabled": false
895    })
896}
897
898pub async fn fetch_user_tweets(
899    xplore: &mut Xplore,
900    user_id: &str,
901    max_tweets: i32,
902    cursor: Option<&str>,
903) -> Result<QueryTweetsResponse> {
904    let endpoint = Endpoints::user_tweets(user_id, max_tweets.min(200), cursor);
905    let url = &endpoint.to_request_url();
906
907    let (value, _) = api::send_request(&mut xplore.auth, url, Method::GET, None).await?;
908
909    let parsed_response = parse_timeline_tweets_v2(&value);
910    Ok(parsed_response)
911}