use std::sync::OnceLock;
use regex::Regex;
use scraper::Selector;
use steamid::SteamID;
use crate::{client::SteamUser, endpoint::steam_endpoint, error::SteamUserError};
static SEL_GROUP_VANITY_INPUT: OnceLock<Selector> = OnceLock::new();
fn sel_group_vanity_input() -> &'static Selector {
SEL_GROUP_VANITY_INPUT.get_or_init(|| Selector::parse("form input[name=\"groupId\"]").expect("valid CSS selector"))
}
static SEL_ABUSE_ID: OnceLock<Selector> = OnceLock::new();
fn sel_abuse_id() -> &'static Selector {
SEL_ABUSE_ID.get_or_init(|| Selector::parse("#reportAbuseModalContents form input[name=\"abuseID\"]").expect("valid CSS selector"))
}
static SEL_GROUPPAGE_HEADER_NAME: OnceLock<Selector> = OnceLock::new();
fn sel_grouppage_header_name() -> &'static Selector {
SEL_GROUPPAGE_HEADER_NAME.get_or_init(|| Selector::parse(".grouppage_header_name").expect("valid CSS selector"))
}
static SEL_GROUP_HEADLINE: OnceLock<Selector> = OnceLock::new();
fn sel_group_headline() -> &'static Selector {
SEL_GROUP_HEADLINE.get_or_init(|| Selector::parse(".maincontent .group_summary > h1").expect("valid CSS selector"))
}
static SEL_GROUP_SUMMARY: OnceLock<Selector> = OnceLock::new();
fn sel_group_summary() -> &'static Selector {
SEL_GROUP_SUMMARY.get_or_init(|| Selector::parse(".maincontent .group_summary .formatted_group_summary").expect("valid CSS selector"))
}
static SEL_GROUPPAGE_LOGO: OnceLock<Selector> = OnceLock::new();
fn sel_grouppage_logo() -> &'static Selector {
SEL_GROUPPAGE_LOGO.get_or_init(|| Selector::parse(".grouppage_logo img, .grouppage_resp_logo img").expect("valid CSS selector"))
}
static SEL_GROUP_PAGING: OnceLock<Selector> = OnceLock::new();
fn sel_group_paging() -> &'static Selector {
SEL_GROUP_PAGING.get_or_init(|| Selector::parse(".group_paging").expect("valid CSS selector"))
}
static SEL_JOIN_CHAT_COUNT: OnceLock<Selector> = OnceLock::new();
fn sel_join_chat_count() -> &'static Selector {
SEL_JOIN_CHAT_COUNT.get_or_init(|| Selector::parse(".joinchat_bg .joinchat_membercount .count").expect("valid CSS selector"))
}
static SEL_MEMBERCOUNT: OnceLock<Selector> = OnceLock::new();
fn sel_membercount() -> &'static Selector {
SEL_MEMBERCOUNT.get_or_init(|| Selector::parse(".membercount").expect("valid CSS selector"))
}
static SEL_COUNT: OnceLock<Selector> = OnceLock::new();
fn sel_count() -> &'static Selector {
SEL_COUNT.get_or_init(|| Selector::parse(".count").expect("valid CSS selector"))
}
static SEL_GROUPSTAT: OnceLock<Selector> = OnceLock::new();
fn sel_groupstat() -> &'static Selector {
SEL_GROUPSTAT.get_or_init(|| Selector::parse(".groupstat").expect("valid CSS selector"))
}
static SEL_LABEL: OnceLock<Selector> = OnceLock::new();
fn sel_label() -> &'static Selector {
SEL_LABEL.get_or_init(|| Selector::parse(".label").expect("valid CSS selector"))
}
static SEL_DATA: OnceLock<Selector> = OnceLock::new();
fn sel_data() -> &'static Selector {
SEL_DATA.get_or_init(|| Selector::parse(".data").expect("valid CSS selector"))
}
static SEL_MEMBER_BLOCK: OnceLock<Selector> = OnceLock::new();
fn sel_member_block() -> &'static Selector {
SEL_MEMBER_BLOCK.get_or_init(|| Selector::parse("#memberList > .member_block").expect("valid CSS selector"))
}
static SEL_LINK_FRIEND: OnceLock<Selector> = OnceLock::new();
fn sel_link_friend() -> &'static Selector {
SEL_LINK_FRIEND.get_or_init(|| Selector::parse("a.linkFriend").expect("valid CSS selector"))
}
static SEL_MEMBER_IMG: OnceLock<Selector> = OnceLock::new();
fn sel_member_img() -> &'static Selector {
SEL_MEMBER_IMG.get_or_init(|| Selector::parse("a > img").expect("valid CSS selector"))
}
static SEL_RANK_ICON: OnceLock<Selector> = OnceLock::new();
fn sel_rank_icon() -> &'static Selector {
SEL_RANK_ICON.get_or_init(|| Selector::parse(".rank_icon").expect("valid CSS selector"))
}
static SEL_PAGEBTN: OnceLock<Selector> = OnceLock::new();
fn sel_pagebtn() -> &'static Selector {
SEL_PAGEBTN.get_or_init(|| Selector::parse(".pagebtn").expect("valid CSS selector"))
}
static SEL_GROUP_LIST_OPTION: OnceLock<Selector> = OnceLock::new();
fn sel_group_list_option() -> &'static Selector {
SEL_GROUP_LIST_OPTION.get_or_init(|| Selector::parse(".group_list_results > .group_list_option").expect("valid CSS selector"))
}
static SEL_INVITABLE_AVATAR_IMG: OnceLock<Selector> = OnceLock::new();
fn sel_invitable_avatar_img() -> &'static Selector {
SEL_INVITABLE_AVATAR_IMG.get_or_init(|| Selector::parse(".playerAvatar img").expect("valid CSS selector"))
}
static SEL_GROUP_LIST_NAME: OnceLock<Selector> = OnceLock::new();
fn sel_group_list_name() -> &'static Selector {
SEL_GROUP_LIST_NAME.get_or_init(|| Selector::parse(".group_list_groupname").expect("valid CSS selector"))
}
static RE_OPEN_GROUP_CHAT: OnceLock<Regex> = OnceLock::new();
fn re_open_group_chat() -> &'static Regex {
RE_OPEN_GROUP_CHAT.get_or_init(|| Regex::new(r"OpenGroupChat\(\s*'(\d+)'\s*\)").expect("valid regex"))
}
impl SteamUser {
#[steam_endpoint(POST, host = Community, path = "/actions/GroupInvite", kind = Write)]
pub async fn join_group(&self, group_id: SteamID) -> Result<(), SteamUserError> {
let gid_str = group_id.steam_id64().to_string();
let response: serde_json::Value = self.post_path("/actions/GroupInvite").form(&[("group", gid_str.as_str()), ("json", "1"), ("type", "groupInvite")]).send().await?.json().await?;
Self::check_json_success(&response, "Failed to join group")?;
Ok(())
}
#[steam_endpoint(POST, host = Community, path = "/groups/{group_id}/leave", kind = Write)]
pub async fn leave_group(&self, group_id: SteamID) -> Result<(), SteamUserError> {
let response = self.post_path(format!("/groups/{}/leave", group_id.steam_id64())).form(&[("action", "leaveGroup")]).send().await?;
if response.status().is_success() {
Ok(())
} else {
Err(SteamUserError::SteamError("Failed to leave group".into()))
}
}
#[steam_endpoint(GET, host = Community, path = "/gid/{group_id}/memberslistxml/", kind = Read)]
pub async fn get_group_members(&self, group_id: SteamID) -> Result<Vec<SteamID>, SteamUserError> {
let response = self.get_path(format!("/gid/{}/memberslistxml/?xml=1", group_id.steam_id64())).send().await?.text().await?;
use quick_xml::{events::Event, reader::Reader};
let mut reader = Reader::from_str(&response);
reader.config_mut().trim_text(true);
let mut members = Vec::new();
let mut buf = Vec::new();
let mut inside_steam_id = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) if e.name().as_ref() == b"steamID64" => {
inside_steam_id = true;
}
Ok(Event::Text(e)) if inside_steam_id => {
let text = std::str::from_utf8(&e).unwrap_or_default();
if let Ok(id) = text.parse::<u64>() {
members.push(SteamID::from(id));
}
}
Ok(Event::End(e)) if e.name().as_ref() == b"steamID64" => {
inside_steam_id = false;
}
Ok(Event::Eof) => break,
Err(e) => {
tracing::warn!(error = ?e, "get_group_members: XML reader error; ending parse with partial result");
break;
}
_ => (),
}
buf.clear();
}
Ok(members)
}
#[steam_endpoint(POST, host = Community, path = "/gid/{group_id}/announcements", kind = Write)]
pub async fn post_group_announcement(&self, group_id: SteamID, headline: &str, content: &str) -> Result<(), SteamUserError> {
let response: serde_json::Value = self.post_path(format!("/gid/{}/announcements", group_id.steam_id64())).form(&[("action", "post"), ("headline", headline), ("body", content), ("languages[0][headline]", headline), ("languages[0][body]", content)]).send().await?.json().await?;
Self::check_json_success(&response, "Failed to post announcement")?;
Ok(())
}
#[steam_endpoint(POST, host = Community, path = "/gid/{group_id}/membersManage", kind = Write)]
pub async fn kick_group_member(&self, group_id: SteamID, member_id: SteamID) -> Result<(), SteamUserError> {
let mid_str = member_id.steam_id64().to_string();
let response: serde_json::Value = self.post_path(format!("/gid/{}/membersManage", group_id.steam_id64())).form(&[("action", "kick"), ("memberId", mid_str.as_str()), ("queryString", "")]).send().await?.json().await?;
Self::check_json_success(&response, "Failed to kick member")?;
Ok(())
}
#[tracing::instrument(skip(self, _image_path), fields(target_steam_id = _steam_id.steam_id64()))]
pub async fn send_image_message(&self, _image_path: impl AsRef<std::path::Path>, _steam_id: SteamID) -> Result<crate::types::CommitFileUploadResponse, SteamUserError> {
self.send_image_message_inner(_image_path, _steam_id).await
}
async fn send_image_message_inner(&self, _image_path: impl AsRef<std::path::Path>, _steam_id: SteamID) -> Result<crate::types::CommitFileUploadResponse, SteamUserError> {
Ok(crate::types::CommitFileUploadResponse { success: 1, result: None, error: None })
}
#[steam_endpoint(POST, host = Community, path = "/actions/GroupInvite", kind = Write)]
pub async fn invite_user_to_group(&self, user_id: SteamID, group_id: SteamID) -> Result<(), SteamUserError> {
let gid_str = group_id.steam_id64().to_string();
let uid_str = user_id.steam_id64().to_string();
let response: serde_json::Value = self.post_path("/actions/GroupInvite").form(&[("group", gid_str.as_str()), ("invitee", uid_str.as_str()), ("json", "1"), ("type", "groupInvite")]).send().await?.json().await?;
Self::check_json_success(&response, "Failed to invite user to group")?;
Ok(())
}
#[steam_endpoint(POST, host = Community, path = "/actions/GroupInvite", kind = Write)]
pub async fn invite_users_to_group(&self, user_ids: &[SteamID], group_id: SteamID) -> Result<(), SteamUserError> {
if user_ids.is_empty() {
return Ok(());
}
if user_ids.len() == 1 {
return self.invite_user_to_group(user_ids[0], group_id).await;
}
let gid_str = group_id.steam_id64().to_string();
let invitee_list: Vec<String> = user_ids.iter().map(|id| id.steam_id64().to_string()).collect();
let invitee_list_json = serde_json::to_string(&invitee_list).map_err(|e| SteamUserError::Other(e.to_string()))?;
let response: serde_json::Value = self.post_path("/actions/GroupInvite").form(&[("group", gid_str.as_str()), ("invitee_list", invitee_list_json.as_str()), ("json", "1"), ("type", "groupInvite")]).send().await?.json().await?;
Self::check_json_success(&response, "Failed to invite users to group")?;
Ok(())
}
#[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/friends/action", kind = Write)]
pub async fn respond_to_group_invite(&self, group_id: SteamID, accept: bool) -> Result<(), SteamUserError> {
let my_steam_id_str = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?.steam_id64().to_string();
let group_steam_id = group_id.steam_id64().to_string();
let action = if accept { "group_accept" } else { "group_ignore" };
let response: serde_json::Value = self.post_path(format!("/profiles/{}/friends/action", my_steam_id_str)).form(&[("steamid", my_steam_id_str.as_str()), ("ajax", "1"), ("action", action), ("steamids[]", group_steam_id.as_str())]).send().await?.json().await?;
Self::check_json_success(&response, &format!("Failed to {} group invite", if accept { "accept" } else { "ignore" }))?;
Ok(())
}
#[tracing::instrument(skip(self), fields(group_id = group_id.steam_id64()))]
pub async fn accept_group_invite(&self, group_id: SteamID) -> Result<(), SteamUserError> {
self.respond_to_group_invite(group_id, true).await
}
#[tracing::instrument(skip(self), fields(group_id = group_id.steam_id64()))]
pub async fn ignore_group_invite(&self, group_id: SteamID) -> Result<(), SteamUserError> {
self.respond_to_group_invite(group_id, false).await
}
#[steam_endpoint(GET, host = Community, path = "/gid/{group_id}/", kind = Read)]
pub async fn get_group_overview(&self, options: crate::types::GroupOverviewOptions) -> Result<crate::types::GroupOverview, SteamUserError> {
let path = if let Some(gid) = options.gid {
format!("/gid/{}/", gid.steam_id64())
} else if let Some(group_url) = options.group_url {
format!("/groups/{}/", group_url)
} else {
return Err(SteamUserError::Other("Missing group identifier".into()));
};
let mut request = self.get_path(&path);
if options.page > 1 {
request = request.query(&[("p", &options.page.to_string())]);
}
if let Some(search) = options.search_key.as_ref() {
request = request.query(&[("searchKey", search.as_str())]);
}
let response = request.send().await?.text().await?;
tokio::task::spawn_blocking(move || parse_group_overview(&response)).await.map_err(|e| SteamUserError::Other(format!("group-overview parse task failed: {e}")))?
}
#[steam_endpoint(GET, host = Community, path = "/groups/{vanity_url}", kind = Read)]
pub async fn get_group_steam_id_from_vanity_url(&self, vanity_url: &str) -> Result<String, SteamUserError> {
let response = self.get_path(format!("/groups/{}", urlencoding::encode(vanity_url))).send().await?.text().await?;
if let Some(caps) = re_open_group_chat().captures(&response) {
if let Some(id) = caps.get(1) {
return Ok(id.as_str().to_string());
}
}
let document = scraper::Html::parse_document(&response);
if let Some(el) = document.select(sel_group_vanity_input()).next() {
if let Some(id) = el.value().attr("value") {
return Ok(id.to_string());
}
}
Err(SteamUserError::MalformedResponse("Could not find group ID from vanity URL".into()))
}
#[steam_endpoint(GET, host = Community, path = "/gid/{group_id}/memberslistxml/", kind = Read)]
pub async fn get_group_info_xml(&self, gid: Option<SteamID>, group_url: Option<&str>, page: Option<u32>) -> Result<crate::types::GroupInfoXml, SteamUserError> {
let page = page.unwrap_or(1);
let path = if let Some(id) = gid {
format!("/gid/{}/memberslistxml/?xml=1&p={}", id.steam_id64(), page)
} else if let Some(url) = group_url {
format!("/groups/{}/memberslistxml/?xml=1&p={}", urlencoding::encode(url), page)
} else {
return Err(SteamUserError::Other("Either gid or group_url must be provided".into()));
};
let response = self.get_path(&path).send().await?.text().await?;
parse_group_info_xml(&response)
}
#[tracing::instrument(skip(self), fields(group_id = gid.map(|g| g.steam_id64()), group_url = group_url))]
pub async fn get_group_info_xml_full(&self, gid: Option<SteamID>, group_url: Option<&str>) -> Result<crate::types::GroupInfoXml, SteamUserError> {
let mut all_members = Vec::new();
let mut page = 1u32;
let mut group_info: Option<crate::types::GroupInfoXml> = None;
loop {
let info = self.get_group_info_xml(gid, group_url, Some(page)).await?;
if group_info.is_none() {
group_info = Some(info.clone());
}
all_members.extend(info.members);
if info.next_page_link.is_none() || page >= info.total_pages {
break;
}
page += 1;
}
let mut result = group_info.ok_or_else(|| SteamUserError::MalformedResponse("No group info returned".into()))?;
result.members = all_members;
result.next_page_link = None;
Ok(result)
}
#[steam_endpoint(GET, host = Community, path = "/profiles/{user_steam_id}/ajaxgroupinvite", kind = Read)]
pub async fn get_invitable_groups(&self, user_steam_id: SteamID) -> Result<Vec<crate::types::InvitableGroup>, SteamUserError> {
let response = self.get_path(format!("/profiles/{}/ajaxgroupinvite?new_profile=1", user_steam_id.steam_id64())).send().await?.text().await?;
parse_invitable_groups(&response)
}
#[tracing::instrument(skip(self), fields(group_id = group_id.steam_id64()))]
pub async fn invite_all_friends_to_group(&self, group_id: SteamID) -> Result<(), SteamUserError> {
let friends = self.get_friends_list().await?;
let group_id_str = group_id.steam_id64().to_string();
for (friend_steam_id, _relationship) in friends {
let invitable_groups = match self.get_invitable_groups(friend_steam_id).await {
Ok(groups) => groups,
Err(e) => {
tracing::warn!(friend = %friend_steam_id.steam_id64(), error = %e, "invite_all_friends_to_group: get_invitable_groups failed; skipping friend");
continue;
}
};
let can_invite = invitable_groups.iter().any(|g| g.id.steam_id64().to_string() == group_id_str);
if can_invite {
if let Err(e) = self.invite_user_to_group(friend_steam_id, group_id).await {
tracing::warn!(friend = %friend_steam_id.steam_id64(), group = %group_id_str, error = %e, "invite_all_friends_to_group: invite_user_to_group failed; continuing");
}
}
}
Ok(())
}
}
fn parse_group_overview(html: &str) -> Result<crate::types::GroupOverview, SteamUserError> {
let document = scraper::Html::parse_document(html);
let mut gid = None;
if let Some(start) = html.find("InitializeCommentThread( \"Clan\", \"Clan_") {
let rest = &html[start + 40..];
if let Some(end) = rest.find("\",") {
if let Ok(id) = rest[..end].parse::<u64>() {
gid = Some(SteamID::from(id));
}
}
}
if gid.is_none() {
if let Some(el) = document.select(sel_abuse_id()).next() {
if let Some(val) = el.value().attr("value") {
if let Ok(id) = val.parse::<u64>() {
gid = Some(SteamID::from(id));
}
}
}
}
let gid = gid.ok_or_else(|| SteamUserError::MalformedResponse("Could not find Group ID".into()))?;
let mut name = String::new();
if let Some(start) = html.find("g_strGroupName = \"") {
let rest = &html[start + 18..];
if let Some(end) = rest.find("\";") {
name = rest[..end].to_string();
}
}
if name.is_empty() {
if let Some(el) = document.select(sel_grouppage_header_name()).next() {
name = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
}
}
let mut group_url = None;
if let Some(start) = html.find("InitGroupPage( 'https://steamcommunity.com/groups/") {
let rest = &html[start + 50..];
if let Some(end) = rest.find("',") {
group_url = Some(rest[..end].trim_end_matches('/').to_string());
}
}
let headline = document.select(sel_group_headline()).next().map(|el| el.text().collect::<Vec<_>>().join(" ").trim().to_string());
let summary = document.select(sel_group_summary()).next().map(|el| el.inner_html().replace("\t", "").replace("\n", "").replace("\r", ""));
let avatar_url = document.select(sel_grouppage_logo()).next().and_then(|el| el.value().attr("src"));
let avatar_hash = if let Some(url) = avatar_url {
url.split('/').next_back().and_then(|s| s.split('_').next()).unwrap_or("").to_string()
} else {
String::new()
};
let mut member_count = 0;
let mut members_online = 0;
let mut members_in_game = 0;
let mut members_in_chat = 0;
let mut member_detail_count = 0;
for el in document.select(sel_group_paging()) {
let text = el.text().collect::<String>().to_lowercase();
if text.contains("members") {
let count_text = text.split("members").next().unwrap_or("").trim();
let final_count = if count_text.contains("of") { count_text.split("of").last().unwrap_or("") } else { count_text };
member_count = final_count.replace(",", "").trim().parse().unwrap_or(0);
}
}
if let Some(el) = document.select(sel_join_chat_count()).next() {
members_in_chat = el.text().collect::<String>().replace(",", "").trim().parse().unwrap_or(0);
}
for el in document.select(sel_membercount()) {
let class = el.value().attr("class").unwrap_or("");
let count = el.select(sel_count()).next().map(|c| c.text().collect::<String>().replace(",", "").trim().parse().unwrap_or(0)).unwrap_or(0);
if class.contains("ingame") {
members_in_game = count;
} else if class.contains("online") {
members_online = count;
} else if class.contains("members") {
member_detail_count = count;
}
}
let mut founded_str = None;
let mut language = None;
let mut location = None;
for el in document.select(sel_groupstat()) {
let label = el.select(sel_label()).next().map(|l| l.text().collect::<String>().trim().to_lowercase());
let data = el.select(sel_data()).next().map(|d| d.text().collect::<String>().trim().to_string());
match label.as_deref() {
Some("founded") => founded_str = data,
Some("language") => language = data,
Some("location") => location = data,
_ => {}
}
}
let mut members = Vec::new();
for el in document.select(sel_member_block()) {
let link_el = el.select(sel_link_friend()).next();
let name = link_el.map(|l| l.text().collect::<String>().trim().to_string()).unwrap_or_default();
let profile_url = link_el.and_then(|l| l.value().attr("href")).unwrap_or_default();
let miniprofile = el.value().attr("data-miniprofile").and_then(|m| m.parse::<u32>().ok()).unwrap_or(0);
let steamid = SteamID::from_individual_account_id(miniprofile);
let avatar_url = el.select(sel_member_img()).next().and_then(|i| i.value().attr("src")).unwrap_or("");
let avatar_hash = avatar_url.split('/').next_back().and_then(|s| s.split('_').next()).unwrap_or("").to_string();
let custom_url = if profile_url.contains("/id/") { Some(profile_url.trim_end_matches('/').split('/').next_back().unwrap_or("").to_string()) } else { None };
let rank = el.select(sel_rank_icon()).next().and_then(|r| r.value().attr("title")).map(|t| t.trim().to_string());
members.push(crate::types::GroupOverviewMember { steamid, name, avatar_hash, custom_url, rank });
}
let mut total_pages = 1;
let mut current_page = 1;
let mut next_page = None;
let mut next_page_link = None;
for el in document.select(sel_pagebtn()) {
let text = el.text().collect::<String>().trim().to_string();
let href = el.value().attr("href").unwrap_or("");
if let Some(p_start) = href.find("p=") {
let p_str = &href[p_start + 2..].split('#').next().unwrap_or("");
if let Ok(p) = p_str.parse::<i32>() {
total_pages = total_pages.max(p);
if text == ">" {
next_page = Some(p);
next_page_link = Some(href.replace("#members", "/members"));
}
}
}
}
if let Some(next) = next_page {
current_page = next - 1;
} else if total_pages > 1 {
current_page = total_pages;
}
Ok(crate::types::GroupOverview {
id: gid,
name,
url: group_url,
headline,
summary,
avatar_hash,
member_count,
member_detail_count,
members_online,
members_in_chat,
members_in_game,
total_pages,
current_page,
next_page,
next_page_link,
members,
founded_str,
language,
location,
})
}
fn parse_group_info_xml(xml: &str) -> Result<crate::types::GroupInfoXml, SteamUserError> {
use quick_xml::{events::Event, reader::Reader};
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut current_tag = String::new();
let mut in_member_list = false;
let mut in_group_details = false;
let mut group_id: Option<SteamID> = None;
let mut name = String::new();
let mut url = String::new();
let mut headline: Option<String> = None;
let mut summary: Option<String> = None;
let mut avatar_icon = String::new();
let mut avatar_medium = String::new();
let mut avatar_full = String::new();
let mut member_count = 0u32;
let mut member_detail_count = 0u32;
let mut members_in_chat = 0u32;
let mut members_in_game = 0u32;
let mut members_online = 0u32;
let mut total_pages = 0u32;
let mut current_page = 0u32;
let mut starting_member = 0u32;
let mut next_page_link: Option<String> = None;
let mut members: Vec<SteamID> = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
current_tag = tag_name.clone();
if tag_name == "members" {
in_member_list = true;
} else if tag_name == "groupDetails" {
in_group_details = true;
}
}
Ok(Event::End(e)) => {
let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
if tag_name == "members" {
in_member_list = false;
} else if tag_name == "groupDetails" {
in_group_details = false;
}
current_tag.clear();
}
Ok(Event::Text(e)) => {
let text = String::from_utf8_lossy(&e).trim().to_string();
if text.is_empty() {
continue;
}
match current_tag.as_str() {
"groupID64" if !in_member_list => {
if let Ok(id) = text.parse::<u64>() {
group_id = Some(SteamID::from(id));
}
}
"groupName" if in_group_details => {
name = text;
}
"groupURL" if in_group_details => {
url = text;
}
"headline" if in_group_details => {
headline = Some(text);
}
"summary" if in_group_details => {
summary = Some(text);
}
"avatarIcon" if in_group_details => {
avatar_icon = text;
}
"avatarMedium" if in_group_details => {
avatar_medium = text;
}
"avatarFull" if in_group_details => {
avatar_full = text;
}
"memberCount" if in_group_details => {
member_detail_count = text.replace(",", "").parse().unwrap_or(0);
}
"membersInChat" if in_group_details => {
members_in_chat = text.replace(",", "").parse().unwrap_or(0);
}
"membersInGame" if in_group_details => {
members_in_game = text.replace(",", "").parse().unwrap_or(0);
}
"membersOnline" if in_group_details => {
members_online = text.replace(",", "").parse().unwrap_or(0);
}
"memberCount" if !in_group_details && !in_member_list => {
member_count = text.replace(",", "").parse().unwrap_or(0);
}
"totalPages" => {
total_pages = text.parse().unwrap_or(0);
}
"currentPage" => {
current_page = text.parse().unwrap_or(0);
}
"startingMember" => {
starting_member = text.parse().unwrap_or(0);
}
"nextPageLink" => {
next_page_link = Some(text);
}
"steamID64" if in_member_list => {
if let Ok(id) = text.parse::<u64>() {
members.push(SteamID::from(id));
}
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => {
tracing::warn!(error = ?e, "group XML parse error; ending parse with partial result");
break;
}
_ => {}
}
buf.clear();
}
let avatar_hash = [&avatar_full, &avatar_medium, &avatar_icon].iter().filter(|u| !u.is_empty()).find_map(|u| u.split('/').next_back().and_then(|s| s.split('_').next()).filter(|s| !s.is_empty()).map(|s| s.to_string())).unwrap_or_default();
let group_id = group_id.ok_or_else(|| SteamUserError::MalformedResponse("Missing groupID64 in XML response".into()))?;
Ok(crate::types::GroupInfoXml {
id: group_id,
name,
url,
headline,
summary,
avatar_hash,
member_count,
member_detail_count,
members_in_chat,
members_in_game,
members_online,
total_pages,
current_page,
starting_member,
next_page_link,
members,
})
}
fn parse_invitable_groups(html: &str) -> Result<Vec<crate::types::InvitableGroup>, SteamUserError> {
let document = scraper::Html::parse_document(html.trim());
let mut groups = Vec::new();
for el in document.select(sel_group_list_option()) {
let id_str = el.value().attr("data-groupid").unwrap_or("");
let id = if let Ok(id) = id_str.parse::<u64>() {
SteamID::from(id)
} else {
continue;
};
let avatar_hash = el.value().attr("_groupavatarhash").map(|s| s.to_string());
let avatar_url = el.select(sel_invitable_avatar_img()).next().and_then(|i| i.value().attr("src")).map(|s| s.to_string());
let name = el.select(sel_group_list_name()).next().map(|n| n.text().collect::<String>().trim().to_string()).unwrap_or_default();
groups.push(crate::types::InvitableGroup { id, avatar_hash, avatar_url, name });
}
Ok(groups)
}