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}