Skip to main content

vapour_protocol/
friends.rs

1use crate::{
2    emsg::EMsg,
3    message::Packet,
4    protobuf::{
5        CMsgClientChangeStatus, CMsgClientFriendsList, CMsgClientPersonaState,
6        CMsgClientRequestFriendData, CMsgProtoBufHeader,
7    },
8};
9
10// EClientPersonaStateFlag: Status=1 | PlayerName=2 | LastSeen=64 | GameExtraInfo=256
11const PERSONA_STATE_REQUESTED: u32 = 1 | 2 | 64 | 256;
12
13// EFriendRelationship::Friend
14const RELATIONSHIP_FRIEND: u32 = 3;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum PersonaState {
18    Offline,
19    Online,
20    Busy,
21    Away,
22    Snooze,
23    LookingToTrade,
24    LookingToPlay,
25    Invisible,
26}
27
28impl PersonaState {
29    pub fn from_raw(raw: u32) -> Self {
30        match raw {
31            1 => Self::Online,
32            2 => Self::Busy,
33            3 => Self::Away,
34            4 => Self::Snooze,
35            5 => Self::LookingToTrade,
36            6 => Self::LookingToPlay,
37            7 => Self::Invisible,
38            _ => Self::Offline,
39        }
40    }
41
42    pub fn to_raw(self) -> u32 {
43        match self {
44            Self::Offline => 0,
45            Self::Online => 1,
46            Self::Busy => 2,
47            Self::Away => 3,
48            Self::Snooze => 4,
49            Self::LookingToTrade => 5,
50            Self::LookingToPlay => 6,
51            Self::Invisible => 7,
52        }
53    }
54}
55
56#[derive(Debug, Clone)]
57pub struct Friend {
58    pub steamid: u64,
59    pub relationship: u32,
60}
61
62#[derive(Debug, Clone)]
63pub struct Persona {
64    pub steamid: u64,
65    pub name: String,
66    pub state: PersonaState,
67    pub game_app_id: Option<u32>,
68    pub game_name: Option<String>,
69    pub avatar_hash: Option<Vec<u8>>,
70    /// True when this update explicitly included game fields (even if they were cleared).
71    /// False when game fields were absent from the protobuf — existing values should be kept.
72    pub game_fields_present: bool,
73}
74
75/// One entry from an app's PICS `config/launch` block — how Steam itself would start the game.
76/// Used by the direct (no-Steam) launch path to resolve the executable without the Steam client.
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct LaunchEntry {
79    /// Executable path, relative to the game's install directory (e.g. `bin/game.exe`).
80    pub executable: String,
81    /// Launch arguments, if any.
82    pub arguments: Option<String>,
83    /// Working directory, relative to the install directory; `None` means the install root.
84    pub workingdir: Option<String>,
85    /// `config/launch[i].type` — "default", "none", "config", … `None`/empty when unset.
86    pub launch_type: Option<String>,
87    /// `config/launch[i].config.oslist` — comma list ("windows", "linux", "macos").
88    pub oslist: Option<String>,
89    /// `config/launch[i].config.osarch` — "32" / "64".
90    pub osarch: Option<String>,
91    /// `config/launch[i].config.betakey` — present only for beta-gated launch options.
92    pub betakey: Option<String>,
93}
94
95#[derive(Debug, Clone)]
96pub struct ProtocolGame {
97    pub appid: u32,
98    pub name: String,
99    pub playtime_forever: i32,
100    pub rtime_last_played: u32,
101    pub img_icon_url: Option<String>,
102    /// Steam appinfo `common.type` (lowercased): "game", "application", "tool", … `None` when
103    /// the appinfo could not be resolved. Used by the TUI to filter the library by type.
104    pub app_type: Option<String>,
105    /// Appinfo `config.installdir` — the game's folder name under `steamapps/common/`. `None`
106    /// when unresolved or for the recently-played / Web-API fallback paths.
107    pub installdir: Option<String>,
108    /// Appinfo `config/launch` entries (how Steam would launch it). Empty when unresolved.
109    pub launch: Vec<LaunchEntry>,
110}
111
112#[derive(Debug, Clone)]
113pub struct ProtocolAchievement {
114    pub apiname: String,
115    pub achieved: bool,
116    pub unlocktime: u64,
117    pub name: Option<String>,
118    pub description: Option<String>,
119}
120
121#[derive(Debug)]
122pub enum FriendsEvent {
123    FriendsList(Vec<Friend>),
124    PersonaStates(Vec<Persona>),
125    RecentlyPlayedGames(Vec<ProtocolGame>),
126    OwnedGames(Vec<ProtocolGame>),
127    PlayerAchievements {
128        appid: u32,
129        achievements: Vec<ProtocolAchievement>,
130    },
131    /// A 1-on-1 friend message arrived (or a cross-session echo of our own send).
132    IncomingMessage(crate::chat::ChatMessage),
133    /// One of our own outgoing messages was confirmed by Steam, stamped with the authoritative
134    /// `server_timestamp` + `ordinal` (so the UI can append/dedupe it like any other message).
135    MessageSent(crate::chat::ChatMessage),
136    /// A friend started typing in the 1-on-1 conversation.
137    TypingNotification {
138        steamid: u64,
139    },
140    /// Result of a `GetRecentMessages` history fetch for one conversation (oldest first).
141    RecentMessages {
142        steamid: u64,
143        messages: Vec<crate::chat::ChatMessage>,
144    },
145}
146
147/// Decode a packet into a FriendsEvent, returning None for unrelated packets.
148pub fn decode(packet: &Packet) -> Option<FriendsEvent> {
149    if packet.emsg == EMsg::ClientFriendsList.raw() {
150        let msg = packet.decode_body::<CMsgClientFriendsList>().ok()?;
151        let friends = msg
152            .friends
153            .into_iter()
154            .filter(|f| f.efriendrelationship() == RELATIONSHIP_FRIEND)
155            .map(|f| Friend {
156                steamid: f.ulfriendid(),
157                relationship: f.efriendrelationship(),
158            })
159            .collect();
160        return Some(FriendsEvent::FriendsList(friends));
161    }
162
163    if packet.emsg == EMsg::ClientPersonaState.raw() {
164        let msg = packet.decode_body::<CMsgClientPersonaState>().ok()?;
165        let personas = msg
166            .friends
167            .into_iter()
168            .map(|f| {
169                let game_fields_present = f.game_played_app_id.is_some() || f.game_name.is_some();
170                Persona {
171                    steamid: f.friendid(),
172                    state: PersonaState::from_raw(f.persona_state()),
173                    name: f.player_name.unwrap_or_default(),
174                    game_app_id: f.game_played_app_id.filter(|&id| id != 0),
175                    game_name: f.game_name.filter(|s| !s.is_empty()),
176                    avatar_hash: f.avatar_hash.filter(|h| !h.is_empty()),
177                    game_fields_present,
178                }
179            })
180            .collect();
181        return Some(FriendsEvent::PersonaStates(personas));
182    }
183
184    None
185}
186
187pub fn build_request_friend_data(
188    connection_state: &crate::connection::ConnectionState,
189    steamids: Vec<u64>,
190) -> (CMsgProtoBufHeader, CMsgClientRequestFriendData) {
191    let header = CMsgProtoBufHeader {
192        steamid: connection_state.steamid,
193        client_sessionid: connection_state.client_session_id,
194        ..Default::default()
195    };
196    let body = CMsgClientRequestFriendData {
197        persona_state_requested: Some(PERSONA_STATE_REQUESTED),
198        friends: steamids,
199    };
200    (header, body)
201}
202
203pub fn build_change_status(
204    connection_state: &crate::connection::ConnectionState,
205    state: PersonaState,
206) -> (CMsgProtoBufHeader, CMsgClientChangeStatus) {
207    let header = CMsgProtoBufHeader {
208        steamid: connection_state.steamid,
209        client_sessionid: connection_state.client_session_id,
210        ..Default::default()
211    };
212    let body = CMsgClientChangeStatus {
213        persona_state: Some(state.to_raw()),
214        persona_set_by_user: Some(true),
215        ..Default::default()
216    };
217    (header, body)
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::{
224        emsg::EMsg,
225        message::{decode_frame, encode_message},
226        protobuf::{CMsgClientPersonaState, CMsgProtoBufHeader},
227    };
228
229    #[test]
230    fn decodes_persona_state_into_friends_event() {
231        let mut friend = CMsgClientPersonaState::default();
232        let f = crate::protobuf::c_msg_client_persona_state::Friend {
233            friendid: Some(76561198000000001),
234            player_name: Some("Alice".to_owned()),
235            persona_state: Some(1), // Online
236            game_name: Some("Half-Life 2".to_owned()),
237            game_played_app_id: Some(220),
238            ..Default::default()
239        };
240        friend.friends.push(f);
241
242        let header = CMsgProtoBufHeader::default();
243        let encoded = encode_message(EMsg::ClientPersonaState, &header, &friend).unwrap();
244        let packets = decode_frame(&encoded).unwrap();
245        assert_eq!(packets.len(), 1);
246
247        let event = decode(&packets[0]).expect("should decode as FriendsEvent");
248        match event {
249            FriendsEvent::PersonaStates(personas) => {
250                assert_eq!(personas.len(), 1);
251                assert_eq!(personas[0].steamid, 76561198000000001);
252                assert_eq!(personas[0].name, "Alice");
253                assert_eq!(personas[0].state, PersonaState::Online);
254                assert_eq!(personas[0].game_app_id, Some(220));
255                assert_eq!(personas[0].game_name.as_deref(), Some("Half-Life 2"));
256                assert!(personas[0].game_fields_present);
257            }
258            _ => panic!("expected PersonaStates"),
259        }
260    }
261
262    #[test]
263    fn filters_non_friend_relationships() {
264        let mut msg = crate::protobuf::CMsgClientFriendsList::default();
265        // relationship 3 = Friend, 1 = Blocked
266        let friend_entry = crate::protobuf::c_msg_client_friends_list::Friend {
267            ulfriendid: Some(76561198000000002),
268            efriendrelationship: Some(3),
269        };
270        msg.friends.push(friend_entry);
271        let blocked_entry = crate::protobuf::c_msg_client_friends_list::Friend {
272            ulfriendid: Some(76561198000000099),
273            efriendrelationship: Some(1),
274        };
275        msg.friends.push(blocked_entry);
276
277        let header = CMsgProtoBufHeader::default();
278        let encoded = encode_message(EMsg::ClientFriendsList, &header, &msg).unwrap();
279        let packets = decode_frame(&encoded).unwrap();
280
281        let event = decode(&packets[0]).expect("should decode");
282        match event {
283            FriendsEvent::FriendsList(friends) => {
284                assert_eq!(friends.len(), 1);
285                assert_eq!(friends[0].steamid, 76561198000000002);
286            }
287            _ => panic!("expected FriendsList"),
288        }
289    }
290}