1use crate::{
2 emsg::EMsg,
3 message::Packet,
4 protobuf::{
5 CMsgClientChangeStatus, CMsgClientFriendsList, CMsgClientPersonaState,
6 CMsgClientRequestFriendData, CMsgProtoBufHeader,
7 },
8};
9
10const PERSONA_STATE_REQUESTED: u32 = 1 | 2 | 64 | 256;
12
13const 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 pub game_fields_present: bool,
73}
74
75#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct LaunchEntry {
79 pub executable: String,
81 pub arguments: Option<String>,
83 pub workingdir: Option<String>,
85 pub launch_type: Option<String>,
87 pub oslist: Option<String>,
89 pub osarch: Option<String>,
91 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 pub app_type: Option<String>,
105 pub installdir: Option<String>,
108 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 IncomingMessage(crate::chat::ChatMessage),
133 MessageSent(crate::chat::ChatMessage),
136 TypingNotification {
138 steamid: u64,
139 },
140 RecentMessages {
142 steamid: u64,
143 messages: Vec<crate::chat::ChatMessage>,
144 },
145}
146
147pub 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), 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 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}