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 access_token = self
818 .session
819 .mobile_access_token
820 .as_deref()
821 .or(self.session.access_token.as_deref())
822 .ok_or(SteamUserError::MissingCredential { field: "access_token" })?;
823
824 let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &body);
825 let params = [("access_token", access_token), ("origin", "https://store.steampowered.com"), ("input_protobuf_encoded", &encoded)];
826
827 let response = self.get_path("/IPlayerService/GetFriendsGameplayInfo/v1").query(¶ms).send().await?;
828
829 if !response.status().is_success() {
830 let status = response.status().as_u16();
831 let url = response.url().to_string();
832 return Err(SteamUserError::HttpStatus { status, url });
833 }
834
835 let bytes = response.bytes().await?;
836 let response_proto = CPlayerGetFriendsGameplayInfoResponse::decode(bytes)?;
837
838 let convert_list = |list: Vec<steam_protos::messages::player::c_player_get_friends_gameplay_info_response::FriendsGameplayInfo>| {
839 list.into_iter()
840 .map(|item| crate::types::gameplay::FriendsGameplayInfo {
841 steam_id: SteamID::from(item.steamid.unwrap_or(0)),
842 minutes_played: item.minutes_played.unwrap_or(0),
843 minutes_played_forever: item.minutes_played_forever.unwrap_or(0),
844 })
845 .collect()
846 };
847
848 Ok(crate::types::gameplay::GameplayInfoResponse {
849 your_info: response_proto.your_info.map(|info| crate::types::gameplay::OwnGameplayInfo {
850 steam_id: SteamID::from(info.steamid.unwrap_or(0)),
851 minutes_played: info.minutes_played.unwrap_or(0),
852 minutes_played_forever: info.minutes_played_forever.unwrap_or(0),
853 in_wishlist: info.in_wishlist.unwrap_or(false),
854 owned: info.owned.unwrap_or(false),
855 }),
856 in_game: convert_list(response_proto.in_game),
857 played_recently: convert_list(response_proto.played_recently),
858 played_ever: convert_list(response_proto.played_ever),
859 owns: convert_list(response_proto.owns),
860 in_wishlist: convert_list(response_proto.in_wishlist),
861 })
862 }
863
864 #[steam_endpoint(GET, host = Community, path = "/tradeoffer/new/", kind = Read)]
875 pub async fn get_friend_since(&self, steam_id: SteamID) -> Result<Option<String>, SteamUserError> {
876 let account_id = steam_id.account_id.to_string();
877 let body = self.get_path("/tradeoffer/new/").query(&[("partner", &account_id)]).send().await?.text().await?;
878 Ok(parse_friend_since(&body))
879 }
880
881 #[steam_endpoint(GET, host = Community, path = "/invites/ajaxredeem", kind = Write)]
901 pub async fn accept_quick_invite_link(&self, invite_link: &str) -> Result<(), SteamUserError> {
902 let invite_token = invite_link.trim_end_matches('/').rsplit('/').next().ok_or_else(|| SteamUserError::MalformedResponse("Invalid invite link format".into()))?;
904
905 let parsed = url::Url::parse(invite_link).map_err(|e| SteamUserError::InvalidInput(format!("invalid invite_link: {e}")))?;
912 let host = match parsed.host_str() {
913 Some("s.team") => Host::ShortLink,
914 Some("steamcommunity.com") => Host::Community,
915 Some(other) => return Err(SteamUserError::InvalidInput(format!("invite_link host must be s.team or steamcommunity.com, got {other}"))),
916 None => return Err(SteamUserError::InvalidInput("invite_link has no host".into())),
917 };
918 let path_and_query: &str = &parsed[url::Position::BeforePath..];
919 let body = self.get_path_on(host, path_and_query).send().await?.text().await?;
920
921 let steamid_user = parse_profile_data_steamid(&body).ok_or_else(|| SteamUserError::MalformedResponse("Could not find steamid in invite page".into()))?;
923
924 let session_id = self.session.session_id.as_deref().ok_or(SteamUserError::NotLoggedIn)?;
926
927 let raw: RedeemResponseRaw = self
928 .get_path("/invites/ajaxredeem")
929 .query(&[("sessionid", session_id), ("steamid_user", steamid_user.as_str()), ("invite_token", invite_token)])
930 .send()
931 .await?
932 .json()
933 .await?;
934
935 let success = raw.success.unwrap_or(0);
936
937 if success != 1 {
938 return Err(SteamUserError::SteamError(format!("Failed to accept invite (code: {})", success)));
940 }
941
942 Ok(())
943 }
944
945 #[steam_endpoint(GET, host = Community, path = "/invites/ajaxredeem", kind = Write)]
959 pub async fn accept_quick_invite_data(&self, steamid_user: &str, invite_token: &str) -> Result<(), SteamUserError> {
960 let session_id = self.session.session_id.as_deref().ok_or(SteamUserError::NotLoggedIn)?;
961
962 let raw: RedeemResponseRaw = self
963 .get_path("/invites/ajaxredeem")
964 .query(&[("sessionid", session_id), ("steamid_user", steamid_user), ("invite_token", invite_token)])
965 .send()
966 .await?
967 .json()
968 .await?;
969
970 let success = raw.success.unwrap_or(0);
971
972 if success != 1 {
973 return Err(SteamUserError::SteamError(format!("Failed to accept invite (code: {})", success)));
977 }
978
979 Ok(())
980 }
981}
982
983fn parse_pending_friend_list(html: &str, selector: &str) -> Vec<crate::types::PendingFriend> {
985 let document = scraper::Html::parse_document(html);
986 let row_selector = match scraper::Selector::parse(selector) {
987 Ok(s) => s,
988 Err(e) => {
989 tracing::warn!(selector, error = ?e, "parse_pending_friend_list: invalid CSS selector; returning empty");
990 return Vec::new();
991 }
992 };
993
994 let mut results = Vec::new();
995
996 for element in document.select(&row_selector) {
997 let steam_id_str = element.value().attr("data-steamid").unwrap_or("");
998 let account_id_str = element.value().attr("data-accountid").unwrap_or("0");
999
1000 let steam_id = match steam_id_str.parse::<u64>() {
1001 Ok(id) => SteamID::from(id),
1002 Err(e) => {
1003 tracing::warn!(data_steamid = steam_id_str, error = %e, "parse_pending_friend_list: malformed data-steamid; skipping row");
1004 continue;
1005 }
1006 };
1007 let account_id = account_id_str.parse::<u32>().unwrap_or(0);
1008
1009 if account_id == 0 {
1010 continue;
1011 }
1012
1013 let name = element.select(sel_invite_block_name()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
1015
1016 let link = element.select(sel_invite_block_name()).next().and_then(|el| el.value().attr("href")).unwrap_or("").to_string();
1017
1018 let avatar_selector_str = format!(".playerAvatar a > img[data-miniprofile=\"{}\"]", account_id);
1020 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() };
1021
1022 let level = element.select(sel_friend_player_level()).next().map(|el| el.text().collect::<String>().trim().parse::<u32>().unwrap_or(0)).unwrap_or(0);
1024
1025 results.push(crate::types::PendingFriend { name, link, avatar, steam_id, account_id, level });
1026 }
1027
1028 results
1029}
1030
1031fn parse_friend_since(html: &str) -> Option<String> {
1033 let document = scraper::Html::parse_document(html);
1034 let container_selector = scraper::Selector::parse(".trade_partner_header.responsive_trade_offersection").ok()?;
1035 let info_block_selector = scraper::Selector::parse(".trade_partner_info_block").ok()?;
1036 let info_text_selector = scraper::Selector::parse(".trade_partner_info_text").ok()?;
1037
1038 let container = document.select(&container_selector).next()?;
1039
1040 for info_block in container.select(&info_block_selector) {
1041 let full_text: String = info_block.text().collect();
1042
1043 if full_text.contains("You've been friends since") || full_text.contains("You've been friends for") {
1044 if let Some(info_text) = info_block.select(&info_text_selector).next() {
1045 let friend_since = info_text.text().collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ");
1046
1047 if !friend_since.is_empty() {
1048 return Some(friend_since);
1049 }
1050 }
1051 }
1052 }
1053
1054 None
1055}
1056
1057fn parse_js_json_var(html: &str, var_name: &str) -> Option<serde_json::Value> {
1061 let marker = format!("{} = ", var_name);
1062 let start = html.find(&marker)?;
1063 let rest = &html[start + marker.len()..];
1064 let end = rest.find(";\n").or_else(|| rest.find(";\r")).or_else(|| rest.find(";\t")).or_else(|| rest.find(';'))?;
1065 serde_json::from_str(rest[..end].trim()).ok()
1066}
1067
1068fn parse_profile_data_steamid(html: &str) -> Option<String> {
1070 let val = parse_js_json_var(html, "g_rgProfileData")?;
1071 val.get("steamid").and_then(|v| v.as_str()).map(|s| s.to_string())
1072}
1073
1074fn parse_friend_page_info(html: &str, document: &scraper::Html) -> Option<crate::types::FriendPageInfo> {
1079 use crate::types::FriendPageInfo;
1080
1081 let mut info = FriendPageInfo::default();
1082 let mut found_anything = false;
1083
1084 if let Some(val) = parse_js_json_var(html, "g_rgProfileData") {
1085 if let Some(name) = val.get("personaname").and_then(|v| v.as_str()) {
1086 info.persona_name = name.to_string();
1087 }
1088 if let Some(url) = val.get("url").and_then(|v| v.as_str()) {
1089 info.profile_url = url.to_string();
1090 }
1091 if let Some(sid) = val.get("steamid").and_then(|v| v.as_str()) {
1092 if let Ok(id64) = sid.parse::<u64>() {
1093 info.steam_id = SteamID::from(id64);
1094 }
1095 }
1096 found_anything = true;
1097 }
1098
1099 if let Some(val) = parse_js_json_var(html, "g_rgCounts") {
1100 if let Ok(counts) = serde_json::from_value::<FriendsCountRaw>(val) {
1103 info.friends_count = counts.friends;
1104 info.friends_pending_count = counts.friends_pending;
1105 info.blocked_count = counts.friends_blocked;
1106 info.following_count = counts.following;
1107 info.groups_count = counts.groups;
1108 info.groups_pending_count = counts.groups_pending;
1109 found_anything = true;
1110 }
1111 }
1112
1113 if let Some(start) = html.find("g_cFriendsLimit = ") {
1115 let rest = &html[start + 18..];
1116 if let Some(end) = rest.find(';') {
1117 if let Ok(limit) = rest[..end].trim().parse::<u32>() {
1118 info.friends_limit = limit;
1119 found_anything = true;
1120 }
1121 }
1122 }
1123
1124 let wallet = crate::services::account::parse_wallet_balance(document);
1125 if wallet.main_balance.is_some() {
1126 info.wallet_balance = Some(wallet);
1127 found_anything = true;
1128 }
1129
1130 if found_anything {
1131 Some(info)
1132 } else {
1133 None
1134 }
1135}
1136
1137fn parse_friend_list(html: &str) -> crate::types::FriendListPage {
1138 let document = scraper::Html::parse_document(html);
1139 let mut results = Vec::new();
1140
1141 for element in document.select(sel_persona_miniprofile()) {
1142 let miniprofile = element.value().attr("data-miniprofile").and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
1143 if miniprofile == 0 {
1144 continue;
1145 }
1146
1147 let steam_id = SteamID::from_individual_account_id(miniprofile);
1148
1149 let profile_url = element.select(sel_selectable_overlay()).next().and_then(|el| el.value().attr("href")).unwrap_or("").to_string();
1151 if profile_url.is_empty() {
1152 continue;
1153 }
1154
1155 let avatar_src = element.select(sel_player_avatar_img()).next().and_then(|el| el.value().attr("src")).unwrap_or("");
1157 let avatar_hash = get_avatar_hash_from_url(avatar_src).unwrap_or_default();
1158 let avatar = get_avatar_url_from_hash(&avatar_hash, AvatarSize::Full).unwrap_or_else(|| avatar_src.to_string());
1159
1160 let mut username = String::new();
1162 let mut game = String::new();
1163 let mut last_online = String::new();
1164 let mut is_nickname = false;
1165
1166 if let Some(content) = element.select(sel_friend_block_content()).next() {
1167 is_nickname = content.select(sel_player_nickname_hint()).next().is_some();
1168
1169 if let Some(game_el) = content.select(sel_friend_game_link()).next() {
1170 game = game_el.text().collect::<String>().trim().replace("In-Game", "").trim().to_string();
1171 }
1172
1173 if let Some(last_el) = content.select(sel_friend_last_online()).next() {
1174 last_online = last_el.text().collect::<String>().trim().to_string();
1175 }
1176
1177 if let Some(first_text) = content.text().next() {
1179 username = first_text.trim().to_string();
1180 }
1181
1182 if username.is_empty() {
1183 let full_text = content.text().collect::<String>();
1184 username = full_text.trim().split('\n').next().unwrap_or("").trim().to_string();
1185 }
1186 }
1187
1188 if username == "[deleted]" {
1189 continue;
1190 }
1191
1192 let online_status = if element.value().classes().any(|c| c == "in-game") {
1193 "ingame"
1194 } else if element.value().classes().any(|c| c == "online") {
1195 "online"
1196 } else {
1197 "offline"
1198 }
1199 .to_string();
1200
1201 let custom_url = extract_custom_url(&profile_url);
1202
1203 results.push(crate::types::FriendDetails {
1204 username,
1205 steam_id,
1206 game,
1207 online_status,
1208 last_online,
1209 miniprofile: miniprofile as u64,
1210 is_nickname,
1211 avatar,
1212 avatar_hash,
1213 profile_url,
1214 custom_url,
1215 });
1216 }
1217
1218 if results.is_empty() && !html.trim().is_empty() && document.select(sel_persona_miniprofile()).next().is_none() {
1219 dump_html("friends_list_empty", html);
1220 }
1221
1222 let page_info = parse_friend_page_info(html, &document);
1223
1224 crate::types::FriendListPage { friends: results, page_info }
1225}
1226
1227fn parse_search_results(html: &str, current_page: u32) -> (Vec<crate::types::CommunitySearchPlayer>, Option<u32>, Option<u32>) {
1228 let document = scraper::Html::parse_document(html);
1229 let mut players = Vec::new();
1230
1231 for element in document.select(sel_search_row()) {
1232 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);
1233 if miniprofile == 0 {
1234 continue;
1235 }
1236
1237 let steam_id = SteamID::from_individual_account_id(miniprofile);
1238
1239 let avatar_src = element.select(sel_avatar_medium_img()).next().and_then(|el| el.value().attr("src")).unwrap_or("");
1240 let avatar_hash = get_avatar_hash_from_url(avatar_src).unwrap_or_default();
1241
1242 let search_persona_el = element.select(sel_search_persona_name()).next();
1243 let name = search_persona_el.map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
1244 let profile_url = search_persona_el.and_then(|el| el.value().attr("href")).unwrap_or("").to_string();
1245 let custom_url = extract_custom_url(&profile_url);
1246
1247 players.push(crate::types::CommunitySearchPlayer { miniprofile: miniprofile as u64, steam_id, avatar_hash, name, profile_url, custom_url });
1248 }
1249
1250 let mut prev_page = None;
1251 let mut next_page = None;
1252
1253 for paging_el in document.select(sel_community_search_paging()) {
1254 let onclick = paging_el.value().attr("onclick").unwrap_or("");
1255 if onclick.contains("CommunitySearch.PrevPage()") {
1256 prev_page = Some(current_page.saturating_sub(1));
1257 } else if onclick.contains("CommunitySearch.NextPage()") {
1258 next_page = Some(current_page + 1);
1259 }
1260 }
1261
1262 if players.is_empty() && !html.trim().is_empty() && document.select(sel_search_row()).next().is_none() {
1263 dump_html("search_friends_empty", html);
1264 }
1265
1266 (players, prev_page, next_page)
1267}
1268
1269#[cfg(test)]
1270mod gameplay_info_tests {
1271 use crate::{SteamUser, SteamUserError};
1272
1273 #[tokio::test]
1281 async fn requires_access_token() {
1282 let user = SteamUser::new(&["steamLoginSecure=76561198000000000"]).expect("build user from cookie");
1283 let err = user.get_friends_gameplay_info(730).await.expect_err("call must require an access token");
1284 assert!(
1285 matches!(err, SteamUserError::MissingCredential { field: "access_token" }),
1286 "expected MissingCredential {{ field: \"access_token\" }}, got: {err:?}"
1287 );
1288 }
1289}