1use 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
19fn 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
45fn 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
62fn 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
78fn 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#[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#[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#[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#[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#[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#[derive(Deserialize)]
166struct RedeemResponseRaw {
167 #[serde(default, deserialize_with = "de_opt_i32_int_or_string")]
168 success: Option<i32>,
169}
170
171#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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(¶ms).send().await?.json().await?;
701
702 Self::check_json_success(&response, "Failed to remove friends")?;
703 Ok(())
704 }
705
706 #[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(¶ms).send().await?.json().await?;
728
729 Self::check_json_success(&response, "Failed to unfollow users")?;
730 Ok(())
731 }
732
733 #[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 #[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 #[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 #[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 #[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(¶ms).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 #[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 #[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 let invite_token = invite_link.trim_end_matches('/').rsplit('/').next().ok_or_else(|| SteamUserError::MalformedResponse("Invalid invite link format".into()))?;
890
891 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 let steamid_user = parse_profile_data_steamid(&body).ok_or_else(|| SteamUserError::MalformedResponse("Could not find steamid in invite page".into()))?;
909
910 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 return Err(SteamUserError::SteamError(format!("Failed to accept invite (code: {})", success)));
926 }
927
928 Ok(())
929 }
930
931 #[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 return Err(SteamUserError::SteamError(format!("Failed to accept invite (code: {})", success)));
963 }
964
965 Ok(())
966 }
967}
968
969fn 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 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 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 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
1017fn 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
1043fn 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
1054fn 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
1060fn 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 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 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 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 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 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 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}