Skip to main content

steam_user/services/
groups.rs

1//! Group management services.
2
3use 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    /// Joins a Steam group.
123    ///
124    /// # Arguments
125    ///
126    /// * `group_id` - The [`SteamID`] of the group to join.
127    #[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    /// Leaves a Steam group.
139    ///
140    /// # Arguments
141    ///
142    /// * `group_id` - The [`SteamID`] of the group to leave.
143    #[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    /// Retrieves the list of member SteamIDs for a given group.
155    ///
156    /// Fetches the members list by scraping the group's XML members list at
157    /// `https://steamcommunity.com/gid/<id>/memberslistxml/?xml=1`.
158    ///
159    /// # Arguments
160    ///
161    /// * `group_id` - The [`SteamID`] of the group.
162    ///
163    /// # Returns
164    ///
165    /// Returns a `Vec<SteamID>` containing all members of the group.
166    #[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        // XML view is often easier to parse for members list: memberslistxml/?xml=1
169        let response = self.get_path(format!("/gid/{}/memberslistxml/?xml=1", group_id.steam_id64())).send().await?.text().await?;
170
171        // Parse XML using quick-xml
172        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        // Track if we are inside a <steamID64> tag
181        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    /// Posts an announcement to a Steam group.
211    ///
212    /// # Arguments
213    ///
214    /// * `group_id` - The [`SteamID`] of the group.
215    /// * `headline` - The title of the announcement.
216    /// * `content` - The body text of the announcement.
217    #[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    /// Kicks a member from a Steam group.
227    ///
228    /// # Arguments
229    ///
230    /// * `group_id` - The [`SteamID`] of the group.
231    /// * `member_id` - The [`SteamID`] of the member to kick.
232    #[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    // unimplemented stub — no #[steam_endpoint] until a real network call exists
244    #[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        // ...
251        Ok(crate::types::CommitFileUploadResponse { success: 1, result: None, error: None })
252    }
253
254    /// Invites a user to a Steam group.
255    ///
256    /// # Arguments
257    ///
258    /// * `user_id` - The [`SteamID`] of the user to invite.
259    /// * `group_id` - The [`SteamID`] of the group to invite them to.
260    #[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    /// Invites multiple users to a Steam group in a single request.
273    ///
274    /// # Arguments
275    ///
276    /// * `user_ids` - A slice of [`SteamID`]s of the users to invite.
277    /// * `group_id` - The [`SteamID`] of the group.
278    #[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    /// Responds to a Steam group invitation by accepting or ignoring it.
300    ///
301    /// This is the core function for handling group invitations. Use the
302    /// convenience methods [`Self::accept_group_invite`] or
303    /// [`Self::ignore_group_invite`] for simpler usage.
304    ///
305    /// # Arguments
306    /// * `group_id` - The SteamID of the group whose invitation to respond to.
307    /// * `accept` - If `true`, accepts the invite; if `false`, ignores/declines
308    ///   it.
309    ///
310    /// # Returns
311    /// * `Ok(())` on success.
312    /// * `Err(SteamUserError)` if the request fails or the response indicates
313    ///   failure.
314    #[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    /// Accept a pending Steam group invitation.
328    ///
329    /// This is a convenience wrapper around [`Self::respond_to_group_invite`]
330    /// with `accept = true`.
331    ///
332    /// # Arguments
333    /// * `group_id` - The SteamID of the group whose invitation to accept.
334    ///
335    /// # Returns
336    /// * `Ok(())` on success.
337    /// * `Err(SteamUserError)` if the request fails.
338    // delegates to `respond_to_group_invite` — no #[steam_endpoint]
339    #[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    /// Ignore/decline a pending Steam group invitation.
345    ///
346    /// This is a convenience wrapper around [`Self::respond_to_group_invite`]
347    /// with `accept = false`.
348    ///
349    /// # Arguments
350    /// * `group_id` - The SteamID of the group whose invitation to ignore.
351    ///
352    /// # Returns
353    /// * `Ok(())` on success.
354    /// * `Err(SteamUserError)` if the request fails.
355    // delegates to `respond_to_group_invite` — no #[steam_endpoint]
356    #[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    /// Fetches an overview of a group, including member counts, headline, and
362    /// summary.
363    ///
364    /// # Arguments
365    ///
366    /// * `options` - A [`crate::types::GroupOverviewOptions`] struct specifying
367    ///   the group and pagination settings.
368    ///
369    /// # Returns
370    ///
371    /// Returns a [`crate::types::GroupOverview`] struct with the gathered
372    /// information.
373    #[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        // Group overview pages can list 1000+ members; parsing them is the
393        // largest CPU cost in this service. Run it on the blocking pool.
394        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    /// Resolves a group vanity URL to its SteamID.
398    ///
399    /// # Arguments
400    ///
401    /// * `vanity_url` - The group's vanity URL (e.g., "valve" for
402    ///   steamcommunity.com/groups/valve)
403    ///
404    /// # Returns
405    ///
406    /// Returns the group's SteamID64 as a string, or an error if not found.
407    ///
408    /// # Example
409    ///
410    /// ```ignore
411    /// let group_id = client.get_group_steam_id_from_vanity_url("valve").await?;
412    /// ```
413    #[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        // Try to find OpenGroupChat('XXXX')
418        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        // Fallback: parse HTML for form input
425        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    /// Gets group information via the XML API.
436    ///
437    /// # Arguments
438    ///
439    /// * `gid` - Optional group SteamID. If provided, uses
440    ///   `/gid/{id}/memberslistxml/`.
441    /// * `group_url` - Optional group vanity URL. If provided, uses
442    ///   `/groups/{url}/memberslistxml/`.
443    /// * `page` - Page number (1-indexed, default 1).
444    ///
445    /// # Returns
446    ///
447    /// Returns a [`crate::types::GroupInfoXml`] struct with group details and
448    /// member list.
449    #[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    /// Gets full group information via the XML API, paginating through all
465    /// members.
466    ///
467    /// # Arguments
468    ///
469    /// * `gid` - Optional group SteamID.
470    /// * `group_url` - Optional group vanity URL.
471    ///
472    /// # Returns
473    ///
474    /// Returns a [`crate::types::GroupInfoXml`] struct with all members across
475    /// all pages.
476    // paginates `get_group_info_xml` — no #[steam_endpoint]
477    #[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    /// Gets the list of groups that a user can be invited to.
505    ///
506    /// # Arguments
507    ///
508    /// * `user_steam_id` - The SteamID of the user to check invitable groups
509    ///   for.
510    ///
511    /// # Returns
512    ///
513    /// Returns a list of [`crate::types::InvitableGroup`] that the user can be
514    /// invited to.
515    #[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    /// Invites all friends to a Steam group.
522    ///
523    /// For each friend, checks if the group is invitable, and if so, sends an
524    /// invite.
525    ///
526    /// # Arguments
527    ///
528    /// * `group_id` - The SteamID of the group to invite friends to.
529    ///
530    /// # Returns
531    ///
532    /// Returns `Ok(())` on completion. Individual invite failures are silently
533    /// ignored.
534    // composite — no #[steam_endpoint]
535    #[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    // GID extraction
566    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    // Name
589    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    // URL
603    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    // Headline
612    let headline = document.select(sel_group_headline()).next().map(|el| el.text().collect::<Vec<_>>().join(" ").trim().to_string());
613
614    // Summary
615    let summary = document.select(sel_group_summary()).next().map(|el| el.inner_html().replace("\t", "").replace("\n", "").replace("\r", ""));
616
617    // Avatar Hash
618    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        // Extract hash from URL: .../fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg
621        url.split('/').next_back().and_then(|s| s.split('_').next()).unwrap_or("").to_string()
622    } else {
623        String::new()
624    };
625
626    // Member counts
627    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            // Handle "1-15 of 1,234"
638            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    // Founded, Language, Location
661    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    // Members list
678    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        // data-miniprofile is a 32-bit Steam account ID. Parse directly into u32
685        // so an out-of-range value is rejected (None) instead of silently
686        // truncating into a corrupted SteamID.
687        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    // Pagination
701    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    // We can't easily determine current page from the buttons alone if it's not in
723    // the URL, but usually it's passed in. For now we assume if next_page is P,
724    // current is P-1.
725    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
754/// Parses XML API response for group info.
755fn 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    // Extract avatar hash from one of the avatar URLs
884    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
908/// Parses HTML response for invitable groups.
909fn 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}