Skip to main content

steam_user/services/
friends.rs

1//! User interaction services.
2
3use std::sync::OnceLock;
4
5use scraper::Selector;
6use serde::Deserialize;
7use steamid::SteamID;
8
9use crate::{
10    client::SteamUser,
11    endpoint::{steam_endpoint, Host},
12    error::SteamUserError,
13    utils::{
14        avatar::{extract_custom_url, get_avatar_hash_from_url, get_avatar_url_from_hash, AvatarSize},
15        debug::dump_html,
16    },
17};
18
19// =========================================================================
20// Deserialization helpers
21//
22// Steam's JSON sometimes returns numeric fields as strings, sometimes as
23// numbers, and sometimes wraps strings with thousands-separator commas
24// (e.g. "1,234"). The helpers below normalise those shapes into typed
25// fields so call sites can be `#[derive(Deserialize)]` instead of walking
26// `serde_json::Value` by hand.
27// =========================================================================
28
29/// Accepts either an integer or a numeric string and produces a `u32`.
30/// Strips ASCII commas so values like `"1,234"` round-trip correctly.
31fn de_u32_int_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
32    use serde::de::Error;
33    #[derive(Deserialize)]
34    #[serde(untagged)]
35    enum N {
36        Int(u64),
37        Str(String),
38    }
39    match N::deserialize(d)? {
40        N::Int(n) => u32::try_from(n).map_err(D::Error::custom),
41        N::Str(s) => s.replace(',', "").parse().map_err(D::Error::custom),
42    }
43}
44
45/// Accepts either an integer or a numeric string and produces an `Option<i32>`.
46/// Missing fields, JSON null, or unparseable strings collapse to `None`.
47fn de_opt_i32_int_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<i32>, D::Error> {
48    #[derive(Deserialize)]
49    #[serde(untagged)]
50    enum N {
51        Int(i64),
52        Str(String),
53        Null,
54    }
55    Ok(match Option::<N>::deserialize(d)? {
56        Some(N::Int(n)) => i32::try_from(n).ok(),
57        Some(N::Str(s)) => s.replace(',', "").parse().ok(),
58        Some(N::Null) | None => None,
59    })
60}
61
62/// Accepts either an integer or a numeric string and produces an `Option<u64>`.
63fn de_opt_u64_int_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
64    #[derive(Deserialize)]
65    #[serde(untagged)]
66    enum N {
67        Int(u64),
68        Str(String),
69        Null,
70    }
71    Ok(match Option::<N>::deserialize(d)? {
72        Some(N::Int(n)) => Some(n),
73        Some(N::Str(s)) => s.replace(',', "").parse().ok(),
74        Some(N::Null) | None => None,
75    })
76}
77
78/// Accepts either an integer (treated as `1` ⇒ true) or a bool. Defaults to
79/// `false` on missing fields.
80fn de_bool_int_or_bool<'de, D: serde::Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
81    #[derive(Deserialize)]
82    #[serde(untagged)]
83    enum B {
84        Bool(bool),
85        Int(i64),
86        Null,
87    }
88    Ok(match Option::<B>::deserialize(d)? {
89        Some(B::Bool(b)) => b,
90        Some(B::Int(n)) => n == 1,
91        Some(B::Null) | None => false,
92    })
93}
94
95// =========================================================================
96// Raw response shapes
97// =========================================================================
98
99/// Raw shape of the `/search/SearchCommunityAjax` response.
100#[derive(Deserialize)]
101struct CommunitySearchResponseRaw {
102    #[serde(default)]
103    success: i64,
104    #[serde(default)]
105    html: String,
106    #[serde(default = "default_search_filter")]
107    search_filter: String,
108    #[serde(default, deserialize_with = "de_opt_u64_int_or_string")]
109    search_page: Option<u64>,
110    #[serde(default, deserialize_with = "de_u32_int_or_string")]
111    search_result_count: u32,
112    #[serde(default)]
113    search_text: String,
114}
115
116fn default_search_filter() -> String {
117    "users".to_string()
118}
119
120/// Raw shape of a quick-invite "invite" sub-document.
121#[derive(Deserialize, Default)]
122struct QuickInviteRaw {
123    #[serde(default)]
124    invite_token: Option<String>,
125    #[serde(default, deserialize_with = "de_opt_i32_int_or_string")]
126    invite_limit: Option<i32>,
127    #[serde(default, deserialize_with = "de_opt_i32_int_or_string")]
128    invite_duration: Option<i32>,
129    #[serde(default, deserialize_with = "de_opt_u64_int_or_string")]
130    time_created: Option<u64>,
131}
132
133/// Raw shape of `/invites/ajaxcreate`.
134#[derive(Deserialize)]
135struct QuickInviteCreateResponseRaw {
136    #[serde(default, deserialize_with = "de_bool_int_or_bool")]
137    success: bool,
138    #[serde(default)]
139    invite: Option<QuickInviteRaw>,
140}
141
142/// Raw shape of `/invites/ajaxgetall`.
143#[derive(Deserialize)]
144struct QuickInviteListResponseRaw {
145    #[serde(default, deserialize_with = "de_bool_int_or_bool")]
146    success: bool,
147    #[serde(default)]
148    tokens: Vec<QuickInviteTokenRaw>,
149}
150
151/// Single entry in the list returned by `/invites/ajaxgetall`.
152#[derive(Deserialize)]
153struct QuickInviteTokenRaw {
154    #[serde(default)]
155    invite_token: String,
156    #[serde(default, deserialize_with = "de_opt_i32_int_or_string")]
157    invite_limit: Option<i32>,
158    #[serde(default, deserialize_with = "de_opt_i32_int_or_string")]
159    invite_duration: Option<i32>,
160    #[serde(default, deserialize_with = "de_opt_u64_int_or_string")]
161    time_created: Option<u64>,
162}
163
164/// Raw shape of `/invites/ajaxredeem`.
165#[derive(Deserialize)]
166struct RedeemResponseRaw {
167    #[serde(default, deserialize_with = "de_opt_i32_int_or_string")]
168    success: Option<i32>,
169}
170
171/// Raw shape of the `g_rgCounts` JS variable embedded in the friends page.
172#[derive(Deserialize, Default)]
173#[serde(default)]
174struct FriendsCountRaw {
175    #[serde(rename = "cFriends", deserialize_with = "de_u32_int_or_string")]
176    friends: u32,
177    #[serde(rename = "cFriendsPending", deserialize_with = "de_u32_int_or_string")]
178    friends_pending: u32,
179    #[serde(rename = "cFriendsBlocked", deserialize_with = "de_u32_int_or_string")]
180    friends_blocked: u32,
181    #[serde(rename = "cFollowing", deserialize_with = "de_u32_int_or_string")]
182    following: u32,
183    #[serde(rename = "cGroups", deserialize_with = "de_u32_int_or_string")]
184    groups: u32,
185    #[serde(rename = "cGroupsPending", deserialize_with = "de_u32_int_or_string")]
186    groups_pending: u32,
187}
188
189static SEL_INVITE_BLOCK_NAME: OnceLock<Selector> = OnceLock::new();
190fn sel_invite_block_name() -> &'static Selector {
191    SEL_INVITE_BLOCK_NAME.get_or_init(|| Selector::parse(".invite_block_name > a.linkTitle").expect("valid CSS selector"))
192}
193
194static SEL_FRIEND_PLAYER_LEVEL: OnceLock<Selector> = OnceLock::new();
195fn sel_friend_player_level() -> &'static Selector {
196    SEL_FRIEND_PLAYER_LEVEL.get_or_init(|| Selector::parse(".friendPlayerLevel .friendPlayerLevelNum").expect("valid CSS selector"))
197}
198
199static SEL_PERSONA_MINIPROFILE: OnceLock<Selector> = OnceLock::new();
200fn sel_persona_miniprofile() -> &'static Selector {
201    SEL_PERSONA_MINIPROFILE.get_or_init(|| Selector::parse(".persona[data-miniprofile]").expect("valid CSS selector"))
202}
203
204static SEL_SELECTABLE_OVERLAY: OnceLock<Selector> = OnceLock::new();
205fn sel_selectable_overlay() -> &'static Selector {
206    SEL_SELECTABLE_OVERLAY.get_or_init(|| Selector::parse("a.selectable_overlay").expect("valid CSS selector"))
207}
208
209static SEL_PLAYER_AVATAR_IMG: OnceLock<Selector> = OnceLock::new();
210fn sel_player_avatar_img() -> &'static Selector {
211    SEL_PLAYER_AVATAR_IMG.get_or_init(|| Selector::parse(".player_avatar img, .playerAvatar img").expect("valid CSS selector"))
212}
213
214static SEL_FRIEND_BLOCK_CONTENT: OnceLock<Selector> = OnceLock::new();
215fn sel_friend_block_content() -> &'static Selector {
216    SEL_FRIEND_BLOCK_CONTENT.get_or_init(|| Selector::parse(".friend_block_content, .friendBlockContent").expect("valid CSS selector"))
217}
218
219static SEL_PLAYER_NICKNAME_HINT: OnceLock<Selector> = OnceLock::new();
220fn sel_player_nickname_hint() -> &'static Selector {
221    SEL_PLAYER_NICKNAME_HINT.get_or_init(|| Selector::parse(".player_nickname_hint").expect("valid CSS selector"))
222}
223
224static SEL_FRIEND_GAME_LINK: OnceLock<Selector> = OnceLock::new();
225fn sel_friend_game_link() -> &'static Selector {
226    SEL_FRIEND_GAME_LINK.get_or_init(|| Selector::parse(".friend_game_link, .linkFriend_in-game").expect("valid CSS selector"))
227}
228
229static SEL_FRIEND_LAST_ONLINE: OnceLock<Selector> = OnceLock::new();
230fn sel_friend_last_online() -> &'static Selector {
231    SEL_FRIEND_LAST_ONLINE.get_or_init(|| Selector::parse(".friend_last_online_text").expect("valid CSS selector"))
232}
233
234static SEL_SEARCH_ROW: OnceLock<Selector> = OnceLock::new();
235fn sel_search_row() -> &'static Selector {
236    SEL_SEARCH_ROW.get_or_init(|| Selector::parse(".search_row").expect("valid CSS selector"))
237}
238
239static SEL_MEDIUM_HOLDER: OnceLock<Selector> = OnceLock::new();
240fn sel_medium_holder() -> &'static Selector {
241    SEL_MEDIUM_HOLDER.get_or_init(|| Selector::parse(".mediumHolder_default[data-miniprofile]").expect("valid CSS selector"))
242}
243
244static SEL_AVATAR_MEDIUM_IMG: OnceLock<Selector> = OnceLock::new();
245fn sel_avatar_medium_img() -> &'static Selector {
246    SEL_AVATAR_MEDIUM_IMG.get_or_init(|| Selector::parse(".avatarMedium a img").expect("valid CSS selector"))
247}
248
249static SEL_SEARCH_PERSONA_NAME: OnceLock<Selector> = OnceLock::new();
250fn sel_search_persona_name() -> &'static Selector {
251    SEL_SEARCH_PERSONA_NAME.get_or_init(|| Selector::parse("a.searchPersonaName").expect("valid CSS selector"))
252}
253
254static SEL_COMMUNITY_SEARCH_PAGING: OnceLock<Selector> = OnceLock::new();
255fn sel_community_search_paging() -> &'static Selector {
256    SEL_COMMUNITY_SEARCH_PAGING.get_or_init(|| Selector::parse(".community_searchresults_paging a").expect("valid CSS selector"))
257}
258
259impl SteamUser {
260    /// Sends a friend request to a user.
261    ///
262    /// # Arguments
263    ///
264    /// * `user_id` - The [`SteamID`] of the user to add.
265    ///
266    /// # Example
267    ///
268    /// ```rust,no_run
269    /// # use steam_user::SteamUser;
270    /// # use steamid::SteamID;
271    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
272    /// let target = SteamID::from(76561197960287930);
273    /// user.add_friend(target).await?;
274    /// # Ok(())
275    /// # }
276    /// ```
277    #[steam_endpoint(POST, host = Community, path = "/actions/AddFriendAjax", kind = Write)]
278    pub async fn add_friend(&self, user_id: SteamID) -> Result<(), SteamUserError> {
279        let steam_id = user_id.steam_id64().to_string();
280
281        let response: serde_json::Value = self.post_path("/actions/AddFriendAjax").form(&[("steamid", steam_id.as_str()), ("accept_invite", "0")]).send().await?.json().await?;
282
283        Self::check_json_success(&response, "Failed to add friend")?;
284
285        Ok(())
286    }
287
288    /// Removes a user from the friend list.
289    ///
290    /// # Arguments
291    ///
292    /// * `user_id` - The [`SteamID`] of the friend to remove.
293    ///
294    /// # Example
295    ///
296    /// ```rust,no_run
297    /// # use steam_user::SteamUser;
298    /// # use steamid::SteamID;
299    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
300    /// let target = SteamID::from(76561197960287930);
301    /// user.remove_friend(target).await?;
302    /// # Ok(())
303    /// # }
304    /// ```
305    #[steam_endpoint(POST, host = Community, path = "/actions/RemoveFriendAjax", kind = Write)]
306    pub async fn remove_friend(&self, user_id: SteamID) -> Result<(), SteamUserError> {
307        let steam_id = user_id.steam_id64().to_string();
308
309        let response: serde_json::Value = self.post_path("/actions/RemoveFriendAjax").form(&[("steamid", steam_id.as_str())]).send().await?.json().await?;
310
311        Self::check_json_success(&response, "Failed to remove friend")?;
312
313        Ok(())
314    }
315
316    /// Accepts a pending friend request from a user.
317    ///
318    /// # Arguments
319    ///
320    /// * `user_id` - The [`SteamID`] of the user whose request to accept.
321    ///
322    /// # Example
323    ///
324    /// ```rust,no_run
325    /// # use steam_user::SteamUser;
326    /// # use steamid::SteamID;
327    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
328    /// let requester = SteamID::from(76561197960287930);
329    /// user.accept_friend_request(requester).await?;
330    /// # Ok(())
331    /// # }
332    /// ```
333    #[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/friends/action", kind = Write)]
334    pub async fn accept_friend_request(&self, user_id: SteamID) -> Result<(), SteamUserError> {
335        let my_steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?.steam_id64().to_string();
336        let target_steam_id = user_id.steam_id64().to_string();
337
338        let url = format!("/profiles/{}/friends/action", my_steam_id);
339
340        let response: serde_json::Value = self.post_path(&url).form(&[("steamid", my_steam_id.as_str()), ("ajax", "1"), ("action", "accept"), ("steamids[]", target_steam_id.as_str())]).send().await?.json().await?;
341
342        Self::check_json_success(&response, "Failed to accept friend request")?;
343
344        Ok(())
345    }
346
347    /// Ignores or declines a pending friend request.
348    ///
349    /// # Arguments
350    ///
351    /// * `user_id` - The [`SteamID`] of the user whose request to ignore.
352    #[steam_endpoint(POST, host = Community, path = "/actions/IgnoreFriendInviteAjax", kind = Write)]
353    pub async fn ignore_friend_request(&self, user_id: SteamID) -> Result<(), SteamUserError> {
354        let steam_id = user_id.steam_id64().to_string();
355
356        let response: serde_json::Value = self.post_path("/actions/IgnoreFriendInviteAjax").form(&[("steamid", steam_id.as_str())]).send().await?.json().await?;
357
358        Self::check_json_success(&response, "Failed to ignore friend request")?;
359
360        Ok(())
361    }
362
363    /// Sets or removes a communication block (block/unblock) for a user.
364    ///
365    /// # Arguments
366    ///
367    /// * `user_id` - The [`SteamID`] of the user to block or unblock.
368    /// * `block` - `true` to block, `false` to unblock.
369    #[steam_endpoint(POST, host = Community, path = "/actions/BlockUserAjax", kind = Write)]
370    pub async fn set_communication_block(&self, user_id: SteamID, block: bool) -> Result<(), SteamUserError> {
371        let steam_id = user_id.steam_id64().to_string();
372        let block_val = if block { "1" } else { "0" };
373
374        let response: serde_json::Value = self.post_path("/actions/BlockUserAjax").form(&[("steamid", steam_id.as_str()), ("block", block_val)]).send().await?.json().await?;
375
376        Self::check_json_success(&response, &format!("Failed to {} user", if block { "block" } else { "unblock" }))?;
377
378        Ok(())
379    }
380
381    /// Blocks all communication from a specified user.
382    // delegates to `set_communication_block` — no #[steam_endpoint]
383    #[tracing::instrument(skip(self), fields(target_steam_id = user_id.steam_id64()))]
384    pub async fn block_user(&self, user_id: SteamID) -> Result<(), SteamUserError> {
385        self.set_communication_block(user_id, true).await
386    }
387
388    /// Unblocks a previously blocked user.
389    // delegates to `set_communication_block` — no #[steam_endpoint]
390    #[tracing::instrument(skip(self), fields(target_steam_id = user_id.steam_id64()))]
391    pub async fn unblock_user(&self, user_id: SteamID) -> Result<(), SteamUserError> {
392        self.set_communication_block(user_id, false).await
393    }
394
395    /// Fetches the raw friend list from the AJAX endpoint.
396    ///
397    /// Returns `(success_code, friends_array)`. Handles success code 21 (no
398    /// friends) by returning an empty array.
399    #[steam_endpoint(GET, host = Community, path = "/textfilter/ajaxgetfriendslist", kind = Read)]
400    async fn fetch_friends_list_raw(&self) -> Result<(i64, Vec<serde_json::Value>), SteamUserError> {
401        let response: serde_json::Value = self.get_path("/textfilter/ajaxgetfriendslist").send().await?.json().await?;
402
403        let success = response.get("success").and_then(|v| v.as_i64()).unwrap_or(0);
404
405        if success != 1 {
406            if success == 21 {
407                return Ok((21, Vec::new()));
408            }
409            return Err(SteamUserError::from_eresult(i32::try_from(success).unwrap_or(i32::MIN)));
410        }
411
412        let friends_list = response.get("friendslist").and_then(|v| v.get("friends")).and_then(|v| v.as_array()).ok_or_else(|| SteamUserError::MalformedResponse("Missing friends list".into()))?;
413
414        Ok((1, friends_list.clone()))
415    }
416
417    /// Retrieves the friend list with relationship status codes.
418    ///
419    /// Fetches a list of friends and their relationship status (e.g., 3 for
420    /// Friend, 2 for Request Sent, etc.).
421    ///
422    /// # Returns
423    ///
424    /// Returns a `HashMap<SteamID, i32>` where the key is the SteamID and the
425    /// value is the relationship status integer.
426    // delegates to `fetch_friends_list_raw` — no #[steam_endpoint]
427    #[tracing::instrument(skip(self))]
428    pub async fn get_friends_list(&self) -> Result<std::collections::HashMap<SteamID, i32>, SteamUserError> {
429        let (_, friends_list) = self.fetch_friends_list_raw().await?;
430
431        let mut friends = std::collections::HashMap::with_capacity(friends_list.len());
432        for friend in &friends_list {
433            if let (Some(id), Some(rel)) = (friend.get("ulfriendid").and_then(|v| v.as_str()), friend.get("efriendrelationship").and_then(|v| v.as_i64())) {
434                if let Ok(steam_id) = id.parse::<u64>() {
435                    if let Ok(rel_i32) = i32::try_from(rel) {
436                        friends.insert(SteamID::from(steam_id), rel_i32);
437                    }
438                }
439            }
440        }
441
442        Ok(friends)
443    }
444
445    /// Retrieves the friend list for the currently authenticated user with full
446    /// profile details.
447    ///
448    /// Scrapes the user's friends page to gather detailed information about
449    /// each friend.
450    ///
451    /// # Returns
452    ///
453    /// Returns a `Vec<FriendDetails>` containing information like usernames,
454    /// online status, and avatars.
455    // delegates to `get_friends_details_of_user` — no #[steam_endpoint]
456    #[tracing::instrument(skip(self))]
457    pub async fn get_friends_details(&self) -> Result<crate::types::FriendListPage, SteamUserError> {
458        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
459        self.get_friends_details_of_user(steam_id).await
460    }
461
462    /// Retrieves the friend list for a specific Steam account with full
463    /// details.
464    ///
465    /// # Arguments
466    ///
467    /// * `user_id` - The [`SteamID`] of the user whose friend list you want to
468    ///   retrieve.
469    #[steam_endpoint(GET, host = Community, path = "/profiles/{user_id}/friends/", kind = Read)]
470    pub async fn get_friends_details_of_user(&self, user_id: SteamID) -> Result<crate::types::FriendListPage, SteamUserError> {
471        let body = self.get_path(format!("/profiles/{}/friends/", user_id.steam_id64())).send().await?.text().await?;
472        Ok(parse_friend_list(&body))
473    }
474
475    /// Follows a user on Steam to see their public activity in your activity
476    /// feed.
477    #[steam_endpoint(POST, host = Community, path = "/profiles/{user_id}/followuser/", kind = Write)]
478    pub async fn follow_user(&self, user_id: SteamID) -> Result<(), SteamUserError> {
479        self.send_profile_follow_action(user_id, "follow").await
480    }
481
482    /// Unfollows a previously followed user.
483    #[steam_endpoint(POST, host = Community, path = "/profiles/{user_id}/unfollowuser/", kind = Write)]
484    pub async fn unfollow_user(&self, user_id: SteamID) -> Result<(), SteamUserError> {
485        self.send_profile_follow_action(user_id, "unfollow").await
486    }
487
488    /// Searches for Steam Community users by their profile name.
489    ///
490    /// # Arguments
491    ///
492    /// * `query` - The search string.
493    /// * `page` - The results page number (starting from 1).
494    #[steam_endpoint(GET, host = Community, path = "/search/SearchCommunityAjax", kind = Read)]
495    pub async fn search_users(&self, query: &str, page: u32) -> Result<crate::types::CommunitySearchResult, SteamUserError> {
496        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?.steam_id64().to_string();
497
498        let raw: CommunitySearchResponseRaw = self.get_path("/search/SearchCommunityAjax").query(&[("text", query), ("filter", "users"), ("steamid", steam_id.as_str()), ("accept_invite", "0"), ("page", &page.to_string())]).send().await?.json().await?;
499
500        if raw.success != 1 {
501            return Err(SteamUserError::SteamError("Search failed".to_string()));
502        }
503
504        let search_page = raw.search_page.and_then(|n| u32::try_from(n).ok()).unwrap_or(page);
505        let search_text = if raw.search_text.is_empty() { query.to_string() } else { raw.search_text };
506
507        let (players, prev_page, next_page) = parse_search_results(&raw.html, search_page);
508
509        Ok(crate::types::CommunitySearchResult {
510            players,
511            prev_page,
512            next_page,
513            search_filter: raw.search_filter,
514            search_page,
515            search_result_count: raw.search_result_count,
516            search_text,
517        })
518    }
519
520    /// Creates an instant friend invite link for the current user.
521    ///
522    /// This generates a short link (e.g., `https://s.team/p/XXXX-XXXX/TOKEN`) that anyone can use
523    /// to instantly add you as a friend without searching.
524    // delegates to `get_quick_invite_data` — no #[steam_endpoint]
525    #[tracing::instrument(skip(self))]
526    pub async fn create_instant_invite(&self) -> Result<String, SteamUserError> {
527        let my_steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
528        let short_url = format!("https://s.team/p/{}", steam_friend_code::create_short_steam_friend_code(my_steam_id.account_id));
529        let invite_data = self.get_quick_invite_data().await?;
530
531        if let Some(token) = invite_data.invite_token {
532            Ok(format!("{}/{}", short_url, token))
533        } else {
534            Err(SteamUserError::SteamError("Failed to generate invite token".to_string()))
535        }
536    }
537
538    /// Retrieves the primary quick invite token and associated metadata for the
539    /// current user.
540    #[steam_endpoint(POST, host = Community, path = "/invites/ajaxcreate", kind = Write)]
541    pub async fn get_quick_invite_data(&self) -> Result<crate::types::QuickInviteData, SteamUserError> {
542        let my_steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
543
544        let raw: QuickInviteCreateResponseRaw = self.post_path("/invites/ajaxcreate").form(&[("steamid_user", my_steam_id.steam_id64().to_string()), ("duration", "2592000".to_string())]).send().await?.json().await?;
545
546        if let (true, Some(invite)) = (raw.success, raw.invite) {
547            Ok(crate::types::QuickInviteData {
548                success: true,
549                invite_token: invite.invite_token,
550                invite_limit: invite.invite_limit,
551                invite_duration: invite.invite_duration,
552                time_created: invite.time_created,
553                steam_id: Some(my_steam_id),
554            })
555        } else {
556            Ok(crate::types::QuickInviteData {
557                success: false,
558                invite_token: None,
559                invite_limit: None,
560                invite_duration: None,
561                time_created: None,
562                steam_id: Some(my_steam_id),
563            })
564        }
565    }
566
567    /// Retrieves all active quick invite tokens for the authenticated user.
568    #[steam_endpoint(GET, host = Community, path = "/invites/ajaxgetall", kind = Read)]
569    pub async fn get_current_quick_invite_tokens(&self) -> Result<crate::types::QuickInviteTokensResponse, SteamUserError> {
570        let my_steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
571
572        let raw: QuickInviteListResponseRaw = self.get_path("/invites/ajaxgetall").send().await?.json().await?;
573
574        let tokens = raw
575            .tokens
576            .into_iter()
577            .map(|t| crate::types::QuickInviteToken {
578                invite_token: t.invite_token,
579                invite_limit: t.invite_limit.unwrap_or(0),
580                invite_duration: t.invite_duration.unwrap_or(0),
581                time_created: t.time_created.unwrap_or(0),
582                steam_id: Some(my_steam_id),
583            })
584            .collect();
585
586        Ok(crate::types::QuickInviteTokensResponse { success: raw.success, tokens })
587    }
588
589    /// Internal helper for follow/unfollow actions.
590    #[steam_endpoint(POST, host = Community, path = "/profiles/{user_id}/{action}user/", kind = Write)]
591    async fn send_profile_follow_action(&self, user_id: SteamID, action: &str) -> Result<(), SteamUserError> {
592        let target_steam_id = user_id.steam_id64().to_string();
593
594        let response: serde_json::Value = self.post_path(format!("/profiles/{}/{}user/", target_steam_id, action)).form(&([] as [(&str, &str); 0])).send().await?.json().await?;
595        Self::check_json_success(&response, &format!("Failed to {} user", action))?;
596        Ok(())
597    }
598
599    // =========================================================================
600    // Extended Friend Management Methods
601    // =========================================================================
602
603    /// Retrieves the list of players the current user is following.
604    ///
605    /// # Returns
606    ///
607    /// Returns a `Vec<FriendDetails>` containing information about each
608    /// followed user.
609    // delegates to `get_following_list_of_user` — no #[steam_endpoint]
610    #[tracing::instrument(skip(self))]
611    pub async fn get_following_list(&self) -> Result<crate::types::FriendListPage, SteamUserError> {
612        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
613        self.get_following_list_of_user(steam_id).await
614    }
615
616    /// Retrieves the list of players a specific user is following.
617    ///
618    /// # Arguments
619    ///
620    /// * `user_id` - The [`SteamID`] of the user whose following list to
621    ///   retrieve.
622    #[steam_endpoint(GET, host = Community, path = "/profiles/{user_id}/following/", kind = Read)]
623    pub async fn get_following_list_of_user(&self, user_id: SteamID) -> Result<crate::types::FriendListPage, SteamUserError> {
624        let body = self.get_path(format!("/profiles/{}/following/", user_id.steam_id64())).send().await?.text().await?;
625        Ok(parse_friend_list(&body))
626    }
627
628    /// Retrieves a simple list of friend SteamIDs for the current user.
629    ///
630    /// This method uses the AJAX API to fetch only SteamIDs of confirmed
631    /// friends, filtering out pending requests and other relationship
632    /// types.
633    ///
634    /// # Returns
635    ///
636    /// Returns a `Vec<SteamID>` containing the SteamIDs of all confirmed
637    /// friends.
638    // delegates to `fetch_friends_list_raw` — no #[steam_endpoint]
639    #[tracing::instrument(skip(self))]
640    pub async fn get_my_friends_id_list(&self) -> Result<Vec<SteamID>, SteamUserError> {
641        let (_, friends_list) = self.fetch_friends_list_raw().await?;
642
643        // EFriendRelationship.Friend = 3
644        const FRIEND_RELATIONSHIP: i64 = 3;
645
646        let mut friends = Vec::new();
647        for friend in &friends_list {
648            let relationship = friend.get("efriendrelationship").and_then(|v| v.as_i64()).unwrap_or(0);
649
650            if relationship == FRIEND_RELATIONSHIP {
651                if let Some(id_str) = friend.get("ulfriendid").and_then(|v| v.as_str()) {
652                    if let Ok(steam_id) = id_str.parse::<u64>() {
653                        friends.push(SteamID::from(steam_id));
654                    }
655                }
656            }
657        }
658
659        Ok(friends)
660    }
661
662    /// Retrieves the list of pending friend requests (both incoming and
663    /// outgoing).
664    ///
665    /// # Returns
666    ///
667    /// Returns a `PendingFriendList` containing both sent and received friend
668    /// invites.
669    #[steam_endpoint(GET, host = Community, path = "/profiles/{steam_id}/friends/pending", kind = Read)]
670    pub async fn get_pending_friend_list(&self) -> Result<crate::types::PendingFriendList, SteamUserError> {
671        let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?.steam_id64();
672
673        let body = self.get_path(format!("/profiles/{}/friends/pending", steam_id)).send().await?.text().await?;
674
675        let sent_invites = parse_pending_friend_list(&body, "#search_results_sentinvites > div");
676        let received_invites = parse_pending_friend_list(&body, "#search_results > div");
677
678        Ok(crate::types::PendingFriendList { sent_invites, received_invites })
679    }
680
681    /// Removes multiple friends at once.
682    ///
683    /// # Arguments
684    ///
685    /// * `steam_ids` - A slice of [`SteamID`]s to remove from the friend list.
686    #[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/friends/action", kind = Write)]
687    pub async fn remove_friends(&self, steam_ids: &[SteamID]) -> Result<(), SteamUserError> {
688        if steam_ids.is_empty() {
689            return Ok(());
690        }
691
692        let my_steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?.steam_id64().to_string();
693
694        let mut params = vec![("steamid", my_steam_id), ("ajax", "1".to_string()), ("action", "remove".to_string())];
695
696        for steam_id in steam_ids {
697            params.push(("steamids[]", steam_id.steam_id64().to_string()));
698        }
699
700        let response: serde_json::Value = self.post_path(format!("/profiles/{}/friends/action", params[0].1)).form(&params).send().await?.json().await?;
701
702        Self::check_json_success(&response, "Failed to remove friends")?;
703        Ok(())
704    }
705
706    /// Unfollows multiple users at once.
707    ///
708    /// # Arguments
709    ///
710    /// * `steam_ids` - A slice of [`SteamID`]s to unfollow.
711    #[steam_endpoint(POST, host = Community, path = "/profiles/{steam_id}/friends/action", kind = Write)]
712    pub async fn unfollow_users(&self, steam_ids: &[SteamID]) -> Result<(), SteamUserError> {
713        if steam_ids.is_empty() {
714            return Ok(());
715        }
716
717        let my_steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?.steam_id64().to_string();
718
719        let path = format!("/profiles/{}/friends/action", my_steam_id);
720
721        let mut params = vec![("steamid", my_steam_id.clone()), ("ajax", "1".to_string()), ("action", "unfollow".to_string())];
722
723        for steam_id in steam_ids {
724            params.push(("steamids[]", steam_id.steam_id64().to_string()));
725        }
726
727        let response: serde_json::Value = self.post_path(&path).form(&params).send().await?.json().await?;
728
729        Self::check_json_success(&response, "Failed to unfollow users")?;
730        Ok(())
731    }
732
733    /// Unfollows all users the current user is following.
734    // composite — no #[steam_endpoint]
735    #[tracing::instrument(skip(self))]
736    pub async fn unfollow_all_following(&self) -> Result<(), SteamUserError> {
737        let page = self.get_following_list().await?;
738        if page.friends.is_empty() {
739            return Ok(());
740        }
741
742        let steam_ids: Vec<SteamID> = page.friends.iter().map(|f| f.steam_id).collect();
743        self.unfollow_users(&steam_ids).await
744    }
745
746    /// Cancels a pending outgoing friend request.
747    ///
748    /// # Arguments
749    /// This is equivalent to removing a friend - the same API endpoint is used.
750    ///
751    ///
752    /// * `steam_id` - The [`SteamID`] of the user whose friend request to
753    ///   cancel.
754    // delegates to `remove_friend` — no #[steam_endpoint]
755    #[tracing::instrument(skip(self), fields(target_steam_id = steam_id.steam_id64()))]
756    pub async fn cancel_friend_request(&self, steam_id: SteamID) -> Result<(), SteamUserError> {
757        self.remove_friend(steam_id).await
758    }
759
760    /// Retrieves mutual friends between the current user and another user.
761    ///
762    /// # Arguments
763    ///
764    /// * `steam_id` - The [`SteamID`] of the user to find mutual friends with.
765    ///
766    /// # Returns
767    ///
768    /// Returns a `Vec<FriendDetails>` containing mutual friends.
769    #[steam_endpoint(GET, host = Community, path = "/actions/PlayerList/", kind = Read)]
770    pub async fn get_friends_in_common(&self, steam_id: SteamID) -> Result<Vec<crate::types::FriendDetails>, SteamUserError> {
771        let account_id = steam_id.account_id.to_string();
772        let body = self.get_path("/actions/PlayerList/").query(&[("type", "friendsincommon"), ("target", &account_id)]).send().await?.text().await?;
773        Ok(parse_friend_list(&body).friends)
774    }
775
776    /// Retrieves friends who are members of a specific group.
777    ///
778    /// # Arguments
779    ///
780    /// * `group_id` - The [`SteamID`] of the group.
781    ///
782    /// # Returns
783    ///
784    /// Returns a `Vec<FriendDetails>` containing friends who are in the group.
785    #[steam_endpoint(GET, host = Community, path = "/actions/PlayerList/", kind = Read)]
786    pub async fn get_friends_in_group(&self, group_id: SteamID) -> Result<Vec<crate::types::FriendDetails>, SteamUserError> {
787        let account_id = group_id.account_id.to_string();
788        let body = self.get_path("/actions/PlayerList/").query(&[("type", "friendsingroup"), ("target", &account_id)]).send().await?.text().await?;
789        Ok(parse_friend_list(&body).friends)
790    }
791
792    /// Retrieves friends' gameplay stats for a specific app.
793    ///
794    /// # Arguments
795    ///
796    /// * `app_id` - The App ID of the game to query.
797    ///
798    /// # Returns
799    ///
800    /// Returns a `GameplayInfoResponse` containing gameplay info.
801    #[steam_endpoint(GET, host = Api, path = "/IPlayerService/GetFriendsGameplayInfo/v1", kind = Read)]
802    pub async fn get_friends_gameplay_info(&self, app_id: u32) -> Result<crate::types::gameplay::GameplayInfoResponse, SteamUserError> {
803        use prost::Message;
804        use steam_protos::messages::player::{CPlayerGetFriendsGameplayInfoRequest, CPlayerGetFriendsGameplayInfoResponse};
805
806        let request = CPlayerGetFriendsGameplayInfoRequest { appid: Some(app_id) };
807
808        let mut body = Vec::new();
809        request.encode(&mut body)?;
810
811        let params = [("origin", "https://store.steampowered.com"), ("input_protobuf_encoded", &base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body))];
812
813        let response = self.get_path("/IPlayerService/GetFriendsGameplayInfo/v1").query(&params).send().await?;
814
815        if !response.status().is_success() {
816            let status = response.status().as_u16();
817            let url = response.url().to_string();
818            return Err(SteamUserError::HttpStatus { status, url });
819        }
820
821        let bytes = response.bytes().await?;
822        let response_proto = CPlayerGetFriendsGameplayInfoResponse::decode(bytes)?;
823
824        let convert_list = |list: Vec<steam_protos::messages::player::c_player_get_friends_gameplay_info_response::FriendsGameplayInfo>| {
825            list.into_iter()
826                .map(|item| crate::types::gameplay::FriendsGameplayInfo {
827                    steam_id: SteamID::from(item.steamid.unwrap_or(0)),
828                    minutes_played: item.minutes_played.unwrap_or(0),
829                    minutes_played_forever: item.minutes_played_forever.unwrap_or(0),
830                })
831                .collect()
832        };
833
834        Ok(crate::types::gameplay::GameplayInfoResponse {
835            your_info: response_proto.your_info.map(|info| crate::types::gameplay::OwnGameplayInfo {
836                steam_id: SteamID::from(info.steamid.unwrap_or(0)),
837                minutes_played: info.minutes_played.unwrap_or(0),
838                minutes_played_forever: info.minutes_played_forever.unwrap_or(0),
839                in_wishlist: info.in_wishlist.unwrap_or(false),
840                owned: info.owned.unwrap_or(false),
841            }),
842            in_game: convert_list(response_proto.in_game),
843            played_recently: convert_list(response_proto.played_recently),
844            played_ever: convert_list(response_proto.played_ever),
845            owns: convert_list(response_proto.owns),
846            in_wishlist: convert_list(response_proto.in_wishlist),
847        })
848    }
849
850    /// Retrieves the date when a friendship with a user started.
851    ///
852    /// # Arguments
853    ///
854    /// * `steam_id` - The [`SteamID`] of the friend.
855    ///
856    /// # Returns
857    ///
858    /// Returns `Some(String)` with the friendship date (e.g., "13 June, 2023")
859    /// or `None` if not found.
860    #[steam_endpoint(GET, host = Community, path = "/tradeoffer/new/", kind = Read)]
861    pub async fn get_friend_since(&self, steam_id: SteamID) -> Result<Option<String>, SteamUserError> {
862        let account_id = steam_id.account_id.to_string();
863        let body = self.get_path("/tradeoffer/new/").query(&[("partner", &account_id)]).send().await?.text().await?;
864        Ok(parse_friend_since(&body))
865    }
866
867    /// Accepts a friend request via a quick invite link.
868    ///
869    /// Quick invite links are in the format `https://s.team/p/XXXX-XXXX/TOKEN`
870    /// or `https://steamcommunity.com/user/XXXX/TOKEN`.
871    ///
872    /// # Arguments
873    ///
874    /// * `invite_link` - The full quick invite URL.
875    ///
876    /// # Example
877    ///
878    /// ```rust,no_run
879    /// # use steam_user::SteamUser;
880    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
881    /// user.accept_quick_invite_link("https://s.team/p/abcd-efgh/mytoken")
882    ///     .await?;
883    /// # Ok(())
884    /// # }
885    /// ```
886    #[steam_endpoint(GET, host = Community, path = "/invites/ajaxredeem", kind = Write)]
887    pub async fn accept_quick_invite_link(&self, invite_link: &str) -> Result<(), SteamUserError> {
888        // Extract invite token from the URL (last path segment)
889        let invite_token = invite_link.trim_end_matches('/').rsplit('/').next().ok_or_else(|| SteamUserError::MalformedResponse("Invalid invite link format".into()))?;
890
891        // Fetch the invite page to extract the user's SteamID.
892        //
893        // `invite_link` is a caller-supplied URL on either `s.team`
894        // (URL shortener, 302s to community) or `steamcommunity.com`.
895        // Parse the URL, route to the matching `Host`, then let the
896        // Steam client follow any redirect with cookies attached.
897        let parsed = url::Url::parse(invite_link).map_err(|e| SteamUserError::InvalidInput(format!("invalid invite_link: {e}")))?;
898        let host = match parsed.host_str() {
899            Some("s.team") => Host::ShortLink,
900            Some("steamcommunity.com") => Host::Community,
901            Some(other) => return Err(SteamUserError::InvalidInput(format!("invite_link host must be s.team or steamcommunity.com, got {other}"))),
902            None => return Err(SteamUserError::InvalidInput("invite_link has no host".into())),
903        };
904        let path_and_query: &str = &parsed[url::Position::BeforePath..];
905        let body = self.get_path_on(host, path_and_query).send().await?.text().await?;
906
907        // Parse g_rgProfileData from the page
908        let steamid_user = parse_profile_data_steamid(&body).ok_or_else(|| SteamUserError::MalformedResponse("Could not find steamid in invite page".into()))?;
909
910        // Redeem the invite
911        let session_id = self.session.session_id.as_deref().ok_or(SteamUserError::NotLoggedIn)?;
912
913        let raw: RedeemResponseRaw = self
914            .get_path("/invites/ajaxredeem")
915            .query(&[("sessionid", session_id), ("steamid_user", steamid_user.as_str()), ("invite_token", invite_token)])
916            .send()
917            .await?
918            .json()
919            .await?;
920
921        let success = raw.success.unwrap_or(0);
922
923        if success != 1 {
924            // success: 8 = link expired, success: 91 = other error
925            return Err(SteamUserError::SteamError(format!("Failed to accept invite (code: {})", success)));
926        }
927
928        Ok(())
929    }
930
931    /// Accepts a friend request via quick invite data (steamid and token).
932    ///
933    /// This is the lower-level method that `accept_quick_invite_link` uses
934    /// internally.
935    ///
936    /// # Arguments
937    ///
938    /// * `steamid_user` - The SteamID of the user who created the invite.
939    /// * `invite_token` - The unique invite token.
940    ///
941    /// # Returns
942    ///
943    /// Returns `Ok(())` on success, or an error with the failure code.
944    #[steam_endpoint(GET, host = Community, path = "/invites/ajaxredeem", kind = Write)]
945    pub async fn accept_quick_invite_data(&self, steamid_user: &str, invite_token: &str) -> Result<(), SteamUserError> {
946        let session_id = self.session.session_id.as_deref().ok_or(SteamUserError::NotLoggedIn)?;
947
948        let raw: RedeemResponseRaw = self
949            .get_path("/invites/ajaxredeem")
950            .query(&[("sessionid", session_id), ("steamid_user", steamid_user), ("invite_token", invite_token)])
951            .send()
952            .await?
953            .json()
954            .await?;
955
956        let success = raw.success.unwrap_or(0);
957
958        if success != 1 {
959            // success: 1 = success
960            // success: 8 = link expired
961            // success: 91 = other error
962            return Err(SteamUserError::SteamError(format!("Failed to accept invite (code: {})", success)));
963        }
964
965        Ok(())
966    }
967}
968
969/// Parses pending friend list from HTML.
970fn parse_pending_friend_list(html: &str, selector: &str) -> Vec<crate::types::PendingFriend> {
971    let document = scraper::Html::parse_document(html);
972    let row_selector = match scraper::Selector::parse(selector) {
973        Ok(s) => s,
974        Err(e) => {
975            tracing::warn!(selector, error = ?e, "parse_pending_friend_list: invalid CSS selector; returning empty");
976            return Vec::new();
977        }
978    };
979
980    let mut results = Vec::new();
981
982    for element in document.select(&row_selector) {
983        let steam_id_str = element.value().attr("data-steamid").unwrap_or("");
984        let account_id_str = element.value().attr("data-accountid").unwrap_or("0");
985
986        let steam_id = match steam_id_str.parse::<u64>() {
987            Ok(id) => SteamID::from(id),
988            Err(e) => {
989                tracing::warn!(data_steamid = steam_id_str, error = %e, "parse_pending_friend_list: malformed data-steamid; skipping row");
990                continue;
991            }
992        };
993        let account_id = account_id_str.parse::<u32>().unwrap_or(0);
994
995        if account_id == 0 {
996            continue;
997        }
998
999        // Name
1000        let name = element.select(sel_invite_block_name()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
1001
1002        let link = element.select(sel_invite_block_name()).next().and_then(|el| el.value().attr("href")).unwrap_or("").to_string();
1003
1004        // Avatar
1005        let avatar_selector_str = format!(".playerAvatar a > img[data-miniprofile=\"{}\"]", account_id);
1006        let avatar = if let Ok(avatar_selector) = scraper::Selector::parse(&avatar_selector_str) { element.select(&avatar_selector).next().and_then(|el| el.value().attr("src")).unwrap_or("").to_string() } else { String::new() };
1007
1008        // Level
1009        let level = element.select(sel_friend_player_level()).next().map(|el| el.text().collect::<String>().trim().parse::<u32>().unwrap_or(0)).unwrap_or(0);
1010
1011        results.push(crate::types::PendingFriend { name, link, avatar, steam_id, account_id, level });
1012    }
1013
1014    results
1015}
1016
1017/// Parses the "friends since" date from trade offer page HTML.
1018fn parse_friend_since(html: &str) -> Option<String> {
1019    let document = scraper::Html::parse_document(html);
1020    let container_selector = scraper::Selector::parse(".trade_partner_header.responsive_trade_offersection").ok()?;
1021    let info_block_selector = scraper::Selector::parse(".trade_partner_info_block").ok()?;
1022    let info_text_selector = scraper::Selector::parse(".trade_partner_info_text").ok()?;
1023
1024    let container = document.select(&container_selector).next()?;
1025
1026    for info_block in container.select(&info_block_selector) {
1027        let full_text: String = info_block.text().collect();
1028
1029        if full_text.contains("You've been friends since") || full_text.contains("You've been friends for") {
1030            if let Some(info_text) = info_block.select(&info_text_selector).next() {
1031                let friend_since = info_text.text().collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ");
1032
1033                if !friend_since.is_empty() {
1034                    return Some(friend_since);
1035                }
1036            }
1037        }
1038    }
1039
1040    None
1041}
1042
1043/// Extracts and parses a JavaScript variable assignment from HTML.
1044///
1045/// Looks for `var_name = <json>;` and returns the parsed JSON value.
1046fn parse_js_json_var(html: &str, var_name: &str) -> Option<serde_json::Value> {
1047    let marker = format!("{} = ", var_name);
1048    let start = html.find(&marker)?;
1049    let rest = &html[start + marker.len()..];
1050    let end = rest.find(";\n").or_else(|| rest.find(";\r")).or_else(|| rest.find(";\t")).or_else(|| rest.find(';'))?;
1051    serde_json::from_str(rest[..end].trim()).ok()
1052}
1053
1054/// Extracts steamid from g_rgProfileData in HTML.
1055fn parse_profile_data_steamid(html: &str) -> Option<String> {
1056    let val = parse_js_json_var(html, "g_rgProfileData")?;
1057    val.get("steamid").and_then(|v| v.as_str()).map(|s| s.to_string())
1058}
1059
1060/// Parses page-level metadata from the friend/following HTML page.
1061///
1062/// Extracts `g_rgProfileData`, `g_rgCounts`, `g_cFriendsLimit` JS variables
1063/// and the wallet balance element from the already-parsed document.
1064fn parse_friend_page_info(html: &str, document: &scraper::Html) -> Option<crate::types::FriendPageInfo> {
1065    use crate::types::FriendPageInfo;
1066
1067    let mut info = FriendPageInfo::default();
1068    let mut found_anything = false;
1069
1070    if let Some(val) = parse_js_json_var(html, "g_rgProfileData") {
1071        if let Some(name) = val.get("personaname").and_then(|v| v.as_str()) {
1072            info.persona_name = name.to_string();
1073        }
1074        if let Some(url) = val.get("url").and_then(|v| v.as_str()) {
1075            info.profile_url = url.to_string();
1076        }
1077        if let Some(sid) = val.get("steamid").and_then(|v| v.as_str()) {
1078            if let Ok(id64) = sid.parse::<u64>() {
1079                info.steam_id = SteamID::from(id64);
1080            }
1081        }
1082        found_anything = true;
1083    }
1084
1085    if let Some(val) = parse_js_json_var(html, "g_rgCounts") {
1086        // The deserializer accepts ints, numeric strings, or comma-formatted
1087        // strings. Missing or unparseable fields collapse to 0 via `Default`.
1088        if let Ok(counts) = serde_json::from_value::<FriendsCountRaw>(val) {
1089            info.friends_count = counts.friends;
1090            info.friends_pending_count = counts.friends_pending;
1091            info.blocked_count = counts.friends_blocked;
1092            info.following_count = counts.following;
1093            info.groups_count = counts.groups;
1094            info.groups_pending_count = counts.groups_pending;
1095            found_anything = true;
1096        }
1097    }
1098
1099    // g_cFriendsLimit is a plain integer, not JSON
1100    if let Some(start) = html.find("g_cFriendsLimit = ") {
1101        let rest = &html[start + 18..];
1102        if let Some(end) = rest.find(';') {
1103            if let Ok(limit) = rest[..end].trim().parse::<u32>() {
1104                info.friends_limit = limit;
1105                found_anything = true;
1106            }
1107        }
1108    }
1109
1110    let wallet = crate::services::account::parse_wallet_balance(document);
1111    if wallet.main_balance.is_some() {
1112        info.wallet_balance = Some(wallet);
1113        found_anything = true;
1114    }
1115
1116    if found_anything {
1117        Some(info)
1118    } else {
1119        None
1120    }
1121}
1122
1123fn parse_friend_list(html: &str) -> crate::types::FriendListPage {
1124    let document = scraper::Html::parse_document(html);
1125    let mut results = Vec::new();
1126
1127    for element in document.select(sel_persona_miniprofile()) {
1128        let miniprofile = element.value().attr("data-miniprofile").and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
1129        if miniprofile == 0 {
1130            continue;
1131        }
1132
1133        let steam_id = SteamID::from_individual_account_id(miniprofile);
1134
1135        // Profile URL
1136        let profile_url = element.select(sel_selectable_overlay()).next().and_then(|el| el.value().attr("href")).unwrap_or("").to_string();
1137        if profile_url.is_empty() {
1138            continue;
1139        }
1140
1141        // Avatar
1142        let avatar_src = element.select(sel_player_avatar_img()).next().and_then(|el| el.value().attr("src")).unwrap_or("");
1143        let avatar_hash = get_avatar_hash_from_url(avatar_src).unwrap_or_default();
1144        let avatar = get_avatar_url_from_hash(&avatar_hash, AvatarSize::Full).unwrap_or_else(|| avatar_src.to_string());
1145
1146        // Username, game, last online, nickname
1147        let mut username = String::new();
1148        let mut game = String::new();
1149        let mut last_online = String::new();
1150        let mut is_nickname = false;
1151
1152        if let Some(content) = element.select(sel_friend_block_content()).next() {
1153            is_nickname = content.select(sel_player_nickname_hint()).next().is_some();
1154
1155            if let Some(game_el) = content.select(sel_friend_game_link()).next() {
1156                game = game_el.text().collect::<String>().trim().replace("In-Game", "").trim().to_string();
1157            }
1158
1159            if let Some(last_el) = content.select(sel_friend_last_online()).next() {
1160                last_online = last_el.text().collect::<String>().trim().to_string();
1161            }
1162
1163            // Take only the first text node to avoid getting game names/last online text
1164            if let Some(first_text) = content.text().next() {
1165                username = first_text.trim().to_string();
1166            }
1167
1168            if username.is_empty() {
1169                let full_text = content.text().collect::<String>();
1170                username = full_text.trim().split('\n').next().unwrap_or("").trim().to_string();
1171            }
1172        }
1173
1174        if username == "[deleted]" {
1175            continue;
1176        }
1177
1178        let online_status = if element.value().classes().any(|c| c == "in-game") {
1179            "ingame"
1180        } else if element.value().classes().any(|c| c == "online") {
1181            "online"
1182        } else {
1183            "offline"
1184        }
1185        .to_string();
1186
1187        let custom_url = extract_custom_url(&profile_url);
1188
1189        results.push(crate::types::FriendDetails {
1190            username,
1191            steam_id,
1192            game,
1193            online_status,
1194            last_online,
1195            miniprofile: miniprofile as u64,
1196            is_nickname,
1197            avatar,
1198            avatar_hash,
1199            profile_url,
1200            custom_url,
1201        });
1202    }
1203
1204    if results.is_empty() && !html.trim().is_empty() && document.select(sel_persona_miniprofile()).next().is_none() {
1205        dump_html("friends_list_empty", html);
1206    }
1207
1208    let page_info = parse_friend_page_info(html, &document);
1209
1210    crate::types::FriendListPage { friends: results, page_info }
1211}
1212
1213fn parse_search_results(html: &str, current_page: u32) -> (Vec<crate::types::CommunitySearchPlayer>, Option<u32>, Option<u32>) {
1214    let document = scraper::Html::parse_document(html);
1215    let mut players = Vec::new();
1216
1217    for element in document.select(sel_search_row()) {
1218        let miniprofile = element.select(sel_medium_holder()).next().and_then(|el| el.value().attr("data-miniprofile")).and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
1219        if miniprofile == 0 {
1220            continue;
1221        }
1222
1223        let steam_id = SteamID::from_individual_account_id(miniprofile);
1224
1225        let avatar_src = element.select(sel_avatar_medium_img()).next().and_then(|el| el.value().attr("src")).unwrap_or("");
1226        let avatar_hash = get_avatar_hash_from_url(avatar_src).unwrap_or_default();
1227
1228        let search_persona_el = element.select(sel_search_persona_name()).next();
1229        let name = search_persona_el.map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
1230        let profile_url = search_persona_el.and_then(|el| el.value().attr("href")).unwrap_or("").to_string();
1231        let custom_url = extract_custom_url(&profile_url);
1232
1233        players.push(crate::types::CommunitySearchPlayer { miniprofile: miniprofile as u64, steam_id, avatar_hash, name, profile_url, custom_url });
1234    }
1235
1236    let mut prev_page = None;
1237    let mut next_page = None;
1238
1239    for paging_el in document.select(sel_community_search_paging()) {
1240        let onclick = paging_el.value().attr("onclick").unwrap_or("");
1241        if onclick.contains("CommunitySearch.PrevPage()") {
1242            prev_page = Some(current_page.saturating_sub(1));
1243        } else if onclick.contains("CommunitySearch.NextPage()") {
1244            next_page = Some(current_page + 1);
1245        }
1246    }
1247
1248    if players.is_empty() && !html.trim().is_empty() && document.select(sel_search_row()).next().is_none() {
1249        dump_html("search_friends_empty", html);
1250    }
1251
1252    (players, prev_page, next_page)
1253}