use reqwest::StatusCode;
use scraper::{Html, Selector};
use steamid::SteamID;
use crate::{
client::SteamUser,
endpoint::steam_endpoint,
error::SteamUserError,
types::{ProfileSettings, SteamProfile},
utils::debug::dump_html,
};
impl SteamUser {
#[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/edit", kind = Write)]
pub async fn edit_profile(&self, settings: ProfileSettings) -> Result<(), SteamUserError> {
let html_content = self.my_profile_get("edit/info").await?;
let document = scraper::Html::parse_document(&html_content);
let selector = scraper::Selector::parse("#profile_edit_config").map_err(|e| SteamUserError::Other(format!("Failed to parse selector: {:?}", e)))?;
let element = document.select(&selector).next().ok_or_else(|| {
dump_html("profile_edit_config_missing", &html_content);
SteamUserError::Other("Could not find #profile_edit_config element".to_string())
})?;
let existing_data_json = element.value().attr("data-profile-edit").ok_or_else(|| SteamUserError::Other("data-profile-edit attribute missing".to_string()))?;
#[derive(serde::Deserialize)]
struct LocationData {
#[serde(rename = "locCountryCode")]
loc_country_code: Option<String>,
#[serde(rename = "locStateCode")]
loc_state_code: Option<String>,
#[serde(rename = "locCityCode")]
loc_city_code: Option<String>,
}
#[derive(serde::Deserialize)]
struct ProfileEditConfig {
#[serde(rename = "strPersonaName")]
str_persona_name: String,
#[serde(rename = "strRealName")]
str_real_name: String,
#[serde(rename = "strSummary")]
str_summary: String,
#[serde(rename = "strCustomURL")]
str_custom_url: String,
#[serde(rename = "LocationData")]
location_data: LocationData,
}
let existing_settings: ProfileEditConfig = serde_json::from_str(existing_data_json).map_err(|e| SteamUserError::Other(format!("Failed to parse profile config JSON: {}", e)))?;
let name = settings.name.as_deref().unwrap_or(&existing_settings.str_persona_name);
let real_name = settings.real_name.as_deref().unwrap_or(&existing_settings.str_real_name);
let summary = settings.summary.as_deref().unwrap_or(&existing_settings.str_summary);
let country = settings.country.as_deref().or(existing_settings.location_data.loc_country_code.as_deref()).unwrap_or("");
let state = settings.state.as_deref().or(existing_settings.location_data.loc_state_code.as_deref()).unwrap_or("");
let city = settings.city.as_deref().or(existing_settings.location_data.loc_city_code.as_deref()).unwrap_or("");
let custom_url = settings.custom_url.as_deref().unwrap_or(&existing_settings.str_custom_url);
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")];
let group_id_str;
if let Some(gid) = settings.primary_group {
group_id_str = gid.to_string();
form.push(("primary_group_steamid", &group_id_str));
} else {
}
self.my_profile_post("edit", &form).await?;
Ok(())
}
#[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/ajaxsetpersonaname", kind = Write)]
pub async fn set_persona_name(&self, name: &str) -> Result<(), SteamUserError> {
tracing::info!(persona_name = %name, "set_persona_name called");
let steam_id = self.session.steam_id.ok_or_else(|| {
tracing::error!("rename: no steam_id in session - user not logged in");
SteamUserError::NotLoggedIn
})?;
tracing::info!(steam_id = %steam_id.steam_id64(), "rename: steam id");
let path = format!("/profiles/{}/ajaxsetpersonaname", steam_id.steam_id64());
tracing::info!(path = %path, "rename: POST request");
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)]);
tracing::debug!("rename: sending HTTP request");
let http_response = request.send().await.map_err(|e| {
tracing::error!(error = %e, "rename: HTTP request failed");
e
})?;
let status = http_response.status();
tracing::info!(status = %status, "rename: HTTP response received");
let response: serde_json::Value = http_response.json().await.map_err(|e| {
tracing::error!(error = %e, "rename: failed to parse JSON response");
e
})?;
tracing::debug!(response = ?response, "rename: Steam API response");
if let Some(success) = response.get("success") {
if success.is_string() {
tracing::info!(persona_name = %name, "rename: persona name changed");
return Ok(());
}
tracing::warn!(success_value = ?success, "rename: 'success' field is not a string");
}
if let Err(e) = Self::check_json_success(&response, "Failed to set persona name") {
tracing::error!(error = %e, "rename: check_json_success failed");
tracing::error!(response = ?response, "rename: full response");
return Err(e);
}
tracing::info!("rename: completed via fallback path");
Ok(())
}
#[steam_endpoint(POST, host = Community, path = "/actions/FileUploader", kind = Upload)]
pub async fn upload_avatar(&self, image: &[u8], format: &str) -> Result<crate::types::AvatarUploadResponse, SteamUserError> {
let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
let content_type = match format.to_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
_ => return Err(SteamUserError::InvalidImageFormat(format.to_string())),
};
let filename = format!("avatar.{}", format);
let part = reqwest::multipart::Part::bytes(image.to_vec()).file_name(filename).mime_str(content_type)?;
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);
let response: serde_json::Value = self.post_path("/actions/FileUploader").multipart(form).send().await?.json().await?;
if !response.get("success").and_then(|v| v.as_bool()).unwrap_or(false) {
let msg = response.get("message").and_then(|v| v.as_str()).unwrap_or("Upload failed");
return Err(SteamUserError::SteamError(msg.to_string()));
}
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()))?;
let hash = crate::utils::avatar::get_avatar_hash_from_url(url).unwrap_or_default();
Ok(crate::types::AvatarUploadResponse { url: url.to_string(), hash })
}
#[steam_endpoint(POST, host = Community, path = "/profiles/ajaxpostuserstatus/", kind = Write)]
pub async fn post_profile_status(&self, text: &str, app_id: Option<u32>) -> Result<u64, SteamUserError> {
let app_id_str = app_id.unwrap_or(0).to_string();
let json: serde_json::Value = self.post_path("/profiles/ajaxpostuserstatus/").form(&[("appid", app_id_str.as_str()), ("status_text", text)]).send().await?.json().await?;
Self::check_json_success(&json, "Failed to post status update")?;
if let Some(html) = json.get("blotter_html").and_then(|v| v.as_str()) {
if let Some(start) = html.find("userstatus_") {
let rest = &html[start + 11..];
if let Some(end) = rest.find('_') {
if let Ok(id) = rest[..end].parse::<u64>() {
return Ok(id);
}
}
}
}
Err(SteamUserError::MalformedResponse("Could not extract post ID".into()))
}
#[tracing::instrument(skip(self, path))]
pub async fn upload_avatar_from_file(&self, path: impl AsRef<std::path::Path>) -> Result<crate::types::AvatarUploadResponse, SteamUserError> {
let path = path.as_ref();
let extension = path.extension().and_then(|e| e.to_str()).ok_or_else(|| SteamUserError::Other("Could not determine file extension".to_string()))?;
let bytes = tokio::fs::read(path).await?;
self.upload_avatar_inner(&bytes, extension).await
}
#[tracing::instrument(skip(self), fields(avatar_url = %url))]
pub async fn upload_avatar_from_url(&self, url: &str) -> Result<crate::types::AvatarUploadResponse, SteamUserError> {
let response = self.external_get(url).send().await?;
if !response.status().is_success() {
return Err(SteamUserError::HttpStatus { status: response.status().as_u16(), url: url.to_string() });
}
let path_part = url.split('?').next().unwrap_or(url);
let extension = path_part.split('.').next_back().unwrap_or("jpg");
let bytes = response.bytes().await?;
self.upload_avatar_inner(&bytes, extension).await
}
#[tracing::instrument(skip(self, image), fields(format = %format, image_len = image.len()))]
async fn upload_avatar_inner(&self, image: &[u8], format: &str) -> Result<crate::types::AvatarUploadResponse, SteamUserError> {
self.upload_avatar(image, format).await
}
#[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/ajaxclearaliashistory/", kind = Write)]
pub async fn clear_previous_aliases(&self) -> Result<(), SteamUserError> {
let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
let response: serde_json::Value = self.post_path(format!("/profiles/{}/ajaxclearaliashistory/", steam_id.steam_id64())).form(&([] as [(&str, &str); 0])).send().await?.json().await?;
let success = response.get("success").is_some_and(|v| v.as_bool().unwrap_or(false) || v.as_i64().unwrap_or(0) == 1);
if success {
Ok(())
} else {
let msg = response.get("errmsg").or_else(|| response.get("message")).and_then(|v| v.as_str()).unwrap_or("Failed to clear aliases");
Err(SteamUserError::SteamError(msg.to_string()))
}
}
#[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/ajaxsetnickname/", kind = Write)]
pub async fn set_nickname(&self, user_id: SteamID, nickname: &str) -> Result<(), SteamUserError> {
let params = [("l", "english")];
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?;
let success = response.get("success").is_some_and(|v| v.as_bool().unwrap_or(false) || v.as_i64().unwrap_or(0) == 1);
if success {
Ok(())
} else {
let msg = response.get("message").and_then(|v| v.as_str()).unwrap_or("Failed to set nickname");
Err(SteamUserError::SteamError(msg.to_string()))
}
}
#[tracing::instrument(skip(self), fields(target_steam_id = user_id.steam_id64()))]
pub async fn remove_nickname(&self, user_id: SteamID) -> Result<(), SteamUserError> {
self.set_nickname(user_id, "").await
}
#[steam_endpoint(GET, host = Community, path = "/profiles/{user_id}/ajaxaliases/", kind = Read)]
pub async fn get_alias_history(&self, user_id: SteamID) -> Result<Vec<crate::types::AliasEntry>, SteamUserError> {
let response = self.get_path(format!("/profiles/{}/ajaxaliases/", user_id.steam_id64())).send().await?;
self.check_response(&response)?;
let aliases: Vec<crate::types::AliasEntry> = response.json().await?;
Ok(aliases)
}
#[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}", kind = Read)]
pub async fn get_profile(&self, steam_id: Option<SteamID>) -> Result<SteamProfile, SteamUserError> {
let path = if let Some(id) = steam_id {
if Some(id) == self.steam_id() {
"/my".to_string()
} else {
format!("/profiles/{}", id.steam_id64())
}
} else {
"/my".to_string()
};
let response = self.get_path(&path).send().await?;
self.check_response(&response)?;
let html_content = response.text().await?;
let document = Html::parse_document(&html_content);
let message_selector = Selector::parse("#message .sectionText").expect("valid CSS selector");
if let Some(element) = document.select(&message_selector).next() {
let text = element.text().collect::<String>().trim().to_string();
if text.contains("The specified profile could not be found") {
return Err(SteamUserError::Other("Profile not found".into()));
}
}
let name_selector = Selector::parse(".persona_name .actual_persona_name").expect("valid CSS selector");
let name = document.select(&name_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
if name.is_empty() {
dump_html("profile_name_missing", &html_content);
}
let real_name_selector = Selector::parse(".header_real_name bdi").expect("valid CSS selector");
let real_name = document.select(&real_name_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
let mut profile_data = serde_json::Value::Null;
if let Some(start) = html_content.find("g_rgProfileData = ") {
let rest = &html_content[start + 18..];
if let Some(end) = rest.find("};") {
let json_str = &rest[..end + 1];
if let Ok(data) = serde_json::from_str::<serde_json::Value>(json_str) {
profile_data = data;
}
}
}
let steam_id_val = profile_data.get("steamid").and_then(|v| v.as_str()).unwrap_or("").parse::<SteamID>().unwrap_or_else(|_| SteamID::new());
let _url_val = profile_data.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
let _personaname_val = profile_data.get("personaname").and_then(|v| v.as_str()).unwrap_or("").to_string();
let summary_val = profile_data.get("summary").and_then(|v| v.as_str()).map(|s| s.to_string());
let online_state_selector = Selector::parse(".profile_header .playerAvatar").expect("valid CSS selector");
let online_state = if let Some(el) = document.select(&online_state_selector).next() {
let classes = el.value().attr("class").unwrap_or("");
if classes.contains("online") {
"online".to_string()
} else if classes.contains("in-game") {
"in-game".to_string()
} else {
"offline".to_string()
}
} else {
"offline".to_string()
};
let location_selector = Selector::parse("img.profile_flag").expect("valid CSS selector");
let location = document
.select(&location_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();
let custom_url = if !_url_val.is_empty() {
if _url_val.ends_with('/') {
_url_val.trim_end_matches('/').split('/').next_back().unwrap_or("").to_string()
} else {
_url_val.split('/').next_back().unwrap_or("").to_string()
}
} else {
"".to_string()
};
let avatar_full_selector = Selector::parse("head > link[rel='image_src']").expect("valid CSS selector");
let avatar_full_url = document.select(&avatar_full_selector).next().and_then(|e| e.value().attr("href")).unwrap_or("");
let avatar_hash = if !avatar_full_url.is_empty() {
avatar_full_url.split('/').next_back().and_then(|s| s.split("_full").next()).unwrap_or("").to_string()
} else {
"".to_string()
};
let avatar_frame_selector = Selector::parse(".profile_header .playerAvatar .profile_avatar_frame img").expect("valid CSS selector");
let avatar_frame = document.select(&avatar_frame_selector).next().and_then(|e| e.value().attr("src")).map(|s| s.to_string());
let private_info_selector = Selector::parse(".profile_header_summary .profile_private_info").expect("valid CSS selector");
let profile_private_info = document.select(&private_info_selector).next().map(|e| e.text().collect::<String>().trim().to_string());
let is_private = if let Some(ref text) = profile_private_info { text.contains("This profile is private") } else { false };
let level_selector = Selector::parse(".persona_level .friendPlayerLevelNum").expect("valid CSS selector");
let level = document.select(&level_selector).next().and_then(|e| e.text().collect::<String>().parse::<u32>().ok());
let ban_selector = Selector::parse(".profile_ban_status");
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() };
let mut is_vac_ban = crate::types::BanStatus::None;
if bans_text.contains("VAC bans on record") {
is_vac_ban = crate::types::BanStatus::Multiple;
} else if bans_text.contains("VAC ban on record") {
is_vac_ban = crate::types::BanStatus::Single;
}
let mut is_game_ban = crate::types::BanStatus::None;
if bans_text.contains("game bans on record") {
is_game_ban = crate::types::BanStatus::Multiple;
} else if bans_text.contains("game ban on record") {
is_game_ban = crate::types::BanStatus::Single;
}
let is_trade_ban = bans_text.contains("Currently trade banned");
let days_since_last_ban = if !bans_text.is_empty() {
bans_text.split_whitespace().zip(bans_text.split_whitespace().skip(1)).find(|(_, next)| next.starts_with("day")).and_then(|(num, _)| num.parse::<u32>().ok())
} else {
None
};
Ok(SteamProfile {
name: if !name.is_empty() { name } else { _personaname_val },
real_name,
online_state,
steam_id: steam_id_val,
avatar_hash,
avatar_frame,
custom_url,
location,
summary: summary_val,
not_yet_setup: false, profile_private_info,
lobby_link: None, add_friend_enable: false, is_private,
url: _url_val,
nickname: None, level,
day_last_ban: None, game_ban: Some(crate::types::GameBanData { is_vac_ban, is_game_ban, is_trade_ban, days_since_last_ban }),
state_message_game: None,
})
}
#[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/{endpoint}", kind = Read)]
pub(crate) async fn my_profile_get(&self, endpoint: &str) -> Result<String, SteamUserError> {
let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
let response = self.get_path(format!("/profiles/{}/{}", steam_id.steam_id64(), endpoint)).send().await?;
self.check_response(&response)?;
Ok(response.text().await?)
}
#[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/{endpoint}", kind = Write)]
pub(crate) async fn my_profile_post(&self, endpoint: &str, form: &[(&str, &str)]) -> Result<String, SteamUserError> {
let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
let response = self.post_path(format!("/profiles/{}/{}", steam_id.steam_id64(), endpoint)).form(form).send().await?;
self.check_response(&response)?;
Ok(response.text().await?)
}
#[allow(dead_code)]
#[steam_endpoint(GET, host = Community, path = "/my", kind = Read)]
pub(crate) async fn get_profile_url_async(&self) -> Result<String, SteamUserError> {
if let Some(ref url) = *self.session.profile_url.lock() {
return Ok(url.clone());
}
let response = self.get_path("/my").send().await?;
if response.status() != StatusCode::FOUND {
return Err(SteamUserError::NotLoggedIn);
}
let location = response.headers().get("location").and_then(|v| v.to_str().ok()).ok_or(SteamUserError::NotLoggedIn)?;
if let Some(start) = location.find("/id/").or_else(|| location.find("/profiles/")) {
let end = location[start + 1..].find('/').map(|i| start + 1 + i).unwrap_or(location.len());
let profile_url = location[start..end].to_string();
*self.session.profile_url.lock() = Some(profile_url.clone());
return Ok(profile_url);
}
Err(SteamUserError::MalformedResponse("Could not extract profile URL".into()))
}
#[steam_endpoint(POST, host = Community, path = "/actions/selectPreviousAvatar", kind = Write)]
pub async fn select_previous_avatar(&self, avatar_hash: &str) -> Result<(), SteamUserError> {
let response: serde_json::Value = self.post_path("/actions/selectPreviousAvatar").form(&[("json", "1"), ("sha", avatar_hash)]).send().await?.json().await?;
let success = response.get("success").is_some_and(|v| v.as_i64().unwrap_or(0) == 1 || v.as_bool().unwrap_or(false));
if success {
Ok(())
} else {
Err(SteamUserError::SteamError("Failed to select previous avatar".into()))
}
}
#[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/edit", kind = Write)]
pub async fn setup_profile(&self) -> Result<bool, SteamUserError> {
let html = self.my_profile_get("edit?welcomed=1").await?;
let document = Html::parse_document(&html);
let title_selector = Selector::parse("head > title").expect("valid CSS selector");
if let Some(title_element) = document.select(&title_selector).next() {
let title = title_element.text().collect::<String>();
return Ok(title == "Steam Community :: Edit Profile");
}
Ok(false)
}
#[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/", kind = Read)]
pub async fn get_user_summary_from_xml(&self, steam_id: SteamID) -> Result<crate::types::UserSummaryXml, SteamUserError> {
let response = self.get_path(format!("/profiles/{}/?xml=1", steam_id.steam_id64())).send().await?;
self.check_response(&response)?;
let xml_content = response.text().await?;
Self::parse_user_summary_xml(&xml_content, steam_id)
}
#[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}", kind = Read)]
pub async fn get_user_summary_from_profile(&self, steam_id: Option<SteamID>) -> Result<crate::types::UserSummaryProfile, SteamUserError> {
let path = if let Some(id) = steam_id { format!("/profiles/{}", id.steam_id64()) } else { "/my".to_string() };
let response = self.get_path(&path).send().await?;
self.check_response(&response)?;
let html_content = response.text().await?;
Self::parse_user_summary_profile(&html_content)
}
fn parse_user_summary_xml(xml_content: &str, fallback_steam_id: SteamID) -> Result<crate::types::UserSummaryXml, SteamUserError> {
use quick_xml::{events::Event, reader::Reader};
let mut reader = Reader::from_str(xml_content);
reader.config_mut().trim_text(true);
let mut current_tag = String::new();
let mut fields: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
current_tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
}
Ok(Event::Text(e)) if !current_tag.is_empty() => {
let text = std::str::from_utf8(&e).unwrap_or_default().to_string();
fields.insert(current_tag.clone(), text);
}
Ok(Event::End(_)) => {
current_tag.clear();
}
Ok(Event::Eof) => break,
Err(e) => {
return Err(SteamUserError::Other(format!("XML parse error: {}", e)));
}
_ => {}
}
buf.clear();
}
if let Some(error) = fields.get("error") {
if error == "The specified profile could not be found." {
return Err(SteamUserError::Other("Profile not found".into()));
}
}
let steam_id_str = fields.get("steamID64").cloned().unwrap_or_default();
let steam_id = steam_id_str.parse::<SteamID>().unwrap_or(fallback_steam_id);
let online_state = fields.get("onlineState").cloned().unwrap_or_else(|| "offline".to_string());
let state_message_full = fields.get("stateMessage").cloned().unwrap_or_default();
let (state_message, state_message_game, state_message_non_steam_game) = Self::parse_state_message(&state_message_full, &online_state);
let visibility_state = fields.get("visibilityState").and_then(|v| v.parse::<i32>().ok());
let vac_banned = fields.get("vacBanned").and_then(|v| v.parse::<i32>().ok());
let is_limited = fields.get("isLimitedAccount").and_then(|v| v.parse::<i32>().ok()).map(|v| v == 1);
let member_since = fields.get("memberSince").and_then(|s| Self::parse_member_since(s));
let avatar_hash = fields.get("avatarFull").or_else(|| fields.get("avatarIcon")).map(|url| Self::extract_avatar_hash(url)).unwrap_or_default();
let privacy_message = fields.get("privacyMessage").cloned();
let not_yet_setup = privacy_message.as_ref().map(|msg| msg.contains("has not yet set up their Steam Community profile")).unwrap_or(false);
Ok(crate::types::UserSummaryXml {
name: fields.get("steamID").cloned().unwrap_or_default(),
real_name: fields.get("realname").cloned(),
steam_id,
online_state,
state_message,
state_message_game,
state_message_non_steam_game,
privacy_state: fields.get("privacyState").cloned().unwrap_or_else(|| "public".to_string()),
visibility_state,
avatar_hash,
vac_banned,
trade_ban_state: fields.get("tradeBanState").cloned(),
is_limited_account: is_limited,
custom_url: fields.get("customURL").cloned(),
member_since,
steam_rating: fields.get("steamRating").cloned(),
location: fields.get("location").cloned(),
summary: fields.get("summary").cloned(),
privacy_message,
not_yet_setup,
})
}
fn parse_state_message(state_msg: &str, online_state: &str) -> (String, Option<String>, Option<String>) {
if state_msg.is_empty() || state_msg == "Online" || state_msg == "Online using Big Picture" || state_msg == "Online using VR" {
return ("online".to_string(), None, None);
}
if state_msg == "Offline" {
return ("offline".to_string(), None, None);
}
if state_msg.starts_with("In non-Steam game<br/>") {
let game = state_msg.replace("In non-Steam game<br/>", "");
return ("in-game".to_string(), None, Some(game));
}
if state_msg.starts_with("In-Game<br/>") {
let game = state_msg.replace("In-Game<br/>", "");
return ("in-game".to_string(), Some(game), None);
}
(online_state.to_string(), None, None)
}
fn parse_member_since(date_str: &str) -> Option<i64> {
use chrono::NaiveDate;
let months: [(&str, u32); 12] = [
("January", 1),
("February", 2),
("March", 3),
("April", 4),
("May", 5),
("June", 6),
("July", 7),
("August", 8),
("September", 9),
("October", 10),
("November", 11),
("December", 12),
];
let parts: Vec<&str> = date_str.split([' ', ',']).filter(|s| !s.is_empty()).collect();
if parts.is_empty() {
return None;
}
let month: u32 = months.iter().find(|(name, _)| *name == parts[0]).map(|(_, num)| *num)?;
let day: u32 = parts.get(1).and_then(|s| s.parse().ok())?;
let year: i32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(2024);
let date = NaiveDate::from_ymd_opt(year, month, day)?;
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1)?;
let days_since_epoch = (date - epoch).num_days();
Some(days_since_epoch * 86_400 * 1000) }
fn extract_avatar_hash(url: &str) -> String {
url.split('/').next_back().and_then(|s| s.split('_').next()).and_then(|s| s.split('.').next()).unwrap_or("").to_string()
}
fn parse_user_summary_profile(html: &str) -> Result<crate::types::UserSummaryProfile, SteamUserError> {
let document = Html::parse_document(html);
let message_selector = Selector::parse("#message .sectionText").expect("valid CSS selector");
if let Some(element) = document.select(&message_selector).next() {
let text = element.text().collect::<String>().trim().to_string();
if text.contains("error") || text.contains("Error") {
return Err(SteamUserError::Other("Profile error".into()));
}
}
let private_selector = Selector::parse(".profile_header_summary .profile_private_info").expect("valid CSS selector");
let profile_private_info = document.select(&private_selector).next().map(|e| e.text().collect::<String>().trim().to_string());
let is_private = profile_private_info.as_ref().map(|t| t.contains("This profile is private")).unwrap_or(false);
let name_selector = Selector::parse(".persona_name .actual_persona_name").expect("valid CSS selector");
let name = document.select(&name_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
let level_selector = Selector::parse(".persona_level .friendPlayerLevelNum").expect("valid CSS selector");
let level = document.select(&level_selector).next().and_then(|e| e.text().collect::<String>().parse::<u32>().ok());
let btn_selector = Selector::parse("#btn > a").expect("valid CSS selector");
let not_yet_setup_link = document.select(&btn_selector).next().and_then(|e| e.value().attr("href")).unwrap_or("");
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);
let mut steam_id = SteamID::new();
let mut url = String::new();
let mut summary = None;
if let Some(start) = html.find("g_rgProfileData = ") {
let rest = &html[start + 18..];
if let Some(end) = rest.find("};") {
let json_str = &rest[..end + 1];
if let Ok(data) = serde_json::from_str::<serde_json::Value>(json_str) {
if let Some(sid) = data.get("steamid").and_then(|v| v.as_str()) {
steam_id = sid.parse().unwrap_or_else(|_| SteamID::new());
}
if let Some(u) = data.get("url").and_then(|v| v.as_str()) {
url = u.trim_end_matches('/').to_string();
}
summary = data.get("summary").and_then(|v| v.as_str()).map(|s| s.to_string());
}
}
}
let custom_url = url.split('/').rfind(|s| !s.is_empty()).unwrap_or("").to_string();
let avatar_selector = Selector::parse(".profile_header .playerAvatar").expect("valid CSS selector");
let online_state = document
.select(&avatar_selector)
.next()
.and_then(|e| e.value().attr("class"))
.map(|classes| {
if classes.contains("in-game") {
"in-game"
} else if classes.contains("online") {
"online"
} else {
"offline"
}
})
.unwrap_or("offline")
.to_string();
let avatar_link_selector = Selector::parse("head > link[rel='image_src']").expect("valid CSS selector");
let avatar_url = document.select(&avatar_link_selector).next().and_then(|e| e.value().attr("href")).unwrap_or("");
let avatar_hash = Self::extract_avatar_hash(avatar_url);
let frame_selector = Selector::parse(".profile_header .playerAvatar .profile_avatar_frame img").expect("valid CSS selector");
let avatar_frame = document.select(&frame_selector).next().and_then(|e| e.value().attr("src")).map(|s| s.to_string());
let flag_selector = Selector::parse("img.profile_flag").expect("valid CSS selector");
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();
let realname_selector = Selector::parse(".header_real_name bdi").expect("valid CSS selector");
let real_name = document.select(&realname_selector).next().map(|e| e.text().collect::<String>().trim().to_string()).unwrap_or_default();
let nickname_selector = Selector::parse(".persona_name .nickname").expect("valid CSS selector");
let nickname = document.select(&nickname_selector).next().map(|e| e.text().collect::<String>().trim().to_string());
let lobby_selector = Selector::parse(".profile_in_game_joingame a[href*='steam://joinlobby/']").expect("valid CSS selector");
let lobby_link = document.select(&lobby_selector).next().and_then(|e| e.value().attr("href")).map(|s| s.to_string());
let friend_btn_selector = Selector::parse("#btn_add_friend").expect("valid CSS selector");
let add_friend_enable = document.select(&friend_btn_selector).next().is_some();
let ban_selector = Selector::parse(".profile_ban_status").expect("valid CSS selector");
let ban_text: String = document.select(&ban_selector).map(|e| e.text().collect::<String>()).collect::<Vec<_>>().join(" ");
let is_vac_ban = if ban_text.contains("VAC bans on record") {
crate::types::BanStatus::Multiple
} else if ban_text.contains("VAC ban on record") {
crate::types::BanStatus::Single
} else {
crate::types::BanStatus::None
};
let is_game_ban = if ban_text.contains("game bans on record") {
crate::types::BanStatus::Multiple
} else if ban_text.contains("game ban on record") {
crate::types::BanStatus::Single
} else {
crate::types::BanStatus::None
};
let is_trade_ban = ban_text.contains("Currently trade banned");
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());
let game_selector = Selector::parse(".profile_in_game .profile_in_game_name").expect("valid CSS selector");
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 };
Ok(crate::types::UserSummaryProfile {
name,
real_name,
online_state,
steam_id,
avatar_hash,
avatar_frame,
custom_url,
location,
summary,
not_yet_setup,
profile_private_info,
lobby_link,
add_friend_enable,
is_private,
url,
nickname,
level,
day_last_ban: None, game_ban: Some(crate::types::GameBanData { is_vac_ban, is_game_ban, is_trade_ban, days_since_last_ban }),
state_message_game,
})
}
#[tracing::instrument(skip(self), fields(identifier = %identifier))]
pub async fn fetch_full_profile(&self, identifier: &str) -> Result<SteamProfile, SteamUserError> {
let steam_id = if let Ok(id) = identifier.parse::<SteamID>() {
id
} else {
let api_key = self.get_web_api_key("localhost").await?;
self.resolve_vanity_url(&api_key, identifier).await?
};
self.get_profile(Some(steam_id)).await
}
#[steam_endpoint(POST, host = Community, path = "/actions/ajaxresolveusers", kind = Read)]
pub async fn resolve_user(&self, steam_id: SteamID) -> Result<Option<crate::types::SteamUserProfile>, SteamUserError> {
let path = format!("/actions/ajaxresolveusers?steamids={}", steam_id.steam_id64());
let response = match self.get_path(&path).send().await {
Ok(res) => res,
Err(e) => {
tracing::error!(path = %path, error = %e, error_debug = ?e, "resolve_user: HTTP request failed");
return Err(e.into());
}
};
self.check_response(&response)?;
let text = response.text().await?;
if text.trim() == "null" {
return Ok(None);
}
let users: Vec<crate::types::SteamUserProfile> = serde_json::from_str(&text).unwrap_or_default();
Ok(users.into_iter().next())
}
#[steam_endpoint(POST, host = Api, path = "/ICommunityService/GetAvatarHistory/v1", kind = Read)]
pub async fn get_avatar_history(&self) -> Result<Vec<crate::types::AvatarHistoryEntry>, SteamUserError> {
use prost::Message;
use steam_protos::messages::community::{CCommunityGetAvatarHistoryRequest, CCommunityGetAvatarHistoryResponse};
let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
let access_token = self.session.access_token.as_deref().ok_or(SteamUserError::MissingCredential { field: "access_token" })?;
let request = CCommunityGetAvatarHistoryRequest { steamid: Some(steam_id.steam_id64()), filter_user_uploaded_only: Some(true) };
let mut proto_bytes = Vec::new();
request.encode(&mut proto_bytes)?;
use base64::Engine;
let encoded_proto = base64::engine::general_purpose::STANDARD.encode(&proto_bytes);
let response = self
.post_path("/ICommunityService/GetAvatarHistory/v1")
.query(&[("access_token", access_token)])
.header("Origin", "https://steamcommunity.com")
.header("Referer", "https://steamcommunity.com/")
.header("Accept", "*/*")
.header("Accept-Language", "en-US,en;q=0.9")
.header("Cache-Control", "no-cache")
.header("Pragma", "no-cache")
.header("Priority", "u=1, i")
.header("Sec-Ch-Ua", "\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"")
.header("Sec-Ch-Ua-Mobile", "?0")
.header("Sec-Ch-Ua-Platform", "\"Windows\"")
.header("Sec-Fetch-Dest", "empty")
.header("Sec-Fetch-Mode", "cors")
.header("Sec-Fetch-Site", "cross-site")
.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")
.form(&[("input_protobuf_encoded", &encoded_proto)])
.send()
.await?;
if !response.status().is_success() {
return Err(SteamUserError::HttpStatus { status: response.status().as_u16(), url: response.url().to_string() });
}
let bytes = response.bytes().await?;
if bytes.is_empty() {
return Ok(Vec::new());
}
let response_proto = CCommunityGetAvatarHistoryResponse::decode(bytes)?;
let entries = response_proto
.avatars
.into_iter()
.map(|a| crate::types::AvatarHistoryEntry {
avatar_sha1: a.avatar_sha1.unwrap_or_default(),
user_uploaded: a.user_uploaded.unwrap_or_default(),
timestamp: a.timestamp.unwrap_or_default(),
})
.collect();
Ok(entries)
}
}