1use std::{collections::HashMap, io::Read, sync::Mutex, time::Duration};
19
20use bytes::Bytes;
21use chrono::{TimeZone, Utc};
22use flate2::read::GzDecoder;
23use once_cell::sync::Lazy;
24use prost::Message;
25use steam_enums::{EChatEntryType, ECsgoGCMsg, EFriendRelationship, EGCBaseClientMsg, EMsg, EPersonaState, EResult};
26use steamid::SteamID;
27use tracing::{error, info};
28
29use super::steam_client::UserPersona;
30pub use crate::utils::parsing::{AppChange, AppInfoData, PackageChange, PackageInfoData};
31use crate::{client::static_app_list::APP_LIST, services::gc::GCMessage, utils::parsing};
32
33#[derive(Debug, Clone)]
39pub enum AuthEvent {
40 LoggedOn { steam_id: SteamID },
42
43 LoggedOff { result: EResult },
45
46 RefreshToken {
49 token: String,
51 account_name: String,
53 },
54
55 WebSession {
66 session_id: String,
68 cookies: Vec<String>,
70 },
71
72 GameConnectTokens {
74 tokens: Vec<Vec<u8>>,
76 },
77}
78
79#[derive(Debug, Clone)]
81pub enum ConnectionEvent {
82 Connected,
84
85 Disconnected {
87 reason: Option<EResult>,
90 will_reconnect: bool,
92 },
93
94 ReconnectAttempt {
96 attempt: u32,
98 max_attempts: u32,
100 delay: Duration,
102 },
103
104 ReconnectFailed {
106 reason: Option<EResult>,
108 attempts: u32,
110 },
111
112 CMList { servers: Vec<String> },
114}
115
116#[derive(Debug, Clone)]
118pub enum FriendsEvent {
119 FriendsList { incremental: bool, friends: Vec<FriendEntry> },
121
122 PersonaState(Box<UserPersona>),
124
125 FriendRelationship { steam_id: SteamID, relationship: EFriendRelationship },
127
128 NicknameChanged { steam_id: SteamID, nickname: Option<String> },
130}
131
132#[derive(Debug, Clone)]
134pub enum ChatEvent {
135 FriendMessage {
137 sender: SteamID,
138 message: String,
139 chat_entry_type: EChatEntryType,
140 timestamp: u32,
141 ordinal: u32,
142 from_limited_account: bool,
144 low_priority: bool,
146 },
147
148 FriendMessageEcho { receiver: SteamID, message: String, timestamp: u32, ordinal: u32 },
150
151 FriendTyping { sender: SteamID },
153
154 FriendTypingEcho { receiver: SteamID },
156
157 FriendLeftConversation { sender: SteamID },
159
160 FriendLeftConversationEcho { receiver: SteamID },
162
163 ChatMessage {
165 chat_group_id: u64,
166 chat_id: u64,
167 sender: SteamID,
168 message: String,
169 timestamp: u32,
170 ordinal: u32,
171 },
173
174 ChatMemberStateChange {
176 chat_group_id: u64,
177 steam_id: SteamID,
178 change: i32, },
180
181 ChatRoomGroupRoomsChange { chat_group_id: u64, default_chat_id: u64, chat_rooms: Vec<ChatRoomState> },
183
184 ChatMessagesModified { chat_group_id: u64, chat_id: u64, messages: Vec<ModifiedChatMessage> },
186
187 ChatRoomGroupHeaderStateChange { chat_group_id: u64, header_state: ChatRoomGroupHeaderState },
189
190 OfflineMessagesFetched { friend_id: SteamID, messages: Vec<crate::services::chat::HistoryMessage> },
192}
193
194#[derive(Debug, Clone)]
196pub enum AppsEvent {
197 LicenseList { licenses: Vec<LicenseEntry> },
199
200 ProductInfoResponse {
202 apps: HashMap<u32, AppInfoData>,
204 packages: HashMap<u32, PackageInfoData>,
206 unknown_apps: Vec<u32>,
208 unknown_packages: Vec<u32>,
210 },
211
212 AccessTokensResponse {
214 app_tokens: HashMap<u32, u64>,
216 package_tokens: HashMap<u32, u64>,
218 app_denied: Vec<u32>,
220 package_denied: Vec<u32>,
222 },
223
224 ProductChangesResponse {
226 current_change_number: u32,
228 app_changes: Vec<AppChange>,
230 package_changes: Vec<PackageChange>,
232 },
233
234 GCReceived(GCMessage),
236
237 PlayingState {
245 blocked: bool,
248 playing_app: u32,
251 },
252}
253
254#[derive(Debug, Clone)]
256pub enum CSGOEvent {
257 Online(CsgoWelcome),
262
263 ClientHello(CsgoClientHello),
268
269 PlayersProfile(Vec<CsgoClientHello>),
274
275 PartyInvite { inviter: SteamID, lobby_id: u64 },
277 PartySearchResults(Vec<CsgoPartyEntry>),
279}
280
281#[derive(Debug, Clone)]
282pub struct CsgoPartyEntry {
283 pub account_id: u32,
284 pub lobby_id: u32,
285 pub game_type: u32,
286 pub loc: u32,
287}
288
289#[derive(Debug, Clone)]
291pub enum ContentEvent {
292 RichPresence {
294 appid: u32,
296 users: Vec<crate::services::rich_presence::RichPresenceData>,
298 },
299}
300
301#[derive(Debug, Clone)]
303pub enum SystemEvent {
304 Debug(String),
306
307 Error(String),
309}
310
311#[derive(Debug, Clone)]
313pub enum AccountEvent {
314 EmailInfo {
316 address: String,
318 validated: bool,
320 },
321
322 AccountLimitations {
324 limited: bool,
326 community_banned: bool,
328 locked: bool,
330 can_invite_friends: bool,
332 },
333
334 Wallet {
336 has_wallet: bool,
338 currency: i32,
340 balance: i64,
342 },
343
344 VacBans {
346 num_bans: u32,
348 appids: Vec<u32>,
350 },
351
352 AccountInfo {
354 name: String,
356 country: String,
358 authed_machines: u32,
360 flags: u32,
362 },
363}
364
365#[derive(Debug, Clone)]
367pub enum NotificationsEvent {
368 TradeOffers {
370 count: u32,
372 },
373
374 OfflineMessages {
376 count: u32,
378 friends: Vec<SteamID>,
380 },
381
382 NewItems {
384 count: u32,
386 },
387
388 NewComments {
390 count: u32,
392 owner_comments: u32,
394 subscription_comments: u32,
396 },
397
398 CommunityMessages {
400 count: u32,
402 },
403
404 NotificationsReceived(Vec<NotificationData>),
406}
407
408#[derive(Debug, Clone)]
433pub enum SteamEvent {
434 Auth(AuthEvent),
436
437 Connection(ConnectionEvent),
439
440 Friends(FriendsEvent),
442
443 Chat(ChatEvent),
445
446 Apps(AppsEvent),
448
449 CSGO(CSGOEvent),
451
452 Content(ContentEvent),
454
455 Account(AccountEvent),
457
458 Notifications(NotificationsEvent),
460
461 System(SystemEvent),
463}
464
465impl SteamEvent {
470 pub fn is_auth(&self) -> bool {
472 matches!(self, SteamEvent::Auth(_))
473 }
474
475 pub fn is_connection(&self) -> bool {
477 matches!(self, SteamEvent::Connection(_))
478 }
479
480 pub fn is_friends(&self) -> bool {
482 matches!(self, SteamEvent::Friends(_))
483 }
484
485 pub fn is_chat(&self) -> bool {
487 matches!(self, SteamEvent::Chat(_))
488 }
489
490 pub fn is_apps(&self) -> bool {
492 matches!(self, SteamEvent::Apps(_))
493 }
494
495 pub fn is_content(&self) -> bool {
497 matches!(self, SteamEvent::Content(_))
498 }
499
500 pub fn is_system(&self) -> bool {
502 matches!(self, SteamEvent::System(_))
503 }
504
505 pub fn is_account(&self) -> bool {
507 matches!(self, SteamEvent::Account(_))
508 }
509
510 pub fn is_notifications(&self) -> bool {
512 matches!(self, SteamEvent::Notifications(_))
513 }
514
515 pub fn is_csgo(&self) -> bool {
517 matches!(self, SteamEvent::CSGO(_))
518 }
519
520 pub fn chat_sender(&self) -> Option<SteamID> {
522 match self {
523 SteamEvent::Chat(ChatEvent::FriendMessage { sender, .. }) => Some(*sender),
524 SteamEvent::Chat(ChatEvent::FriendTyping { sender }) => Some(*sender),
525 _ => None,
526 }
527 }
528}
529
530#[derive(Debug, Clone)]
536pub struct FriendEntry {
537 pub steam_id: SteamID,
538 pub relationship: EFriendRelationship,
539}
540
541#[derive(Debug, Clone, Default)]
543pub struct LicenseEntry {
544 pub package_id: u32,
545 pub time_created: u32,
546 pub time_next_process: u32,
547 pub minute_limit: i32,
548 pub minutes_used: i32,
549 pub payment_method: u32,
550 pub flags: u32,
551 pub purchase_country_code: String,
552 pub license_type: u32,
553 pub territory_code: i32,
554 pub change_number: i32,
555 pub owner_id: u32,
556 pub initial_period: u32,
557 pub initial_time_unit: u32,
558 pub renewal_period: u32,
559 pub renewal_time_unit: u32,
560 pub access_token: u64,
561 pub master_package_id: u32,
562}
563
564#[derive(Debug, Clone)]
566pub struct CsgoWelcome {
567 pub prime: bool,
569 pub elevated_state: u32,
571 pub bonus_xp_usedflags: u32,
573 pub items: Vec<steam_protos::CSOEconItem>,
575}
576
577#[derive(Debug, Clone)]
579pub struct CsgoClientHello {
580 pub account_id: u32,
582 pub vac_banned: i32,
584 pub penalty_seconds: u32,
586 pub penalty_reason: u32,
588 pub player_level: i32,
590 pub player_cur_xp: i32,
592 pub player_xp_bonus_flags: i32,
594 pub ranking: Option<CsgoRanking>,
596 pub commendation: Option<CsgoCommendation>,
598 pub players_online: u32,
600 pub servers_online: u32,
601 pub ongoing_matches: u32,
602}
603
604#[derive(Debug, Clone)]
606pub struct CsgoRanking {
607 pub rank_id: u32,
609 pub wins: u32,
611 pub rank_type_id: u32,
614}
615
616#[derive(Debug, Clone)]
618pub struct CsgoCommendation {
619 pub cmd_friendly: u32,
621 pub cmd_teaching: u32,
623 pub cmd_leader: u32,
625}
626
627#[derive(Debug, Clone)]
629pub struct ChatRoomState {
630 pub chat_id: u64,
631 pub chat_name: String,
632 pub voice_allowed: bool,
633 pub members_in_voice: Vec<SteamID>,
634 pub time_last_message: u32,
635 pub last_message: String,
636 pub steamid_last_message: SteamID,
637}
638
639#[derive(Debug, Clone)]
641pub struct ModifiedChatMessage {
642 pub server_timestamp: u32,
643 pub ordinal: u32,
644 pub deleted: bool,
645}
646
647#[derive(Debug, Clone)]
649pub struct ChatRoomGroupHeaderState {
650 pub name: String,
651 pub steamid_owner: SteamID,
652 pub appid: Option<u32>,
653 pub steamid_clan: Option<SteamID>,
654 pub avatar_sha: Vec<u8>,
655 pub default_chat_id: u64,
656}
657
658#[derive(Debug, Clone)]
660pub struct NotificationData {
661 pub id: u64,
662 pub notification_type: i32,
663 pub body_data: String,
664 pub read: bool,
665 pub timestamp: u32,
666 pub hidden: bool,
667 pub expiry: Option<u32>,
668 pub viewed: Option<u32>,
669}
670
671pub struct MessageHandler;
677
678#[derive(Debug)]
680pub struct DecodedMessage {
681 pub events: Vec<SteamEvent>,
683 pub job_id_target: Option<u64>,
686 pub body: Bytes,
688}
689
690impl MessageHandler {
691 pub fn decode_message(data: &[u8]) -> Vec<SteamEvent> {
696 Self::decode_packet(data).into_iter().flat_map(|m| m.events).collect()
697 }
698
699 pub fn decode_packet(data: &[u8]) -> Vec<DecodedMessage> {
705 if data.len() < 8 {
706 return vec![];
707 }
708
709 let raw_emsg = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
711 let is_protobuf = (raw_emsg & 0x80000000) != 0;
712 let emsg = raw_emsg & !0x80000000;
713 let emsg_type = EMsg::from_i32((raw_emsg & !0x80000000) as i32).unwrap_or(EMsg::Invalid);
714
715 if is_protobuf {
716 Self::decode_protobuf_message_with_job(emsg_type, emsg, &data[4..])
717 } else {
718 vec![DecodedMessage { events: Self::decode_extended_message(emsg_type, &data[4..]), job_id_target: None, body: Bytes::new() }]
719 }
720 }
721
722 pub fn decode_message_with_job(data: &[u8]) -> DecodedMessage {
727 let mut messages = Self::decode_packet(data);
730 if messages.is_empty() {
731 return DecodedMessage { events: vec![], job_id_target: None, body: Bytes::new() };
732 }
733
734 if messages.len() == 1 {
735 return messages.pop().expect("Messages should not be empty if pop returned None here");
736 }
737
738 let mut all_events = Vec::new();
740 for msg in messages {
741 all_events.extend(msg.events);
742 }
743
744 DecodedMessage {
745 events: all_events,
746 job_id_target: None, body: Bytes::new(),
748 }
749 }
750
751 pub fn decode_single(data: &[u8]) -> Option<SteamEvent> {
753 Self::decode_message(data).into_iter().next()
754 }
755
756 #[allow(dead_code)]
758 fn decode_protobuf_message(emsg: EMsg, raw_emsg: u32, data: &[u8]) -> Vec<SteamEvent> {
759 Self::decode_packet_protobuf(emsg, raw_emsg, data).into_iter().flat_map(|m| m.events).collect()
760 }
761
762 fn decode_protobuf_message_with_job(emsg: EMsg, raw_emsg: u32, data: &[u8]) -> Vec<DecodedMessage> {
764 Self::decode_packet_protobuf(emsg, raw_emsg, data)
765 }
766
767 fn decode_packet_protobuf(emsg: EMsg, raw_emsg: u32, data: &[u8]) -> Vec<DecodedMessage> {
769 use prost::Message as _;
770
771 use crate::protocol::header::CMsgProtoBufHeader;
772
773 if data.len() < 4 {
774 return vec![];
775 }
776
777 let header_len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
779
780 if data.len() < 4 + header_len {
781 return vec![];
782 }
783
784 let header_bytes = &data[4..4 + header_len];
786 let parsed_header = CMsgProtoBufHeader::decode(header_bytes).ok();
787
788 let job_id_target = parsed_header.as_ref().and_then(|h| h.jobid_target).filter(|&id| id != u64::MAX);
789
790 let target_job_name = parsed_header.as_ref().and_then(|h| h.target_job_name.clone());
792
793 let body_slice = &data[4 + header_len..];
795
796 if emsg == EMsg::Multi {
798 return Self::handle_multi(body_slice);
799 }
800
801 let body_bytes = Bytes::copy_from_slice(body_slice);
802
803 let events = match emsg {
804 EMsg::ClientLogOnResponse => Self::handle_logon_response(body_slice).into_iter().collect(),
805 EMsg::ClientLoggedOff => Self::handle_logged_off(body_slice).into_iter().collect(),
806 EMsg::ClientFriendsList => {
807 info!("[MessageHandler] Received EMsg::ClientFriendsList packet, body size: {} bytes", body_slice.len());
808 Self::handle_friends_list(body_slice).into_iter().collect()
809 }
810 EMsg::ClientPersonaState => Self::handle_persona_state(body_slice),
811 EMsg::ClientLicenseList => Self::handle_license_list(body_slice).into_iter().collect(),
812 EMsg::ClientCMList => Self::handle_cm_list(body_slice).into_iter().collect(),
813 EMsg::ClientFromGC => Self::handle_from_gc(body_slice).into_iter().collect(),
814 EMsg::ServiceMethod => {
815 if let Some(ref job_name) = target_job_name {
817 Self::handle_service_method_by_name(job_name, body_slice).into_iter().collect()
818 } else {
819 Self::handle_service_method_legacy(body_slice).into_iter().collect()
821 }
822 }
823 EMsg::ClientPICSProductInfoResponse => Self::handle_pics_product_info(body_slice).into_iter().collect(),
824 EMsg::ClientPICSAccessTokenResponse => Self::handle_pics_access_tokens(body_slice).into_iter().collect(),
825 EMsg::ClientPICSChangesSinceResponse => Self::handle_pics_changes(body_slice).into_iter().collect(),
826 EMsg::ClientEmailAddrInfo => Self::handle_email_info(body_slice).into_iter().collect(),
828 EMsg::ClientIsLimitedAccount => Self::handle_account_limitations(body_slice).into_iter().collect(),
829 EMsg::ClientWalletInfoUpdate => Self::handle_wallet_info(body_slice).into_iter().collect(),
830 EMsg::ClientAccountInfo => Self::handle_account_info(body_slice).into_iter().collect(),
831 EMsg::ClientGameConnectTokens => Self::handle_game_connect_tokens(body_slice).into_iter().collect(),
832 EMsg::ClientUserNotifications => Self::handle_user_notifications(body_slice).into_iter().collect(),
834 EMsg::ClientChatOfflineMessageNotification => Self::handle_offline_messages(body_slice).into_iter().collect(),
835 EMsg::ClientItemAnnouncements => Self::handle_item_announcements(body_slice).into_iter().collect(),
836 EMsg::ClientCommentNotifications => Self::handle_comment_notifications(body_slice).into_iter().collect(),
837 EMsg::ClientMMSInviteToLobby => Self::handle_mms_invite(body_slice).into_iter().collect(),
838 EMsg::ClientPlayingSessionState => Self::handle_playing_session_state(body_slice).into_iter().collect(),
840 EMsg::ClientFriendsGroupsList | EMsg::ClientVACBanStatus | EMsg::ClientSessionToken | EMsg::ClientServerList | EMsg::ServiceMethodResponse | EMsg::ClientMarketingMessageUpdate2 => {
843 vec![]
845 }
846 _ => {
849 if emsg == EMsg::Invalid {
850 vec![SteamEvent::System(SystemEvent::Debug(format!("Unknown EMsg ID: {}", raw_emsg)))]
852 } else {
853 vec![]
860 }
861 }
862 };
863
864 vec![DecodedMessage { events, job_id_target, body: body_bytes }]
865 }
866
867 fn decode_extended_message(emsg: EMsg, _data: &[u8]) -> Vec<SteamEvent> {
869 vec![SteamEvent::System(SystemEvent::Debug(format!("Unhandled extended message: {:?}", emsg)))]
870 }
871
872 fn handle_multi(body: &[u8]) -> Vec<DecodedMessage> {
878 use rayon::prelude::*;
879
880 if let Ok(msg) = steam_protos::CMsgMulti::decode(body) {
881 let message_body = match msg.message_body {
882 Some(body) => body,
883 None => return vec![],
884 };
885
886 let payload = if msg.size_unzipped.unwrap_or(0) > 0 {
887 match Self::decompress_gzip(&message_body, msg.size_unzipped.unwrap_or(4096) as usize) {
889 Ok(decompressed) => decompressed,
890 Err(e) => {
891 return vec![DecodedMessage {
893 events: vec![SteamEvent::System(SystemEvent::Error(format!("Failed to decompress Multi: {}", e)))],
894 job_id_target: None,
895 body: Bytes::new(),
896 }];
897 }
898 }
899 } else {
900 message_body
902 };
903
904 let mut sub_messages: Vec<(usize, usize)> = Vec::new();
906 let mut offset = 0;
907
908 while offset + 4 <= payload.len() {
909 let sub_size = u32::from_le_bytes([payload[offset], payload[offset + 1], payload[offset + 2], payload[offset + 3]]) as usize;
910 offset += 4;
911
912 if offset + sub_size > payload.len() {
913 break;
914 }
915
916 sub_messages.push((offset, sub_size));
917 offset += sub_size;
918 }
919
920 if sub_messages.len() >= 4 {
922 return sub_messages
924 .par_iter()
925 .flat_map(|&(start, size)| {
926 let sub_msg = &payload[start..start + size];
927 Self::decode_packet(sub_msg)
928 })
929 .collect();
930 } else {
931 return sub_messages
933 .iter()
934 .flat_map(|&(start, size)| {
935 let sub_msg = &payload[start..start + size];
936 Self::decode_packet(sub_msg)
937 })
938 .collect();
939 }
940 }
941 vec![]
942 }
943
944 fn decompress_gzip(data: &[u8], capacity_hint: usize) -> Result<Vec<u8>, std::io::Error> {
946 let mut decoder = GzDecoder::new(data);
947 let mut decompressed = Vec::with_capacity(capacity_hint);
948 decoder.read_to_end(&mut decompressed)?;
949 Ok(decompressed)
950 }
951
952 fn handle_logon_response(body: &[u8]) -> Option<SteamEvent> {
953 use crate::utils::parsing::parse_logon_response;
954
955 match parse_logon_response(body) {
956 Ok(data) => {
957 if data.eresult == EResult::OK {
958 Some(SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: data.steam_id }))
959 } else {
960 Some(SteamEvent::Auth(AuthEvent::LoggedOff { result: data.eresult }))
962 }
963 }
964 Err(e) => Some(SteamEvent::System(SystemEvent::Error(format!("Failed to parse logon response: {}", e)))),
965 }
966 }
967
968 fn handle_logged_off(body: &[u8]) -> Option<SteamEvent> {
969 use crate::utils::parsing::parse_logged_off;
970
971 match parse_logged_off(body) {
972 Ok(result) => Some(SteamEvent::Auth(AuthEvent::LoggedOff { result })),
973 Err(e) => Some(SteamEvent::System(SystemEvent::Error(format!("Failed to parse logged off message: {}", e)))),
974 }
975 }
976
977 fn handle_friends_list(body: &[u8]) -> Option<SteamEvent> {
978 use crate::utils::parsing::parse_friends_list;
979
980 match parse_friends_list(body) {
981 Ok(data) => {
982 info!("Received FriendsList event: incremental={}, count={}", data.incremental, data.friends.len());
983 Some(SteamEvent::Friends(FriendsEvent::FriendsList {
984 incremental: data.incremental,
985 friends: data.friends.into_iter().map(|f| FriendEntry { steam_id: f.steam_id, relationship: f.relationship }).collect(),
986 }))
987 }
988 Err(e) => {
989 error!("Failed to parse friends list: {}", e);
990 Some(SteamEvent::System(SystemEvent::Error(format!("Failed to parse friends list: {}", e))))
991 }
992 }
993 }
994
995 fn resolve_game_name(game_id: Option<u64>) -> Option<String> {
996 static GAME_NAME_CACHE: Lazy<Mutex<HashMap<u32, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));
998
999 if let Some(game_id) = game_id {
1002 if game_id > 0 {
1003 let id_u32 = game_id as u32;
1005
1006 if let Ok(cache) = GAME_NAME_CACHE.lock() {
1008 if let Some(name) = cache.get(&id_u32) {
1009 return Some(name.clone());
1010 }
1011 }
1012
1013 if let Ok(idx) = APP_LIST.binary_search_by_key(&id_u32, |&(id, _)| id) {
1015 let name = APP_LIST[idx].1.to_string();
1016
1017 if let Ok(mut cache) = GAME_NAME_CACHE.lock() {
1019 cache.insert(id_u32, name.clone());
1020 }
1021
1022 return Some(name);
1023 }
1024 }
1025 }
1026 None
1027 }
1028
1029 fn handle_persona_state(body: &[u8]) -> Vec<SteamEvent> {
1030 if let Ok(msg) = steam_protos::CMsgClientPersonaState::decode(body) {
1031 return msg
1032 .friends
1033 .into_iter()
1034 .map(|friend| {
1035 let mut avatar_hash = friend.avatar_hash.as_ref().map(hex::encode);
1036
1037 if let Some(ref hash) = avatar_hash {
1039 if hash == "0000000000000000000000000000000000000000" {
1040 avatar_hash = Some("fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb".to_string());
1041 }
1042 }
1043
1044 let rich_presence: HashMap<String, String> = friend
1045 .rich_presence
1046 .into_iter()
1047 .filter_map(|kv| {
1048 let key = kv.key?;
1049 let value = kv.value?;
1050 Some((key, value))
1051 })
1052 .collect();
1053
1054 let steam_player_group = rich_presence.get("steam_player_group").filter(|s| s.as_str() != "0").cloned();
1056 let rich_presence_status = rich_presence.get("status").cloned();
1058 let game_map = rich_presence.get("game:map").cloned();
1060 let game_score = rich_presence.get("game:score").cloned();
1062 let num_players = rich_presence.get("members:numPlayers").and_then(|s| s.parse::<u32>().ok());
1064
1065 SteamEvent::Friends(FriendsEvent::PersonaState(Box::new(UserPersona {
1066 steam_id: SteamID::from(friend.friendid.unwrap_or(0)),
1067 player_name: friend.player_name.clone().unwrap_or_default(),
1068 persona_state: EPersonaState::from_i32(friend.persona_state.unwrap_or(0) as i32).unwrap_or(EPersonaState::Offline),
1069 persona_state_flags: friend.persona_state_flags.unwrap_or(0),
1070 avatar_hash,
1071 game_id: friend.gameid.or_else(|| friend.game_played_app_id.map(|id| id as u64)),
1074 game_name: Self::resolve_game_name(friend.gameid.or_else(|| friend.game_played_app_id.map(|id| id as u64))),
1075 last_logon: friend.last_logon.and_then(|ts| Utc.timestamp_opt(ts as i64, 0).single()),
1076 last_logoff: friend.last_logoff.and_then(|ts| Utc.timestamp_opt(ts as i64, 0).single()),
1077 last_seen_online: friend.last_seen_online.and_then(|ts| Utc.timestamp_opt(ts as i64, 0).single()),
1078 rich_presence,
1079 steam_player_group,
1080 rich_presence_status,
1081 game_map,
1082 game_score,
1083 num_players,
1084 unread_count: 0,
1085 last_message_time: 0,
1086 })))
1087 })
1088 .collect();
1089 }
1090 vec![]
1091 }
1092
1093 fn handle_license_list(body: &[u8]) -> Option<SteamEvent> {
1094 if let Ok(msg) = steam_protos::CMsgClientLicenseList::decode(body) {
1095 let licenses: Vec<LicenseEntry> = msg
1096 .licenses
1097 .iter()
1098 .map(|l| LicenseEntry {
1099 package_id: l.package_id.unwrap_or(0),
1100 time_created: l.time_created.unwrap_or(0),
1101 time_next_process: l.time_next_process.unwrap_or(0),
1102 minute_limit: l.minute_limit.unwrap_or(0),
1103 minutes_used: l.minutes_used.unwrap_or(0),
1104 payment_method: l.payment_method.unwrap_or(0),
1105 flags: l.flags.unwrap_or(0),
1106 purchase_country_code: l.purchase_country_code.clone().unwrap_or_default(),
1107 license_type: l.license_type.unwrap_or(0),
1108 territory_code: l.territory_code.unwrap_or(0),
1109 change_number: l.change_number.unwrap_or(0),
1110 owner_id: l.owner_id.unwrap_or(0),
1111 initial_period: l.initial_period.unwrap_or(0),
1112 initial_time_unit: l.initial_time_unit.unwrap_or(0),
1113 renewal_period: l.renewal_period.unwrap_or(0),
1114 renewal_time_unit: l.renewal_time_unit.unwrap_or(0),
1115 access_token: l.access_token.unwrap_or(0),
1116 master_package_id: l.master_package_id.unwrap_or(0),
1117 })
1118 .collect();
1119
1120 return Some(SteamEvent::Apps(AppsEvent::LicenseList { licenses }));
1121 }
1122 None
1123 }
1124
1125 fn handle_friend_typing_echo(body: &[u8]) -> Option<SteamEvent> {
1127 if let Ok(msg) = steam_protos::CFriendMessagesAckMessageNotification::decode(body) {
1128 if let Some(steamid) = msg.steamid_partner {
1129 return Some(SteamEvent::Chat(ChatEvent::FriendTypingEcho { receiver: SteamID::from(steamid) }));
1130 }
1131 }
1132 None
1133 }
1134
1135 fn handle_chatroom_notification(job_name: &str, body: &[u8]) -> Option<SteamEvent> {
1136 match job_name {
1137 "ChatRoomClient.NotifyIncomingChatMessage#1" => {
1138 if let Ok(msg) = steam_protos::CChatRoomIncomingChatMessageNotification::decode(body) {
1139 return Some(SteamEvent::Chat(ChatEvent::ChatMessage {
1140 chat_group_id: msg.chat_group_id.unwrap_or(0),
1141 chat_id: msg.chat_id.unwrap_or(0),
1142 sender: SteamID::from(msg.steamid_sender.unwrap_or(0)),
1143 message: msg.message.unwrap_or_default(),
1144 timestamp: msg.timestamp.unwrap_or(0),
1145 ordinal: msg.ordinal.unwrap_or(0),
1146 }));
1147 }
1148 }
1149 "ChatRoomClient.NotifyMemberStateChange#1" => {
1150 if let Ok(msg) = steam_protos::CChatRoomMemberStateChangeNotification::decode(body) {
1151 let steamid = msg.member.as_ref().and_then(|m| m.accountid).map(SteamID::from_individual_account_id).unwrap_or_default();
1152 return Some(SteamEvent::Chat(ChatEvent::ChatMemberStateChange { chat_group_id: msg.chat_group_id.unwrap_or(0), steam_id: steamid, change: msg.change.unwrap_or(0) }));
1153 }
1154 }
1155 "ChatRoomClient.NotifyChatRoomGroupRoomsChange#1" => {
1156 if let Ok(msg) = steam_protos::CChatRoomChatRoomGroupRoomsChangeNotification::decode(body) {
1157 let chat_rooms = msg
1158 .chat_rooms
1159 .into_iter()
1160 .map(|room| {
1161 let members_in_voice = room.members_in_voice.into_iter().map(SteamID::from_individual_account_id).collect();
1162 ChatRoomState {
1163 chat_id: room.chat_id.unwrap_or(0),
1164 chat_name: room.chat_name.unwrap_or_default(),
1165 voice_allowed: room.voice_allowed.unwrap_or(false),
1166 members_in_voice,
1167 time_last_message: room.time_last_message.unwrap_or(0),
1168 last_message: room.last_message.unwrap_or_default(),
1169 steamid_last_message: SteamID::from_individual_account_id(room.accountid_last_message.unwrap_or(0)),
1170 }
1171 })
1172 .collect();
1173 return Some(SteamEvent::Chat(ChatEvent::ChatRoomGroupRoomsChange { chat_group_id: msg.chat_group_id.unwrap_or(0), default_chat_id: msg.default_chat_id.unwrap_or(0), chat_rooms }));
1174 }
1175 }
1176 "ChatRoomClient.NotifyChatMessageModified#1" => {
1177 if let Ok(msg) = steam_protos::CChatRoomChatMessageModifiedNotification::decode(body) {
1178 let messages = msg
1179 .messages
1180 .into_iter()
1181 .map(|m| ModifiedChatMessage {
1182 server_timestamp: m.server_timestamp.unwrap_or(0),
1183 ordinal: m.ordinal.unwrap_or(0),
1184 deleted: m.deleted.unwrap_or(false),
1185 })
1186 .collect();
1187 return Some(SteamEvent::Chat(ChatEvent::ChatMessagesModified { chat_group_id: msg.chat_group_id.unwrap_or(0), chat_id: msg.chat_id.unwrap_or(0), messages }));
1188 }
1189 }
1190 "ChatRoomClient.NotifyChatRoomHeaderStateChange#1" => {
1191 if let Ok(msg) = steam_protos::CChatRoomChatRoomHeaderStateNotification::decode(body) {
1192 if let Some(header) = msg.header_state {
1193 return Some(SteamEvent::Chat(ChatEvent::ChatRoomGroupHeaderStateChange {
1194 chat_group_id: header.chat_group_id.unwrap_or(0),
1195 header_state: ChatRoomGroupHeaderState {
1196 name: header.chat_name.unwrap_or_default(),
1197 steamid_owner: SteamID::from(header.steamid_owner.unwrap_or(0)),
1198 appid: header.appid,
1199 steamid_clan: header.steamid_clan.map(SteamID::from),
1200 avatar_sha: header.avatar_sha.unwrap_or_default(),
1201 default_chat_id: header.default_chat_id.unwrap_or(0) as u64,
1202 },
1203 }));
1204 }
1205 }
1206 }
1207 _ => {}
1208 }
1209 None
1210 }
1211
1212 fn handle_player_notification(job_name: &str, body: &[u8]) -> Option<SteamEvent> {
1213 if job_name == "PlayerClient.NotifyFriendNicknameChanged#1" {
1214 if let Ok(msg) = steam_protos::CPlayerFriendNicknameChangedNotification::decode(body) {
1215 return Some(SteamEvent::Friends(FriendsEvent::NicknameChanged { steam_id: SteamID::from_individual_account_id(msg.accountid.unwrap_or(0)), nickname: msg.nickname }));
1216 }
1217 }
1218 None
1219 }
1220
1221 fn handle_steam_notification(body: &[u8]) -> Option<SteamEvent> {
1222 if let Ok(msg) = steam_protos::CSteamNotificationNotificationsReceivedNotification::decode(body) {
1223 let notifications = msg
1224 .notifications
1225 .into_iter()
1226 .map(|n| NotificationData {
1227 id: n.notification_id.unwrap_or(0),
1228 notification_type: n.notification_type.unwrap_or(0),
1229 body_data: n.body_data.unwrap_or_default(),
1230 read: n.read.unwrap_or(false),
1231 timestamp: n.timestamp.unwrap_or(0),
1232 hidden: n.hidden.unwrap_or(false),
1233 expiry: n.expiry,
1234 viewed: n.viewed,
1235 })
1236 .collect();
1237 return Some(SteamEvent::Notifications(NotificationsEvent::NotificationsReceived(notifications)));
1238 }
1239 None
1240 }
1241
1242 fn handle_cm_list(body: &[u8]) -> Option<SteamEvent> {
1243 if let Ok(msg) = steam_protos::CMsgClientCMList::decode(body) {
1244 return Some(SteamEvent::Connection(ConnectionEvent::CMList { servers: msg.cm_websocket_addresses }));
1245 }
1246 None
1247 }
1248
1249 fn handle_service_method_by_name(job_name: &str, body: &[u8]) -> Option<SteamEvent> {
1256 match job_name {
1257 "FriendMessagesClient.IncomingMessage#1" => Self::handle_friend_message_notification(body),
1259
1260 "FriendMessagesClient.NotifyAckMessageEcho#1" => Self::handle_friend_typing_echo(body),
1262
1263 "SteamNotificationClient.NotificationsReceived#1" => Self::handle_steam_notification(body),
1265
1266 name if name.starts_with("ChatRoomClient.") => Self::handle_chatroom_notification(name, body),
1268
1269 name if name.starts_with("PlayerClient.") => Self::handle_player_notification(name, body),
1271
1272 _ => None,
1274 }
1275 }
1276
1277 fn handle_service_method_legacy(body: &[u8]) -> Option<SteamEvent> {
1279 Self::handle_friend_message_notification(body)
1281 }
1282
1283 fn handle_friend_message_notification(body: &[u8]) -> Option<SteamEvent> {
1292 if let Ok(msg) = steam_protos::CFriendMessagesIncomingMessageNotification::decode(body) {
1293 let steamid_friend = msg.steamid_friend.unwrap_or(0);
1295 if steamid_friend == 0 {
1296 return None;
1298 }
1299
1300 let steam_id = SteamID::from_steam_id64(steamid_friend);
1301 let local_echo = msg.local_echo.unwrap_or(false);
1302 let chat_entry_type = msg.chat_entry_type.unwrap_or(1);
1303
1304 let message = msg.message_no_bbcode.clone().filter(|s| !s.is_empty()).or_else(|| msg.message.clone()).unwrap_or_default();
1306
1307 match chat_entry_type {
1309 val if val == EChatEntryType::Typing as i32 => {
1311 if local_echo {
1312 return Some(SteamEvent::Chat(ChatEvent::FriendTypingEcho { receiver: steam_id }));
1313 } else {
1314 return Some(SteamEvent::Chat(ChatEvent::FriendTyping { sender: steam_id }));
1315 }
1316 }
1317
1318 val if val == EChatEntryType::LeftConversation as i32 => {
1320 if local_echo {
1321 return Some(SteamEvent::Chat(ChatEvent::FriendLeftConversationEcho { receiver: steam_id }));
1322 } else {
1323 return Some(SteamEvent::Chat(ChatEvent::FriendLeftConversation { sender: steam_id }));
1324 }
1325 }
1326
1327 _ => {
1329 if local_echo {
1330 return Some(SteamEvent::Chat(ChatEvent::FriendMessageEcho {
1331 receiver: steam_id,
1332 message,
1333 timestamp: msg.rtime32_server_timestamp.unwrap_or(0),
1334 ordinal: msg.ordinal.unwrap_or(0),
1335 }));
1336 } else {
1337 return Some(SteamEvent::Chat(ChatEvent::FriendMessage {
1338 sender: steam_id,
1339 message,
1340 chat_entry_type: EChatEntryType::from_i32(chat_entry_type).unwrap_or(EChatEntryType::ChatMsg),
1341 timestamp: msg.rtime32_server_timestamp.unwrap_or(0),
1342 ordinal: msg.ordinal.unwrap_or(0),
1343 from_limited_account: msg.from_limited_account.unwrap_or(false),
1344 low_priority: msg.low_priority.unwrap_or(false),
1345 }));
1346 }
1347 }
1348 }
1349 }
1350 None
1351 }
1352
1353 fn handle_from_gc(body: &[u8]) -> Vec<SteamEvent> {
1354 let msg = match steam_protos::CMsgGCClient::decode(body) {
1355 Ok(m) => m,
1356 Err(_) => return vec![],
1357 };
1358
1359 let gc_msg = match crate::services::gc::parse_gc_message(&msg) {
1360 Some(m) => m,
1361 None => return vec![],
1362 };
1363
1364 let mut events = vec![SteamEvent::Apps(AppsEvent::GCReceived(gc_msg.clone()))];
1365
1366 if gc_msg.appid == 730 {
1368 if let Some(event) = Self::handle_csgo_gc_message(gc_msg.msg_type, &gc_msg.payload) {
1369 events.push(event);
1370 }
1371 }
1372
1373 events
1374 }
1375
1376 fn handle_csgo_gc_message(msg_type: u32, payload: &[u8]) -> Option<SteamEvent> {
1378 let msg_i32 = msg_type as i32;
1379
1380 if let Some(msg) = ECsgoGCMsg::from_i32(msg_i32) {
1381 match msg {
1382 ECsgoGCMsg::MatchmakingGC2ClientHello => return Self::handle_csgo_client_hello(payload),
1383 ECsgoGCMsg::PlayersProfile => return Self::handle_csgo_players_profile(payload),
1384 ECsgoGCMsg::Party_Search => return Self::handle_csgo_party_search_results(payload),
1385 ECsgoGCMsg::Party_Invite => return Self::handle_csgo_party_invite(payload),
1386 _ => {}
1387 }
1388 }
1389
1390 if let Some(msg) = EGCBaseClientMsg::from_i32(msg_i32) {
1391 if msg == EGCBaseClientMsg::ClientConnectionStatus {
1392 return Self::handle_csgo_welcome(payload);
1393 }
1394 }
1395
1396 None
1397 }
1398
1399 fn handle_csgo_welcome(payload: &[u8]) -> Option<SteamEvent> {
1401 let welcome = steam_protos::CMsgClientWelcome::decode(payload).ok()?;
1402
1403 let mut prime = false;
1404 let mut elevated_state = 0;
1405 let mut bonus_xp_usedflags = 0;
1406 let mut items = Vec::new();
1407
1408 for cache in welcome.outofdate_subscribed_caches {
1409 for object in cache.objects {
1410 match object.type_id {
1411 Some(1) => {
1413 for data in &object.object_data {
1414 if let Ok(item) = steam_protos::CSOEconItem::decode(&**data) {
1415 items.push(item);
1416 }
1417 }
1418 }
1419 Some(7) => {
1421 for data in &object.object_data {
1422 if let Ok(account) = steam_protos::CSOEconGameAccountClient::decode(&**data) {
1423 elevated_state = account.elevated_state.unwrap_or(0);
1424 bonus_xp_usedflags = account.bonus_xp_usedflags.unwrap_or(0);
1425
1426 if (bonus_xp_usedflags & 16) != 0 || elevated_state == 5 {
1428 prime = true;
1429 }
1430 }
1431 }
1432 }
1433 _ => {}
1434 }
1435 }
1436 }
1437
1438 Some(SteamEvent::CSGO(CSGOEvent::Online(CsgoWelcome { prime, elevated_state, bonus_xp_usedflags, items })))
1439 }
1440
1441 fn handle_csgo_client_hello(payload: &[u8]) -> Option<SteamEvent> {
1443 let hello = steam_protos::CMsgGccStrike15V2MatchmakingGc2ClientHello::decode(payload).ok()?;
1444 Some(SteamEvent::CSGO(CSGOEvent::ClientHello(Self::build_csgo_client_hello(&hello))))
1445 }
1446
1447 fn handle_csgo_players_profile(payload: &[u8]) -> Option<SteamEvent> {
1449 let profile_msg = steam_protos::CMsgGccStrike15V2PlayersProfile::decode(payload).ok()?;
1450
1451 let profiles: Vec<CsgoClientHello> = profile_msg.account_profiles.iter().map(Self::build_csgo_client_hello).collect();
1452
1453 Some(SteamEvent::CSGO(CSGOEvent::PlayersProfile(profiles)))
1454 }
1455
1456 fn handle_csgo_party_invite(payload: &[u8]) -> Option<SteamEvent> {
1458 let invite = steam_protos::CMsgGccStrike15V2PartyInvite::decode(payload).ok()?;
1459
1460 Some(SteamEvent::CSGO(CSGOEvent::PartyInvite {
1461 inviter: SteamID::from_individual_account_id(invite.accountid.unwrap_or(0)),
1462 lobby_id: invite.lobbyid.unwrap_or(0) as u64,
1463 }))
1464 }
1465
1466 fn handle_csgo_party_search_results(payload: &[u8]) -> Option<SteamEvent> {
1468 let results = steam_protos::CMsgGccStrike15V2PartySearchResults::decode(payload).ok()?;
1469
1470 let entries: Vec<CsgoPartyEntry> = results
1471 .entries
1472 .into_iter()
1473 .map(|e| CsgoPartyEntry {
1474 account_id: e.accountid.unwrap_or(0),
1475 lobby_id: e.id.unwrap_or(0),
1476 game_type: e.game_type.unwrap_or(0),
1477 loc: e.loc.unwrap_or(0),
1478 })
1479 .collect();
1480
1481 Some(SteamEvent::CSGO(CSGOEvent::PartySearchResults(entries)))
1482 }
1483
1484 pub(crate) fn build_csgo_client_hello(hello: &steam_protos::CMsgGccStrike15V2MatchmakingGc2ClientHello) -> CsgoClientHello {
1487 let ranking = hello.ranking.as_ref().map(|r| CsgoRanking { rank_id: r.rank_id.unwrap_or(0), wins: r.wins.unwrap_or(0), rank_type_id: r.rank_type_id.unwrap_or(0) });
1488
1489 let commendation = hello.commendation.as_ref().map(|c| CsgoCommendation {
1490 cmd_friendly: c.cmd_friendly.unwrap_or(0),
1491 cmd_teaching: c.cmd_teaching.unwrap_or(0),
1492 cmd_leader: c.cmd_leader.unwrap_or(0),
1493 });
1494
1495 let global_stats = hello.global_stats.as_ref();
1496
1497 CsgoClientHello {
1498 account_id: hello.account_id.unwrap_or(0),
1499 vac_banned: hello.vac_banned.unwrap_or(0),
1500 penalty_seconds: hello.penalty_seconds.unwrap_or(0),
1501 penalty_reason: hello.penalty_reason.unwrap_or(0),
1502 player_level: hello.player_level.unwrap_or(0),
1503 player_cur_xp: hello.player_cur_xp.unwrap_or(0),
1504 player_xp_bonus_flags: hello.player_xp_bonus_flags.unwrap_or(0),
1505 ranking,
1506 commendation,
1507 players_online: global_stats.and_then(|s| s.players_online).unwrap_or(0),
1508 servers_online: global_stats.and_then(|s| s.servers_online).unwrap_or(0),
1509 ongoing_matches: global_stats.and_then(|s| s.ongoing_matches).unwrap_or(0),
1510 }
1511 }
1512
1513 fn handle_pics_product_info(body: &[u8]) -> Option<SteamEvent> {
1514 parsing::parse_pics_product_info(body).ok().map(|data| {
1515 SteamEvent::Apps(AppsEvent::ProductInfoResponse {
1516 apps: data.apps,
1517 packages: data.packages,
1518 unknown_apps: data.unknown_apps,
1519 unknown_packages: data.unknown_packages,
1520 })
1521 })
1522 }
1523
1524 fn handle_pics_access_tokens(body: &[u8]) -> Option<SteamEvent> {
1525 parsing::parse_pics_access_tokens(body).ok().map(|data| {
1526 SteamEvent::Apps(AppsEvent::AccessTokensResponse {
1527 app_tokens: data.app_tokens,
1528 package_tokens: data.package_tokens,
1529 app_denied: data.app_denied,
1530 package_denied: data.package_denied,
1531 })
1532 })
1533 }
1534
1535 fn handle_pics_changes(body: &[u8]) -> Option<SteamEvent> {
1536 parsing::parse_pics_changes(body).ok().map(|data| {
1537 SteamEvent::Apps(AppsEvent::ProductChangesResponse {
1538 current_change_number: data.current_change_number,
1539 app_changes: data.app_changes,
1540 package_changes: data.package_changes,
1541 })
1542 })
1543 }
1544
1545 fn handle_mms_invite(body: &[u8]) -> Option<SteamEvent> {
1550 if let Ok(msg) = steam_protos::CMsgClientMmsInviteToLobby::decode(body) {
1551 return Some(SteamEvent::CSGO(CSGOEvent::PartyInvite {
1552 inviter: SteamID::from(0), lobby_id: msg.steam_id_lobby.unwrap_or(0),
1554 }));
1555 }
1556 None
1557 }
1558
1559 fn handle_email_info(body: &[u8]) -> Option<SteamEvent> {
1560 if let Ok(msg) = steam_protos::CMsgClientEmailAddrInfo::decode(body) {
1561 return Some(SteamEvent::Account(AccountEvent::EmailInfo { address: msg.email_address.unwrap_or_default(), validated: msg.email_is_validated.unwrap_or(false) }));
1562 }
1563 None
1564 }
1565
1566 fn handle_account_limitations(body: &[u8]) -> Option<SteamEvent> {
1567 if let Ok(msg) = steam_protos::CMsgClientIsLimitedAccount::decode(body) {
1568 return Some(SteamEvent::Account(AccountEvent::AccountLimitations {
1569 limited: msg.bis_limited_account.unwrap_or(false),
1570 community_banned: msg.bis_community_banned.unwrap_or(false),
1571 locked: msg.bis_locked_account.unwrap_or(false),
1572 can_invite_friends: msg.bis_limited_account_allowed_to_invite_friends.unwrap_or(true),
1573 }));
1574 }
1575 None
1576 }
1577
1578 fn handle_wallet_info(body: &[u8]) -> Option<SteamEvent> {
1579 if let Ok(msg) = steam_protos::CMsgClientWalletInfoUpdate::decode(body) {
1580 return Some(SteamEvent::Account(AccountEvent::Wallet {
1581 has_wallet: msg.has_wallet.unwrap_or(false),
1582 currency: msg.currency.unwrap_or(0),
1583 balance: msg.balance64.unwrap_or(msg.balance.unwrap_or(0) as i64),
1584 }));
1585 }
1586 None
1587 }
1588
1589 fn handle_account_info(body: &[u8]) -> Option<SteamEvent> {
1590 if let Ok(msg) = steam_protos::CMsgClientAccountInfo::decode(body) {
1591 return Some(SteamEvent::Account(AccountEvent::AccountInfo {
1592 name: msg.persona_name.unwrap_or_default(),
1593 country: msg.ip_country.unwrap_or_default(),
1594 authed_machines: msg.count_authed_computers.unwrap_or(0) as u32,
1595 flags: msg.account_flags.unwrap_or(0),
1596 }));
1597 }
1598 None
1599 }
1600
1601 fn handle_game_connect_tokens(body: &[u8]) -> Option<SteamEvent> {
1602 if let Ok(msg) = steam_protos::CMsgClientGameConnectTokens::decode(body) {
1603 return Some(SteamEvent::Auth(AuthEvent::GameConnectTokens { tokens: msg.tokens }));
1604 }
1605 None
1606 }
1607
1608 fn handle_playing_session_state(body: &[u8]) -> Option<SteamEvent> {
1609 if let Ok(msg) = steam_protos::CMsgClientPlayingSessionState::decode(body) {
1610 return Some(SteamEvent::Apps(AppsEvent::PlayingState { blocked: msg.playing_blocked.unwrap_or(false), playing_app: msg.playing_app.unwrap_or(0) }));
1611 }
1612 None
1613 }
1614
1615 fn handle_user_notifications(body: &[u8]) -> Option<SteamEvent> {
1620 if let Ok(msg) = steam_protos::CMsgClientUserNotifications::decode(body) {
1621 for notif in &msg.notifications {
1624 let notif_type = notif.user_notification_type.unwrap_or(0);
1625 let count = notif.count.unwrap_or(0);
1626
1627 match notif_type {
1628 1 => {
1629 return Some(SteamEvent::Notifications(NotificationsEvent::TradeOffers { count }));
1630 }
1631 3 => {
1632 return Some(SteamEvent::Notifications(NotificationsEvent::CommunityMessages { count }));
1633 }
1634 _ => {}
1635 }
1636 }
1637 }
1638 None
1639 }
1640
1641 fn handle_offline_messages(body: &[u8]) -> Option<SteamEvent> {
1642 if let Ok(msg) = steam_protos::CMsgClientOfflineMessageNotification::decode(body) {
1643 let friends: Vec<SteamID> = msg
1644 .friends_with_offline_messages
1645 .iter()
1646 .map(|&account_id| {
1647 SteamID::from_individual_account_id(account_id)
1649 })
1650 .collect();
1651
1652 return Some(SteamEvent::Notifications(NotificationsEvent::OfflineMessages { count: msg.offline_messages.unwrap_or(0), friends }));
1653 }
1654 None
1655 }
1656
1657 fn handle_item_announcements(body: &[u8]) -> Option<SteamEvent> {
1658 if let Ok(msg) = steam_protos::CMsgClientItemAnnouncements::decode(body) {
1659 return Some(SteamEvent::Notifications(NotificationsEvent::NewItems { count: msg.count_new_items.unwrap_or(0) }));
1660 }
1661 None
1662 }
1663
1664 fn handle_comment_notifications(body: &[u8]) -> Option<SteamEvent> {
1665 if let Ok(msg) = steam_protos::CMsgClientCommentNotifications::decode(body) {
1666 return Some(SteamEvent::Notifications(NotificationsEvent::NewComments {
1667 count: msg.count_new_comments.unwrap_or(0),
1668 owner_comments: msg.count_new_comments_owner.unwrap_or(0),
1669 subscription_comments: msg.count_new_comments_subscriptions.unwrap_or(0),
1670 }));
1671 }
1672 None
1673 }
1674}
1675
1676#[cfg(test)]
1677mod tests {
1678 use std::time::Duration;
1679
1680 use super::*;
1681
1682 fn test_steam_id() -> SteamID {
1687 SteamID::from_steam_id64(76561198000000000)
1688 }
1689
1690 #[test]
1695 fn test_auth_event_logged_on() {
1696 let steam_id = test_steam_id();
1697 let event = SteamEvent::Auth(AuthEvent::LoggedOn { steam_id });
1698
1699 assert!(event.is_auth());
1700 assert!(!event.is_connection());
1701 assert!(!event.is_chat());
1702
1703 match event {
1705 SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: sid }) => {
1706 assert_eq!(sid.steam_id64(), 76561198000000000);
1707 }
1708 _ => panic!("Expected LoggedOn event"),
1709 }
1710 }
1711
1712 #[test]
1713 fn test_auth_event_logged_off() {
1714 let event = SteamEvent::Auth(AuthEvent::LoggedOff { result: EResult::LoggedInElsewhere });
1715
1716 assert!(event.is_auth());
1717
1718 match event {
1719 SteamEvent::Auth(AuthEvent::LoggedOff { result }) => {
1720 assert_eq!(result, EResult::LoggedInElsewhere);
1721 }
1722 _ => panic!("Expected LoggedOff event"),
1723 }
1724 }
1725
1726 #[test]
1727 fn test_auth_event_refresh_token() {
1728 let event = SteamEvent::Auth(AuthEvent::RefreshToken { token: "test_token_123".to_string(), account_name: "test_user".to_string() });
1729
1730 assert!(event.is_auth());
1731
1732 match event {
1733 SteamEvent::Auth(AuthEvent::RefreshToken { token, account_name }) => {
1734 assert_eq!(token, "test_token_123");
1735 assert_eq!(account_name, "test_user");
1736 }
1737 _ => panic!("Expected RefreshToken event"),
1738 }
1739 }
1740
1741 #[test]
1746 fn test_connection_event_connected() {
1747 let event = SteamEvent::Connection(ConnectionEvent::Connected);
1748
1749 assert!(event.is_connection());
1750 assert!(!event.is_auth());
1751
1752 assert!(matches!(event, SteamEvent::Connection(ConnectionEvent::Connected)));
1753 }
1754
1755 #[test]
1756 fn test_connection_event_disconnected() {
1757 let event = SteamEvent::Connection(ConnectionEvent::Disconnected { reason: Some(EResult::NoConnection), will_reconnect: true });
1758
1759 assert!(event.is_connection());
1760
1761 match event {
1762 SteamEvent::Connection(ConnectionEvent::Disconnected { reason, will_reconnect }) => {
1763 assert_eq!(reason, Some(EResult::NoConnection));
1764 assert!(will_reconnect);
1765 }
1766 _ => panic!("Expected Disconnected event"),
1767 }
1768 }
1769
1770 #[test]
1771 fn test_connection_event_reconnect_attempt() {
1772 let event = SteamEvent::Connection(ConnectionEvent::ReconnectAttempt { attempt: 3, max_attempts: 10, delay: Duration::from_secs(5) });
1773
1774 assert!(event.is_connection());
1775
1776 match event {
1777 SteamEvent::Connection(ConnectionEvent::ReconnectAttempt { attempt, max_attempts, delay }) => {
1778 assert_eq!(attempt, 3);
1779 assert_eq!(max_attempts, 10);
1780 assert_eq!(delay, Duration::from_secs(5));
1781 }
1782 _ => panic!("Expected ReconnectAttempt event"),
1783 }
1784 }
1785
1786 #[test]
1787 fn test_connection_event_reconnect_failed() {
1788 let event = SteamEvent::Connection(ConnectionEvent::ReconnectFailed { reason: Some(EResult::ServiceUnavailable), attempts: 10 });
1789
1790 match event {
1791 SteamEvent::Connection(ConnectionEvent::ReconnectFailed { reason, attempts }) => {
1792 assert_eq!(reason, Some(EResult::ServiceUnavailable));
1793 assert_eq!(attempts, 10);
1794 }
1795 _ => panic!("Expected ReconnectFailed event"),
1796 }
1797 }
1798
1799 #[test]
1800 fn test_connection_event_cm_list() {
1801 let servers = vec!["cm1.steampowered.com:443".to_string(), "cm2.steampowered.com:443".to_string()];
1802 let event = SteamEvent::Connection(ConnectionEvent::CMList { servers: servers.clone() });
1803
1804 match event {
1805 SteamEvent::Connection(ConnectionEvent::CMList { servers: s }) => {
1806 assert_eq!(s.len(), 2);
1807 assert_eq!(s[0], "cm1.steampowered.com:443");
1808 }
1809 _ => panic!("Expected CMList event"),
1810 }
1811 }
1812
1813 #[test]
1818 fn test_friends_event_friends_list() {
1819 let friends = vec![FriendEntry { steam_id: test_steam_id(), relationship: EFriendRelationship::Friend }];
1820
1821 let event = SteamEvent::Friends(FriendsEvent::FriendsList { incremental: false, friends });
1822
1823 assert!(event.is_friends());
1824 assert!(!event.is_chat());
1825
1826 match event {
1827 SteamEvent::Friends(FriendsEvent::FriendsList { incremental, friends }) => {
1828 assert!(!incremental);
1829 assert_eq!(friends.len(), 1);
1830 assert_eq!(friends[0].relationship, EFriendRelationship::Friend);
1831 }
1832 _ => panic!("Expected FriendsList event"),
1833 }
1834 }
1835
1836 #[test]
1837 fn test_friends_event_persona_state() {
1838 let persona = UserPersona {
1839 steam_id: test_steam_id(),
1840 player_name: "TestPlayer".to_string(),
1841 persona_state: EPersonaState::Online,
1842 avatar_hash: Some("abc123".to_string()),
1843 game_name: Some("Counter-Strike 2".to_string()),
1844 game_id: Some(730),
1845 ..Default::default()
1846 };
1847
1848 let event = SteamEvent::Friends(FriendsEvent::PersonaState(Box::new(persona)));
1849
1850 assert!(event.is_friends());
1851
1852 match event {
1853 SteamEvent::Friends(FriendsEvent::PersonaState(p)) => {
1854 assert_eq!(p.player_name, "TestPlayer");
1855 assert_eq!(p.persona_state, EPersonaState::Online);
1856 assert_eq!(p.game_id, Some(730));
1857 }
1858 _ => panic!("Expected PersonaState event"),
1859 }
1860 }
1861
1862 #[test]
1863 fn test_friends_event_relationship() {
1864 let event = SteamEvent::Friends(FriendsEvent::FriendRelationship { steam_id: test_steam_id(), relationship: EFriendRelationship::Blocked });
1865
1866 match event {
1867 SteamEvent::Friends(FriendsEvent::FriendRelationship { steam_id, relationship }) => {
1868 assert_eq!(steam_id.steam_id64(), 76561198000000000);
1869 assert_eq!(relationship, EFriendRelationship::Blocked);
1870 }
1871 _ => panic!("Expected FriendRelationship event"),
1872 }
1873 }
1874
1875 #[test]
1880 fn test_chat_event_friend_message() {
1881 let event = SteamEvent::Chat(ChatEvent::FriendMessage {
1882 sender: test_steam_id(),
1883 message: "Hello, World!".to_string(),
1884 chat_entry_type: EChatEntryType::ChatMsg,
1885 timestamp: 1702000000,
1886 ordinal: 1,
1887 from_limited_account: false,
1888 low_priority: false,
1889 });
1890
1891 assert!(event.is_chat());
1892 assert!(!event.is_friends());
1893
1894 assert!(event.chat_sender().is_some());
1896 assert_eq!(event.chat_sender().unwrap_or_default().steam_id64(), 76561198000000000);
1897
1898 match event {
1899 SteamEvent::Chat(ChatEvent::FriendMessage { sender, message, chat_entry_type, timestamp, ordinal, from_limited_account, low_priority }) => {
1900 assert_eq!(sender.steam_id64(), 76561198000000000);
1901 assert_eq!(message, "Hello, World!");
1902 assert_eq!(chat_entry_type, EChatEntryType::ChatMsg);
1903 assert_eq!(timestamp, 1702000000);
1904 assert_eq!(ordinal, 1);
1905 assert!(!from_limited_account);
1906 assert!(!low_priority);
1907 }
1908 _ => panic!("Expected FriendMessage event"),
1909 }
1910 }
1911
1912 #[test]
1913 fn test_chat_event_friend_typing() {
1914 let event = SteamEvent::Chat(ChatEvent::FriendTyping { sender: test_steam_id() });
1915
1916 assert!(event.is_chat());
1917 assert!(event.chat_sender().is_some());
1918
1919 match event {
1920 SteamEvent::Chat(ChatEvent::FriendTyping { sender }) => {
1921 assert_eq!(sender.steam_id64(), 76561198000000000);
1922 }
1923 _ => panic!("Expected FriendTyping event"),
1924 }
1925 }
1926
1927 #[test]
1932 fn test_apps_event_license_list() {
1933 let licenses = vec![LicenseEntry {
1934 package_id: 12345,
1935 time_created: 1600000000,
1936 license_type: 1,
1937 flags: 0,
1938 access_token: 0,
1939 ..Default::default()
1940 }];
1941
1942 let event = SteamEvent::Apps(AppsEvent::LicenseList { licenses });
1943
1944 assert!(event.is_apps());
1945
1946 match event {
1947 SteamEvent::Apps(AppsEvent::LicenseList { licenses }) => {
1948 assert_eq!(licenses.len(), 1);
1949 assert_eq!(licenses[0].package_id, 12345);
1950 }
1951 _ => panic!("Expected LicenseList event"),
1952 }
1953 }
1954
1955 #[test]
1956 fn test_apps_event_product_info_response() {
1957 let mut apps = HashMap::new();
1958 apps.insert(730, AppInfoData { app_id: 730, change_number: 12345, missing_token: false, app_info: None });
1959
1960 let event = SteamEvent::Apps(AppsEvent::ProductInfoResponse { apps, packages: HashMap::new(), unknown_apps: vec![99999], unknown_packages: vec![] });
1961
1962 assert!(event.is_apps());
1963
1964 match event {
1965 SteamEvent::Apps(AppsEvent::ProductInfoResponse { apps, packages, unknown_apps, unknown_packages }) => {
1966 assert_eq!(apps.len(), 1);
1967 assert!(apps.contains_key(&730));
1968 assert_eq!(apps[&730].change_number, 12345);
1969 assert!(packages.is_empty());
1970 assert_eq!(unknown_apps, vec![99999]);
1971 assert!(unknown_packages.is_empty());
1972 }
1973 _ => panic!("Expected ProductInfoResponse event"),
1974 }
1975 }
1976
1977 #[test]
1978 fn test_apps_event_access_tokens_response() {
1979 let mut app_tokens = HashMap::new();
1980 app_tokens.insert(730, 123456789);
1981
1982 let event = SteamEvent::Apps(AppsEvent::AccessTokensResponse { app_tokens, package_tokens: HashMap::new(), app_denied: vec![440], package_denied: vec![] });
1983
1984 match event {
1985 SteamEvent::Apps(AppsEvent::AccessTokensResponse { app_tokens, package_tokens, app_denied, package_denied }) => {
1986 assert_eq!(app_tokens[&730], 123456789);
1987 assert!(package_tokens.is_empty());
1988 assert_eq!(app_denied, vec![440]);
1989 assert!(package_denied.is_empty());
1990 }
1991 _ => panic!("Expected AccessTokensResponse event"),
1992 }
1993 }
1994
1995 #[test]
1996 fn test_apps_event_product_changes_response() {
1997 let app_changes = vec![AppChange { app_id: 730, change_number: 99999, needs_token: false }];
1998
1999 let event = SteamEvent::Apps(AppsEvent::ProductChangesResponse { current_change_number: 100000, app_changes, package_changes: vec![] });
2000
2001 match event {
2002 SteamEvent::Apps(AppsEvent::ProductChangesResponse { current_change_number, app_changes, package_changes }) => {
2003 assert_eq!(current_change_number, 100000);
2004 assert_eq!(app_changes.len(), 1);
2005 assert_eq!(app_changes[0].app_id, 730);
2006 assert!(package_changes.is_empty());
2007 }
2008 _ => panic!("Expected ProductChangesResponse event"),
2009 }
2010 }
2011
2012 #[test]
2017 fn test_content_event_rich_presence() {
2018 let users = vec![crate::services::rich_presence::RichPresenceData {
2019 steam_id: test_steam_id(),
2020 appid: 730,
2021 data: {
2022 let mut map = HashMap::new();
2023 map.insert("status".to_string(), "In Game".to_string());
2024 map
2025 },
2026 }];
2027
2028 let event = SteamEvent::Content(ContentEvent::RichPresence { appid: 730, users });
2029
2030 assert!(event.is_content());
2031
2032 match event {
2033 SteamEvent::Content(ContentEvent::RichPresence { appid, users }) => {
2034 assert_eq!(appid, 730);
2035 assert_eq!(users.len(), 1);
2036 assert_eq!(users[0].data.get("status"), Some(&"In Game".to_string()));
2037 }
2038 _ => panic!("Expected RichPresence event"),
2039 }
2040 }
2041
2042 #[test]
2047 fn test_system_event_debug() {
2048 let event = SteamEvent::System(SystemEvent::Debug("Test debug message".to_string()));
2049
2050 assert!(event.is_system());
2051 assert!(!event.is_auth());
2052
2053 match event {
2054 SteamEvent::System(SystemEvent::Debug(msg)) => {
2055 assert_eq!(msg, "Test debug message");
2056 }
2057 _ => panic!("Expected Debug event"),
2058 }
2059 }
2060
2061 #[test]
2062 fn test_system_event_error() {
2063 let event = SteamEvent::System(SystemEvent::Error("Something went wrong".to_string()));
2064
2065 assert!(event.is_system());
2066
2067 match event {
2068 SteamEvent::System(SystemEvent::Error(msg)) => {
2069 assert_eq!(msg, "Something went wrong");
2070 }
2071 _ => panic!("Expected Error event"),
2072 }
2073 }
2074
2075 #[test]
2080 fn test_all_is_category_methods() {
2081 let events = vec![
2082 (SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: SteamID::new() }), "auth"),
2083 (SteamEvent::Connection(ConnectionEvent::Connected), "connection"),
2084 (SteamEvent::Friends(FriendsEvent::FriendsList { incremental: false, friends: vec![] }), "friends"),
2085 (SteamEvent::Chat(ChatEvent::FriendTyping { sender: SteamID::new() }), "chat"),
2086 (SteamEvent::Apps(AppsEvent::LicenseList { licenses: vec![] }), "apps"),
2087 (SteamEvent::Content(ContentEvent::RichPresence { appid: 0, users: vec![] }), "content"),
2088 (SteamEvent::System(SystemEvent::Debug("".to_string())), "system"),
2089 ];
2090
2091 for (event, expected_category) in events {
2092 match expected_category {
2093 "auth" => {
2094 assert!(event.is_auth());
2095 assert!(!event.is_connection());
2096 assert!(!event.is_friends());
2097 assert!(!event.is_chat());
2098 assert!(!event.is_apps());
2099 assert!(!event.is_content());
2100 assert!(!event.is_system());
2101 }
2102 "connection" => {
2103 assert!(!event.is_auth());
2104 assert!(event.is_connection());
2105 assert!(!event.is_friends());
2106 assert!(!event.is_chat());
2107 assert!(!event.is_apps());
2108 assert!(!event.is_content());
2109 assert!(!event.is_system());
2110 }
2111 "friends" => {
2112 assert!(!event.is_auth());
2113 assert!(!event.is_connection());
2114 assert!(event.is_friends());
2115 assert!(!event.is_chat());
2116 assert!(!event.is_apps());
2117 assert!(!event.is_content());
2118 assert!(!event.is_system());
2119 }
2120 "chat" => {
2121 assert!(!event.is_auth());
2122 assert!(!event.is_connection());
2123 assert!(!event.is_friends());
2124 assert!(event.is_chat());
2125 assert!(!event.is_apps());
2126 assert!(!event.is_content());
2127 assert!(!event.is_system());
2128 }
2129 "apps" => {
2130 assert!(!event.is_auth());
2131 assert!(!event.is_connection());
2132 assert!(!event.is_friends());
2133 assert!(!event.is_chat());
2134 assert!(event.is_apps());
2135 assert!(!event.is_content());
2136 assert!(!event.is_system());
2137 }
2138 "content" => {
2139 assert!(!event.is_auth());
2140 assert!(!event.is_connection());
2141 assert!(!event.is_friends());
2142 assert!(!event.is_chat());
2143 assert!(!event.is_apps());
2144 assert!(event.is_content());
2145 assert!(!event.is_system());
2146 }
2147 "system" => {
2148 assert!(!event.is_auth());
2149 assert!(!event.is_connection());
2150 assert!(!event.is_friends());
2151 assert!(!event.is_chat());
2152 assert!(!event.is_apps());
2153 assert!(!event.is_content());
2154 assert!(event.is_system());
2155 }
2156 _ => panic!("Unknown category"),
2157 }
2158 }
2159 }
2160
2161 #[test]
2162 fn test_chat_sender_helper() {
2163 let msg_event = SteamEvent::Chat(ChatEvent::FriendMessage {
2165 sender: test_steam_id(),
2166 message: "test".to_string(),
2167 chat_entry_type: EChatEntryType::ChatMsg,
2168 timestamp: 0,
2169 ordinal: 0,
2170 from_limited_account: false,
2171 low_priority: false,
2172 });
2173 assert!(msg_event.chat_sender().is_some());
2174 assert_eq!(msg_event.chat_sender().unwrap_or_default().steam_id64(), 76561198000000000);
2175
2176 let typing_event = SteamEvent::Chat(ChatEvent::FriendTyping { sender: test_steam_id() });
2178 assert!(typing_event.chat_sender().is_some());
2179
2180 let auth_event = SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: SteamID::new() });
2182 assert!(auth_event.chat_sender().is_none());
2183
2184 let conn_event = SteamEvent::Connection(ConnectionEvent::Connected);
2185 assert!(conn_event.chat_sender().is_none());
2186 }
2187
2188 #[test]
2193 fn test_events_are_cloneable() {
2194 let original = SteamEvent::Chat(ChatEvent::FriendMessage {
2195 sender: test_steam_id(),
2196 message: "Clone me!".to_string(),
2197 chat_entry_type: EChatEntryType::ChatMsg,
2198 timestamp: 123,
2199 ordinal: 1,
2200 from_limited_account: false,
2201 low_priority: false,
2202 });
2203
2204 let cloned = original.clone();
2205
2206 match (original, cloned) {
2207 (SteamEvent::Chat(ChatEvent::FriendMessage { message: m1, .. }), SteamEvent::Chat(ChatEvent::FriendMessage { message: m2, .. })) => {
2208 assert_eq!(m1, m2);
2209 assert_eq!(m1, "Clone me!");
2210 }
2211 _ => panic!("Clone failed"),
2212 }
2213 }
2214
2215 #[test]
2216 fn test_events_are_debuggable() {
2217 let event = SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: test_steam_id() });
2218
2219 let debug_str = format!("{:?}", event);
2220 assert!(debug_str.contains("Auth"));
2221 assert!(debug_str.contains("LoggedOn"));
2222 }
2223
2224 #[test]
2229 fn test_decompress_gzip() {
2230 use std::io::Write;
2231
2232 use flate2::{write::GzEncoder, Compression};
2233
2234 let original = b"Hello, Steam Multi Message!";
2235
2236 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
2237 encoder.write_all(original).unwrap_or_default();
2238 let compressed = encoder.finish().unwrap_or_default();
2239
2240 let decompressed = MessageHandler::decompress_gzip(&compressed, original.len()).unwrap_or_default();
2241 assert_eq!(decompressed, original);
2242 }
2243
2244 #[test]
2245 fn test_decode_message_too_short() {
2246 let events = MessageHandler::decode_message(&[0, 1, 2, 3]);
2248 assert!(events.is_empty());
2249 }
2250
2251 #[test]
2256 fn test_exhaustive_category_matching() {
2257 let events = vec![
2258 SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: SteamID::new() }),
2259 SteamEvent::Connection(ConnectionEvent::Connected),
2260 SteamEvent::Friends(FriendsEvent::FriendsList { incremental: false, friends: vec![] }),
2261 SteamEvent::Chat(ChatEvent::FriendTyping { sender: SteamID::new() }),
2262 SteamEvent::Apps(AppsEvent::LicenseList { licenses: vec![] }),
2263 SteamEvent::Content(ContentEvent::RichPresence { appid: 0, users: vec![] }),
2264 SteamEvent::System(SystemEvent::Debug("test".to_string())),
2265 ];
2266
2267 for event in events {
2268 let category = match event {
2270 SteamEvent::Auth(_) => "auth",
2271 SteamEvent::Connection(_) => "connection",
2272 SteamEvent::Friends(_) => "friends",
2273 SteamEvent::Chat(_) => "chat",
2274 SteamEvent::Apps(_) => "apps",
2275 SteamEvent::Content(_) => "content",
2276 SteamEvent::System(_) => "system",
2277 SteamEvent::Account(_) => "account",
2278 SteamEvent::Notifications(_) => "notifications",
2279 SteamEvent::CSGO(_) => "csgo",
2280 };
2281 assert!(!category.is_empty());
2282 }
2283 }
2284
2285 #[test]
2286 fn test_selective_category_matching() {
2287 let event = SteamEvent::Chat(ChatEvent::FriendMessage {
2289 sender: test_steam_id(),
2290 message: "Hello".to_string(),
2291 chat_entry_type: EChatEntryType::ChatMsg,
2292 timestamp: 0,
2293 ordinal: 0,
2294 from_limited_account: false,
2295 low_priority: false,
2296 });
2297
2298 let mut handled = false;
2299
2300 if let SteamEvent::Chat(ChatEvent::FriendMessage { message, .. }) = event {
2301 assert_eq!(message, "Hello");
2302 handled = true;
2303 }
2304
2305 assert!(handled);
2306 }
2307}