agent_twitter_client/
relationships.rs

1use crate::api::requests::request_api;
2use crate::api::requests::request_form_api;
3use crate::error::{Result, TwitterError};
4use crate::models::Profile;
5use crate::timeline::v1::QueryProfilesResponse;
6use chrono::{DateTime, Utc};
7use reqwest::Method;
8use serde::Deserialize;
9use serde_json::{json, Value};
10use crate::api::client::TwitterClient;
11#[derive(Debug, Deserialize)]
12pub struct RelationshipResponse {
13    pub data: Option<RelationshipData>,
14    #[serde(skip)]
15    pub errors: Option<Vec<TwitterError>>,
16}
17
18#[derive(Debug, Deserialize)]
19pub struct RelationshipData {
20    pub user: UserRelationships,
21}
22
23#[derive(Debug, Deserialize)]
24pub struct UserRelationships {
25    pub result: UserResult,
26}
27
28#[derive(Debug, Deserialize)]
29pub struct UserResult {
30    pub timeline: Timeline,
31    pub rest_id: Option<String>,
32}
33
34#[derive(Debug, Deserialize)]
35pub struct Timeline {
36    pub timeline: TimelineData,
37}
38
39#[derive(Debug, Deserialize)]
40pub struct TimelineData {
41    pub instructions: Vec<TimelineInstruction>,
42}
43
44#[derive(Debug, Deserialize)]
45#[serde(tag = "type")]
46pub enum TimelineInstruction {
47    #[serde(rename = "TimelineAddEntries")]
48    AddEntries { entries: Vec<TimelineEntry> },
49    #[serde(rename = "TimelineReplaceEntry")]
50    ReplaceEntry { entry: TimelineEntry },
51}
52
53#[derive(Debug, Deserialize)]
54pub struct TimelineEntry {
55    pub content: EntryContent,
56    pub entry_id: String,
57    pub sort_index: String,
58}
59
60#[derive(Debug, Deserialize)]
61pub struct EntryContent {
62    #[serde(rename = "itemContent")]
63    pub item_content: Option<ItemContent>,
64    pub cursor: Option<CursorContent>,
65}
66
67#[derive(Debug, Deserialize)]
68pub struct ItemContent {
69    #[serde(rename = "user_results")]
70    pub user_results: Option<UserResults>,
71    #[serde(rename = "userDisplayType")]
72    pub user_display_type: Option<String>,
73}
74
75#[derive(Debug, Deserialize)]
76pub struct UserResults {
77    pub result: UserResultData,
78}
79
80#[derive(Debug, Deserialize)]
81pub struct UserResultData {
82    #[serde(rename = "typename")]
83    pub type_name: Option<String>,
84    #[serde(rename = "mediaColor")]
85    pub media_color: Option<MediaColor>,
86    pub id: Option<String>,
87    pub rest_id: Option<String>,
88    pub affiliates_highlighted_label: Option<Value>,
89    pub has_graduated_access: Option<bool>,
90    pub is_blue_verified: Option<bool>,
91    pub profile_image_shape: Option<String>,
92    pub legacy: Option<UserLegacy>,
93    pub professional: Option<Professional>,
94}
95
96#[derive(Debug, Deserialize)]
97pub struct MediaColor {
98    pub r: Option<ColorPalette>,
99}
100
101#[derive(Debug, Deserialize)]
102pub struct ColorPalette {
103    pub ok: Option<Value>,
104}
105
106#[derive(Debug, Deserialize)]
107pub struct UserLegacy {
108    pub following: Option<bool>,
109    pub followed_by: Option<bool>,
110    pub screen_name: Option<String>,
111    pub name: Option<String>,
112    pub description: Option<String>,
113    pub location: Option<String>,
114    pub url: Option<String>,
115    pub protected: Option<bool>,
116    pub verified: Option<bool>,
117    pub followers_count: Option<i32>,
118    pub friends_count: Option<i32>,
119    pub statuses_count: Option<i32>,
120    pub listed_count: Option<i32>,
121    pub created_at: Option<String>,
122    pub profile_image_url_https: Option<String>,
123    pub profile_banner_url: Option<String>,
124    pub pinned_tweet_ids_str: Option<String>,
125}
126
127#[derive(Debug, Deserialize)]
128pub struct Professional {
129    pub rest_id: Option<String>,
130    pub professional_type: Option<String>,
131    pub category: Option<Vec<ProfessionalCategory>>,
132}
133
134#[derive(Debug, Deserialize)]
135pub struct ProfessionalCategory {
136    pub id: i64,
137    pub name: String,
138}
139
140#[derive(Debug, Deserialize)]
141pub struct CursorContent {
142    pub value: String,
143    pub cursor_type: Option<String>,
144}
145
146#[derive(Debug, Deserialize)]
147pub struct RelationshipTimeline {
148    pub data: Option<RelationshipTimelineData>,
149    pub errors: Option<Vec<TwitterError>>,
150}
151
152#[derive(Debug, Deserialize)]
153pub struct RelationshipTimelineData {
154    pub user: UserData,
155}
156
157#[derive(Debug, Deserialize)]
158pub struct UserData {
159    pub result: RelationshipUserResult,
160}
161
162#[derive(Debug, Deserialize)]
163pub struct RelationshipUserResult {
164    pub timeline: Timeline,
165}
166
167#[derive(Debug, Deserialize)]
168pub struct InnerTimeline {
169    pub instructions: Vec<Instruction>,
170}
171
172#[derive(Debug, Deserialize)]
173#[serde(tag = "type")]
174pub enum Instruction {
175    #[serde(rename = "TimelineAddEntries")]
176    AddEntries {
177        entries: Vec<RelationshipTimelineEntry>,
178    },
179    #[serde(rename = "TimelineReplaceEntry")]
180    ReplaceEntry { entry: RelationshipTimelineEntry },
181}
182
183#[derive(Debug, Deserialize)]
184pub struct RelationshipTimelineEntry {
185    pub content: EntryContent,
186    pub entry_id: String,
187    pub sort_index: String,
188}
189
190#[derive(Debug, Deserialize)]
191pub struct RelationshipTimelineContainer {
192    pub timeline: InnerTimeline,
193}
194
195#[derive(Debug, Deserialize)]
196pub struct RelationshipTimelineWrapper {
197    pub timeline: InnerTimeline,
198}
199pub async fn get_following(
200    client: &TwitterClient,
201    user_id: &str,
202    count: i32,
203    cursor: Option<String>,
204) -> Result<(Vec<Profile>, Option<String>)> {
205    let response = fetch_profile_following(client, user_id, count, cursor).await?;
206    Ok((response.profiles, response.next))
207}
208pub async fn get_followers(
209    client: &TwitterClient,
210    user_id: &str,
211    count: i32,
212    cursor: Option<String>,
213) -> Result<(Vec<Profile>, Option<String>)> {
214    let response = fetch_profile_following(client, user_id, count, cursor).await?;
215    Ok((response.profiles, response.next))
216}
217
218pub async fn fetch_profile_following(
219    client: &TwitterClient,
220    user_id: &str,
221    max_profiles: i32,
222    cursor: Option<String>,
223) -> Result<QueryProfilesResponse> {
224    let timeline = get_following_timeline(client, user_id, max_profiles, cursor).await?;
225
226    Ok(parse_relationship_timeline(&timeline))
227}
228
229async fn get_following_timeline(
230    client: &TwitterClient,
231    user_id: &str,
232    max_items: i32,
233    cursor: Option<String>,
234) -> Result<RelationshipTimeline> {
235
236    let count = if max_items > 50 { 50 } else { max_items };
237
238    let mut variables = json!({
239        "userId": user_id,
240        "count": count,
241        "includePromotedContent": false,
242    });
243
244    if let Some(cursor_val) = cursor {
245        if !cursor_val.is_empty() {
246            variables["cursor"] = json!(cursor_val);
247        }
248    }
249
250    let features = json!({
251        "responsive_web_twitter_article_tweet_consumption_enabled": false,
252        "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
253        "longform_notetweets_inline_media_enabled": true,
254        "responsive_web_media_download_video_enabled": false,
255    });
256
257    let url = format!(
258        "https://twitter.com/i/api/graphql/iSicc7LrzWGBgDPL0tM_TQ/Following?variables={}&features={}",
259        urlencoding::encode(&variables.to_string()),
260        urlencoding::encode(&features.to_string())
261    );
262
263    let mut headers = reqwest::header::HeaderMap::new();
264    client.auth.install_headers(&mut headers).await?;
265
266    let (_data, _) = request_api::<RelationshipTimeline>(&client.client, &url, headers, Method::GET, None).await?;
267
268    Ok(_data)
269}
270
271fn parse_relationship_timeline(timeline: &RelationshipTimeline) -> QueryProfilesResponse {
272    let mut profiles = Vec::new();
273    let mut next_cursor = None;
274    let mut previous_cursor = None;
275
276    if let Some(data) = &timeline.data {
277        for instruction in &data.user.result.timeline.timeline.instructions {
278            match instruction {
279                TimelineInstruction::AddEntries { entries } => {
280                    for entry in entries {
281                        if let Some(item_content) = &entry.content.item_content {
282                            if let Some(user_results) = &item_content.user_results {
283                                if let Some(legacy) = &user_results.result.legacy {
284                                    let profile = Profile {
285                                        username: legacy.screen_name.clone().unwrap_or_default(),
286                                        name: legacy.name.clone().unwrap_or_default(),
287                                        id: user_results
288                                            .result
289                                            .rest_id
290                                            .as_ref()
291                                            .map(String::from)
292                                            .unwrap_or_default(),
293                                        description: legacy.description.clone(),
294                                        location: legacy.location.clone(),
295                                        url: legacy.url.clone(),
296                                        protected: legacy.protected.unwrap_or_default(),
297                                        verified: legacy.verified.unwrap_or_default(),
298                                        followers_count: legacy.followers_count.unwrap_or_default(),
299                                        following_count: legacy.friends_count.unwrap_or_default(),
300                                        tweets_count: legacy.statuses_count.unwrap_or_default(),
301                                        listed_count: legacy.listed_count.unwrap_or_default(),
302                                        created_at: legacy
303                                            .created_at
304                                            .as_ref()
305                                            .and_then(|date| {
306                                                DateTime::parse_from_str(
307                                                    date,
308                                                    "%a %b %d %H:%M:%S %z %Y",
309                                                )
310                                                .ok()
311                                                .map(|dt| dt.with_timezone(&Utc))
312                                            })
313                                            .unwrap_or_default(),
314                                        profile_image_url: legacy.profile_image_url_https.clone(),
315                                        profile_banner_url: legacy.profile_banner_url.clone(),
316                                        pinned_tweet_id: legacy.pinned_tweet_ids_str.clone(),
317                                        is_blue_verified: Some(
318                                            user_results.result.is_blue_verified.unwrap_or(false),
319                                        ),
320                                    };
321
322                                    profiles.push(profile);
323                                }
324                            }
325                        } else if let Some(cursor_content) = &entry.content.cursor {
326                            match cursor_content.cursor_type.as_deref() {
327                                Some("Bottom") => next_cursor = Some(cursor_content.value.clone()),
328                                Some("Top") => previous_cursor = Some(cursor_content.value.clone()),
329                                _ => {}
330                            }
331                        }
332                    }
333                }
334                TimelineInstruction::ReplaceEntry { entry } => {
335                    if let Some(cursor_content) = &entry.content.cursor {
336                        match cursor_content.cursor_type.as_deref() {
337                            Some("Bottom") => next_cursor = Some(cursor_content.value.clone()),
338                            Some("Top") => previous_cursor = Some(cursor_content.value.clone()),
339                            _ => {}
340                        }
341                    }
342                }
343            }
344        }
345    }
346
347    QueryProfilesResponse {
348        profiles,
349        next: next_cursor,
350        previous: previous_cursor,
351    }
352}
353
354pub async fn follow_user(client: &TwitterClient, username: &str) -> Result<()> {
355    let user_id = crate::profile::get_user_id_by_screen_name(client, username).await?;
356
357    let url = "https://api.twitter.com/1.1/friendships/create.json";
358
359    let form = vec![
360        (
361            "include_profile_interstitial_type".to_string(),
362            "1".to_string(),
363        ),
364        ("skip_status".to_string(), "true".to_string()),
365        ("user_id".to_string(), user_id),
366    ];
367
368    let mut headers = reqwest::header::HeaderMap::new();
369    client.auth.install_headers(&mut headers).await?;
370
371    headers.insert(
372        "Content-Type",
373        "application/x-www-form-urlencoded".parse().unwrap(),
374    );
375    headers.insert(
376        "Referer",
377        format!("https://twitter.com/{}", username).parse().unwrap(),
378    );
379    headers.insert("X-Twitter-Active-User", "yes".parse().unwrap());
380    headers.insert("X-Twitter-Auth-Type", "OAuth2Session".parse().unwrap());
381    headers.insert("X-Twitter-Client-Language", "en".parse().unwrap());
382
383    let (_, _) = request_form_api::<Value>(&client.client, url, headers, form).await?;
384
385    Ok(())
386}
387
388pub async fn unfollow_user(client: &TwitterClient, username: &str) -> Result<()> {
389
390    let user_id = crate::profile::get_user_id_by_screen_name(client, username).await?;
391
392    let url = "https://api.twitter.com/1.1/friendships/destroy.json";
393
394    let form = vec![
395        (
396            "include_profile_interstitial_type".to_string(),
397            "1".to_string(),
398        ),
399        ("skip_status".to_string(), "true".to_string()),
400        ("user_id".to_string(), user_id),
401    ];
402
403    let mut headers = reqwest::header::HeaderMap::new();
404    client.auth.install_headers(&mut headers).await?;
405
406    headers.insert(
407        "Content-Type",
408        "application/x-www-form-urlencoded".parse().unwrap(),
409    );
410    headers.insert(
411        "Referer",
412        format!("https://twitter.com/{}", username).parse().unwrap(),
413    );
414    headers.insert("X-Twitter-Active-User", "yes".parse().unwrap());
415    headers.insert("X-Twitter-Auth-Type", "OAuth2Session".parse().unwrap());
416    headers.insert("X-Twitter-Client-Language", "en".parse().unwrap());
417
418    let (_, _) = request_form_api::<Value>(&client.client, url, headers, form).await?;
419
420    Ok(())
421}