agent_twitter_client/
profile.rs

1use crate::api::requests::request_api;
2use crate::error::{Result, TwitterError};
3use crate::models::Profile;
4use chrono::{DateTime, Utc};
5use lazy_static::lazy_static;
6use reqwest::header::HeaderMap;
7use reqwest::Method;
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10use std::collections::HashMap;
11use std::sync::Mutex;
12use crate::api::client::TwitterClient;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct UserProfile {
16    pub id: String,
17    pub id_str: String,
18    pub name: String,
19    pub screen_name: String,
20    pub location: Option<String>,
21    pub description: Option<String>,
22    pub url: Option<String>,
23    pub protected: bool,
24    pub followers_count: i32,
25    pub friends_count: i32,
26    pub listed_count: i32,
27    pub created_at: String,
28    pub favourites_count: i32,
29    pub verified: bool,
30    pub statuses_count: i32,
31    pub profile_image_url_https: String,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct LegacyUserRaw {
36    pub created_at: Option<String>,
37    pub description: Option<String>,
38    pub entities: Option<UserEntitiesRaw>,
39    pub favourites_count: Option<i32>,
40    pub followers_count: Option<i32>,
41    pub friends_count: Option<i32>,
42    pub media_count: Option<i32>,
43    pub statuses_count: Option<i32>,
44    pub id_str: Option<String>,
45    pub listed_count: Option<i32>,
46    pub name: Option<String>,
47    pub location: String,
48    pub geo_enabled: Option<bool>,
49    pub pinned_tweet_ids_str: Option<Vec<String>>,
50    pub profile_background_color: Option<String>,
51    pub profile_banner_url: Option<String>,
52    pub profile_image_url_https: Option<String>,
53    pub protected: Option<bool>,
54    pub screen_name: Option<String>,
55    pub verified: Option<bool>,
56    pub has_custom_timelines: Option<bool>,
57    pub has_extended_profile: Option<bool>,
58    pub url: Option<String>,
59    pub can_dm: Option<bool>,
60    #[serde(rename = "userId")]
61    pub user_id: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct UserEntitiesRaw {
66    pub url: Option<UserUrlEntity>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct UserUrlEntity {
71    pub urls: Option<Vec<ExpandedUrl>>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ExpandedUrl {
76    pub expanded_url: Option<String>,
77}
78
79lazy_static! {
80    static ref ID_CACHE: Mutex<HashMap<String, String>> = Mutex::new(HashMap::new());
81}
82
83pub fn parse_profile(user: &LegacyUserRaw, is_blue_verified: Option<bool>) -> Profile {
84    let mut profile = Profile {
85        id: user.user_id.clone().unwrap_or_default(),
86        username: user.screen_name.clone().unwrap_or_default(),
87        name: user.name.clone().unwrap_or_default(),
88        description: user.description.clone(),
89        location: Some(user.location.clone()),
90        url: user.url.clone(),
91        protected: user.protected.unwrap_or(false),
92        verified: user.verified.unwrap_or(false),
93        followers_count: user.followers_count.unwrap_or(0),
94        following_count: user.friends_count.unwrap_or(0),
95        tweets_count: user.statuses_count.unwrap_or(0),
96        listed_count: user.listed_count.unwrap_or(0),
97        is_blue_verified: Some(is_blue_verified.unwrap_or(false)),
98        created_at: user
99            .created_at
100            .as_ref()
101            .and_then(|date_str| {
102                DateTime::parse_from_str(date_str, "%a %b %d %H:%M:%S %z %Y")
103                    .ok()
104                    .map(|dt| dt.with_timezone(&Utc))
105            })
106            .unwrap_or_else(Utc::now),
107        profile_image_url: user
108            .profile_image_url_https
109            .as_ref()
110            .map(|url| url.replace("_normal", "")),
111        profile_banner_url: user.profile_banner_url.clone(),
112        pinned_tweet_id: user
113            .pinned_tweet_ids_str
114            .as_ref()
115            .and_then(|ids| ids.first().cloned()),
116    };
117
118    // Set website URL from entities using functional chaining
119    user.entities
120        .as_ref()
121        .and_then(|entities| entities.url.as_ref())
122        .and_then(|url_entity| url_entity.urls.as_ref())
123        .and_then(|urls| urls.first())
124        .and_then(|first_url| first_url.expanded_url.as_ref())
125        .map(|expanded_url| profile.url = Some(expanded_url.clone()));
126
127    profile
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct UserResults {
132    pub result: UserResult,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(tag = "__typename")]
137pub enum UserResult {
138    User(UserData),
139    UserUnavailable(UserUnavailable),
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct UserData {
144    pub id: String,
145    pub rest_id: String,
146    pub affiliates_highlighted_label: Option<serde_json::Value>,
147    pub has_graduated_access: bool,
148    pub is_blue_verified: bool,
149    pub profile_image_shape: String,
150    pub legacy: LegacyUserRaw,
151    pub smart_blocked_by: bool,
152    pub smart_blocking: bool,
153    pub legacy_extended_profile: Option<serde_json::Value>,
154    pub is_profile_translatable: bool,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct UserUnavailable {
159    pub reason: String,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct UserRaw {
164    pub data: UserRawData,
165    pub errors: Option<Vec<TwitterApiErrorRaw>>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct UserRawData {
170    pub user: UserRawUser,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct UserRawUser {
175    pub result: UserRawResult,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct UserRawResult {
180    pub rest_id: Option<String>,
181    pub is_blue_verified: Option<bool>,
182    pub legacy: LegacyUserRaw,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct TwitterApiErrorRaw {
187    pub message: String,
188    pub code: i32,
189}
190
191pub async fn get_profile(client: &TwitterClient, screen_name: &str) -> Result<Profile> {
192    let mut headers = HeaderMap::new();
193    client.auth.install_headers(&mut headers).await?;
194
195    let variables = json!({
196        "screen_name": screen_name,
197        "withSafetyModeUserFields": true
198    });
199
200    let features = json!({
201        "hidden_profile_likes_enabled": false,
202        "hidden_profile_subscriptions_enabled": false,
203        "responsive_web_graphql_exclude_directive_enabled": true,
204        "verified_phone_label_enabled": false,
205        "subscriptions_verification_info_is_identity_verified_enabled": false,
206        "subscriptions_verification_info_verified_since_enabled": true,
207        "highlights_tweets_tab_ui_enabled": true,
208        "creator_subscriptions_tweet_preview_api_enabled": true,
209        "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
210        "responsive_web_graphql_timeline_navigation_enabled": true
211    });
212
213    let field_toggles = json!({
214        "withAuxiliaryUserLabels": false
215    });
216
217    let (response, _) = request_api::<UserRaw>(
218        &client.client,
219        "https://twitter.com/i/api/graphql/G3KGOASz96M-Qu0nwmGXNg/UserByScreenName",
220        headers,
221        Method::GET,
222        Some(json!({
223            "variables": variables,
224            "features": features,
225            "fieldToggles": field_toggles
226        })),
227    )
228    .await?;
229
230    if let Some(errors) = response.errors {
231        if !errors.is_empty() {
232            return Err(TwitterError::Api(errors[0].message.clone()));
233        }
234    }
235    let user_raw_result = &response.data.user.result;
236    let mut legacy = user_raw_result.legacy.clone();
237    let rest_id = user_raw_result.rest_id.clone();
238    let is_blue_verified = user_raw_result.is_blue_verified;
239    legacy.user_id = rest_id;
240    if legacy.screen_name.is_none() || legacy.screen_name.as_ref().unwrap().is_empty() {
241        return Err(TwitterError::Api(format!(
242            "Either {} does not exist or is private.",
243            screen_name
244        )));
245    }
246    Ok(parse_profile(&legacy, is_blue_verified))
247}
248
249pub async fn get_screen_name_by_user_id(client: &TwitterClient, user_id: &str) -> Result<String> {
250    let mut headers = HeaderMap::new();
251    client.auth.install_headers(&mut headers).await?;
252
253    let variables = json!({
254        "userId": user_id,
255        "withSafetyModeUserFields": true
256    });
257
258    let features = json!({
259        "hidden_profile_subscriptions_enabled": true,
260        "rweb_tipjar_consumption_enabled": true,
261        "responsive_web_graphql_exclude_directive_enabled": true,
262        "verified_phone_label_enabled": false,
263        "highlights_tweets_tab_ui_enabled": true,
264        "responsive_web_twitter_article_notes_tab_enabled": true,
265        "subscriptions_feature_can_gift_premium": false,
266        "creator_subscriptions_tweet_preview_api_enabled": true,
267        "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
268        "responsive_web_graphql_timeline_navigation_enabled": true
269    });
270
271    let (response, _) = request_api::<UserRaw>(
272        &client.client,
273        "https://twitter.com/i/api/graphql/xf3jd90KKBCUxdlI_tNHZw/UserByRestId",
274        headers,
275        Method::GET,
276        Some(json!({
277            "variables": variables,
278            "features": features
279        })),
280    )
281    .await?;
282
283    if let Some(errors) = response.errors {
284        if !errors.is_empty() {
285            return Err(TwitterError::Api(errors[0].message.clone()));
286        }
287    }
288
289    if let Some(user) = response.data.user.result.legacy.screen_name {
290        Ok(user)
291    } else {
292        Err(TwitterError::Api(format!(
293            "Either user with ID {} does not exist or is private.",
294            user_id
295        )))
296    }
297}
298
299pub async fn get_user_id_by_screen_name(
300    client: &TwitterClient,
301    screen_name: &str,
302) -> Result<String> {
303    if let Some(cached_id) = ID_CACHE.lock().unwrap().get(screen_name) {
304        return Ok(cached_id.clone());
305    }
306
307    let profile = get_profile(client, screen_name).await?;
308    if let Some(user_id) = Some(profile.id) {
309        ID_CACHE
310            .lock()
311            .unwrap()
312            .insert(screen_name.to_string(), user_id.clone());
313        Ok(user_id)
314    } else {
315        Err(TwitterError::Api("User ID is undefined".into()))
316    }
317}