Skip to main content

steam_client/services/
friends.rs

1//! Friends management for Steam client.
2//!
3//! This module provides functionality for managing Steam friends:
4//! - Adding/removing friends
5//! - Blocking/unblocking users
6//! - Friends groups management
7//! - Nicknames
8//! - Steam levels
9
10use std::collections::HashMap;
11
12use steam_enums::{EClientPersonaStateFlag, EFriendRelationship, EPersonaState, EResult};
13use steamid::SteamID;
14
15use crate::{
16    client::{FriendEntry, FriendsEvent, SteamEvent},
17    error::SteamError,
18    SteamClient,
19};
20
21impl SteamClient {
22    /// Request the friends list from Steam.
23    ///
24    /// This sends a ClientFriendsList message which should trigger a response
25    /// from the server with the full friends list.
26    pub async fn request_friends(&mut self) -> Result<(), SteamError> {
27        if !self.is_logged_in() {
28            return Err(SteamError::NotLoggedOn);
29        }
30
31        let msg = steam_protos::CMsgClientFriendsList { bincremental: Some(false), friends: vec![], ..Default::default() };
32
33        self.send_message(steam_enums::EMsg::ClientFriendsList, &msg).await
34    }
35
36    /// Request friends list using Unified Messages
37    /// (FriendsList.GetFriendsList#1).
38    ///
39    /// This is the preferred method for Web Logons where ClientFriendsList (CM)
40    /// is not supported.
41    pub async fn request_friends_unified(&mut self) -> Result<Vec<steam_protos::cmsg_client_friends_list::Friend>, SteamError> {
42        if !self.is_logged_in() {
43            return Err(SteamError::NotLoggedOn);
44        }
45
46        let msg = steam_protos::CFriendsListGetFriendsListRequest {
47            role_mask: None, // Get all relationships
48        };
49
50        let response: steam_protos::CFriendsListGetFriendsListResponse = self.send_unified_request_and_wait("FriendsList.GetFriendsList#1", &msg).await?;
51
52        let friends = response.friendslist.as_ref().map(|f| f.friends.clone()).unwrap_or_default();
53        self.handle_friends_list_unified_response(response).await;
54        Ok(friends)
55    }
56
57    /// Request friends list using Unified Messages in the background.
58    ///
59    /// The response will be automatically handled when it arrives, updating
60    /// internal state and emitting a `FriendsList` event. Use this to avoid
61    /// deadlocks in single-threaded event loops.
62    pub async fn request_friends_unified_trigger(&mut self) -> Result<(), SteamError> {
63        if !self.is_logged_in() {
64            return Err(SteamError::NotLoggedOn);
65        }
66
67        let msg = steam_protos::CFriendsListGetFriendsListRequest { role_mask: None };
68
69        self.send_service_method_background("FriendsList.GetFriendsList#1", &msg, crate::client::steam_client::BackgroundTask::FriendsList).await
70    }
71
72    /// Internal handler for unified friends list response.
73    pub(crate) async fn handle_friends_list_unified_response(&mut self, response: steam_protos::CFriendsListGetFriendsListResponse) {
74        // Unwrap the wrapped CMsgClientFriendsList
75        let friends_list = match response.friendslist {
76            Some(fl) => fl,
77            None => {
78                tracing::warn!("[SteamClient] Received empty unified friends list response");
79                return;
80            }
81        };
82
83        tracing::debug!("[SteamClient] Handling unified friends list response with {} friends", friends_list.friends.len());
84        // Update internal friends list and prepare event entries
85        let mut entries = Vec::new();
86        for friend in &friends_list.friends {
87            if let Some(id_64) = friend.ulfriendid {
88                let steam_id = SteamID::from(id_64);
89                let relationship = friend.efriendrelationship.unwrap_or(0);
90                let rel_enum = steam_enums::EFriendRelationship::from_i32(relationship as i32).unwrap_or(steam_enums::EFriendRelationship::None);
91
92                if rel_enum == steam_enums::EFriendRelationship::None {
93                    self.social.write().friends.remove(&steam_id);
94                } else {
95                    self.social.write().friends.insert(steam_id, relationship);
96                }
97
98                entries.push(FriendEntry { steam_id, relationship: rel_enum });
99            }
100        }
101
102        tracing::debug!("[SteamClient] Processed {} friend entries, emitting FriendsList event", entries.len());
103
104        // Emit FriendsList event so that post_process_event can trigger
105        // auto-persona-fetch
106        self.event_queue.push_back(SteamEvent::Friends(FriendsEvent::FriendsList { incremental: friends_list.bincremental.unwrap_or(false), friends: entries }));
107    }
108}
109
110/// Friend information.
111#[derive(Debug, Clone)]
112pub struct Friend {
113    /// Friend's SteamID.
114    pub steam_id: SteamID,
115    /// Relationship type.
116    pub relationship: EFriendRelationship,
117}
118
119/// Add friend result.
120#[derive(Debug, Clone)]
121pub struct AddFriendResult {
122    /// The result code.
123    pub eresult: EResult,
124    /// The friend's persona name.
125    pub persona_name: Option<String>,
126}
127
128/// Friends group information.
129#[derive(Debug, Clone)]
130pub struct FriendsGroup {
131    /// Group ID.
132    pub group_id: u32,
133    /// Group name.
134    pub name: String,
135    /// Members in this group.
136    pub members: Vec<SteamID>,
137}
138
139impl SteamClient {
140    /// Set your persona (online status and optionally name).
141    ///
142    /// # Important: Invisible vs Offline
143    /// * `EPersonaState::Invisible` (7): You remain connected to the CM and
144    ///   receive chats/invites, but you appear "Offline" to friends. Use this
145    ///   if you want to "appear offline".
146    /// * `EPersonaState::Offline` (0): You are effectively disconnected from
147    ///   the Friends network. You will not receive friend messages or updates.
148    ///
149    /// # Arguments
150    /// * `state` - Your new online state
151    /// * `name` - Optional new profile name
152    pub async fn set_persona(&mut self, state: EPersonaState, name: Option<String>) -> Result<(), SteamError> {
153        if !self.is_logged_in() {
154            return Err(SteamError::NotLoggedOn);
155        }
156
157        // Handle semantically correct state for "Offline"
158        // If user explicitly requests Offline while connected, they likely mean
159        // Invisible unless they really want to drop from the friends network.
160        // However, we pass it raw as requested, just documenting the difference.
161        // But wait, the task said: "Ensure set_persona handles the semantic difference
162        // correctly". If passing 'Offline' (0) causes us to lose chat
163        // connectivity but stay logged on, that might be what is intended by
164        // the enum, but users often confuse them.
165
166        let msg = steam_protos::CMsgClientChangeStatus { persona_state: Some(state as u32), player_name: name.clone(), ..Default::default() };
167
168        // Record for session recovery
169        self.session_recovery.record_persona_state(state, name);
170
171        self.send_message(steam_enums::EMsg::ClientChangeStatus, &msg).await
172    }
173
174    /// Add a friend (or accept a friend invitation).
175    ///
176    /// # Arguments
177    /// * `steam_id` - The SteamID of the user to add
178    pub async fn add_friend(&mut self, steam_id: SteamID) -> Result<AddFriendResult, SteamError> {
179        if !self.is_logged_in() {
180            return Err(SteamError::NotLoggedOn);
181        }
182
183        let msg = steam_protos::CMsgClientAddFriend { steamid_to_add: Some(steam_id.steam_id64()), ..Default::default() };
184
185        let response: steam_protos::CMsgClientAddFriendResponse = self.send_request_and_wait(steam_enums::EMsg::ClientAddFriend, &msg).await?;
186
187        Ok(AddFriendResult {
188            eresult: steam_enums::EResult::from_i32(response.eresult.unwrap_or(0)).unwrap_or(steam_enums::EResult::Fail),
189            persona_name: response.persona_name_added,
190        })
191    }
192
193    /// Remove a friend (or decline a friend invitation).
194    ///
195    /// # Arguments
196    /// * `steam_id` - The SteamID of the user to remove
197    pub async fn remove_friend(&mut self, steam_id: SteamID) -> Result<(), SteamError> {
198        if !self.is_logged_in() {
199            return Err(SteamError::NotLoggedOn);
200        }
201
202        let msg = steam_protos::CMsgClientRemoveFriend { friendid: Some(steam_id.steam_id64()) };
203
204        self.send_message(steam_enums::EMsg::ClientRemoveFriend, &msg).await
205    }
206
207    /// Request persona information for one or more users.
208    ///
209    /// # Arguments
210    /// * `steam_ids` - The SteamIDs of users to get personas for
211    pub async fn get_personas(&mut self, steam_ids: Vec<SteamID>) -> Result<(), SteamError> {
212        if !self.is_logged_in() {
213            return Err(SteamError::NotLoggedOn);
214        }
215
216        let msg = steam_protos::CMsgClientRequestFriendData {
217            friends: steam_ids.iter().map(|s| s.steam_id64()).collect(),
218            persona_state_requested: Some(
219                (EClientPersonaStateFlag::Status as u32)
220                    | (EClientPersonaStateFlag::PlayerName as u32)
221                    | (EClientPersonaStateFlag::QueryPort as u32)
222                    | (EClientPersonaStateFlag::SourceID as u32)
223                    | (EClientPersonaStateFlag::Presence as u32)
224                    | (EClientPersonaStateFlag::Metadata as u32)
225                    | (EClientPersonaStateFlag::LastSeen as u32)
226                    | (EClientPersonaStateFlag::UserClanRank as u32)
227                    | (EClientPersonaStateFlag::GameExtraInfo as u32)
228                    | (EClientPersonaStateFlag::GameDataBlob as u32)
229                    | (EClientPersonaStateFlag::ClanData as u32)
230                    | (EClientPersonaStateFlag::Facebook as u32)
231                    | (EClientPersonaStateFlag::RichPresence as u32)
232                    | (EClientPersonaStateFlag::Broadcast as u32)
233                    | (EClientPersonaStateFlag::Watching as u32),
234            ),
235        };
236
237        self.send_message(steam_enums::EMsg::ClientRequestFriendData, &msg).await
238    }
239
240    /// Get persona information with caching.
241    ///
242    /// Checks the local cache first, only queries Steam servers for
243    /// missing/expired entries. Results are returned from the cache and the
244    /// internal `users` HashMap.
245    ///
246    /// # Arguments
247    /// * `steam_ids` - The SteamIDs to get personas for
248    /// * `force_refresh` - If true, bypass cache and fetch fresh data from
249    ///   Steam
250    ///
251    /// # Returns
252    /// A map of SteamID to UserPersona for all requested IDs that were found.
253    ///
254    /// # Note
255    /// The cache is automatically updated when `PersonaState` events are
256    /// received. Use `force_refresh` when you need guaranteed fresh data.
257    ///
258    /// # Example
259    /// ```rust,ignore
260    /// // Get personas, using cache when possible
261    /// let personas = client.get_personas_cached(friend_ids, false).await?;
262    /// for (steam_id, persona) in personas {
263    ///     tracing::info!("{}: {}", steam_id.steam3(), persona.player_name);
264    /// }
265    ///
266    /// // Force refresh from Steam servers
267    /// let fresh = client.get_personas_cached(friend_ids, true).await?;
268    /// ```
269    pub async fn get_personas_cached(&mut self, steam_ids: Vec<SteamID>, force_refresh: bool) -> Result<HashMap<SteamID, crate::client::UserPersona>, SteamError> {
270        if !self.is_logged_in() {
271            return Err(SteamError::NotLoggedOn);
272        }
273
274        let mut result = HashMap::new();
275        let mut to_fetch = Vec::new();
276
277        if force_refresh {
278            to_fetch = steam_ids;
279        } else {
280            // Check persona cache first (TTL-based)
281            let (cached, missing) = self.social.read().persona_cache.get_many(&steam_ids);
282            for persona in cached {
283                result.insert(persona.steam_id, persona);
284            }
285
286            // For remaining, check the users HashMap (no TTL).
287            // Clone the persona out before re-locking persona_cache to avoid
288            // holding the social read guard for longer than necessary.
289            for id in missing {
290                let maybe_persona = self.social.read().users.get(&id).cloned();
291                if let Some(persona) = maybe_persona {
292                    self.social.read().persona_cache.insert(id, persona.clone());
293                    result.insert(id, persona);
294                } else {
295                    to_fetch.push(id);
296                }
297            }
298        }
299
300        // Fetch missing entries from Steam
301        if !to_fetch.is_empty() {
302            // Request personas from Steam servers
303            self.get_personas(to_fetch.clone()).await?;
304
305            // Note: The actual persona data will arrive via PersonaState events
306            // and will be stored in self.users and self.persona_cache.
307            // For immediate results, we could wait for the events, but that
308            // requires changing the API. For now, return what we have.
309            //
310            // The caller should either:
311            // 1. Poll for events and then call get_personas_cached again
312            // 2. Use this method after already having received PersonaState
313            //    events
314        }
315
316        Ok(result)
317    }
318
319    /// Block a user.
320    ///
321    /// # Arguments
322    /// * `steam_id` - The SteamID of the user to block
323    pub async fn block_user(&mut self, steam_id: SteamID) -> Result<(), SteamError> {
324        if !self.is_logged_in() {
325            return Err(SteamError::NotLoggedOn);
326        }
327
328        let msg = steam_protos::CPlayerIgnoreFriendRequest { steamid: Some(steam_id.steam_id64()), unignore: Some(false) };
329
330        let _response: steam_protos::CPlayerIgnoreFriendResponse = self.send_unified_request_and_wait("Player.IgnoreFriend#1", &msg).await?;
331
332        Ok(())
333    }
334
335    /// Unblock a user.
336    ///
337    /// # Arguments
338    /// * `steam_id` - The SteamID of the user to unblock
339    pub async fn unblock_user(&mut self, steam_id: SteamID) -> Result<(), SteamError> {
340        if !self.is_logged_in() {
341            return Err(SteamError::NotLoggedOn);
342        }
343
344        let msg = steam_protos::CPlayerIgnoreFriendRequest { steamid: Some(steam_id.steam_id64()), unignore: Some(true) };
345
346        let _response: steam_protos::CPlayerIgnoreFriendResponse = self.send_unified_request_and_wait("Player.IgnoreFriend#1", &msg).await?;
347
348        Ok(())
349    }
350
351    /// Create a friends group (tag).
352    ///
353    /// # Arguments
354    /// * `name` - The name for the new group
355    ///
356    /// # Returns
357    /// The group ID of the newly created group.
358    pub async fn create_friends_group(&mut self, name: &str) -> Result<u32, SteamError> {
359        if !self.is_logged_in() {
360            return Err(SteamError::NotLoggedOn);
361        }
362
363        let msg = steam_protos::CMsgClientCreateFriendsGroup {
364            groupname: Some(name.to_string()),
365            steamid: self.steam_id.as_ref().map(|s| s.steam_id64()),
366            ..Default::default()
367        };
368
369        // Send request and wait for response
370        let response: steam_protos::CMsgClientCreateFriendsGroupResponse = self.send_request_and_wait(steam_enums::EMsg::AMClientCreateFriendsGroup, &msg).await?;
371
372        // Check result
373        let eresult = steam_enums::EResult::from_i32(response.eresult.unwrap_or(1) as i32).unwrap_or(steam_enums::EResult::Fail);
374        if eresult != steam_enums::EResult::OK {
375            return Err(SteamError::SteamResult(eresult));
376        }
377
378        Ok(response.groupid.unwrap_or(0) as u32)
379    }
380
381    /// Delete a friends group (tag).
382    ///
383    /// # Arguments
384    /// * `group_id` - The ID of the group to delete
385    pub async fn delete_friends_group(&mut self, group_id: u32) -> Result<(), SteamError> {
386        if !self.is_logged_in() {
387            return Err(SteamError::NotLoggedOn);
388        }
389
390        let msg = steam_protos::CMsgClientDeleteFriendsGroup { steamid: self.steam_id.as_ref().map(|s| s.steam_id64()), groupid: Some(group_id as i32) };
391
392        self.send_message(steam_enums::EMsg::AMClientDeleteFriendsGroup, &msg).await
393    }
394
395    /// Rename a friends group (tag).
396    ///
397    /// # Arguments
398    /// * `group_id` - The ID of the group to rename
399    /// * `name` - The new name for the group
400    pub async fn rename_friends_group(&mut self, group_id: u32, name: &str) -> Result<(), SteamError> {
401        if !self.is_logged_in() {
402            return Err(SteamError::NotLoggedOn);
403        }
404
405        let msg = steam_protos::CMsgClientRenameFriendsGroup { groupid: Some(group_id as i32), groupname: Some(name.to_string()) };
406
407        self.send_message(steam_enums::EMsg::AMClientManageFriendsGroup, &msg).await
408    }
409
410    /// Add a friend to a friends group (tag).
411    ///
412    /// # Arguments
413    /// * `group_id` - The ID of the group
414    /// * `steam_id` - The SteamID of the friend to add
415    pub async fn add_friend_to_group(&mut self, group_id: u32, steam_id: SteamID) -> Result<(), SteamError> {
416        if !self.is_logged_in() {
417            return Err(SteamError::NotLoggedOn);
418        }
419
420        let msg = steam_protos::CMsgClientAddFriendToGroup { groupid: Some(group_id as i32), steamiduser: Some(steam_id.steam_id64()) };
421
422        self.send_message(steam_enums::EMsg::AMClientAddFriendToGroup, &msg).await
423    }
424
425    /// Remove a friend from a friends group (tag).
426    ///
427    /// # Arguments
428    /// * `group_id` - The ID of the group
429    /// * `steam_id` - The SteamID of the friend to remove
430    pub async fn remove_friend_from_group(&mut self, group_id: u32, steam_id: SteamID) -> Result<(), SteamError> {
431        if !self.is_logged_in() {
432            return Err(SteamError::NotLoggedOn);
433        }
434
435        let msg = steam_protos::CMsgClientRemoveFriendFromGroup { groupid: Some(group_id as i32), steamiduser: Some(steam_id.steam_id64()) };
436
437        self.send_message(steam_enums::EMsg::AMClientRemoveFriendFromGroup, &msg).await
438    }
439
440    /// Set a nickname for a friend.
441    ///
442    /// # Arguments
443    /// * `steam_id` - The SteamID of the friend
444    /// * `nickname` - The nickname to set (empty string to remove)
445    pub async fn set_nickname(&mut self, steam_id: SteamID, nickname: &str) -> Result<(), SteamError> {
446        if !self.is_logged_in() {
447            return Err(SteamError::NotLoggedOn);
448        }
449
450        // Use Player.SetPerFriendPreferences service method
451        let prefs = steam_protos::PerFriendPreferences { nickname: Some(nickname.to_string()), ..Default::default() };
452
453        let msg = steam_protos::CPlayerSetPerFriendPreferencesRequest { accountid: Some(steam_id.account_id), preferences: Some(prefs) };
454
455        self.send_service_method("Player.SetPerFriendPreferences#1", &msg).await
456    }
457
458    /// Get nicknames for all friends.
459    ///
460    /// # Returns
461    /// A map of SteamID to nickname.
462    /// Get nicknames for all friends.
463    ///
464    /// # Returns
465    /// A map of SteamID to nickname.
466    /// Get nicknames for all friends.
467    ///
468    /// # Returns
469    /// A map of SteamID to nickname.
470    pub async fn get_nicknames(&mut self) -> Result<HashMap<SteamID, String>, SteamError> {
471        if !self.is_logged_in() {
472            return Err(SteamError::NotLoggedOn);
473        }
474
475        // Use Player.GetNicknameList service method
476        let msg = steam_protos::CPlayerGetNicknameListRequest {};
477
478        // Send and wait for response
479        let response: steam_protos::CPlayerGetNicknameListResponse = self.send_unified_request_and_wait("Player.GetNicknameList#1", &msg).await?;
480
481        let mut nicknames = HashMap::new();
482        for nickname in response.nicknames {
483            if let Some(accountid) = nickname.accountid {
484                let steam_id = SteamID::from_individual_account_id(accountid);
485                if let Some(name) = nickname.nickname {
486                    nicknames.insert(steam_id, name);
487                }
488            }
489        }
490
491        Ok(nicknames)
492    }
493
494    /// Get persona name history for one or more users.
495    ///
496    /// # Arguments
497    /// * `steam_ids` - The SteamIDs to get name history for
498    ///
499    /// # Returns
500    /// A map of SteamID to list of historical names (with timestamps).
501    pub async fn get_persona_name_history(&mut self, steam_ids: Vec<SteamID>) -> Result<HashMap<SteamID, Vec<crate::types::PersonaNameHistory>>, SteamError> {
502        if !self.is_logged_in() {
503            return Err(SteamError::NotLoggedOn);
504        }
505
506        let ids: Vec<steam_protos::cmsg_client_am_get_persona_name_history::IdInstance> = steam_ids.iter().map(|s| steam_protos::cmsg_client_am_get_persona_name_history::IdInstance { steamid: Some(s.steam_id64()) }).collect();
507
508        let msg = steam_protos::CMsgClientAMGetPersonaNameHistory { id_count: Some(ids.len() as i32), ids };
509
510        let response: steam_protos::CMsgClientAMGetPersonaNameHistoryResponse = self.send_request_and_wait(steam_enums::EMsg::ClientAMGetPersonaNameHistory, &msg).await?;
511
512        let mut result = HashMap::new();
513
514        for resp in response.responses {
515            if let Some(steamid_64) = resp.steamid {
516                let steam_id = SteamID::from(steamid_64);
517                let mut history = Vec::new();
518
519                for name in resp.names {
520                    history.push(crate::types::PersonaNameHistory { name: name.name.unwrap_or_default(), name_since: name.name_since.unwrap_or(0) });
521                }
522
523                result.insert(steam_id, history);
524            }
525        }
526
527        Ok(result)
528    }
529
530    /// Get Steam levels for multiple users.
531    ///
532    /// # Arguments
533    /// * `steam_ids` - The SteamIDs to get levels for
534    ///
535    /// # Returns
536    /// A map of SteamID to Steam level.
537    pub async fn get_steam_levels(&mut self, steam_ids: Vec<SteamID>) -> Result<HashMap<SteamID, u32>, SteamError> {
538        if !self.is_logged_in() {
539            return Err(SteamError::NotLoggedOn);
540        }
541
542        let msg = steam_protos::CMsgClientFSGetFriendsSteamLevels { accountids: steam_ids.iter().map(|s| s.account_id).collect() };
543
544        // Send and wait for response
545        let response: steam_protos::CMsgClientFSGetFriendsSteamLevelsResponse = self.send_request_and_wait(steam_enums::EMsg::ClientFSGetFriendsSteamLevels, &msg).await?;
546
547        let mut levels = HashMap::new();
548        for friend in response.friends {
549            if let Some(account_id) = friend.accountid {
550                let steam_id = SteamID::from_individual_account_id(account_id);
551                if let Some(level) = friend.level {
552                    levels.insert(steam_id, level);
553                }
554            }
555        }
556
557        Ok(levels)
558    }
559
560    /// Get the level of your game badge (and also your Steam level).
561    ///
562    /// # Arguments
563    /// * `app_id` - AppID of game in question
564    ///
565    /// # Returns
566    /// A tuple containing (steam_level, regular_badge_level, foil_badge_level).
567    pub async fn get_game_badge_level(&mut self, app_id: u32) -> Result<(u32, i32, i32), SteamError> {
568        if !self.is_logged_in() {
569            return Err(SteamError::NotLoggedOn);
570        }
571
572        let msg = steam_protos::CPlayerGetGameBadgeLevelsRequest { appid: Some(app_id) };
573
574        let response: steam_protos::CPlayerGetGameBadgeLevelsResponse = self.send_unified_request_and_wait("Player.GetGameBadgeLevels#1", &msg).await?;
575
576        let mut regular = 0;
577        let mut foil = 0;
578
579        for badge in response.badges {
580            if badge.series != Some(1) {
581                continue;
582            }
583
584            if badge.border_color == Some(0) {
585                regular = badge.level.unwrap_or(0);
586            } else if badge.border_color == Some(1) {
587                foil = badge.level.unwrap_or(0);
588            }
589        }
590
591        Ok((response.player_level.unwrap_or(0), regular, foil))
592    }
593
594    /// Invite a friend to a Steam group.
595    ///
596    /// # Arguments
597    /// * `steam_id` - The SteamID of the user to invite
598    /// * `group_id` - The SteamID of the group
599    pub async fn invite_to_group(&mut self, steam_id: SteamID, group_id: SteamID) -> Result<(), SteamError> {
600        if !self.is_logged_in() {
601            return Err(SteamError::NotLoggedOn);
602        }
603
604        use byteorder::{WriteBytesExt, LE};
605        let mut buf = Vec::with_capacity(17);
606        buf.write_u64::<LE>(steam_id.steam_id64()).map_err(|e| SteamError::Other(e.to_string()))?;
607        buf.write_u64::<LE>(group_id.steam_id64()).map_err(|e| SteamError::Other(e.to_string()))?;
608        buf.write_u8(1).map_err(|e| SteamError::Other(e.to_string()))?;
609
610        self.send_binary_message(steam_enums::EMsg::ClientInviteUserToClan, &buf).await
611    }
612
613    /// Respond to a Steam group invitation.
614    ///
615    /// # Arguments
616    /// * `group_id` - The SteamID of the group
617    /// * `accept` - True to join, false to decline
618    pub async fn respond_to_group_invite(&mut self, group_id: SteamID, accept: bool) -> Result<(), SteamError> {
619        if !self.is_logged_in() {
620            return Err(SteamError::NotLoggedOn);
621        }
622
623        use byteorder::{WriteBytesExt, LE};
624        let mut buf = Vec::with_capacity(9);
625        buf.write_u64::<LE>(group_id.steam_id64()).map_err(|e| SteamError::Other(e.to_string()))?;
626        buf.write_u8(if accept { 1 } else { 0 }).map_err(|e| SteamError::Other(e.to_string()))?;
627
628        self.send_binary_message(steam_enums::EMsg::ClientAcknowledgeClanInvite, &buf).await
629    }
630
631    /// Invite a friend to a game.
632    ///
633    /// # Arguments
634    /// * `steam_id` - The SteamID of the friend to invite
635    /// * `connect_string` - The connection string for the game
636    pub async fn invite_to_game(&mut self, steam_id: SteamID, connect_string: &str) -> Result<(), SteamError> {
637        if !self.is_logged_in() {
638            return Err(SteamError::NotLoggedOn);
639        }
640
641        let msg = steam_protos::CMsgClientInviteToGame {
642            steam_id_dest: Some(steam_id.steam_id64()),
643            connect_string: Some(connect_string.to_string()),
644            ..Default::default()
645        };
646
647        self.send_message(steam_enums::EMsg::ClientInviteToGame, &msg).await
648    }
649
650    // ========================================================================
651    // Quick Invite Links
652    // ========================================================================
653
654    /// Create a quick invite link.
655    ///
656    /// Quick invite links allow others to add you as a friend without needing
657    /// to know your SteamID or username.
658    ///
659    /// # Arguments
660    /// * `invite_limit` - Maximum number of uses (None = unlimited)
661    /// * `invite_duration` - Duration in seconds the link is valid (None = no
662    ///   expiry)
663    ///
664    /// # Returns
665    /// Information about the created invite link.
666    ///
667    /// # Example
668    /// ```rust,ignore
669    /// // Create an invite link valid for 1 hour with max 5 uses
670    /// let link = client.create_quick_invite_link(Some(5), Some(3600)).await?;
671    /// tracing::info!("Share this link: {}", link.invite_link);
672    /// ```
673    pub async fn create_quick_invite_link(&mut self, invite_limit: Option<u32>, invite_duration: Option<u32>) -> Result<crate::types::QuickInviteLink, SteamError> {
674        if !self.is_logged_in() {
675            return Err(SteamError::NotLoggedOn);
676        }
677
678        // Clone steam_id before mutably borrowing self
679        let steam_id = self.steam_id.ok_or(SteamError::NotLoggedOn)?;
680
681        let msg = steam_protos::CUserAccountCreateFriendInviteTokenRequest { invite_limit, invite_duration, invite_note: None };
682
683        let response: steam_protos::CUserAccountCreateFriendInviteTokenResponse = self.send_unified_request_and_wait("UserAccount.CreateFriendInviteToken#1", &msg).await?;
684
685        // Generate the link URL from our SteamID
686        let friend_code = steam_friend_code::create_short_steam_friend_code(steam_id.account_id);
687        let invite_token = response.invite_token.unwrap_or_default();
688
689        Ok(crate::types::QuickInviteLink {
690            invite_link: format!("https://s.team/p/{}/{}", friend_code, invite_token),
691            invite_token,
692            invite_limit: response.invite_limit,
693            invite_duration: response.invite_duration,
694            time_created: response.time_created,
695            valid: response.valid.unwrap_or(true),
696        })
697    }
698
699    /// List all quick invite links for this account.
700    ///
701    /// # Returns
702    /// A list of all active invite links.
703    pub async fn list_quick_invite_links(&mut self) -> Result<Vec<crate::types::QuickInviteLink>, SteamError> {
704        if !self.is_logged_in() {
705            return Err(SteamError::NotLoggedOn);
706        }
707
708        let msg = steam_protos::CUserAccountGetFriendInviteTokensRequest {};
709
710        self.send_service_method("UserAccount.GetFriendInviteTokens#1", &msg).await?;
711
712        // Response will come via poll_event
713        // Full links are constructed when response is received
714        Ok(Vec::new())
715    }
716
717    /// Revoke a quick invite link.
718    ///
719    /// # Arguments
720    /// * `link` - The invite link URL or just the token
721    pub async fn revoke_quick_invite_link(&mut self, link: &str) -> Result<(), SteamError> {
722        if !self.is_logged_in() {
723            return Err(SteamError::NotLoggedOn);
724        }
725
726        let (_, token) = steam_friend_code::parse_quick_invite_link(link).ok_or_else(|| SteamError::Other("Invalid quick invite link format".into()))?;
727
728        let msg = steam_protos::CUserAccountRevokeFriendInviteTokenRequest { invite_token: Some(token) };
729
730        self.send_service_method("UserAccount.RevokeFriendInviteToken#1", &msg).await
731    }
732
733    /// Get the SteamID of the owner of an invite link.
734    ///
735    /// This is a synchronous operation that parses the link locally.
736    ///
737    /// # Arguments
738    /// * `link` - The invite link URL
739    ///
740    /// # Returns
741    /// The SteamID of the link owner.
742    pub fn get_quick_invite_link_steam_id(&self, link: &str) -> Result<SteamID, SteamError> {
743        let (friend_code, _) = steam_friend_code::parse_quick_invite_link(link).ok_or_else(|| SteamError::Other("Invalid quick invite link format".into()))?;
744        let account_id = steam_friend_code::parse_short_steam_friend_code(&friend_code).ok_or_else(|| SteamError::Other("Invalid friend code".into()))?;
745        Ok(SteamID::from_individual_account_id(account_id))
746    }
747
748    /// Check if a quick invite link is valid.
749    ///
750    /// # Arguments
751    /// * `link` - The invite link URL
752    ///
753    /// # Returns
754    /// Validity information including the owner's SteamID.
755    pub async fn check_quick_invite_link_validity(&mut self, link: &str) -> Result<crate::types::QuickInviteLinkValidity, SteamError> {
756        if !self.is_logged_in() {
757            return Err(SteamError::NotLoggedOn);
758        }
759
760        let (friend_code, token) = steam_friend_code::parse_quick_invite_link(link).ok_or_else(|| SteamError::Other("Invalid quick invite link format".into()))?;
761        let owner_steam_id_account = steam_friend_code::parse_short_steam_friend_code(&friend_code).ok_or_else(|| SteamError::Other("Invalid friend code".into()))?;
762        let owner_steam_id = SteamID::from_individual_account_id(owner_steam_id_account);
763
764        let msg = steam_protos::CUserAccountViewFriendInviteTokenRequest { steamid: Some(owner_steam_id.steam_id64()), invite_token: Some(token) };
765
766        self.send_service_method("UserAccount.ViewFriendInviteToken#1", &msg).await?;
767
768        // Response will come via poll_event
769        Ok(crate::types::QuickInviteLinkValidity {
770            valid: true, // Placeholder - actual value from response
771            steam_id: Some(owner_steam_id),
772            invite_duration: None,
773        })
774    }
775
776    /// Redeem a quick invite link (add the link owner as a friend).
777    ///
778    /// # Arguments
779    /// * `link` - The invite link URL
780    pub async fn redeem_quick_invite_link(&mut self, link: &str) -> Result<(), SteamError> {
781        if !self.is_logged_in() {
782            return Err(SteamError::NotLoggedOn);
783        }
784
785        let (friend_code, token) = steam_friend_code::parse_quick_invite_link(link).ok_or_else(|| SteamError::Other("Invalid quick invite link format".into()))?;
786        let owner_steam_id_account = steam_friend_code::parse_short_steam_friend_code(&friend_code).ok_or_else(|| SteamError::Other("Invalid friend code".into()))?;
787        let owner_steam_id = SteamID::from_individual_account_id(owner_steam_id_account);
788
789        let msg = steam_protos::CUserAccountRedeemFriendInviteTokenRequest { steamid: Some(owner_steam_id.steam_id64()), invite_token: Some(token) };
790
791        self.send_service_method("UserAccount.RedeemFriendInviteToken#1", &msg).await
792    }
793}