1use std::sync::OnceLock;
4
5use regex::Regex;
6use scraper::Selector;
7use steamid::SteamID;
8
9use crate::{client::SteamUser, endpoint::steam_endpoint, error::SteamUserError};
10
11static SEL_GROUP_VANITY_INPUT: OnceLock<Selector> = OnceLock::new();
12fn sel_group_vanity_input() -> &'static Selector {
13 SEL_GROUP_VANITY_INPUT.get_or_init(|| Selector::parse("form input[name=\"groupId\"]").expect("valid CSS selector"))
14}
15
16static SEL_ABUSE_ID: OnceLock<Selector> = OnceLock::new();
17fn sel_abuse_id() -> &'static Selector {
18 SEL_ABUSE_ID.get_or_init(|| Selector::parse("#reportAbuseModalContents form input[name=\"abuseID\"]").expect("valid CSS selector"))
19}
20
21static SEL_GROUPPAGE_HEADER_NAME: OnceLock<Selector> = OnceLock::new();
22fn sel_grouppage_header_name() -> &'static Selector {
23 SEL_GROUPPAGE_HEADER_NAME.get_or_init(|| Selector::parse(".grouppage_header_name").expect("valid CSS selector"))
24}
25
26static SEL_GROUP_HEADLINE: OnceLock<Selector> = OnceLock::new();
27fn sel_group_headline() -> &'static Selector {
28 SEL_GROUP_HEADLINE.get_or_init(|| Selector::parse(".maincontent .group_summary > h1").expect("valid CSS selector"))
29}
30
31static SEL_GROUP_SUMMARY: OnceLock<Selector> = OnceLock::new();
32fn sel_group_summary() -> &'static Selector {
33 SEL_GROUP_SUMMARY.get_or_init(|| Selector::parse(".maincontent .group_summary .formatted_group_summary").expect("valid CSS selector"))
34}
35
36static SEL_GROUPPAGE_LOGO: OnceLock<Selector> = OnceLock::new();
37fn sel_grouppage_logo() -> &'static Selector {
38 SEL_GROUPPAGE_LOGO.get_or_init(|| Selector::parse(".grouppage_logo img, .grouppage_resp_logo img").expect("valid CSS selector"))
39}
40
41static SEL_GROUP_PAGING: OnceLock<Selector> = OnceLock::new();
42fn sel_group_paging() -> &'static Selector {
43 SEL_GROUP_PAGING.get_or_init(|| Selector::parse(".group_paging").expect("valid CSS selector"))
44}
45
46static SEL_JOIN_CHAT_COUNT: OnceLock<Selector> = OnceLock::new();
47fn sel_join_chat_count() -> &'static Selector {
48 SEL_JOIN_CHAT_COUNT.get_or_init(|| Selector::parse(".joinchat_bg .joinchat_membercount .count").expect("valid CSS selector"))
49}
50
51static SEL_MEMBERCOUNT: OnceLock<Selector> = OnceLock::new();
52fn sel_membercount() -> &'static Selector {
53 SEL_MEMBERCOUNT.get_or_init(|| Selector::parse(".membercount").expect("valid CSS selector"))
54}
55
56static SEL_COUNT: OnceLock<Selector> = OnceLock::new();
57fn sel_count() -> &'static Selector {
58 SEL_COUNT.get_or_init(|| Selector::parse(".count").expect("valid CSS selector"))
59}
60
61static SEL_GROUPSTAT: OnceLock<Selector> = OnceLock::new();
62fn sel_groupstat() -> &'static Selector {
63 SEL_GROUPSTAT.get_or_init(|| Selector::parse(".groupstat").expect("valid CSS selector"))
64}
65
66static SEL_LABEL: OnceLock<Selector> = OnceLock::new();
67fn sel_label() -> &'static Selector {
68 SEL_LABEL.get_or_init(|| Selector::parse(".label").expect("valid CSS selector"))
69}
70
71static SEL_DATA: OnceLock<Selector> = OnceLock::new();
72fn sel_data() -> &'static Selector {
73 SEL_DATA.get_or_init(|| Selector::parse(".data").expect("valid CSS selector"))
74}
75
76static SEL_MEMBER_BLOCK: OnceLock<Selector> = OnceLock::new();
77fn sel_member_block() -> &'static Selector {
78 SEL_MEMBER_BLOCK.get_or_init(|| Selector::parse("#memberList > .member_block").expect("valid CSS selector"))
79}
80
81static SEL_LINK_FRIEND: OnceLock<Selector> = OnceLock::new();
82fn sel_link_friend() -> &'static Selector {
83 SEL_LINK_FRIEND.get_or_init(|| Selector::parse("a.linkFriend").expect("valid CSS selector"))
84}
85
86static SEL_MEMBER_IMG: OnceLock<Selector> = OnceLock::new();
87fn sel_member_img() -> &'static Selector {
88 SEL_MEMBER_IMG.get_or_init(|| Selector::parse("a > img").expect("valid CSS selector"))
89}
90
91static SEL_RANK_ICON: OnceLock<Selector> = OnceLock::new();
92fn sel_rank_icon() -> &'static Selector {
93 SEL_RANK_ICON.get_or_init(|| Selector::parse(".rank_icon").expect("valid CSS selector"))
94}
95
96static SEL_PAGEBTN: OnceLock<Selector> = OnceLock::new();
97fn sel_pagebtn() -> &'static Selector {
98 SEL_PAGEBTN.get_or_init(|| Selector::parse(".pagebtn").expect("valid CSS selector"))
99}
100
101static SEL_GROUP_LIST_OPTION: OnceLock<Selector> = OnceLock::new();
102fn sel_group_list_option() -> &'static Selector {
103 SEL_GROUP_LIST_OPTION.get_or_init(|| Selector::parse(".group_list_results > .group_list_option").expect("valid CSS selector"))
104}
105
106static SEL_INVITABLE_AVATAR_IMG: OnceLock<Selector> = OnceLock::new();
107fn sel_invitable_avatar_img() -> &'static Selector {
108 SEL_INVITABLE_AVATAR_IMG.get_or_init(|| Selector::parse(".playerAvatar img").expect("valid CSS selector"))
109}
110
111static SEL_GROUP_LIST_NAME: OnceLock<Selector> = OnceLock::new();
112fn sel_group_list_name() -> &'static Selector {
113 SEL_GROUP_LIST_NAME.get_or_init(|| Selector::parse(".group_list_groupname").expect("valid CSS selector"))
114}
115
116static RE_OPEN_GROUP_CHAT: OnceLock<Regex> = OnceLock::new();
117fn re_open_group_chat() -> &'static Regex {
118 RE_OPEN_GROUP_CHAT.get_or_init(|| Regex::new(r"OpenGroupChat\(\s*'(\d+)'\s*\)").expect("valid regex"))
119}
120
121impl SteamUser {
122 #[steam_endpoint(POST, host = Community, path = "/actions/GroupInvite", kind = Write)]
128 pub async fn join_group(&self, group_id: SteamID) -> Result<(), SteamUserError> {
129 let gid_str = group_id.steam_id64().to_string();
130
131 let response: serde_json::Value = self.post_path("/actions/GroupInvite").form(&[("group", gid_str.as_str()), ("json", "1"), ("type", "groupInvite")]).send().await?.json().await?;
132
133 Self::check_json_success(&response, "Failed to join group")?;
134
135 Ok(())
136 }
137
138 #[steam_endpoint(POST, host = Community, path = "/groups/{group_id}/leave", kind = Write)]
144 pub async fn leave_group(&self, group_id: SteamID) -> Result<(), SteamUserError> {
145 let response = self.post_path(format!("/groups/{}/leave", group_id.steam_id64())).form(&[("action", "leaveGroup")]).send().await?;
146
147 if response.status().is_success() {
148 Ok(())
149 } else {
150 Err(SteamUserError::SteamError("Failed to leave group".into()))
151 }
152 }
153
154 #[steam_endpoint(GET, host = Community, path = "/gid/{group_id}/memberslistxml/", kind = Read)]
167 pub async fn get_group_members(&self, group_id: SteamID) -> Result<Vec<SteamID>, SteamUserError> {
168 let response = self.get_path(format!("/gid/{}/memberslistxml/?xml=1", group_id.steam_id64())).send().await?.text().await?;
170
171 use quick_xml::{events::Event, reader::Reader};
173
174 let mut reader = Reader::from_str(&response);
175 reader.config_mut().trim_text(true);
176
177 let mut members = Vec::new();
178 let mut buf = Vec::new();
179
180 let mut inside_steam_id = false;
182
183 loop {
184 match reader.read_event_into(&mut buf) {
185 Ok(Event::Start(e)) if e.name().as_ref() == b"steamID64" => {
186 inside_steam_id = true;
187 }
188 Ok(Event::Text(e)) if inside_steam_id => {
189 let text = std::str::from_utf8(&e).unwrap_or_default();
190 if let Ok(id) = text.parse::<u64>() {
191 members.push(SteamID::from(id));
192 }
193 }
194 Ok(Event::End(e)) if e.name().as_ref() == b"steamID64" => {
195 inside_steam_id = false;
196 }
197 Ok(Event::Eof) => break,
198 Err(e) => {
199 tracing::warn!(error = ?e, "get_group_members: XML reader error; ending parse with partial result");
200 break;
201 }
202 _ => (),
203 }
204 buf.clear();
205 }
206
207 Ok(members)
208 }
209
210 #[steam_endpoint(POST, host = Community, path = "/gid/{group_id}/announcements", kind = Write)]
218 pub async fn post_group_announcement(&self, group_id: SteamID, headline: &str, content: &str) -> Result<(), SteamUserError> {
219 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?;
220
221 Self::check_json_success(&response, "Failed to post announcement")?;
222
223 Ok(())
224 }
225
226 #[steam_endpoint(POST, host = Community, path = "/gid/{group_id}/membersManage", kind = Write)]
233 pub async fn kick_group_member(&self, group_id: SteamID, member_id: SteamID) -> Result<(), SteamUserError> {
234 let mid_str = member_id.steam_id64().to_string();
235
236 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?;
237
238 Self::check_json_success(&response, "Failed to kick member")?;
239
240 Ok(())
241 }
242
243 #[tracing::instrument(skip(self, _image_path), fields(target_steam_id = _steam_id.steam_id64()))]
245 pub async fn send_image_message(&self, _image_path: impl AsRef<std::path::Path>, _steam_id: SteamID) -> Result<crate::types::CommitFileUploadResponse, SteamUserError> {
246 self.send_image_message_inner(_image_path, _steam_id).await
247 }
248
249 async fn send_image_message_inner(&self, _image_path: impl AsRef<std::path::Path>, _steam_id: SteamID) -> Result<crate::types::CommitFileUploadResponse, SteamUserError> {
250 Ok(crate::types::CommitFileUploadResponse { success: 1, result: None, error: None })
252 }
253
254 #[steam_endpoint(POST, host = Community, path = "/actions/GroupInvite", kind = Write)]
261 pub async fn invite_user_to_group(&self, user_id: SteamID, group_id: SteamID) -> Result<(), SteamUserError> {
262 let gid_str = group_id.steam_id64().to_string();
263 let uid_str = user_id.steam_id64().to_string();
264
265 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?;
266
267 Self::check_json_success(&response, "Failed to invite user to group")?;
268
269 Ok(())
270 }
271
272 #[steam_endpoint(POST, host = Community, path = "/actions/GroupInvite", kind = Write)]
279 pub async fn invite_users_to_group(&self, user_ids: &[SteamID], group_id: SteamID) -> Result<(), SteamUserError> {
280 if user_ids.is_empty() {
281 return Ok(());
282 }
283
284 if user_ids.len() == 1 {
285 return self.invite_user_to_group(user_ids[0], group_id).await;
286 }
287
288 let gid_str = group_id.steam_id64().to_string();
289 let invitee_list: Vec<String> = user_ids.iter().map(|id| id.steam_id64().to_string()).collect();
290 let invitee_list_json = serde_json::to_string(&invitee_list).map_err(|e| SteamUserError::Other(e.to_string()))?;
291
292 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?;
293
294 Self::check_json_success(&response, "Failed to invite users to group")?;
295
296 Ok(())
297 }
298
299 #[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/friends/action", kind = Write)]
315 pub async fn respond_to_group_invite(&self, group_id: SteamID, accept: bool) -> Result<(), SteamUserError> {
316 let my_steam_id_str = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?.steam_id64().to_string();
317 let group_steam_id = group_id.steam_id64().to_string();
318 let action = if accept { "group_accept" } else { "group_ignore" };
319
320 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?;
321
322 Self::check_json_success(&response, &format!("Failed to {} group invite", if accept { "accept" } else { "ignore" }))?;
323
324 Ok(())
325 }
326
327 #[tracing::instrument(skip(self), fields(group_id = group_id.steam_id64()))]
340 pub async fn accept_group_invite(&self, group_id: SteamID) -> Result<(), SteamUserError> {
341 self.respond_to_group_invite(group_id, true).await
342 }
343
344 #[tracing::instrument(skip(self), fields(group_id = group_id.steam_id64()))]
357 pub async fn ignore_group_invite(&self, group_id: SteamID) -> Result<(), SteamUserError> {
358 self.respond_to_group_invite(group_id, false).await
359 }
360
361 #[steam_endpoint(GET, host = Community, path = "/gid/{group_id}/", kind = Read)]
374 pub async fn get_group_overview(&self, options: crate::types::GroupOverviewOptions) -> Result<crate::types::GroupOverview, SteamUserError> {
375 let path = if let Some(gid) = options.gid {
376 format!("/gid/{}/", gid.steam_id64())
377 } else if let Some(group_url) = options.group_url {
378 format!("/groups/{}/", group_url)
379 } else {
380 return Err(SteamUserError::Other("Missing group identifier".into()));
381 };
382
383 let mut request = self.get_path(&path);
384 if options.page > 1 {
385 request = request.query(&[("p", &options.page.to_string())]);
386 }
387 if let Some(search) = options.search_key.as_ref() {
388 request = request.query(&[("searchKey", search.as_str())]);
389 }
390
391 let response = request.send().await?.text().await?;
392 tokio::task::spawn_blocking(move || parse_group_overview(&response)).await.map_err(|e| SteamUserError::Other(format!("group-overview parse task failed: {e}")))?
395 }
396
397 #[steam_endpoint(GET, host = Community, path = "/groups/{vanity_url}", kind = Read)]
414 pub async fn get_group_steam_id_from_vanity_url(&self, vanity_url: &str) -> Result<String, SteamUserError> {
415 let response = self.get_path(format!("/groups/{}", urlencoding::encode(vanity_url))).send().await?.text().await?;
416
417 if let Some(caps) = re_open_group_chat().captures(&response) {
419 if let Some(id) = caps.get(1) {
420 return Ok(id.as_str().to_string());
421 }
422 }
423
424 let document = scraper::Html::parse_document(&response);
426 if let Some(el) = document.select(sel_group_vanity_input()).next() {
427 if let Some(id) = el.value().attr("value") {
428 return Ok(id.to_string());
429 }
430 }
431
432 Err(SteamUserError::MalformedResponse("Could not find group ID from vanity URL".into()))
433 }
434
435 #[steam_endpoint(GET, host = Community, path = "/gid/{group_id}/memberslistxml/", kind = Read)]
450 pub async fn get_group_info_xml(&self, gid: Option<SteamID>, group_url: Option<&str>, page: Option<u32>) -> Result<crate::types::GroupInfoXml, SteamUserError> {
451 let page = page.unwrap_or(1);
452 let path = if let Some(id) = gid {
453 format!("/gid/{}/memberslistxml/?xml=1&p={}", id.steam_id64(), page)
454 } else if let Some(url) = group_url {
455 format!("/groups/{}/memberslistxml/?xml=1&p={}", urlencoding::encode(url), page)
456 } else {
457 return Err(SteamUserError::Other("Either gid or group_url must be provided".into()));
458 };
459
460 let response = self.get_path(&path).send().await?.text().await?;
461 parse_group_info_xml(&response)
462 }
463
464 #[tracing::instrument(skip(self), fields(group_id = gid.map(|g| g.steam_id64()), group_url = group_url))]
478 pub async fn get_group_info_xml_full(&self, gid: Option<SteamID>, group_url: Option<&str>) -> Result<crate::types::GroupInfoXml, SteamUserError> {
479 let mut all_members = Vec::new();
480 let mut page = 1u32;
481 let mut group_info: Option<crate::types::GroupInfoXml> = None;
482
483 loop {
484 let info = self.get_group_info_xml(gid, group_url, Some(page)).await?;
485
486 if group_info.is_none() {
487 group_info = Some(info.clone());
488 }
489
490 all_members.extend(info.members);
491
492 if info.next_page_link.is_none() || page >= info.total_pages {
493 break;
494 }
495 page += 1;
496 }
497
498 let mut result = group_info.ok_or_else(|| SteamUserError::MalformedResponse("No group info returned".into()))?;
499 result.members = all_members;
500 result.next_page_link = None;
501 Ok(result)
502 }
503
504 #[steam_endpoint(GET, host = Community, path = "/profiles/{user_steam_id}/ajaxgroupinvite", kind = Read)]
516 pub async fn get_invitable_groups(&self, user_steam_id: SteamID) -> Result<Vec<crate::types::InvitableGroup>, SteamUserError> {
517 let response = self.get_path(format!("/profiles/{}/ajaxgroupinvite?new_profile=1", user_steam_id.steam_id64())).send().await?.text().await?;
518 parse_invitable_groups(&response)
519 }
520
521 #[tracing::instrument(skip(self), fields(group_id = group_id.steam_id64()))]
536 pub async fn invite_all_friends_to_group(&self, group_id: SteamID) -> Result<(), SteamUserError> {
537 let friends = self.get_friends_list().await?;
538 let group_id_str = group_id.steam_id64().to_string();
539
540 for (friend_steam_id, _relationship) in friends {
541 let invitable_groups = match self.get_invitable_groups(friend_steam_id).await {
542 Ok(groups) => groups,
543 Err(e) => {
544 tracing::warn!(friend = %friend_steam_id.steam_id64(), error = %e, "invite_all_friends_to_group: get_invitable_groups failed; skipping friend");
545 continue;
546 }
547 };
548
549 let can_invite = invitable_groups.iter().any(|g| g.id.steam_id64().to_string() == group_id_str);
550
551 if can_invite {
552 if let Err(e) = self.invite_user_to_group(friend_steam_id, group_id).await {
553 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");
554 }
555 }
556 }
557
558 Ok(())
559 }
560}
561
562fn parse_group_overview(html: &str) -> Result<crate::types::GroupOverview, SteamUserError> {
563 let document = scraper::Html::parse_document(html);
564
565 let mut gid = None;
567 if let Some(start) = html.find("InitializeCommentThread( \"Clan\", \"Clan_") {
568 let rest = &html[start + 40..];
569 if let Some(end) = rest.find("\",") {
570 if let Ok(id) = rest[..end].parse::<u64>() {
571 gid = Some(SteamID::from(id));
572 }
573 }
574 }
575
576 if gid.is_none() {
577 if let Some(el) = document.select(sel_abuse_id()).next() {
578 if let Some(val) = el.value().attr("value") {
579 if let Ok(id) = val.parse::<u64>() {
580 gid = Some(SteamID::from(id));
581 }
582 }
583 }
584 }
585
586 let gid = gid.ok_or_else(|| SteamUserError::MalformedResponse("Could not find Group ID".into()))?;
587
588 let mut name = String::new();
590 if let Some(start) = html.find("g_strGroupName = \"") {
591 let rest = &html[start + 18..];
592 if let Some(end) = rest.find("\";") {
593 name = rest[..end].to_string();
594 }
595 }
596 if name.is_empty() {
597 if let Some(el) = document.select(sel_grouppage_header_name()).next() {
598 name = el.text().collect::<Vec<_>>().join(" ").trim().to_string();
599 }
600 }
601
602 let mut group_url = None;
604 if let Some(start) = html.find("InitGroupPage( 'https://steamcommunity.com/groups/") {
605 let rest = &html[start + 50..];
606 if let Some(end) = rest.find("',") {
607 group_url = Some(rest[..end].trim_end_matches('/').to_string());
608 }
609 }
610
611 let headline = document.select(sel_group_headline()).next().map(|el| el.text().collect::<Vec<_>>().join(" ").trim().to_string());
613
614 let summary = document.select(sel_group_summary()).next().map(|el| el.inner_html().replace("\t", "").replace("\n", "").replace("\r", ""));
616
617 let avatar_url = document.select(sel_grouppage_logo()).next().and_then(|el| el.value().attr("src"));
619 let avatar_hash = if let Some(url) = avatar_url {
620 url.split('/').next_back().and_then(|s| s.split('_').next()).unwrap_or("").to_string()
622 } else {
623 String::new()
624 };
625
626 let mut member_count = 0;
628 let mut members_online = 0;
629 let mut members_in_game = 0;
630 let mut members_in_chat = 0;
631 let mut member_detail_count = 0;
632
633 for el in document.select(sel_group_paging()) {
634 let text = el.text().collect::<String>().to_lowercase();
635 if text.contains("members") {
636 let count_text = text.split("members").next().unwrap_or("").trim();
637 let final_count = if count_text.contains("of") { count_text.split("of").last().unwrap_or("") } else { count_text };
639 member_count = final_count.replace(",", "").trim().parse().unwrap_or(0);
640 }
641 }
642
643 if let Some(el) = document.select(sel_join_chat_count()).next() {
644 members_in_chat = el.text().collect::<String>().replace(",", "").trim().parse().unwrap_or(0);
645 }
646
647 for el in document.select(sel_membercount()) {
648 let class = el.value().attr("class").unwrap_or("");
649 let count = el.select(sel_count()).next().map(|c| c.text().collect::<String>().replace(",", "").trim().parse().unwrap_or(0)).unwrap_or(0);
650
651 if class.contains("ingame") {
652 members_in_game = count;
653 } else if class.contains("online") {
654 members_online = count;
655 } else if class.contains("members") {
656 member_detail_count = count;
657 }
658 }
659
660 let mut founded_str = None;
662 let mut language = None;
663 let mut location = None;
664
665 for el in document.select(sel_groupstat()) {
666 let label = el.select(sel_label()).next().map(|l| l.text().collect::<String>().trim().to_lowercase());
667 let data = el.select(sel_data()).next().map(|d| d.text().collect::<String>().trim().to_string());
668
669 match label.as_deref() {
670 Some("founded") => founded_str = data,
671 Some("language") => language = data,
672 Some("location") => location = data,
673 _ => {}
674 }
675 }
676
677 let mut members = Vec::new();
679 for el in document.select(sel_member_block()) {
680 let link_el = el.select(sel_link_friend()).next();
681 let name = link_el.map(|l| l.text().collect::<String>().trim().to_string()).unwrap_or_default();
682 let profile_url = link_el.and_then(|l| l.value().attr("href")).unwrap_or_default();
683
684 let miniprofile = el.value().attr("data-miniprofile").and_then(|m| m.parse::<u32>().ok()).unwrap_or(0);
688 let steamid = SteamID::from_individual_account_id(miniprofile);
689
690 let avatar_url = el.select(sel_member_img()).next().and_then(|i| i.value().attr("src")).unwrap_or("");
691 let avatar_hash = avatar_url.split('/').next_back().and_then(|s| s.split('_').next()).unwrap_or("").to_string();
692
693 let custom_url = if profile_url.contains("/id/") { Some(profile_url.trim_end_matches('/').split('/').next_back().unwrap_or("").to_string()) } else { None };
694
695 let rank = el.select(sel_rank_icon()).next().and_then(|r| r.value().attr("title")).map(|t| t.trim().to_string());
696
697 members.push(crate::types::GroupOverviewMember { steamid, name, avatar_hash, custom_url, rank });
698 }
699
700 let mut total_pages = 1;
702 let mut current_page = 1;
703 let mut next_page = None;
704 let mut next_page_link = None;
705
706 for el in document.select(sel_pagebtn()) {
707 let text = el.text().collect::<String>().trim().to_string();
708 let href = el.value().attr("href").unwrap_or("");
709
710 if let Some(p_start) = href.find("p=") {
711 let p_str = &href[p_start + 2..].split('#').next().unwrap_or("");
712 if let Ok(p) = p_str.parse::<i32>() {
713 total_pages = total_pages.max(p);
714 if text == ">" {
715 next_page = Some(p);
716 next_page_link = Some(href.replace("#members", "/members"));
717 }
718 }
719 }
720 }
721
722 if let Some(next) = next_page {
726 current_page = next - 1;
727 } else if total_pages > 1 {
728 current_page = total_pages;
729 }
730
731 Ok(crate::types::GroupOverview {
732 id: gid,
733 name,
734 url: group_url,
735 headline,
736 summary,
737 avatar_hash,
738 member_count,
739 member_detail_count,
740 members_online,
741 members_in_chat,
742 members_in_game,
743 total_pages,
744 current_page,
745 next_page,
746 next_page_link,
747 members,
748 founded_str,
749 language,
750 location,
751 })
752}
753
754fn parse_group_info_xml(xml: &str) -> Result<crate::types::GroupInfoXml, SteamUserError> {
756 use quick_xml::{events::Event, reader::Reader};
757
758 let mut reader = Reader::from_str(xml);
759 reader.config_mut().trim_text(true);
760
761 let mut buf = Vec::new();
762 let mut current_tag = String::new();
763 let mut in_member_list = false;
764 let mut in_group_details = false;
765
766 let mut group_id: Option<SteamID> = None;
767 let mut name = String::new();
768 let mut url = String::new();
769 let mut headline: Option<String> = None;
770 let mut summary: Option<String> = None;
771 let mut avatar_icon = String::new();
772 let mut avatar_medium = String::new();
773 let mut avatar_full = String::new();
774 let mut member_count = 0u32;
775 let mut member_detail_count = 0u32;
776 let mut members_in_chat = 0u32;
777 let mut members_in_game = 0u32;
778 let mut members_online = 0u32;
779 let mut total_pages = 0u32;
780 let mut current_page = 0u32;
781 let mut starting_member = 0u32;
782 let mut next_page_link: Option<String> = None;
783 let mut members: Vec<SteamID> = Vec::new();
784
785 loop {
786 match reader.read_event_into(&mut buf) {
787 Ok(Event::Start(e)) => {
788 let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
789 current_tag = tag_name.clone();
790 if tag_name == "members" {
791 in_member_list = true;
792 } else if tag_name == "groupDetails" {
793 in_group_details = true;
794 }
795 }
796 Ok(Event::End(e)) => {
797 let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
798 if tag_name == "members" {
799 in_member_list = false;
800 } else if tag_name == "groupDetails" {
801 in_group_details = false;
802 }
803 current_tag.clear();
804 }
805 Ok(Event::Text(e)) => {
806 let text = String::from_utf8_lossy(&e).trim().to_string();
807 if text.is_empty() {
808 continue;
809 }
810
811 match current_tag.as_str() {
812 "groupID64" if !in_member_list => {
813 if let Ok(id) = text.parse::<u64>() {
814 group_id = Some(SteamID::from(id));
815 }
816 }
817 "groupName" if in_group_details => {
818 name = text;
819 }
820 "groupURL" if in_group_details => {
821 url = text;
822 }
823 "headline" if in_group_details => {
824 headline = Some(text);
825 }
826 "summary" if in_group_details => {
827 summary = Some(text);
828 }
829 "avatarIcon" if in_group_details => {
830 avatar_icon = text;
831 }
832 "avatarMedium" if in_group_details => {
833 avatar_medium = text;
834 }
835 "avatarFull" if in_group_details => {
836 avatar_full = text;
837 }
838 "memberCount" if in_group_details => {
839 member_detail_count = text.replace(",", "").parse().unwrap_or(0);
840 }
841 "membersInChat" if in_group_details => {
842 members_in_chat = text.replace(",", "").parse().unwrap_or(0);
843 }
844 "membersInGame" if in_group_details => {
845 members_in_game = text.replace(",", "").parse().unwrap_or(0);
846 }
847 "membersOnline" if in_group_details => {
848 members_online = text.replace(",", "").parse().unwrap_or(0);
849 }
850 "memberCount" if !in_group_details && !in_member_list => {
851 member_count = text.replace(",", "").parse().unwrap_or(0);
852 }
853 "totalPages" => {
854 total_pages = text.parse().unwrap_or(0);
855 }
856 "currentPage" => {
857 current_page = text.parse().unwrap_or(0);
858 }
859 "startingMember" => {
860 starting_member = text.parse().unwrap_or(0);
861 }
862 "nextPageLink" => {
863 next_page_link = Some(text);
864 }
865 "steamID64" if in_member_list => {
866 if let Ok(id) = text.parse::<u64>() {
867 members.push(SteamID::from(id));
868 }
869 }
870 _ => {}
871 }
872 }
873 Ok(Event::Eof) => break,
874 Err(e) => {
875 tracing::warn!(error = ?e, "group XML parse error; ending parse with partial result");
876 break;
877 }
878 _ => {}
879 }
880 buf.clear();
881 }
882
883 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();
885
886 let group_id = group_id.ok_or_else(|| SteamUserError::MalformedResponse("Missing groupID64 in XML response".into()))?;
887
888 Ok(crate::types::GroupInfoXml {
889 id: group_id,
890 name,
891 url,
892 headline,
893 summary,
894 avatar_hash,
895 member_count,
896 member_detail_count,
897 members_in_chat,
898 members_in_game,
899 members_online,
900 total_pages,
901 current_page,
902 starting_member,
903 next_page_link,
904 members,
905 })
906}
907
908fn parse_invitable_groups(html: &str) -> Result<Vec<crate::types::InvitableGroup>, SteamUserError> {
910 let document = scraper::Html::parse_document(html.trim());
911 let mut groups = Vec::new();
912
913 for el in document.select(sel_group_list_option()) {
914 let id_str = el.value().attr("data-groupid").unwrap_or("");
915 let id = if let Ok(id) = id_str.parse::<u64>() {
916 SteamID::from(id)
917 } else {
918 continue;
919 };
920
921 let avatar_hash = el.value().attr("_groupavatarhash").map(|s| s.to_string());
922
923 let avatar_url = el.select(sel_invitable_avatar_img()).next().and_then(|i| i.value().attr("src")).map(|s| s.to_string());
924
925 let name = el.select(sel_group_list_name()).next().map(|n| n.text().collect::<String>().trim().to_string()).unwrap_or_default();
926
927 groups.push(crate::types::InvitableGroup { id, avatar_hash, avatar_url, name });
928 }
929
930 Ok(groups)
931}