Skip to main content

steam_user/services/
profile.rs

1//! Profile management services.
2
3use reqwest::StatusCode;
4use scraper::{Html, Selector};
5use steamid::SteamID;
6
7use crate::{
8    client::SteamUser,
9    endpoint::steam_endpoint,
10    error::SteamUserError,
11    types::{ProfileSettings, SteamProfile},
12    utils::debug::dump_html,
13};
14
15impl SteamUser {
16    /// Updates the authenticated user's profile settings (name, real name,
17    /// summary, etc.).
18    ///
19    /// This method preserves existing settings for any fields not specified in
20    /// the `settings` argument.
21    #[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/edit", kind = Write)]
22    pub async fn edit_profile(&self, settings: ProfileSettings) -> Result<(), SteamUserError> {
23        // 1. Get current settings to preserve values we aren't changing
24        let html_content = self.my_profile_get("edit/info").await?;
25
26        // 2. Parse the HTML response
27        let document = scraper::Html::parse_document(&html_content);
28        let selector = scraper::Selector::parse("#profile_edit_config").map_err(|e| SteamUserError::Other(format!("Failed to parse selector: {:?}", e)))?;
29
30        let element = document.select(&selector).next().ok_or_else(|| {
31            dump_html("profile_edit_config_missing", &html_content);
32            SteamUserError::Other("Could not find #profile_edit_config element".to_string())
33        })?;
34
35        let existing_data_json = element.value().attr("data-profile-edit").ok_or_else(|| SteamUserError::Other("data-profile-edit attribute missing".to_string()))?;
36
37        #[derive(serde::Deserialize)]
38        struct LocationData {
39            #[serde(rename = "locCountryCode")]
40            loc_country_code: Option<String>,
41            #[serde(rename = "locStateCode")]
42            loc_state_code: Option<String>,
43            #[serde(rename = "locCityCode")]
44            loc_city_code: Option<String>,
45        }
46
47        #[derive(serde::Deserialize)]
48        struct ProfileEditConfig {
49            #[serde(rename = "strPersonaName")]
50            str_persona_name: String,
51            #[serde(rename = "strRealName")]
52            str_real_name: String,
53            #[serde(rename = "strSummary")]
54            str_summary: String,
55            #[serde(rename = "strCustomURL")]
56            str_custom_url: String,
57            #[serde(rename = "LocationData")]
58            location_data: LocationData,
59        }
60
61        let existing_settings: ProfileEditConfig = serde_json::from_str(existing_data_json).map_err(|e| SteamUserError::Other(format!("Failed to parse profile config JSON: {}", e)))?;
62
63        // 3. Merge settings
64        let name = settings.name.as_deref().unwrap_or(&existing_settings.str_persona_name);
65        let real_name = settings.real_name.as_deref().unwrap_or(&existing_settings.str_real_name);
66        let summary = settings.summary.as_deref().unwrap_or(&existing_settings.str_summary);
67
68        // Location logic: if country is provided, use it. If not, use existing.
69        // If country is updated, state and city should probably be reset or carefully
70        // handled? simple merge:
71        let country = settings.country.as_deref().or(existing_settings.location_data.loc_country_code.as_deref()).unwrap_or("");
72        let state = settings.state.as_deref().or(existing_settings.location_data.loc_state_code.as_deref()).unwrap_or("");
73        let city = settings.city.as_deref().or(existing_settings.location_data.loc_city_code.as_deref()).unwrap_or("");
74
75        let custom_url = settings.custom_url.as_deref().unwrap_or(&existing_settings.str_custom_url);
76
77        let mut form = vec![("type", "profileSave"), ("personaName", name), ("real_name", real_name), ("summary", summary), ("country", country), ("state", state), ("city", city), ("customURL", custom_url), ("json", "1")];
78
79        // Primary group
80        let group_id_str;
81        if let Some(gid) = settings.primary_group {
82            group_id_str = gid.to_string();
83            form.push(("primary_group_steamid", &group_id_str));
84        } else {
85            // We can't easily scrape primary group from the config object shown
86            // in JS? JS implementation:
87            // case 'primaryGroup': ...
88            // It doesn't seem to scrape primary group to preserve it
89            // explicitly, but maybe the form submission doesn't
90            // change it if omitted? The JS implementation creates a
91            // "values" object with defaults from existingSettings.
92            // But existingSettings in JS (from data-profile-edit) doesn't seem
93            // to have primary group? Wait, the JS `values` object
94            // DOES NOT include primary_group_steamid by default. It
95            // only adds it if `settings.primaryGroup` is present.
96            // So if we don't send it, it probably remains unchanged.
97        }
98
99        self.my_profile_post("edit", &form).await?;
100
101        // If custom URL changed, clear profile URL cache (if we had one, but we don't
102        // seem to cache it here)
103
104        Ok(())
105    }
106
107    /// Updates only the persona (display) name of the authenticated user.
108    #[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/ajaxsetpersonaname", kind = Write)]
109    pub async fn set_persona_name(&self, name: &str) -> Result<(), SteamUserError> {
110        tracing::info!(persona_name = %name, "set_persona_name called");
111
112        let steam_id = self.session.steam_id.ok_or_else(|| {
113            tracing::error!("rename: no steam_id in session - user not logged in");
114            SteamUserError::NotLoggedIn
115        })?;
116
117        tracing::info!(steam_id = %steam_id.steam_id64(), "rename: steam id");
118
119        let path = format!("/profiles/{}/ajaxsetpersonaname", steam_id.steam_id64());
120
121        tracing::info!(path = %path, "rename: POST request");
122
123        let request = self.post_path(&path).header("X-Requested-With", "XMLHttpRequest").header("X-Prototype-Version", "1.7").header("X-KL-Ajax-Request", "Ajax_Request").form(&[("persona", name)]);
124
125        tracing::debug!("rename: sending HTTP request");
126
127        let http_response = request.send().await.map_err(|e| {
128            tracing::error!(error = %e, "rename: HTTP request failed");
129            e
130        })?;
131
132        let status = http_response.status();
133        tracing::info!(status = %status, "rename: HTTP response received");
134
135        let response: serde_json::Value = http_response.json().await.map_err(|e| {
136            tracing::error!(error = %e, "rename: failed to parse JSON response");
137            e
138        })?;
139
140        tracing::debug!(response = ?response, "rename: Steam API response");
141
142        // Check for success (Steam returns the new name as string in 'success' field on
143        // success)
144        if let Some(success) = response.get("success") {
145            if success.is_string() {
146                tracing::info!(persona_name = %name, "rename: persona name changed");
147                return Ok(());
148            }
149            tracing::warn!(success_value = ?success, "rename: 'success' field is not a string");
150        }
151
152        if let Err(e) = Self::check_json_success(&response, "Failed to set persona name") {
153            tracing::error!(error = %e, "rename: check_json_success failed");
154            tracing::error!(response = ?response, "rename: full response");
155            return Err(e);
156        }
157
158        tracing::info!("rename: completed via fallback path");
159        Ok(())
160    }
161
162    /// Uploads a new avatar image for the authenticated user.
163    ///
164    /// # Arguments
165    ///
166    /// * `image` - The raw image bytes.
167    /// * `format` - The image format extension (e.g., "jpg", "png", "gif").
168    ///
169    /// # Returns
170    ///
171    /// Returns the URL of the uploaded avatar on success.
172    #[steam_endpoint(POST, host = Community, path = "/actions/FileUploader", kind = Upload)]
173    pub async fn upload_avatar(&self, image: &[u8], format: &str) -> Result<crate::types::AvatarUploadResponse, SteamUserError> {
174        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
175
176        let content_type = match format.to_lowercase().as_str() {
177            "jpg" | "jpeg" => "image/jpeg",
178            "png" => "image/png",
179            "gif" => "image/gif",
180            _ => return Err(SteamUserError::InvalidImageFormat(format.to_string())),
181        };
182
183        let filename = format!("avatar.{}", format);
184
185        let part = reqwest::multipart::Part::bytes(image.to_vec()).file_name(filename).mime_str(content_type)?;
186
187        let form = reqwest::multipart::Form::new().text("MAX_FILE_SIZE", image.len().to_string()).text("type", "player_avatar_image").text("sId", steam_id.steam_id64().to_string()).text("doSub", "1").text("json", "1").part("avatar", part);
188
189        let response: serde_json::Value = self.post_path("/actions/FileUploader").multipart(form).send().await?.json().await?;
190
191        if !response.get("success").and_then(|v| v.as_bool()).unwrap_or(false) {
192            let msg = response.get("message").and_then(|v| v.as_str()).unwrap_or("Upload failed");
193            return Err(SteamUserError::SteamError(msg.to_string()));
194        }
195
196        let url = response.get("images").and_then(|v| v.get("full")).and_then(|v| v.as_str()).ok_or_else(|| SteamUserError::MalformedResponse("Missing image URL".into()))?;
197
198        let hash = crate::utils::avatar::get_avatar_hash_from_url(url).unwrap_or_default();
199
200        Ok(crate::types::AvatarUploadResponse { url: url.to_string(), hash })
201    }
202
203    /// Posts a status update to the authenticated user's profile activity feed.
204    ///
205    /// # Arguments
206    ///
207    /// * `text` - The status message to post.
208    /// * `app_id` - Optional App ID to associate with the status.
209    ///
210    /// # Returns
211    ///
212    /// Returns the unique ID of the posted status update.
213    #[steam_endpoint(POST, host = Community, path = "/profiles/ajaxpostuserstatus/", kind = Write)]
214    pub async fn post_profile_status(&self, text: &str, app_id: Option<u32>) -> Result<u64, SteamUserError> {
215        let app_id_str = app_id.unwrap_or(0).to_string();
216
217        let json: serde_json::Value = self.post_path("/profiles/ajaxpostuserstatus/").form(&[("appid", app_id_str.as_str()), ("status_text", text)]).send().await?.json().await?;
218
219        Self::check_json_success(&json, "Failed to post status update")?;
220
221        // Extract post ID from blotter_html
222        if let Some(html) = json.get("blotter_html").and_then(|v| v.as_str()) {
223            if let Some(start) = html.find("userstatus_") {
224                let rest = &html[start + 11..];
225                if let Some(end) = rest.find('_') {
226                    if let Ok(id) = rest[..end].parse::<u64>() {
227                        return Ok(id);
228                    }
229                }
230            }
231        }
232
233        Err(SteamUserError::MalformedResponse("Could not extract post ID".into()))
234    }
235    /// Upload a new avatar from a local file.
236    // delegates to `upload_avatar` — no #[steam_endpoint]
237    #[tracing::instrument(skip(self, path))]
238    pub async fn upload_avatar_from_file(&self, path: impl AsRef<std::path::Path>) -> Result<crate::types::AvatarUploadResponse, SteamUserError> {
239        let path = path.as_ref();
240        let extension = path.extension().and_then(|e| e.to_str()).ok_or_else(|| SteamUserError::Other("Could not determine file extension".to_string()))?;
241        let bytes = tokio::fs::read(path).await?;
242
243        self.upload_avatar_inner(&bytes, extension).await
244    }
245
246    /// Upload a new avatar from a URL.
247    // delegates to `upload_avatar` — no #[steam_endpoint]
248    #[tracing::instrument(skip(self), fields(avatar_url = %url))]
249    pub async fn upload_avatar_from_url(&self, url: &str) -> Result<crate::types::AvatarUploadResponse, SteamUserError> {
250        // Caller-supplied URL on an arbitrary host — use the external
251        // client so we don't leak Steam cookies and don't burn Steam's
252        // rate-limit budget on third-party CDNs.
253        let response = self.external_get(url).send().await?;
254
255        if !response.status().is_success() {
256            return Err(SteamUserError::HttpStatus { status: response.status().as_u16(), url: url.to_string() });
257        }
258
259        // Wait, if url has query params, split('.') last might be wrong.
260        // url: "https://example.com/image.png?foo=bar" -> "png?foo=bar"
261        let path_part = url.split('?').next().unwrap_or(url);
262        let extension = path_part.split('.').next_back().unwrap_or("jpg");
263
264        let bytes = response.bytes().await?;
265
266        self.upload_avatar_inner(&bytes, extension).await
267    }
268
269    #[tracing::instrument(skip(self, image), fields(format = %format, image_len = image.len()))]
270    async fn upload_avatar_inner(&self, image: &[u8], format: &str) -> Result<crate::types::AvatarUploadResponse, SteamUserError> {
271        self.upload_avatar(image, format).await
272    }
273
274    /// Clear the user's previous name aliases history.
275    ///
276    /// This removes all previous display names shown in the "Also known as"
277    /// section of the Steam profile.
278    #[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/ajaxclearaliashistory/", kind = Write)]
279    pub async fn clear_previous_aliases(&self) -> Result<(), SteamUserError> {
280        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
281        let response: serde_json::Value = self.post_path(format!("/profiles/{}/ajaxclearaliashistory/", steam_id.steam_id64())).form(&([] as [(&str, &str); 0])).send().await?.json().await?;
282
283        let success = response.get("success").is_some_and(|v| v.as_bool().unwrap_or(false) || v.as_i64().unwrap_or(0) == 1);
284
285        if success {
286            Ok(())
287        } else {
288            let msg = response.get("errmsg").or_else(|| response.get("message")).and_then(|v| v.as_str()).unwrap_or("Failed to clear aliases");
289            Err(SteamUserError::SteamError(msg.to_string()))
290        }
291    }
292
293    /// Set a nickname for another user.
294    #[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/ajaxsetnickname/", kind = Write)]
295    pub async fn set_nickname(&self, user_id: SteamID, nickname: &str) -> Result<(), SteamUserError> {
296        let params = [("l", "english")];
297
298        let response: serde_json::Value = self.post_path(format!("/profiles/{}/ajaxsetnickname/", user_id.steam_id64())).query(&params).header("X-Requested-With", "XMLHttpRequest").header("X-Prototype-Version", "1.7").header("X-KL-Ajax-Request", "Ajax_Request").form(&[("nickname", nickname)]).send().await?.json().await?;
299
300        let success = response.get("success").is_some_and(|v| v.as_bool().unwrap_or(false) || v.as_i64().unwrap_or(0) == 1);
301
302        if success {
303            Ok(())
304        } else {
305            let msg = response.get("message").and_then(|v| v.as_str()).unwrap_or("Failed to set nickname");
306            Err(SteamUserError::SteamError(msg.to_string()))
307        }
308    }
309
310    /// Remove a nickname for another user.
311    // delegates to `set_nickname` — no #[steam_endpoint]
312    #[tracing::instrument(skip(self), fields(target_steam_id = user_id.steam_id64()))]
313    pub async fn remove_nickname(&self, user_id: SteamID) -> Result<(), SteamUserError> {
314        self.set_nickname(user_id, "").await
315    }
316
317    /// Retrieves the alias history for a user, showing their previous display
318    /// names.
319    #[steam_endpoint(GET, host = Community, path = "/profiles/{user_id}/ajaxaliases/", kind = Read)]
320    pub async fn get_alias_history(&self, user_id: SteamID) -> Result<Vec<crate::types::AliasEntry>, SteamUserError> {
321        let response = self.get_path(format!("/profiles/{}/ajaxaliases/", user_id.steam_id64())).send().await?;
322        self.check_response(&response)?;
323
324        let aliases: Vec<crate::types::AliasEntry> = response.json().await?;
325        Ok(aliases)
326    }
327    /// Retrieves full profile information for a specified user.
328    ///
329    /// If `steam_id` is `None` or matches the authenticated user, it fetches
330    /// the private-view profile.
331    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}", kind = Read)]
332    pub async fn get_profile(&self, steam_id: Option<SteamID>) -> Result<SteamProfile, SteamUserError> {
333        let path = if let Some(id) = steam_id {
334            if Some(id) == self.steam_id() {
335                "/my".to_string()
336            } else {
337                format!("/profiles/{}", id.steam_id64())
338            }
339        } else {
340            "/my".to_string()
341        };
342
343        let response = self.get_path(&path).send().await?;
344        self.check_response(&response)?;
345        let html_content = response.text().await?;
346
347        // Parse HTML
348        let document = Html::parse_document(&html_content);
349
350        // Check for error text
351        let message_selector = Selector::parse("#message .sectionText").expect("valid CSS selector");
352        if let Some(element) = document.select(&message_selector).next() {
353            let text = element.text().collect::<String>().trim().to_string();
354            // Simple check, real implementation might need more robust error detection
355            if text.contains("The specified profile could not be found") {
356                return Err(SteamUserError::Other("Profile not found".into()));
357            }
358        }
359
360        let name_selector = Selector::parse(".persona_name .actual_persona_name").expect("valid CSS selector");
361        let name = document.select(&name_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
362
363        if name.is_empty() {
364            dump_html("profile_name_missing", &html_content);
365        }
366
367        let real_name_selector = Selector::parse(".header_real_name bdi").expect("valid CSS selector");
368        let real_name = document.select(&real_name_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
369
370        // Extract g_rgProfileData
371        let mut profile_data = serde_json::Value::Null;
372        if let Some(start) = html_content.find("g_rgProfileData = ") {
373            let rest = &html_content[start + 18..];
374            if let Some(end) = rest.find("};") {
375                let json_str = &rest[..end + 1];
376                if let Ok(data) = serde_json::from_str::<serde_json::Value>(json_str) {
377                    profile_data = data;
378                }
379            }
380        }
381
382        let steam_id_val = profile_data.get("steamid").and_then(|v| v.as_str()).unwrap_or("").parse::<SteamID>().unwrap_or_else(|_| SteamID::new());
383        let _url_val = profile_data.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
384        let _personaname_val = profile_data.get("personaname").and_then(|v| v.as_str()).unwrap_or("").to_string();
385        let summary_val = profile_data.get("summary").and_then(|v| v.as_str()).map(|s| s.to_string());
386
387        // Basic fields
388        let online_state_selector = Selector::parse(".profile_header .playerAvatar").expect("valid CSS selector");
389        let online_state = if let Some(el) = document.select(&online_state_selector).next() {
390            let classes = el.value().attr("class").unwrap_or("");
391            if classes.contains("online") {
392                "online".to_string()
393            } else if classes.contains("in-game") {
394                "in-game".to_string()
395            } else {
396                "offline".to_string()
397            }
398        } else {
399            "offline".to_string()
400        };
401
402        // Location
403        let location_selector = Selector::parse("img.profile_flag").expect("valid CSS selector");
404        let location = document
405            .select(&location_selector)
406            .next()
407            .and_then(|e| e.value().attr("src"))
408            .and_then(|src| {
409                // src is typically .../countryflags/us.gif
410                src.split("/countryflags/").nth(1).and_then(|s| s.split('.').next()).map(|s| s.to_uppercase())
411            })
412            .unwrap_or_default();
413
414        // Custom URL
415        // Typically extracting from g_rgProfileData is easiest, or parse from URL
416        let custom_url = if !_url_val.is_empty() {
417            if _url_val.ends_with('/') {
418                _url_val.trim_end_matches('/').split('/').next_back().unwrap_or("").to_string()
419            } else {
420                _url_val.split('/').next_back().unwrap_or("").to_string()
421            }
422        } else {
423            "".to_string()
424        };
425
426        // Avatar
427        let avatar_full_selector = Selector::parse("head > link[rel='image_src']").expect("valid CSS selector");
428        let avatar_full_url = document.select(&avatar_full_selector).next().and_then(|e| e.value().attr("href")).unwrap_or("");
429
430        let avatar_hash = if !avatar_full_url.is_empty() {
431            // .../avatars/hash_full.jpg
432            avatar_full_url.split('/').next_back().and_then(|s| s.split("_full").next()).unwrap_or("").to_string()
433        } else {
434            "".to_string()
435        };
436
437        let avatar_frame_selector = Selector::parse(".profile_header .playerAvatar .profile_avatar_frame img").expect("valid CSS selector");
438        let avatar_frame = document.select(&avatar_frame_selector).next().and_then(|e| e.value().attr("src")).map(|s| s.to_string());
439
440        // Private info
441        let private_info_selector = Selector::parse(".profile_header_summary .profile_private_info").expect("valid CSS selector");
442        let profile_private_info = document.select(&private_info_selector).next().map(|e| e.text().collect::<String>().trim().to_string());
443
444        let is_private = if let Some(ref text) = profile_private_info { text.contains("This profile is private") } else { false };
445
446        // Level
447        let level_selector = Selector::parse(".persona_level .friendPlayerLevelNum").expect("valid CSS selector");
448        let level = document.select(&level_selector).next().and_then(|e| e.text().collect::<String>().parse::<u32>().ok());
449
450        // Bans
451        // .profile_ban_status
452        let ban_selector = Selector::parse(".profile_ban_status");
453        let bans_text = if let Ok(sel) = ban_selector { document.select(&sel).map(|e| e.text().collect::<String>().trim().to_string()).collect::<Vec<_>>().join(" ") } else { String::new() };
454
455        let mut is_vac_ban = crate::types::BanStatus::None;
456        if bans_text.contains("VAC bans on record") {
457            is_vac_ban = crate::types::BanStatus::Multiple;
458        } else if bans_text.contains("VAC ban on record") {
459            is_vac_ban = crate::types::BanStatus::Single;
460        }
461
462        let mut is_game_ban = crate::types::BanStatus::None;
463        if bans_text.contains("game bans on record") {
464            is_game_ban = crate::types::BanStatus::Multiple;
465        } else if bans_text.contains("game ban on record") {
466            is_game_ban = crate::types::BanStatus::Single;
467        }
468
469        let is_trade_ban = bans_text.contains("Currently trade banned");
470
471        // Days since last ban
472        // "X day(s) since last ban"
473        let days_since_last_ban = if !bans_text.is_empty() {
474            // Look for pattern "X day" or "X days"
475            bans_text.split_whitespace().zip(bans_text.split_whitespace().skip(1)).find(|(_, next)| next.starts_with("day")).and_then(|(num, _)| num.parse::<u32>().ok())
476        } else {
477            None
478        };
479
480        Ok(SteamProfile {
481            name: if !name.is_empty() { name } else { _personaname_val },
482            real_name,
483            online_state,
484            steam_id: steam_id_val,
485            avatar_hash,
486            avatar_frame,
487            custom_url,
488            location,
489            summary: summary_val,
490            not_yet_setup: false, // Parsing this is complex, defaulting false for now
491            profile_private_info,
492            lobby_link: None,         // Need selector
493            add_friend_enable: false, // Need selector
494            is_private,
495            url: _url_val,
496            nickname: None, // Requires looking at specific element
497            level,
498            day_last_ban: None, // Calculation requires math
499            game_ban: Some(crate::types::GameBanData { is_vac_ban, is_game_ban, is_trade_ban, days_since_last_ban }),
500            state_message_game: None,
501        })
502    }
503    // ========================================================================
504    // Internal HTTP helpers
505    // ========================================================================
506
507    /// Make a GET request to a profile endpoint.
508    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/{endpoint}", kind = Read)]
509    pub(crate) async fn my_profile_get(&self, endpoint: &str) -> Result<String, SteamUserError> {
510        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
511
512        let response = self.get_path(format!("/profiles/{}/{}", steam_id.steam_id64(), endpoint)).send().await?;
513        self.check_response(&response)?;
514
515        Ok(response.text().await?)
516    }
517
518    /// Make a POST request to a profile endpoint.
519    #[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/{endpoint}", kind = Write)]
520    pub(crate) async fn my_profile_post(&self, endpoint: &str, form: &[(&str, &str)]) -> Result<String, SteamUserError> {
521        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
522
523        let response = self.post_path(format!("/profiles/{}/{}", steam_id.steam_id64(), endpoint)).form(form).send().await?;
524        self.check_response(&response)?;
525
526        Ok(response.text().await?)
527    }
528
529    /// Get the profile URL for the logged-in user.
530    #[allow(dead_code)]
531    #[steam_endpoint(GET, host = Community, path = "/my", kind = Read)]
532    pub(crate) async fn get_profile_url_async(&self) -> Result<String, SteamUserError> {
533        if let Some(ref url) = *self.session.profile_url.lock() {
534            return Ok(url.clone());
535        }
536
537        let response = self.get_path("/my").send().await?;
538
539        if response.status() != StatusCode::FOUND {
540            return Err(SteamUserError::NotLoggedIn);
541        }
542
543        let location = response.headers().get("location").and_then(|v| v.to_str().ok()).ok_or(SteamUserError::NotLoggedIn)?;
544
545        // Extract /id/name or /profiles/steamid64 from the redirect
546        if let Some(start) = location.find("/id/").or_else(|| location.find("/profiles/")) {
547            let end = location[start + 1..].find('/').map(|i| start + 1 + i).unwrap_or(location.len());
548            let profile_url = location[start..end].to_string();
549            // Cache the profile URL using interior mutability
550            *self.session.profile_url.lock() = Some(profile_url.clone());
551            return Ok(profile_url);
552        }
553
554        Err(SteamUserError::MalformedResponse("Could not extract profile URL".into()))
555    }
556
557    // ========================================================================
558    // User Summary Functions
559    // ========================================================================
560
561    /// Selects a previously used avatar by its SHA hash.
562    ///
563    /// This allows users to revert to any avatar they have previously used.
564    ///
565    /// # Arguments
566    ///
567    /// * `avatar_hash` - The SHA hash of the previous avatar (40 character hex
568    ///   string).
569    ///
570    /// # Returns
571    ///
572    /// Returns `Ok(())` on success.
573    #[steam_endpoint(POST, host = Community, path = "/actions/selectPreviousAvatar", kind = Write)]
574    pub async fn select_previous_avatar(&self, avatar_hash: &str) -> Result<(), SteamUserError> {
575        let response: serde_json::Value = self.post_path("/actions/selectPreviousAvatar").form(&[("json", "1"), ("sha", avatar_hash)]).send().await?.json().await?;
576
577        // Check for success: { success: 1 }
578        let success = response.get("success").is_some_and(|v| v.as_i64().unwrap_or(0) == 1 || v.as_bool().unwrap_or(false));
579
580        if success {
581            Ok(())
582        } else {
583            Err(SteamUserError::SteamError("Failed to select previous avatar".into()))
584        }
585    }
586
587    /// Initializes a new Steam profile (first-time setup).
588    ///
589    /// This is used for accounts that have not yet set up their Steam Community
590    /// profile. It navigates to the profile setup page with the
591    /// `welcomed=1` parameter.
592    ///
593    /// # Returns
594    ///
595    /// Returns `true` if profile setup page was loaded successfully.
596    #[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/edit", kind = Write)]
597    pub async fn setup_profile(&self) -> Result<bool, SteamUserError> {
598        let html = self.my_profile_get("edit?welcomed=1").await?;
599
600        // Check if title is "Steam Community :: Edit Profile"
601        let document = Html::parse_document(&html);
602        let title_selector = Selector::parse("head > title").expect("valid CSS selector");
603
604        if let Some(title_element) = document.select(&title_selector).next() {
605            let title = title_element.text().collect::<String>();
606            return Ok(title == "Steam Community :: Edit Profile");
607        }
608
609        Ok(false)
610    }
611
612    /// Fetches user summary from XML profile endpoint.
613    ///
614    /// This retrieves profile information in XML format from
615    /// `/profiles/{steamid}/?xml=1`. No authentication is required for
616    /// public profiles.
617    ///
618    /// # Arguments
619    ///
620    /// * `steam_id` - The SteamID of the user to fetch.
621    ///
622    /// # Returns
623    ///
624    /// Returns the parsed `UserSummaryXml` on success.
625    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/", kind = Read)]
626    pub async fn get_user_summary_from_xml(&self, steam_id: SteamID) -> Result<crate::types::UserSummaryXml, SteamUserError> {
627        let response = self.get_path(format!("/profiles/{}/?xml=1", steam_id.steam_id64())).send().await?;
628        self.check_response(&response)?;
629        let xml_content = response.text().await?;
630
631        Self::parse_user_summary_xml(&xml_content, steam_id)
632    }
633
634    /// Fetches user summary by parsing the HTML profile page.
635    ///
636    /// # Arguments
637    ///
638    /// * `steam_id` - Optional SteamID. If None, fetches the logged-in user's
639    ///   profile.
640    ///
641    /// # Returns
642    ///
643    /// Returns the parsed `UserSummaryProfile` on success.
644    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}", kind = Read)]
645    pub async fn get_user_summary_from_profile(&self, steam_id: Option<SteamID>) -> Result<crate::types::UserSummaryProfile, SteamUserError> {
646        let path = if let Some(id) = steam_id { format!("/profiles/{}", id.steam_id64()) } else { "/my".to_string() };
647
648        let response = self.get_path(&path).send().await?;
649        self.check_response(&response)?;
650        let html_content = response.text().await?;
651
652        Self::parse_user_summary_profile(&html_content)
653    }
654
655    /// Parse XML profile content into UserSummaryXml.
656    fn parse_user_summary_xml(xml_content: &str, fallback_steam_id: SteamID) -> Result<crate::types::UserSummaryXml, SteamUserError> {
657        use quick_xml::{events::Event, reader::Reader};
658
659        let mut reader = Reader::from_str(xml_content);
660        reader.config_mut().trim_text(true);
661
662        let mut current_tag = String::new();
663        let mut fields: std::collections::HashMap<String, String> = std::collections::HashMap::new();
664        let mut buf = Vec::new();
665
666        loop {
667            match reader.read_event_into(&mut buf) {
668                Ok(Event::Start(e)) => {
669                    current_tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
670                }
671                Ok(Event::Text(e)) if !current_tag.is_empty() => {
672                    let text = std::str::from_utf8(&e).unwrap_or_default().to_string();
673                    fields.insert(current_tag.clone(), text);
674                }
675                Ok(Event::End(_)) => {
676                    current_tag.clear();
677                }
678                Ok(Event::Eof) => break,
679                Err(e) => {
680                    return Err(SteamUserError::Other(format!("XML parse error: {}", e)));
681                }
682                _ => {}
683            }
684            buf.clear();
685        }
686
687        // Check for error response
688        if let Some(error) = fields.get("error") {
689            if error == "The specified profile could not be found." {
690                return Err(SteamUserError::Other("Profile not found".into()));
691            }
692        }
693
694        let steam_id_str = fields.get("steamID64").cloned().unwrap_or_default();
695        let steam_id = steam_id_str.parse::<SteamID>().unwrap_or(fallback_steam_id);
696
697        let online_state = fields.get("onlineState").cloned().unwrap_or_else(|| "offline".to_string());
698
699        // Parse stateMessage
700        let state_message_full = fields.get("stateMessage").cloned().unwrap_or_default();
701        let (state_message, state_message_game, state_message_non_steam_game) = Self::parse_state_message(&state_message_full, &online_state);
702
703        let visibility_state = fields.get("visibilityState").and_then(|v| v.parse::<i32>().ok());
704
705        let vac_banned = fields.get("vacBanned").and_then(|v| v.parse::<i32>().ok());
706
707        let is_limited = fields.get("isLimitedAccount").and_then(|v| v.parse::<i32>().ok()).map(|v| v == 1);
708
709        // Parse memberSince - format: "August 19, 2018" or "September 6, 2019"
710        let member_since = fields.get("memberSince").and_then(|s| Self::parse_member_since(s));
711
712        // Extract avatar hash from avatarIcon or avatarFull
713        let avatar_hash = fields.get("avatarFull").or_else(|| fields.get("avatarIcon")).map(|url| Self::extract_avatar_hash(url)).unwrap_or_default();
714
715        let privacy_message = fields.get("privacyMessage").cloned();
716        let not_yet_setup = privacy_message.as_ref().map(|msg| msg.contains("has not yet set up their Steam Community profile")).unwrap_or(false);
717
718        Ok(crate::types::UserSummaryXml {
719            name: fields.get("steamID").cloned().unwrap_or_default(),
720            real_name: fields.get("realname").cloned(),
721            steam_id,
722            online_state,
723            state_message,
724            state_message_game,
725            state_message_non_steam_game,
726            privacy_state: fields.get("privacyState").cloned().unwrap_or_else(|| "public".to_string()),
727            visibility_state,
728            avatar_hash,
729            vac_banned,
730            trade_ban_state: fields.get("tradeBanState").cloned(),
731            is_limited_account: is_limited,
732            custom_url: fields.get("customURL").cloned(),
733            member_since,
734            steam_rating: fields.get("steamRating").cloned(),
735            location: fields.get("location").cloned(),
736            summary: fields.get("summary").cloned(),
737            privacy_message,
738            not_yet_setup,
739        })
740    }
741
742    /// Parse state message into components.
743    fn parse_state_message(state_msg: &str, online_state: &str) -> (String, Option<String>, Option<String>) {
744        if state_msg.is_empty() || state_msg == "Online" || state_msg == "Online using Big Picture" || state_msg == "Online using VR" {
745            return ("online".to_string(), None, None);
746        }
747        if state_msg == "Offline" {
748            return ("offline".to_string(), None, None);
749        }
750        if state_msg.starts_with("In non-Steam game<br/>") {
751            let game = state_msg.replace("In non-Steam game<br/>", "");
752            return ("in-game".to_string(), None, Some(game));
753        }
754        if state_msg.starts_with("In-Game<br/>") {
755            let game = state_msg.replace("In-Game<br/>", "");
756            return ("in-game".to_string(), Some(game), None);
757        }
758        // Fallback
759        (online_state.to_string(), None, None)
760    }
761
762    /// Parse member since date string to timestamp.
763    fn parse_member_since(date_str: &str) -> Option<i64> {
764        use chrono::NaiveDate;
765
766        // Common formats: "August 19, 2018", "September 6, 2019", "April 24"
767        let months: [(&str, u32); 12] = [
768            ("January", 1),
769            ("February", 2),
770            ("March", 3),
771            ("April", 4),
772            ("May", 5),
773            ("June", 6),
774            ("July", 7),
775            ("August", 8),
776            ("September", 9),
777            ("October", 10),
778            ("November", 11),
779            ("December", 12),
780        ];
781
782        let parts: Vec<&str> = date_str.split([' ', ',']).filter(|s| !s.is_empty()).collect();
783
784        if parts.is_empty() {
785            return None;
786        }
787
788        let month: u32 = months.iter().find(|(name, _)| *name == parts[0]).map(|(_, num)| *num)?;
789        let day: u32 = parts.get(1).and_then(|s| s.parse().ok())?;
790        let year: i32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(2024);
791
792        let date = NaiveDate::from_ymd_opt(year, month, day)?;
793        let epoch = NaiveDate::from_ymd_opt(1970, 1, 1)?;
794        let days_since_epoch = (date - epoch).num_days();
795        Some(days_since_epoch * 86_400 * 1000) // milliseconds
796    }
797
798    /// Extract avatar hash from URL.
799    fn extract_avatar_hash(url: &str) -> String {
800        // URL format: https://avatars.akamai.steamstatic.com/HASH_full.jpg
801        url.split('/').next_back().and_then(|s| s.split('_').next()).and_then(|s| s.split('.').next()).unwrap_or("").to_string()
802    }
803
804    /// Parse HTML profile content into UserSummaryProfile.
805    fn parse_user_summary_profile(html: &str) -> Result<crate::types::UserSummaryProfile, SteamUserError> {
806        let document = Html::parse_document(html);
807
808        // Check for error message
809        let message_selector = Selector::parse("#message .sectionText").expect("valid CSS selector");
810        if let Some(element) = document.select(&message_selector).next() {
811            let text = element.text().collect::<String>().trim().to_string();
812            if text.contains("error") || text.contains("Error") {
813                return Err(SteamUserError::Other("Profile error".into()));
814            }
815        }
816
817        // Private info
818        let private_selector = Selector::parse(".profile_header_summary .profile_private_info").expect("valid CSS selector");
819        let profile_private_info = document.select(&private_selector).next().map(|e| e.text().collect::<String>().trim().to_string());
820
821        let is_private = profile_private_info.as_ref().map(|t| t.contains("This profile is private")).unwrap_or(false);
822
823        // Name
824        let name_selector = Selector::parse(".persona_name .actual_persona_name").expect("valid CSS selector");
825        let name = document.select(&name_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
826
827        // Level
828        let level_selector = Selector::parse(".persona_level .friendPlayerLevelNum").expect("valid CSS selector");
829        let level = document.select(&level_selector).next().and_then(|e| e.text().collect::<String>().parse::<u32>().ok());
830
831        // Not yet setup check
832        let btn_selector = Selector::parse("#btn > a").expect("valid CSS selector");
833        let not_yet_setup_link = document.select(&btn_selector).next().and_then(|e| e.value().attr("href")).unwrap_or("");
834        let not_yet_setup = not_yet_setup_link.ends_with("edit?welcomed=1") || profile_private_info.as_ref().map(|t| t.contains("has not yet set up their Steam Community profile")).unwrap_or(false);
835
836        // Extract g_rgProfileData
837        let mut steam_id = SteamID::new();
838        let mut url = String::new();
839        let mut summary = None;
840
841        if let Some(start) = html.find("g_rgProfileData = ") {
842            let rest = &html[start + 18..];
843            if let Some(end) = rest.find("};") {
844                let json_str = &rest[..end + 1];
845                if let Ok(data) = serde_json::from_str::<serde_json::Value>(json_str) {
846                    if let Some(sid) = data.get("steamid").and_then(|v| v.as_str()) {
847                        steam_id = sid.parse().unwrap_or_else(|_| SteamID::new());
848                    }
849                    if let Some(u) = data.get("url").and_then(|v| v.as_str()) {
850                        url = u.trim_end_matches('/').to_string();
851                    }
852                    summary = data.get("summary").and_then(|v| v.as_str()).map(|s| s.to_string());
853                }
854            }
855        }
856
857        // Custom URL from URL
858        let custom_url = url.split('/').rfind(|s| !s.is_empty()).unwrap_or("").to_string();
859
860        // Online state
861        let avatar_selector = Selector::parse(".profile_header .playerAvatar").expect("valid CSS selector");
862        let online_state = document
863            .select(&avatar_selector)
864            .next()
865            .and_then(|e| e.value().attr("class"))
866            .map(|classes| {
867                if classes.contains("in-game") {
868                    "in-game"
869                } else if classes.contains("online") {
870                    "online"
871                } else {
872                    "offline"
873                }
874            })
875            .unwrap_or("offline")
876            .to_string();
877
878        // Avatar hash
879        let avatar_link_selector = Selector::parse("head > link[rel='image_src']").expect("valid CSS selector");
880        let avatar_url = document.select(&avatar_link_selector).next().and_then(|e| e.value().attr("href")).unwrap_or("");
881        let avatar_hash = Self::extract_avatar_hash(avatar_url);
882
883        // Avatar frame
884        let frame_selector = Selector::parse(".profile_header .playerAvatar .profile_avatar_frame img").expect("valid CSS selector");
885        let avatar_frame = document.select(&frame_selector).next().and_then(|e| e.value().attr("src")).map(|s| s.to_string());
886
887        // Location
888        let flag_selector = Selector::parse("img.profile_flag").expect("valid CSS selector");
889        let location = document.select(&flag_selector).next().and_then(|e| e.value().attr("src")).and_then(|src| src.split("/countryflags/").nth(1).and_then(|s| s.split('.').next()).map(|s| s.to_uppercase())).unwrap_or_default();
890
891        // Real name
892        let realname_selector = Selector::parse(".header_real_name bdi").expect("valid CSS selector");
893        let real_name = document.select(&realname_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
894
895        // Nickname
896        let nickname_selector = Selector::parse(".persona_name .nickname").expect("valid CSS selector");
897        let nickname = document.select(&nickname_selector).next().map(|e| e.text().collect::<String>().trim().to_string());
898
899        // Lobby link
900        let lobby_selector = Selector::parse(".profile_in_game_joingame a[href*='steam://joinlobby/']").expect("valid CSS selector");
901        let lobby_link = document.select(&lobby_selector).next().and_then(|e| e.value().attr("href")).map(|s| s.to_string());
902
903        // Add friend button
904        let friend_btn_selector = Selector::parse("#btn_add_friend").expect("valid CSS selector");
905        let add_friend_enable = document.select(&friend_btn_selector).next().is_some();
906
907        // Game ban info
908        let ban_selector = Selector::parse(".profile_ban_status").expect("valid CSS selector");
909        let ban_text: String = document.select(&ban_selector).map(|e| e.text().collect::<String>()).collect::<Vec<_>>().join(" ");
910
911        let is_vac_ban = if ban_text.contains("VAC bans on record") {
912            crate::types::BanStatus::Multiple
913        } else if ban_text.contains("VAC ban on record") {
914            crate::types::BanStatus::Single
915        } else {
916            crate::types::BanStatus::None
917        };
918
919        let is_game_ban = if ban_text.contains("game bans on record") {
920            crate::types::BanStatus::Multiple
921        } else if ban_text.contains("game ban on record") {
922            crate::types::BanStatus::Single
923        } else {
924            crate::types::BanStatus::None
925        };
926
927        let is_trade_ban = ban_text.contains("Currently trade banned");
928
929        let days_since_last_ban = ban_text.split_whitespace().zip(ban_text.split_whitespace().skip(1)).find(|(_, next)| next.starts_with("day")).and_then(|(num, _)| num.parse::<u32>().ok());
930
931        // In-game name
932        let game_selector = Selector::parse(".profile_in_game .profile_in_game_name").expect("valid CSS selector");
933        let state_message_game = if online_state == "in-game" { document.select(&game_selector).next().map(|e| e.text().collect::<String>().trim().to_string()) } else { None };
934
935        Ok(crate::types::UserSummaryProfile {
936            name,
937            real_name,
938            online_state,
939            steam_id,
940            avatar_hash,
941            avatar_frame,
942            custom_url,
943            location,
944            summary,
945            not_yet_setup,
946            profile_private_info,
947            lobby_link,
948            add_friend_enable,
949            is_private,
950            url,
951            nickname,
952            level,
953            day_last_ban: None, // Would need date math
954            game_ban: Some(crate::types::GameBanData { is_vac_ban, is_game_ban, is_trade_ban, days_since_last_ban }),
955            state_message_game,
956        })
957    }
958
959    // ========================================================================
960    // User Resolution Functions
961    // ========================================================================
962
963    /// Resolves a single user identifier (SteamID or Vanity URL) to a
964    /// SteamProfile.
965    ///
966    /// This function first attempts to parse the identifier as a SteamID.
967    /// If that fails, it treats it as a vanity URL and attempts to resolve it
968    /// using the WebAPI. Note: Resolving a vanity URL requires fetching a
969    /// Web API key, which might fail if the account is limited. Resolves a
970    /// single user identifier (SteamID or Vanity URL) to a SteamProfile (Heavy,
971    /// scraped).
972    // composite — no #[steam_endpoint]
973    #[tracing::instrument(skip(self), fields(identifier = %identifier))]
974    pub async fn fetch_full_profile(&self, identifier: &str) -> Result<SteamProfile, SteamUserError> {
975        let steam_id = if let Ok(id) = identifier.parse::<SteamID>() {
976            id
977        } else {
978            let api_key = self.get_web_api_key("localhost").await?;
979            self.resolve_vanity_url(&api_key, identifier).await?
980        };
981
982        self.get_profile(Some(steam_id)).await
983    }
984
985    /// Resolves a single Steam ID to a lightweight `SteamUserProfile`.
986    #[steam_endpoint(POST, host = Community, path = "/actions/ajaxresolveusers", kind = Read)]
987    pub async fn resolve_user(&self, steam_id: SteamID) -> Result<Option<crate::types::SteamUserProfile>, SteamUserError> {
988        let path = format!("/actions/ajaxresolveusers?steamids={}", steam_id.steam_id64());
989
990        let response = match self.get_path(&path).send().await {
991            Ok(res) => res,
992            Err(e) => {
993                tracing::error!(path = %path, error = %e, error_debug = ?e, "resolve_user: HTTP request failed");
994                return Err(e.into());
995            }
996        };
997        self.check_response(&response)?;
998
999        let text = response.text().await?;
1000        if text.trim() == "null" {
1001            return Ok(None);
1002        }
1003
1004        let users: Vec<crate::types::SteamUserProfile> = serde_json::from_str(&text).unwrap_or_default();
1005        Ok(users.into_iter().next())
1006    }
1007
1008    /// Fetches the avatar history for a user.
1009    ///
1010    /// Uses the protobuf endpoint at `https://api.steampowered.com/ICommunityService/GetAvatarHistory/v1`.
1011    #[steam_endpoint(POST, host = Api, path = "/ICommunityService/GetAvatarHistory/v1", kind = Read)]
1012    pub async fn get_avatar_history(&self) -> Result<Vec<crate::types::AvatarHistoryEntry>, SteamUserError> {
1013        use prost::Message;
1014        use steam_protos::messages::community::{CCommunityGetAvatarHistoryRequest, CCommunityGetAvatarHistoryResponse};
1015
1016        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
1017
1018        // Get access token for authentication
1019        let access_token = self.session.access_token.as_deref().ok_or(SteamUserError::MissingCredential { field: "access_token" })?;
1020
1021        // Prepare protobuf request
1022        let request = CCommunityGetAvatarHistoryRequest { steamid: Some(steam_id.steam_id64()), filter_user_uploaded_only: Some(true) };
1023
1024        let mut proto_bytes = Vec::new();
1025        request.encode(&mut proto_bytes)?;
1026
1027        // Base64-encode the protobuf message for the form field
1028        use base64::Engine;
1029        let encoded_proto = base64::engine::general_purpose::STANDARD.encode(&proto_bytes);
1030
1031        // Send POST request with multipart/form-data (matching Steam's actual API
1032        // format)
1033        let response = self
1034            .post_path("/ICommunityService/GetAvatarHistory/v1")
1035            .query(&[("access_token", access_token)])
1036            .header("Origin", "https://steamcommunity.com")
1037            .header("Referer", "https://steamcommunity.com/")
1038            .header("Accept", "*/*")
1039            .header("Accept-Language", "en-US,en;q=0.9")
1040            .header("Cache-Control", "no-cache")
1041            .header("Pragma", "no-cache")
1042            .header("Priority", "u=1, i")
1043            .header("Sec-Ch-Ua", "\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"")
1044            .header("Sec-Ch-Ua-Mobile", "?0")
1045            .header("Sec-Ch-Ua-Platform", "\"Windows\"")
1046            .header("Sec-Fetch-Dest", "empty")
1047            .header("Sec-Fetch-Mode", "cors")
1048            .header("Sec-Fetch-Site", "cross-site")
1049            .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36")
1050            .form(&[("input_protobuf_encoded", &encoded_proto)])
1051            .send()
1052            .await?;
1053
1054        if !response.status().is_success() {
1055            return Err(SteamUserError::HttpStatus { status: response.status().as_u16(), url: response.url().to_string() });
1056        }
1057
1058        let bytes = response.bytes().await?;
1059
1060        // Empty response means no avatar history - that's valid, not an error
1061        if bytes.is_empty() {
1062            return Ok(Vec::new());
1063        }
1064
1065        let response_proto = CCommunityGetAvatarHistoryResponse::decode(bytes)?;
1066
1067        let entries = response_proto
1068            .avatars
1069            .into_iter()
1070            .map(|a| crate::types::AvatarHistoryEntry {
1071                avatar_sha1: a.avatar_sha1.unwrap_or_default(),
1072                user_uploaded: a.user_uploaded.unwrap_or_default(),
1073                timestamp: a.timestamp.unwrap_or_default(),
1074            })
1075            .collect();
1076
1077        Ok(entries)
1078    }
1079}