1use 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 #[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/edit", kind = Write)]
22 pub async fn edit_profile(&self, settings: ProfileSettings) -> Result<(), SteamUserError> {
23 let html_content = self.my_profile_get("edit/info").await?;
25
26 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 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 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 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 }
98
99 self.my_profile_post("edit", &form).await?;
100
101 Ok(())
105 }
106
107 #[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 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 #[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 #[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 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 #[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 #[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 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 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 #[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 #[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(¶ms).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 #[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 #[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 #[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 let document = Html::parse_document(&html_content);
349
350 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 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 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 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 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.split("/countryflags/").nth(1).and_then(|s| s.split('.').next()).map(|s| s.to_uppercase())
411 })
412 .unwrap_or_default();
413
414 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 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 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 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 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 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 let days_since_last_ban = if !bans_text.is_empty() {
474 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, profile_private_info,
492 lobby_link: None, add_friend_enable: false, is_private,
495 url: _url_val,
496 nickname: None, level,
498 day_last_ban: None, 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 #[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 #[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 #[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 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 *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 #[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 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 #[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 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 #[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 #[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 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 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 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 let member_since = fields.get("memberSince").and_then(|s| Self::parse_member_since(s));
711
712 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 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 (online_state.to_string(), None, None)
760 }
761
762 fn parse_member_since(date_str: &str) -> Option<i64> {
764 use chrono::NaiveDate;
765
766 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) }
797
798 fn extract_avatar_hash(url: &str) -> String {
800 url.split('/').next_back().and_then(|s| s.split('_').next()).and_then(|s| s.split('.').next()).unwrap_or("").to_string()
802 }
803
804 fn parse_user_summary_profile(html: &str) -> Result<crate::types::UserSummaryProfile, SteamUserError> {
806 let document = Html::parse_document(html);
807
808 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 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 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 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 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 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 let custom_url = url.split('/').rfind(|s| !s.is_empty()).unwrap_or("").to_string();
859
860 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 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 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 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 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 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 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 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 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 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, 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 #[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 #[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 #[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 let access_token = self.session.access_token.as_deref().ok_or(SteamUserError::MissingCredential { field: "access_token" })?;
1020
1021 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 use base64::Engine;
1029 let encoded_proto = base64::engine::general_purpose::STANDARD.encode(&proto_bytes);
1030
1031 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 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}