rig_twitter/
profile.rs

1use crate::api::requests::request_api;
2use crate::auth::TwitterAuth;
3use crate::error::{Result, TwitterError};
4use crate::models::Profile;
5use chrono::{DateTime, Utc};
6use lazy_static::lazy_static;
7use reqwest::header::HeaderMap;
8use reqwest::Method;
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use std::collections::HashMap;
12use std::sync::Mutex;
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 if available
119    if let Some(entities) = &user.entities {
120        if let Some(url_entity) = &entities.url {
121            if let Some(urls) = &url_entity.urls {
122                if let Some(first_url) = urls.first() {
123                    if let Some(expanded_url) = &first_url.expanded_url {
124                        profile.url = Some(expanded_url.clone());
125                    }
126                }
127            }
128        }
129    }
130
131    profile
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct UserResults {
136    pub result: UserResult,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(tag = "__typename")]
141pub enum UserResult {
142    User(UserData),
143    UserUnavailable(UserUnavailable),
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct UserData {
148    pub id: String,
149    pub rest_id: String,
150    pub affiliates_highlighted_label: Option<serde_json::Value>,
151    pub has_graduated_access: bool,
152    pub is_blue_verified: bool,
153    pub profile_image_shape: String,
154    pub legacy: LegacyUserRaw,
155    pub smart_blocked_by: bool,
156    pub smart_blocking: bool,
157    pub legacy_extended_profile: Option<serde_json::Value>,
158    pub is_profile_translatable: bool,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct UserUnavailable {
163    pub reason: String,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct UserRaw {
168    pub data: UserRawData,
169    pub errors: Option<Vec<TwitterApiErrorRaw>>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct UserRawData {
174    pub user: UserRawUser,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct UserRawUser {
179    pub result: UserRawResult,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct UserRawResult {
184    pub rest_id: Option<String>,
185    pub is_blue_verified: Option<bool>,
186    pub legacy: LegacyUserRaw,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct TwitterApiErrorRaw {
191    pub message: String,
192    pub code: i32,
193}
194
195pub async fn get_profile(screen_name: &str, auth: &dyn TwitterAuth) -> Result<Profile> {
196    let mut headers = HeaderMap::new();
197    auth.install_headers(&mut headers).await?;
198
199    let variables = json!({
200        "screen_name": screen_name,
201        "withSafetyModeUserFields": true
202    });
203
204    let features = json!({
205        "hidden_profile_likes_enabled": false,
206        "hidden_profile_subscriptions_enabled": false,
207        "responsive_web_graphql_exclude_directive_enabled": true,
208        "verified_phone_label_enabled": false,
209        "subscriptions_verification_info_is_identity_verified_enabled": false,
210        "subscriptions_verification_info_verified_since_enabled": true,
211        "highlights_tweets_tab_ui_enabled": true,
212        "creator_subscriptions_tweet_preview_api_enabled": true,
213        "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
214        "responsive_web_graphql_timeline_navigation_enabled": true
215    });
216
217    let field_toggles = json!({
218        "withAuxiliaryUserLabels": false
219    });
220
221    let (response, _) = request_api::<UserRaw>(
222        "https://twitter.com/i/api/graphql/G3KGOASz96M-Qu0nwmGXNg/UserByScreenName",
223        headers,
224        Method::GET,
225        Some(json!({
226            "variables": variables,
227            "features": features,
228            "fieldToggles": field_toggles
229        })),
230    )
231    .await?;
232
233    // Check for API errors
234    if let Some(errors) = response.errors {
235        if !errors.is_empty() {
236            return Err(TwitterError::Api(errors[0].message.clone()));
237        }
238    }
239    let user_raw_result = &response.data.user.result;
240    let mut legacy = user_raw_result.legacy.clone();
241    let rest_id = user_raw_result.rest_id.clone();
242    let is_blue_verified = user_raw_result.is_blue_verified;
243    legacy.user_id = rest_id;
244    if legacy.screen_name.is_none() || legacy.screen_name.as_ref().unwrap().is_empty() {
245        return Err(TwitterError::Api(format!(
246            "Either {} does not exist or is private.",
247            screen_name
248        )));
249    }
250    Ok(parse_profile(&legacy, is_blue_verified))
251}
252
253pub async fn get_screen_name_by_user_id(user_id: &str, auth: &dyn TwitterAuth) -> Result<String> {
254    let mut headers = HeaderMap::new();
255    auth.install_headers(&mut headers).await?;
256
257    let variables = json!({
258        "userId": user_id,
259        "withSafetyModeUserFields": true
260    });
261
262    let features = json!({
263        "hidden_profile_subscriptions_enabled": true,
264        "rweb_tipjar_consumption_enabled": true,
265        "responsive_web_graphql_exclude_directive_enabled": true,
266        "verified_phone_label_enabled": false,
267        "highlights_tweets_tab_ui_enabled": true,
268        "responsive_web_twitter_article_notes_tab_enabled": true,
269        "subscriptions_feature_can_gift_premium": false,
270        "creator_subscriptions_tweet_preview_api_enabled": true,
271        "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
272        "responsive_web_graphql_timeline_navigation_enabled": true
273    });
274
275    let (response, _) = request_api::<UserRaw>(
276        "https://twitter.com/i/api/graphql/xf3jd90KKBCUxdlI_tNHZw/UserByRestId",
277        headers,
278        Method::GET,
279        Some(json!({
280            "variables": variables,
281            "features": features
282        })),
283    )
284    .await?;
285
286    if let Some(errors) = response.errors {
287        if !errors.is_empty() {
288            return Err(TwitterError::Api(errors[0].message.clone()));
289        }
290    }
291
292    if let Some(user) = response.data.user.result.legacy.screen_name {
293        Ok(user)
294    } else {
295        Err(TwitterError::Api(format!(
296            "Either user with ID {} does not exist or is private.",
297            user_id
298        )))
299    }
300}
301
302pub async fn get_user_id_by_screen_name(
303    screen_name: &str,
304    auth: &dyn TwitterAuth,
305) -> Result<String> {
306    // Check cache first
307    if let Some(cached_id) = ID_CACHE.lock().unwrap().get(screen_name) {
308        return Ok(cached_id.clone());
309    }
310
311    let profile = get_profile(screen_name, auth).await?;
312    println!("profile: {:?}", profile);
313    if let Some(user_id) = Some(profile.id) {
314        // Update cache
315        ID_CACHE
316            .lock()
317            .unwrap()
318            .insert(screen_name.to_string(), user_id.clone());
319        Ok(user_id)
320    } else {
321        Err(TwitterError::Api("User ID is undefined".into()))
322    }
323}