use std::sync::OnceLock;
use scraper::Selector;
use serde::Deserialize;
use steamid::SteamID;
use crate::{
client::SteamUser,
endpoint::{steam_endpoint, Host},
error::SteamUserError,
utils::{
avatar::{extract_custom_url, get_avatar_hash_from_url, get_avatar_url_from_hash, AvatarSize},
debug::dump_html,
},
};
fn de_u32_int_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
use serde::de::Error;
#[derive(Deserialize)]
#[serde(untagged)]
enum N {
Int(u64),
Str(String),
}
match N::deserialize(d)? {
N::Int(n) => u32::try_from(n).map_err(D::Error::custom),
N::Str(s) => s.replace(',', "").parse().map_err(D::Error::custom),
}
}
fn de_opt_i32_int_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<i32>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum N {
Int(i64),
Str(String),
Null,
}
Ok(match Option::<N>::deserialize(d)? {
Some(N::Int(n)) => i32::try_from(n).ok(),
Some(N::Str(s)) => s.replace(',', "").parse().ok(),
Some(N::Null) | None => None,
})
}
fn de_opt_u64_int_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum N {
Int(u64),
Str(String),
Null,
}
Ok(match Option::<N>::deserialize(d)? {
Some(N::Int(n)) => Some(n),
Some(N::Str(s)) => s.replace(',', "").parse().ok(),
Some(N::Null) | None => None,
})
}
fn de_bool_int_or_bool<'de, D: serde::Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum B {
Bool(bool),
Int(i64),
Null,
}
Ok(match Option::<B>::deserialize(d)? {
Some(B::Bool(b)) => b,
Some(B::Int(n)) => n == 1,
Some(B::Null) | None => false,
})
}
#[derive(Deserialize)]
struct CommunitySearchResponseRaw {
#[serde(default)]
success: i64,
#[serde(default)]
html: String,
#[serde(default = "default_search_filter")]
search_filter: String,
#[serde(default, deserialize_with = "de_opt_u64_int_or_string")]
search_page: Option<u64>,
#[serde(default, deserialize_with = "de_u32_int_or_string")]
search_result_count: u32,
#[serde(default)]
search_text: String,
}
fn default_search_filter() -> String {
"users".to_string()
}
#[derive(Deserialize, Default)]
struct QuickInviteRaw {
#[serde(default)]
invite_token: Option<String>,
#[serde(default, deserialize_with = "de_opt_i32_int_or_string")]
invite_limit: Option<i32>,
#[serde(default, deserialize_with = "de_opt_i32_int_or_string")]
invite_duration: Option<i32>,
#[serde(default, deserialize_with = "de_opt_u64_int_or_string")]
time_created: Option<u64>,
}
#[derive(Deserialize)]
struct QuickInviteCreateResponseRaw {
#[serde(default, deserialize_with = "de_bool_int_or_bool")]
success: bool,
#[serde(default)]
invite: Option<QuickInviteRaw>,
}
#[derive(Deserialize)]
struct QuickInviteListResponseRaw {
#[serde(default, deserialize_with = "de_bool_int_or_bool")]
success: bool,
#[serde(default)]
tokens: Vec<QuickInviteTokenRaw>,
}
#[derive(Deserialize)]
struct QuickInviteTokenRaw {
#[serde(default)]
invite_token: String,
#[serde(default, deserialize_with = "de_opt_i32_int_or_string")]
invite_limit: Option<i32>,
#[serde(default, deserialize_with = "de_opt_i32_int_or_string")]
invite_duration: Option<i32>,
#[serde(default, deserialize_with = "de_opt_u64_int_or_string")]
time_created: Option<u64>,
}
#[derive(Deserialize)]
struct RedeemResponseRaw {
#[serde(default, deserialize_with = "de_opt_i32_int_or_string")]
success: Option<i32>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct FriendsCountRaw {
#[serde(rename = "cFriends", deserialize_with = "de_u32_int_or_string")]
friends: u32,
#[serde(rename = "cFriendsPending", deserialize_with = "de_u32_int_or_string")]
friends_pending: u32,
#[serde(rename = "cFriendsBlocked", deserialize_with = "de_u32_int_or_string")]
friends_blocked: u32,
#[serde(rename = "cFollowing", deserialize_with = "de_u32_int_or_string")]
following: u32,
#[serde(rename = "cGroups", deserialize_with = "de_u32_int_or_string")]
groups: u32,
#[serde(rename = "cGroupsPending", deserialize_with = "de_u32_int_or_string")]
groups_pending: u32,
}
static SEL_INVITE_BLOCK_NAME: OnceLock<Selector> = OnceLock::new();
fn sel_invite_block_name() -> &'static Selector {
SEL_INVITE_BLOCK_NAME.get_or_init(|| Selector::parse(".invite_block_name > a.linkTitle").expect("valid CSS selector"))
}
static SEL_FRIEND_PLAYER_LEVEL: OnceLock<Selector> = OnceLock::new();
fn sel_friend_player_level() -> &'static Selector {
SEL_FRIEND_PLAYER_LEVEL.get_or_init(|| Selector::parse(".friendPlayerLevel .friendPlayerLevelNum").expect("valid CSS selector"))
}
static SEL_PERSONA_MINIPROFILE: OnceLock<Selector> = OnceLock::new();
fn sel_persona_miniprofile() -> &'static Selector {
SEL_PERSONA_MINIPROFILE.get_or_init(|| Selector::parse(".persona[data-miniprofile]").expect("valid CSS selector"))
}
static SEL_SELECTABLE_OVERLAY: OnceLock<Selector> = OnceLock::new();
fn sel_selectable_overlay() -> &'static Selector {
SEL_SELECTABLE_OVERLAY.get_or_init(|| Selector::parse("a.selectable_overlay").expect("valid CSS selector"))
}
static SEL_PLAYER_AVATAR_IMG: OnceLock<Selector> = OnceLock::new();
fn sel_player_avatar_img() -> &'static Selector {
SEL_PLAYER_AVATAR_IMG.get_or_init(|| Selector::parse(".player_avatar img, .playerAvatar img").expect("valid CSS selector"))
}
static SEL_FRIEND_BLOCK_CONTENT: OnceLock<Selector> = OnceLock::new();
fn sel_friend_block_content() -> &'static Selector {
SEL_FRIEND_BLOCK_CONTENT.get_or_init(|| Selector::parse(".friend_block_content, .friendBlockContent").expect("valid CSS selector"))
}
static SEL_PLAYER_NICKNAME_HINT: OnceLock<Selector> = OnceLock::new();
fn sel_player_nickname_hint() -> &'static Selector {
SEL_PLAYER_NICKNAME_HINT.get_or_init(|| Selector::parse(".player_nickname_hint").expect("valid CSS selector"))
}
static SEL_FRIEND_GAME_LINK: OnceLock<Selector> = OnceLock::new();
fn sel_friend_game_link() -> &'static Selector {
SEL_FRIEND_GAME_LINK.get_or_init(|| Selector::parse(".friend_game_link, .linkFriend_in-game").expect("valid CSS selector"))
}
static SEL_FRIEND_LAST_ONLINE: OnceLock<Selector> = OnceLock::new();
fn sel_friend_last_online() -> &'static Selector {
SEL_FRIEND_LAST_ONLINE.get_or_init(|| Selector::parse(".friend_last_online_text").expect("valid CSS selector"))
}
static SEL_SEARCH_ROW: OnceLock<Selector> = OnceLock::new();
fn sel_search_row() -> &'static Selector {
SEL_SEARCH_ROW.get_or_init(|| Selector::parse(".search_row").expect("valid CSS selector"))
}
static SEL_MEDIUM_HOLDER: OnceLock<Selector> = OnceLock::new();
fn sel_medium_holder() -> &'static Selector {
SEL_MEDIUM_HOLDER.get_or_init(|| Selector::parse(".mediumHolder_default[data-miniprofile]").expect("valid CSS selector"))
}
static SEL_AVATAR_MEDIUM_IMG: OnceLock<Selector> = OnceLock::new();
fn sel_avatar_medium_img() -> &'static Selector {
SEL_AVATAR_MEDIUM_IMG.get_or_init(|| Selector::parse(".avatarMedium a img").expect("valid CSS selector"))
}
static SEL_SEARCH_PERSONA_NAME: OnceLock<Selector> = OnceLock::new();
fn sel_search_persona_name() -> &'static Selector {
SEL_SEARCH_PERSONA_NAME.get_or_init(|| Selector::parse("a.searchPersonaName").expect("valid CSS selector"))
}
static SEL_COMMUNITY_SEARCH_PAGING: OnceLock<Selector> = OnceLock::new();
fn sel_community_search_paging() -> &'static Selector {
SEL_COMMUNITY_SEARCH_PAGING.get_or_init(|| Selector::parse(".community_searchresults_paging a").expect("valid CSS selector"))
}
impl SteamUser {
#[steam_endpoint(POST, host = Community, path = "/actions/AddFriendAjax", kind = Write)]
pub async fn add_friend(&self, user_id: SteamID) -> Result<(), SteamUserError> {
let steam_id = user_id.steam_id64().to_string();
let response: serde_json::Value = self.post_path("/actions/AddFriendAjax").form(&[("steamid", steam_id.as_str()), ("accept_invite", "0")]).send().await?.json().await?;
Self::check_json_success(&response, "Failed to add friend")?;
Ok(())
}
#[steam_endpoint(POST, host = Community, path = "/actions/RemoveFriendAjax", kind = Write)]
pub async fn remove_friend(&self, user_id: SteamID) -> Result<(), SteamUserError> {
let steam_id = user_id.steam_id64().to_string();
let response: serde_json::Value = self.post_path("/actions/RemoveFriendAjax").form(&[("steamid", steam_id.as_str())]).send().await?.json().await?;
Self::check_json_success(&response, "Failed to remove friend")?;
Ok(())
}
#[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/friends/action", kind = Write)]
pub async fn accept_friend_request(&self, user_id: SteamID) -> Result<(), SteamUserError> {
let my_steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?.steam_id64().to_string();
let target_steam_id = user_id.steam_id64().to_string();
let url = format!("/profiles/{}/friends/action", my_steam_id);
let response: serde_json::Value = self.post_path(&url).form(&[("steamid", my_steam_id.as_str()), ("ajax", "1"), ("action", "accept"), ("steamids[]", target_steam_id.as_str())]).send().await?.json().await?;
Self::check_json_success(&response, "Failed to accept friend request")?;
Ok(())
}
#[steam_endpoint(POST, host = Community, path = "/actions/IgnoreFriendInviteAjax", kind = Write)]
pub async fn ignore_friend_request(&self, user_id: SteamID) -> Result<(), SteamUserError> {
let steam_id = user_id.steam_id64().to_string();
let response: serde_json::Value = self.post_path("/actions/IgnoreFriendInviteAjax").form(&[("steamid", steam_id.as_str())]).send().await?.json().await?;
Self::check_json_success(&response, "Failed to ignore friend request")?;
Ok(())
}
#[steam_endpoint(POST, host = Community, path = "/actions/BlockUserAjax", kind = Write)]
pub async fn set_communication_block(&self, user_id: SteamID, block: bool) -> Result<(), SteamUserError> {
let steam_id = user_id.steam_id64().to_string();
let block_val = if block { "1" } else { "0" };
let response: serde_json::Value = self.post_path("/actions/BlockUserAjax").form(&[("steamid", steam_id.as_str()), ("block", block_val)]).send().await?.json().await?;
Self::check_json_success(&response, &format!("Failed to {} user", if block { "block" } else { "unblock" }))?;
Ok(())
}
#[tracing::instrument(skip(self), fields(target_steam_id = user_id.steam_id64()))]
pub async fn block_user(&self, user_id: SteamID) -> Result<(), SteamUserError> {
self.set_communication_block(user_id, true).await
}
#[tracing::instrument(skip(self), fields(target_steam_id = user_id.steam_id64()))]
pub async fn unblock_user(&self, user_id: SteamID) -> Result<(), SteamUserError> {
self.set_communication_block(user_id, false).await
}
#[steam_endpoint(GET, host = Community, path = "/textfilter/ajaxgetfriendslist", kind = Read)]
async fn fetch_friends_list_raw(&self) -> Result<(i64, Vec<serde_json::Value>), SteamUserError> {
let response: serde_json::Value = self.get_path("/textfilter/ajaxgetfriendslist").send().await?.json().await?;
let success = response.get("success").and_then(|v| v.as_i64()).unwrap_or(0);
if success != 1 {
if success == 21 {
return Ok((21, Vec::new()));
}
return Err(SteamUserError::from_eresult(i32::try_from(success).unwrap_or(i32::MIN)));
}
let friends_list = response.get("friendslist").and_then(|v| v.get("friends")).and_then(|v| v.as_array()).ok_or_else(|| SteamUserError::MalformedResponse("Missing friends list".into()))?;
Ok((1, friends_list.clone()))
}
#[tracing::instrument(skip(self))]
pub async fn get_friends_list(&self) -> Result<std::collections::HashMap<SteamID, i32>, SteamUserError> {
let (_, friends_list) = self.fetch_friends_list_raw().await?;
let mut friends = std::collections::HashMap::with_capacity(friends_list.len());
for friend in &friends_list {
if let (Some(id), Some(rel)) = (friend.get("ulfriendid").and_then(|v| v.as_str()), friend.get("efriendrelationship").and_then(|v| v.as_i64())) {
if let Ok(steam_id) = id.parse::<u64>() {
if let Ok(rel_i32) = i32::try_from(rel) {
friends.insert(SteamID::from(steam_id), rel_i32);
}
}
}
}
Ok(friends)
}
#[tracing::instrument(skip(self))]
pub async fn get_friends_details(&self) -> Result<crate::types::FriendListPage, SteamUserError> {
let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
self.get_friends_details_of_user(steam_id).await
}
#[steam_endpoint(GET, host = Community, path = "/profiles/{user_id}/friends/", kind = Read)]
pub async fn get_friends_details_of_user(&self, user_id: SteamID) -> Result<crate::types::FriendListPage, SteamUserError> {
let body = self.get_path(format!("/profiles/{}/friends/", user_id.steam_id64())).send().await?.text().await?;
Ok(parse_friend_list(&body))
}
#[steam_endpoint(POST, host = Community, path = "/profiles/{user_id}/followuser/", kind = Write)]
pub async fn follow_user(&self, user_id: SteamID) -> Result<(), SteamUserError> {
self.send_profile_follow_action(user_id, "follow").await
}
#[steam_endpoint(POST, host = Community, path = "/profiles/{user_id}/unfollowuser/", kind = Write)]
pub async fn unfollow_user(&self, user_id: SteamID) -> Result<(), SteamUserError> {
self.send_profile_follow_action(user_id, "unfollow").await
}
#[steam_endpoint(GET, host = Community, path = "/search/SearchCommunityAjax", kind = Read)]
pub async fn search_users(&self, query: &str, page: u32) -> Result<crate::types::CommunitySearchResult, SteamUserError> {
let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?.steam_id64().to_string();
let raw: CommunitySearchResponseRaw = self.get_path("/search/SearchCommunityAjax").query(&[("text", query), ("filter", "users"), ("steamid", steam_id.as_str()), ("accept_invite", "0"), ("page", &page.to_string())]).send().await?.json().await?;
if raw.success != 1 {
return Err(SteamUserError::SteamError("Search failed".to_string()));
}
let search_page = raw.search_page.and_then(|n| u32::try_from(n).ok()).unwrap_or(page);
let search_text = if raw.search_text.is_empty() { query.to_string() } else { raw.search_text };
let (players, prev_page, next_page) = parse_search_results(&raw.html, search_page);
Ok(crate::types::CommunitySearchResult {
players,
prev_page,
next_page,
search_filter: raw.search_filter,
search_page,
search_result_count: raw.search_result_count,
search_text,
})
}
#[tracing::instrument(skip(self))]
pub async fn create_instant_invite(&self) -> Result<String, SteamUserError> {
let my_steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
let short_url = format!("https://s.team/p/{}", steam_friend_code::create_short_steam_friend_code(my_steam_id.account_id));
let invite_data = self.get_quick_invite_data().await?;
if let Some(token) = invite_data.invite_token {
Ok(format!("{}/{}", short_url, token))
} else {
Err(SteamUserError::SteamError("Failed to generate invite token".to_string()))
}
}
#[steam_endpoint(POST, host = Community, path = "/invites/ajaxcreate", kind = Write)]
pub async fn get_quick_invite_data(&self) -> Result<crate::types::QuickInviteData, SteamUserError> {
let my_steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
let raw: QuickInviteCreateResponseRaw = self.post_path("/invites/ajaxcreate").form(&[("steamid_user", my_steam_id.steam_id64().to_string()), ("duration", "2592000".to_string())]).send().await?.json().await?;
if let (true, Some(invite)) = (raw.success, raw.invite) {
Ok(crate::types::QuickInviteData {
success: true,
invite_token: invite.invite_token,
invite_limit: invite.invite_limit,
invite_duration: invite.invite_duration,
time_created: invite.time_created,
steam_id: Some(my_steam_id),
})
} else {
Ok(crate::types::QuickInviteData {
success: false,
invite_token: None,
invite_limit: None,
invite_duration: None,
time_created: None,
steam_id: Some(my_steam_id),
})
}
}
#[steam_endpoint(GET, host = Community, path = "/invites/ajaxgetall", kind = Read)]
pub async fn get_current_quick_invite_tokens(&self) -> Result<crate::types::QuickInviteTokensResponse, SteamUserError> {
let my_steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
let raw: QuickInviteListResponseRaw = self.get_path("/invites/ajaxgetall").send().await?.json().await?;
let tokens = raw
.tokens
.into_iter()
.map(|t| crate::types::QuickInviteToken {
invite_token: t.invite_token,
invite_limit: t.invite_limit.unwrap_or(0),
invite_duration: t.invite_duration.unwrap_or(0),
time_created: t.time_created.unwrap_or(0),
steam_id: Some(my_steam_id),
})
.collect();
Ok(crate::types::QuickInviteTokensResponse { success: raw.success, tokens })
}
#[steam_endpoint(POST, host = Community, path = "/profiles/{user_id}/{action}user/", kind = Write)]
async fn send_profile_follow_action(&self, user_id: SteamID, action: &str) -> Result<(), SteamUserError> {
let target_steam_id = user_id.steam_id64().to_string();
let response: serde_json::Value = self.post_path(format!("/profiles/{}/{}user/", target_steam_id, action)).form(&([] as [(&str, &str); 0])).send().await?.json().await?;
Self::check_json_success(&response, &format!("Failed to {} user", action))?;
Ok(())
}
#[tracing::instrument(skip(self))]
pub async fn get_following_list(&self) -> Result<crate::types::FriendListPage, SteamUserError> {
let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
self.get_following_list_of_user(steam_id).await
}
#[steam_endpoint(GET, host = Community, path = "/profiles/{user_id}/following/", kind = Read)]
pub async fn get_following_list_of_user(&self, user_id: SteamID) -> Result<crate::types::FriendListPage, SteamUserError> {
let body = self.get_path(format!("/profiles/{}/following/", user_id.steam_id64())).send().await?.text().await?;
Ok(parse_friend_list(&body))
}
#[tracing::instrument(skip(self))]
pub async fn get_my_friends_id_list(&self) -> Result<Vec<SteamID>, SteamUserError> {
let (_, friends_list) = self.fetch_friends_list_raw().await?;
const FRIEND_RELATIONSHIP: i64 = 3;
let mut friends = Vec::new();
for friend in &friends_list {
let relationship = friend.get("efriendrelationship").and_then(|v| v.as_i64()).unwrap_or(0);
if relationship == FRIEND_RELATIONSHIP {
if let Some(id_str) = friend.get("ulfriendid").and_then(|v| v.as_str()) {
if let Ok(steam_id) = id_str.parse::<u64>() {
friends.push(SteamID::from(steam_id));
}
}
}
}
Ok(friends)
}
#[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/friends/pending", kind = Read)]
pub async fn get_pending_friend_list(&self) -> Result<crate::types::PendingFriendList, SteamUserError> {
let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?.steam_id64();
let body = self.get_path(format!("/profiles/{}/friends/pending", steam_id)).send().await?.text().await?;
let sent_invites = parse_pending_friend_list(&body, "#search_results_sentinvites > div");
let received_invites = parse_pending_friend_list(&body, "#search_results > div");
Ok(crate::types::PendingFriendList { sent_invites, received_invites })
}
#[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/friends/action", kind = Write)]
pub async fn remove_friends(&self, steam_ids: &[SteamID]) -> Result<(), SteamUserError> {
if steam_ids.is_empty() {
return Ok(());
}
let my_steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?.steam_id64().to_string();
let mut params = vec![("steamid", my_steam_id), ("ajax", "1".to_string()), ("action", "remove".to_string())];
for steam_id in steam_ids {
params.push(("steamids[]", steam_id.steam_id64().to_string()));
}
let response: serde_json::Value = self.post_path(format!("/profiles/{}/friends/action", params[0].1)).form(¶ms).send().await?.json().await?;
Self::check_json_success(&response, "Failed to remove friends")?;
Ok(())
}
#[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/friends/action", kind = Write)]
pub async fn unfollow_users(&self, steam_ids: &[SteamID]) -> Result<(), SteamUserError> {
if steam_ids.is_empty() {
return Ok(());
}
let my_steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?.steam_id64().to_string();
let path = format!("/profiles/{}/friends/action", my_steam_id);
let mut params = vec![("steamid", my_steam_id.clone()), ("ajax", "1".to_string()), ("action", "unfollow".to_string())];
for steam_id in steam_ids {
params.push(("steamids[]", steam_id.steam_id64().to_string()));
}
let response: serde_json::Value = self.post_path(&path).form(¶ms).send().await?.json().await?;
Self::check_json_success(&response, "Failed to unfollow users")?;
Ok(())
}
#[tracing::instrument(skip(self))]
pub async fn unfollow_all_following(&self) -> Result<(), SteamUserError> {
let page = self.get_following_list().await?;
if page.friends.is_empty() {
return Ok(());
}
let steam_ids: Vec<SteamID> = page.friends.iter().map(|f| f.steam_id).collect();
self.unfollow_users(&steam_ids).await
}
#[tracing::instrument(skip(self), fields(target_steam_id = steam_id.steam_id64()))]
pub async fn cancel_friend_request(&self, steam_id: SteamID) -> Result<(), SteamUserError> {
self.remove_friend(steam_id).await
}
#[steam_endpoint(GET, host = Community, path = "/actions/PlayerList/", kind = Read)]
pub async fn get_friends_in_common(&self, steam_id: SteamID) -> Result<Vec<crate::types::FriendDetails>, SteamUserError> {
let account_id = steam_id.account_id.to_string();
let body = self.get_path("/actions/PlayerList/").query(&[("type", "friendsincommon"), ("target", &account_id)]).send().await?.text().await?;
Ok(parse_friend_list(&body).friends)
}
#[steam_endpoint(GET, host = Community, path = "/actions/PlayerList/", kind = Read)]
pub async fn get_friends_in_group(&self, group_id: SteamID) -> Result<Vec<crate::types::FriendDetails>, SteamUserError> {
let account_id = group_id.account_id.to_string();
let body = self.get_path("/actions/PlayerList/").query(&[("type", "friendsingroup"), ("target", &account_id)]).send().await?.text().await?;
Ok(parse_friend_list(&body).friends)
}
#[steam_endpoint(GET, host = Api, path = "/IPlayerService/GetFriendsGameplayInfo/v1", kind = Read)]
pub async fn get_friends_gameplay_info(&self, app_id: u32) -> Result<crate::types::gameplay::GameplayInfoResponse, SteamUserError> {
use prost::Message;
use steam_protos::messages::player::{CPlayerGetFriendsGameplayInfoRequest, CPlayerGetFriendsGameplayInfoResponse};
let request = CPlayerGetFriendsGameplayInfoRequest { appid: Some(app_id) };
let mut body = Vec::new();
request.encode(&mut body)?;
let params = [("origin", "https://store.steampowered.com"), ("input_protobuf_encoded", &base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body))];
let response = self.get_path("/IPlayerService/GetFriendsGameplayInfo/v1").query(¶ms).send().await?;
if !response.status().is_success() {
let status = response.status().as_u16();
let url = response.url().to_string();
return Err(SteamUserError::HttpStatus { status, url });
}
let bytes = response.bytes().await?;
let response_proto = CPlayerGetFriendsGameplayInfoResponse::decode(bytes)?;
let convert_list = |list: Vec<steam_protos::messages::player::c_player_get_friends_gameplay_info_response::FriendsGameplayInfo>| {
list.into_iter()
.map(|item| crate::types::gameplay::FriendsGameplayInfo {
steam_id: SteamID::from(item.steamid.unwrap_or(0)),
minutes_played: item.minutes_played.unwrap_or(0),
minutes_played_forever: item.minutes_played_forever.unwrap_or(0),
})
.collect()
};
Ok(crate::types::gameplay::GameplayInfoResponse {
your_info: response_proto.your_info.map(|info| crate::types::gameplay::OwnGameplayInfo {
steam_id: SteamID::from(info.steamid.unwrap_or(0)),
minutes_played: info.minutes_played.unwrap_or(0),
minutes_played_forever: info.minutes_played_forever.unwrap_or(0),
in_wishlist: info.in_wishlist.unwrap_or(false),
owned: info.owned.unwrap_or(false),
}),
in_game: convert_list(response_proto.in_game),
played_recently: convert_list(response_proto.played_recently),
played_ever: convert_list(response_proto.played_ever),
owns: convert_list(response_proto.owns),
in_wishlist: convert_list(response_proto.in_wishlist),
})
}
#[steam_endpoint(GET, host = Community, path = "/tradeoffer/new/", kind = Read)]
pub async fn get_friend_since(&self, steam_id: SteamID) -> Result<Option<String>, SteamUserError> {
let account_id = steam_id.account_id.to_string();
let body = self.get_path("/tradeoffer/new/").query(&[("partner", &account_id)]).send().await?.text().await?;
Ok(parse_friend_since(&body))
}
#[steam_endpoint(GET, host = Community, path = "/invites/ajaxredeem", kind = Write)]
pub async fn accept_quick_invite_link(&self, invite_link: &str) -> Result<(), SteamUserError> {
let invite_token = invite_link.trim_end_matches('/').rsplit('/').next().ok_or_else(|| SteamUserError::MalformedResponse("Invalid invite link format".into()))?;
let parsed = url::Url::parse(invite_link).map_err(|e| SteamUserError::InvalidInput(format!("invalid invite_link: {e}")))?;
let host = match parsed.host_str() {
Some("s.team") => Host::ShortLink,
Some("steamcommunity.com") => Host::Community,
Some(other) => return Err(SteamUserError::InvalidInput(format!("invite_link host must be s.team or steamcommunity.com, got {other}"))),
None => return Err(SteamUserError::InvalidInput("invite_link has no host".into())),
};
let path_and_query: &str = &parsed[url::Position::BeforePath..];
let body = self.get_path_on(host, path_and_query).send().await?.text().await?;
let steamid_user = parse_profile_data_steamid(&body).ok_or_else(|| SteamUserError::MalformedResponse("Could not find steamid in invite page".into()))?;
let session_id = self.session.session_id.as_deref().ok_or(SteamUserError::NotLoggedIn)?;
let raw: RedeemResponseRaw = self
.get_path("/invites/ajaxredeem")
.query(&[("sessionid", session_id), ("steamid_user", steamid_user.as_str()), ("invite_token", invite_token)])
.send()
.await?
.json()
.await?;
let success = raw.success.unwrap_or(0);
if success != 1 {
return Err(SteamUserError::SteamError(format!("Failed to accept invite (code: {})", success)));
}
Ok(())
}
#[steam_endpoint(GET, host = Community, path = "/invites/ajaxredeem", kind = Write)]
pub async fn accept_quick_invite_data(&self, steamid_user: &str, invite_token: &str) -> Result<(), SteamUserError> {
let session_id = self.session.session_id.as_deref().ok_or(SteamUserError::NotLoggedIn)?;
let raw: RedeemResponseRaw = self
.get_path("/invites/ajaxredeem")
.query(&[("sessionid", session_id), ("steamid_user", steamid_user), ("invite_token", invite_token)])
.send()
.await?
.json()
.await?;
let success = raw.success.unwrap_or(0);
if success != 1 {
return Err(SteamUserError::SteamError(format!("Failed to accept invite (code: {})", success)));
}
Ok(())
}
}
fn parse_pending_friend_list(html: &str, selector: &str) -> Vec<crate::types::PendingFriend> {
let document = scraper::Html::parse_document(html);
let row_selector = match scraper::Selector::parse(selector) {
Ok(s) => s,
Err(e) => {
tracing::warn!(selector, error = ?e, "parse_pending_friend_list: invalid CSS selector; returning empty");
return Vec::new();
}
};
let mut results = Vec::new();
for element in document.select(&row_selector) {
let steam_id_str = element.value().attr("data-steamid").unwrap_or("");
let account_id_str = element.value().attr("data-accountid").unwrap_or("0");
let steam_id = match steam_id_str.parse::<u64>() {
Ok(id) => SteamID::from(id),
Err(e) => {
tracing::warn!(data_steamid = steam_id_str, error = %e, "parse_pending_friend_list: malformed data-steamid; skipping row");
continue;
}
};
let account_id = account_id_str.parse::<u32>().unwrap_or(0);
if account_id == 0 {
continue;
}
let name = element.select(sel_invite_block_name()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
let link = element.select(sel_invite_block_name()).next().and_then(|el| el.value().attr("href")).unwrap_or("").to_string();
let avatar_selector_str = format!(".playerAvatar a > img[data-miniprofile=\"{}\"]", account_id);
let avatar = if let Ok(avatar_selector) = scraper::Selector::parse(&avatar_selector_str) { element.select(&avatar_selector).next().and_then(|el| el.value().attr("src")).unwrap_or("").to_string() } else { String::new() };
let level = element.select(sel_friend_player_level()).next().map(|el| el.text().collect::<String>().trim().parse::<u32>().unwrap_or(0)).unwrap_or(0);
results.push(crate::types::PendingFriend { name, link, avatar, steam_id, account_id, level });
}
results
}
fn parse_friend_since(html: &str) -> Option<String> {
let document = scraper::Html::parse_document(html);
let container_selector = scraper::Selector::parse(".trade_partner_header.responsive_trade_offersection").ok()?;
let info_block_selector = scraper::Selector::parse(".trade_partner_info_block").ok()?;
let info_text_selector = scraper::Selector::parse(".trade_partner_info_text").ok()?;
let container = document.select(&container_selector).next()?;
for info_block in container.select(&info_block_selector) {
let full_text: String = info_block.text().collect();
if full_text.contains("You've been friends since") || full_text.contains("You've been friends for") {
if let Some(info_text) = info_block.select(&info_text_selector).next() {
let friend_since = info_text.text().collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ");
if !friend_since.is_empty() {
return Some(friend_since);
}
}
}
}
None
}
fn parse_js_json_var(html: &str, var_name: &str) -> Option<serde_json::Value> {
let marker = format!("{} = ", var_name);
let start = html.find(&marker)?;
let rest = &html[start + marker.len()..];
let end = rest.find(";\n").or_else(|| rest.find(";\r")).or_else(|| rest.find(";\t")).or_else(|| rest.find(';'))?;
serde_json::from_str(rest[..end].trim()).ok()
}
fn parse_profile_data_steamid(html: &str) -> Option<String> {
let val = parse_js_json_var(html, "g_rgProfileData")?;
val.get("steamid").and_then(|v| v.as_str()).map(|s| s.to_string())
}
fn parse_friend_page_info(html: &str, document: &scraper::Html) -> Option<crate::types::FriendPageInfo> {
use crate::types::FriendPageInfo;
let mut info = FriendPageInfo::default();
let mut found_anything = false;
if let Some(val) = parse_js_json_var(html, "g_rgProfileData") {
if let Some(name) = val.get("personaname").and_then(|v| v.as_str()) {
info.persona_name = name.to_string();
}
if let Some(url) = val.get("url").and_then(|v| v.as_str()) {
info.profile_url = url.to_string();
}
if let Some(sid) = val.get("steamid").and_then(|v| v.as_str()) {
if let Ok(id64) = sid.parse::<u64>() {
info.steam_id = SteamID::from(id64);
}
}
found_anything = true;
}
if let Some(val) = parse_js_json_var(html, "g_rgCounts") {
if let Ok(counts) = serde_json::from_value::<FriendsCountRaw>(val) {
info.friends_count = counts.friends;
info.friends_pending_count = counts.friends_pending;
info.blocked_count = counts.friends_blocked;
info.following_count = counts.following;
info.groups_count = counts.groups;
info.groups_pending_count = counts.groups_pending;
found_anything = true;
}
}
if let Some(start) = html.find("g_cFriendsLimit = ") {
let rest = &html[start + 18..];
if let Some(end) = rest.find(';') {
if let Ok(limit) = rest[..end].trim().parse::<u32>() {
info.friends_limit = limit;
found_anything = true;
}
}
}
let wallet = crate::services::account::parse_wallet_balance(document);
if wallet.main_balance.is_some() {
info.wallet_balance = Some(wallet);
found_anything = true;
}
if found_anything {
Some(info)
} else {
None
}
}
fn parse_friend_list(html: &str) -> crate::types::FriendListPage {
let document = scraper::Html::parse_document(html);
let mut results = Vec::new();
for element in document.select(sel_persona_miniprofile()) {
let miniprofile = element.value().attr("data-miniprofile").and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
if miniprofile == 0 {
continue;
}
let steam_id = SteamID::from_individual_account_id(miniprofile);
let profile_url = element.select(sel_selectable_overlay()).next().and_then(|el| el.value().attr("href")).unwrap_or("").to_string();
if profile_url.is_empty() {
continue;
}
let avatar_src = element.select(sel_player_avatar_img()).next().and_then(|el| el.value().attr("src")).unwrap_or("");
let avatar_hash = get_avatar_hash_from_url(avatar_src).unwrap_or_default();
let avatar = get_avatar_url_from_hash(&avatar_hash, AvatarSize::Full).unwrap_or_else(|| avatar_src.to_string());
let mut username = String::new();
let mut game = String::new();
let mut last_online = String::new();
let mut is_nickname = false;
if let Some(content) = element.select(sel_friend_block_content()).next() {
is_nickname = content.select(sel_player_nickname_hint()).next().is_some();
if let Some(game_el) = content.select(sel_friend_game_link()).next() {
game = game_el.text().collect::<String>().trim().replace("In-Game", "").trim().to_string();
}
if let Some(last_el) = content.select(sel_friend_last_online()).next() {
last_online = last_el.text().collect::<String>().trim().to_string();
}
if let Some(first_text) = content.text().next() {
username = first_text.trim().to_string();
}
if username.is_empty() {
let full_text = content.text().collect::<String>();
username = full_text.trim().split('\n').next().unwrap_or("").trim().to_string();
}
}
if username == "[deleted]" {
continue;
}
let online_status = if element.value().classes().any(|c| c == "in-game") {
"ingame"
} else if element.value().classes().any(|c| c == "online") {
"online"
} else {
"offline"
}
.to_string();
let custom_url = extract_custom_url(&profile_url);
results.push(crate::types::FriendDetails {
username,
steam_id,
game,
online_status,
last_online,
miniprofile: miniprofile as u64,
is_nickname,
avatar,
avatar_hash,
profile_url,
custom_url,
});
}
if results.is_empty() && !html.trim().is_empty() && document.select(sel_persona_miniprofile()).next().is_none() {
dump_html("friends_list_empty", html);
}
let page_info = parse_friend_page_info(html, &document);
crate::types::FriendListPage { friends: results, page_info }
}
fn parse_search_results(html: &str, current_page: u32) -> (Vec<crate::types::CommunitySearchPlayer>, Option<u32>, Option<u32>) {
let document = scraper::Html::parse_document(html);
let mut players = Vec::new();
for element in document.select(sel_search_row()) {
let miniprofile = element.select(sel_medium_holder()).next().and_then(|el| el.value().attr("data-miniprofile")).and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
if miniprofile == 0 {
continue;
}
let steam_id = SteamID::from_individual_account_id(miniprofile);
let avatar_src = element.select(sel_avatar_medium_img()).next().and_then(|el| el.value().attr("src")).unwrap_or("");
let avatar_hash = get_avatar_hash_from_url(avatar_src).unwrap_or_default();
let search_persona_el = element.select(sel_search_persona_name()).next();
let name = search_persona_el.map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
let profile_url = search_persona_el.and_then(|el| el.value().attr("href")).unwrap_or("").to_string();
let custom_url = extract_custom_url(&profile_url);
players.push(crate::types::CommunitySearchPlayer { miniprofile: miniprofile as u64, steam_id, avatar_hash, name, profile_url, custom_url });
}
let mut prev_page = None;
let mut next_page = None;
for paging_el in document.select(sel_community_search_paging()) {
let onclick = paging_el.value().attr("onclick").unwrap_or("");
if onclick.contains("CommunitySearch.PrevPage()") {
prev_page = Some(current_page.saturating_sub(1));
} else if onclick.contains("CommunitySearch.NextPage()") {
next_page = Some(current_page + 1);
}
}
if players.is_empty() && !html.trim().is_empty() && document.select(sel_search_row()).next().is_none() {
dump_html("search_friends_empty", html);
}
(players, prev_page, next_page)
}