Skip to main content

steam_client/client/
events.rs

1//! Event system for Steam client.
2//!
3//! This module provides the event loop for receiving and dispatching Steam
4//! messages.
5//!
6//! Events are categorized by domain for better type safety and cleaner pattern
7//! matching:
8//! - [`AuthEvent`] - Authentication and login events
9//! - [`ConnectionEvent`] - Connection lifecycle events
10//! - [`FriendsEvent`] - Friends and social events
11//! - [`ChatEvent`] - Chat and messaging events
12//! - [`AppsEvent`] - App and game events
13//! - [`ContentEvent`] - Content delivery events
14//! - [`AccountEvent`] - Account info, limitations, wallet, VAC bans
15//! - [`NotificationsEvent`] - Trade offers, offline messages, items, comments
16//! - [`SystemEvent`] - Debug and error events
17
18use 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//=============================================================================
34// Event Category Enums
35//=============================================================================
36
37/// Authentication events.
38#[derive(Debug, Clone)]
39pub enum AuthEvent {
40    /// Client logged on successfully.
41    LoggedOn { steam_id: SteamID },
42
43    /// Client was logged off.
44    LoggedOff { result: EResult },
45
46    /// Received a new refresh token (from password authentication).
47    /// This can be saved and used for future logins without password.
48    RefreshToken {
49        /// The refresh token.
50        token: String,
51        /// The account name.
52        account_name: String,
53    },
54
55    /// Web session established.
56    ///
57    /// Emitted after successful login when web cookies are available.
58    /// These cookies can be used to authenticate with Steam web APIs
59    /// (e.g., steamcommunity.com, store.steampowered.com).
60    ///
61    /// This event is emitted when:
62    /// - Login is successful and we have a refresh token that can generate web
63    ///   cookies
64    /// - The `get_web_session()` method is called explicitly after login
65    WebSession {
66        /// The session ID cookie value.
67        session_id: String,
68        /// Cookies in "name=value" format, suitable for HTTP Cookie headers.
69        cookies: Vec<String>,
70    },
71
72    /// Received game connect tokens.
73    GameConnectTokens {
74        /// The tokens.
75        tokens: Vec<Vec<u8>>,
76    },
77}
78
79/// Connection lifecycle events.
80#[derive(Debug, Clone)]
81pub enum ConnectionEvent {
82    /// WebSocket connection established.
83    Connected,
84
85    /// WebSocket connection closed.
86    Disconnected {
87        /// The result code indicating why disconnected.
88        /// `None` if the connection was closed cleanly without an error.
89        reason: Option<EResult>,
90        /// Whether reconnection will be attempted.
91        will_reconnect: bool,
92    },
93
94    /// Client is attempting to reconnect after a disconnection.
95    ReconnectAttempt {
96        /// Current attempt number (1-indexed).
97        attempt: u32,
98        /// Maximum attempts configured.
99        max_attempts: u32,
100        /// Delay before this attempt was made.
101        delay: Duration,
102    },
103
104    /// Reconnection has permanently failed after max attempts.
105    ReconnectFailed {
106        /// Original disconnect reason.
107        reason: Option<EResult>,
108        /// Number of attempts made.
109        attempts: u32,
110    },
111
112    /// Received CM server list.
113    CMList { servers: Vec<String> },
114}
115
116/// Friends and social events.
117#[derive(Debug, Clone)]
118pub enum FriendsEvent {
119    /// Received friend list.
120    FriendsList { incremental: bool, friends: Vec<FriendEntry> },
121
122    /// A friend's persona state changed.
123    PersonaState(Box<UserPersona>),
124
125    /// Friend relationship changed.
126    FriendRelationship { steam_id: SteamID, relationship: EFriendRelationship },
127
128    /// A friend's nickname was changed.
129    NicknameChanged { steam_id: SteamID, nickname: Option<String> },
130}
131
132/// Chat and messaging events.
133#[derive(Debug, Clone)]
134pub enum ChatEvent {
135    /// Received a friend message.
136    FriendMessage {
137        sender: SteamID,
138        message: String,
139        chat_entry_type: EChatEntryType,
140        timestamp: u32,
141        ordinal: u32,
142        /// Whether the message is from a limited account.
143        from_limited_account: bool,
144        /// Whether this is a low priority message.
145        low_priority: bool,
146    },
147
148    /// Echo of a message we sent (received when local_echo is true).
149    FriendMessageEcho { receiver: SteamID, message: String, timestamp: u32, ordinal: u32 },
150
151    /// Friend typing indicator.
152    FriendTyping { sender: SteamID },
153
154    /// Echo of our own typing indicator.
155    FriendTypingEcho { receiver: SteamID },
156
157    /// Friend left the conversation.
158    FriendLeftConversation { sender: SteamID },
159
160    /// Echo of us leaving the conversation.
161    FriendLeftConversationEcho { receiver: SteamID },
162
163    /// Received a group chat message.
164    ChatMessage {
165        chat_group_id: u64,
166        chat_id: u64,
167        sender: SteamID,
168        message: String,
169        timestamp: u32,
170        ordinal: u32,
171        // Mentions and server messages could be added here in the future
172    },
173
174    /// A member's state in a chat room group changed.
175    ChatMemberStateChange {
176        chat_group_id: u64,
177        steam_id: SteamID,
178        change: i32, // EChatRoomMemberStateChange
179    },
180
181    /// Rooms in a chat room group changed.
182    ChatRoomGroupRoomsChange { chat_group_id: u64, default_chat_id: u64, chat_rooms: Vec<ChatRoomState> },
183
184    /// Chat messages in a room were modified (e.g. deleted).
185    ChatMessagesModified { chat_group_id: u64, chat_id: u64, messages: Vec<ModifiedChatMessage> },
186
187    /// A chat room group's header state changed.
188    ChatRoomGroupHeaderStateChange { chat_group_id: u64, header_state: ChatRoomGroupHeaderState },
189
190    /// Fetched offline messages history.
191    OfflineMessagesFetched { friend_id: SteamID, messages: Vec<crate::services::chat::HistoryMessage> },
192}
193
194/// App and game events.
195#[derive(Debug, Clone)]
196pub enum AppsEvent {
197    /// Received licenses list.
198    LicenseList { licenses: Vec<LicenseEntry> },
199
200    /// PICS product info response.
201    ProductInfoResponse {
202        /// App info data keyed by app ID.
203        apps: HashMap<u32, AppInfoData>,
204        /// Package info data keyed by package ID.
205        packages: HashMap<u32, PackageInfoData>,
206        /// Unknown/unavailable app IDs.
207        unknown_apps: Vec<u32>,
208        /// Unknown/unavailable package IDs.
209        unknown_packages: Vec<u32>,
210    },
211
212    /// PICS access tokens response.
213    AccessTokensResponse {
214        /// App access tokens keyed by app ID.
215        app_tokens: HashMap<u32, u64>,
216        /// Package access tokens keyed by package ID.
217        package_tokens: HashMap<u32, u64>,
218        /// App IDs for which tokens were denied.
219        app_denied: Vec<u32>,
220        /// Package IDs for which tokens were denied.
221        package_denied: Vec<u32>,
222    },
223
224    /// PICS product changes response.
225    ProductChangesResponse {
226        /// Current change number.
227        current_change_number: u32,
228        /// App IDs that have changed.
229        app_changes: Vec<AppChange>,
230        /// Package IDs that have changed.
231        package_changes: Vec<PackageChange>,
232    },
233
234    /// Received a Game Coordinator message.
235    GCReceived(GCMessage),
236
237    /// Playing session state changed.
238    ///
239    /// Emitted when:
240    /// - Right after logon, only if a game is being played elsewhere (blocked
241    ///   is true)
242    /// - Whenever a game starts/stops being played on another session
243    /// - Whenever you start/stop playing a game on this session
244    PlayingState {
245        /// True if playing is blocked because this account is playing a game in
246        /// another location
247        blocked: bool,
248        /// The app ID currently being played (elsewhere if blocked, or by this
249        /// session if not)
250        playing_app: u32,
251    },
252}
253
254/// CS:GO specific events.
255#[derive(Debug, Clone)]
256pub enum CSGOEvent {
257    /// CS:GO/CS2 Online event (Welcome message).
258    ///
259    /// Emitted when the CS:GO Game Coordinator sends a welcome message,
260    /// indicating the client is "online" in CS:GO/CS2.
261    Online(CsgoWelcome),
262
263    /// CS:GO/CS2 Client Hello event.
264    ///
265    /// Emitted when the CS:GO Game Coordinator sends a client hello response,
266    /// containing player profile data (rank, level, commendations, etc.).
267    ClientHello(CsgoClientHello),
268
269    /// CS:GO/CS2 Players Profile event.
270    ///
271    /// Emitted when the CS:GO Game Coordinator sends a players profile
272    /// response, usually after a `ClientRequestPlayersProfile` message.
273    PlayersProfile(Vec<CsgoClientHello>),
274
275    /// Received a party invite.
276    PartyInvite { inviter: SteamID, lobby_id: u64 },
277    /// Received party search results.
278    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/// Content delivery events.
290#[derive(Debug, Clone)]
291pub enum ContentEvent {
292    /// Received rich presence info for users.
293    RichPresence {
294        /// The app ID.
295        appid: u32,
296        /// Rich presence data for each user.
297        users: Vec<crate::services::rich_presence::RichPresenceData>,
298    },
299}
300
301/// System/debug events.
302#[derive(Debug, Clone)]
303pub enum SystemEvent {
304    /// Debug/log message.
305    Debug(String),
306
307    /// Error occurred.
308    Error(String),
309}
310
311/// Account events (email, limitations, wallet, VAC).
312#[derive(Debug, Clone)]
313pub enum AccountEvent {
314    /// Email address information.
315    EmailInfo {
316        /// The email address associated with this account.
317        address: String,
318        /// Whether the email has been validated.
319        validated: bool,
320    },
321
322    /// Account limitations status.
323    AccountLimitations {
324        /// Whether this is a limited account.
325        limited: bool,
326        /// Whether the account is community banned.
327        community_banned: bool,
328        /// Whether the account is locked.
329        locked: bool,
330        /// Whether the account can invite friends.
331        can_invite_friends: bool,
332    },
333
334    /// Wallet balance update.
335    Wallet {
336        /// Whether the account has a wallet.
337        has_wallet: bool,
338        /// Currency code.
339        currency: i32,
340        /// Balance in cents.
341        balance: i64,
342    },
343
344    /// VAC ban status.
345    VacBans {
346        /// Number of VAC bans.
347        num_bans: u32,
348        /// App IDs from which the account is banned.
349        appids: Vec<u32>,
350    },
351
352    /// Account info update.
353    AccountInfo {
354        /// Persona name.
355        name: String,
356        /// Country code.
357        country: String,
358        /// Number of authorized machines.
359        authed_machines: u32,
360        /// Account flags.
361        flags: u32,
362    },
363}
364
365/// Notification events (trade offers, messages, items, comments).
366#[derive(Debug, Clone)]
367pub enum NotificationsEvent {
368    /// Trade offers notification.
369    TradeOffers {
370        /// Number of pending trade offers.
371        count: u32,
372    },
373
374    /// Offline messages notification.
375    OfflineMessages {
376        /// Number of offline messages.
377        count: u32,
378        /// Friends who have sent offline messages.
379        friends: Vec<SteamID>,
380    },
381
382    /// New items notification.
383    NewItems {
384        /// Number of new items.
385        count: u32,
386    },
387
388    /// New comments notification.
389    NewComments {
390        /// Total new comments.
391        count: u32,
392        /// Comments on your items.
393        owner_comments: u32,
394        /// Comments in subscribed discussions.
395        subscription_comments: u32,
396    },
397
398    /// Community messages (moderator messages).
399    CommunityMessages {
400        /// Number of unread community messages.
401        count: u32,
402    },
403
404    /// Notifications received.
405    NotificationsReceived(Vec<NotificationData>),
406}
407
408//=============================================================================
409// Top-Level Event Enum
410//=============================================================================
411
412/// Steam client events, categorized by domain.
413///
414/// # Example
415/// ```rust,ignore
416/// match event {
417///     SteamEvent::Auth(auth) => match auth {
418///         AuthEvent::LoggedOn { steam_id } => println!("Logged in as {}", steam_id),
419///         AuthEvent::LoggedOff { result } => println!("Logged off: {:?}", result),
420///         _ => {}
421///     },
422///     SteamEvent::Chat(chat) => match chat {
423///         ChatEvent::FriendMessage { sender, message, .. } => {
424///             println!("{}: {}", sender, message);
425///         }
426///         _ => {}
427///     },
428///     // Ignore other categories
429///     _ => {}
430/// }
431/// ```
432#[derive(Debug, Clone)]
433pub enum SteamEvent {
434    /// Authentication events (login, logout, tokens).
435    Auth(AuthEvent),
436
437    /// Connection lifecycle (connect, disconnect, reconnect).
438    Connection(ConnectionEvent),
439
440    /// Friends and social (friends list, personas).
441    Friends(FriendsEvent),
442
443    /// Chat and messaging (friend messages, typing).
444    Chat(ChatEvent),
445
446    /// Apps and games (licenses, product info, GC).
447    Apps(AppsEvent),
448
449    /// CS:GO specific events.
450    CSGO(CSGOEvent),
451
452    /// Content delivery (rich presence).
453    Content(ContentEvent),
454
455    /// Account events (email, limitations, wallet, VAC).
456    Account(AccountEvent),
457
458    /// Notification events (trade offers, messages, items).
459    Notifications(NotificationsEvent),
460
461    /// System events (debug, errors).
462    System(SystemEvent),
463}
464
465//=============================================================================
466// Helper Methods for SteamEvent
467//=============================================================================
468
469impl SteamEvent {
470    /// Check if this is an authentication event.
471    pub fn is_auth(&self) -> bool {
472        matches!(self, SteamEvent::Auth(_))
473    }
474
475    /// Check if this is a connection event.
476    pub fn is_connection(&self) -> bool {
477        matches!(self, SteamEvent::Connection(_))
478    }
479
480    /// Check if this is a friends event.
481    pub fn is_friends(&self) -> bool {
482        matches!(self, SteamEvent::Friends(_))
483    }
484
485    /// Check if this is a chat event.
486    pub fn is_chat(&self) -> bool {
487        matches!(self, SteamEvent::Chat(_))
488    }
489
490    /// Check if this is an apps event.
491    pub fn is_apps(&self) -> bool {
492        matches!(self, SteamEvent::Apps(_))
493    }
494
495    /// Check if this is a content event.
496    pub fn is_content(&self) -> bool {
497        matches!(self, SteamEvent::Content(_))
498    }
499
500    /// Check if this is a system event.
501    pub fn is_system(&self) -> bool {
502        matches!(self, SteamEvent::System(_))
503    }
504
505    /// Check if this is an account event.
506    pub fn is_account(&self) -> bool {
507        matches!(self, SteamEvent::Account(_))
508    }
509
510    /// Check if this is a notifications event.
511    pub fn is_notifications(&self) -> bool {
512        matches!(self, SteamEvent::Notifications(_))
513    }
514
515    /// Check if this is a CS:GO event.
516    pub fn is_csgo(&self) -> bool {
517        matches!(self, SteamEvent::CSGO(_))
518    }
519
520    /// Get the sender SteamID if this is a chat message.
521    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//=============================================================================
531// Data Structures
532//=============================================================================
533
534/// Friend entry from friends list.
535#[derive(Debug, Clone)]
536pub struct FriendEntry {
537    pub steam_id: SteamID,
538    pub relationship: EFriendRelationship,
539}
540
541/// License entry.
542#[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/// CS:GO Welcome data.
565#[derive(Debug, Clone)]
566pub struct CsgoWelcome {
567    /// Whether the account has Prime status.
568    pub prime: bool,
569    /// Elevated state (5 = bought prime).
570    pub elevated_state: u32,
571    /// Bonus XP used flags (16 = prestige earned/prime).
572    pub bonus_xp_usedflags: u32,
573    /// Inventory items.
574    pub items: Vec<steam_protos::CSOEconItem>,
575}
576
577/// CS:GO Client Hello data (GC message 9110 response).
578#[derive(Debug, Clone)]
579pub struct CsgoClientHello {
580    /// The account ID.
581    pub account_id: u32,
582    /// Whether the account is VAC banned (0 = not banned).
583    pub vac_banned: i32,
584    /// Penalty cooldown seconds remaining.
585    pub penalty_seconds: u32,
586    /// Penalty reason code.
587    pub penalty_reason: u32,
588    /// Player CS:GO profile level.
589    pub player_level: i32,
590    /// Player current XP (subtract 327680000 for actual XP).
591    pub player_cur_xp: i32,
592    /// Player XP bonus flags.
593    pub player_xp_bonus_flags: i32,
594    /// Competitive ranking info.
595    pub ranking: Option<CsgoRanking>,
596    /// Player commendation counts.
597    pub commendation: Option<CsgoCommendation>,
598    /// Global statistics (players online, servers online, etc.).
599    pub players_online: u32,
600    pub servers_online: u32,
601    pub ongoing_matches: u32,
602}
603
604/// CS:GO player ranking information.
605#[derive(Debug, Clone)]
606pub struct CsgoRanking {
607    /// Rank ID (1-18 for competitive, higher for Premier).
608    pub rank_id: u32,
609    /// Number of competitive wins.
610    pub wins: u32,
611    /// Rank type (6 = competitive, 7 = wingman, 10 = danger zone, 11 =
612    /// premier).
613    pub rank_type_id: u32,
614}
615
616/// CS:GO player commendation counts.
617#[derive(Debug, Clone)]
618pub struct CsgoCommendation {
619    /// Friendly commendations.
620    pub cmd_friendly: u32,
621    /// Teaching commendations.
622    pub cmd_teaching: u32,
623    /// Leader commendations.
624    pub cmd_leader: u32,
625}
626
627/// Chat room state.
628#[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/// A modified chat message.
640#[derive(Debug, Clone)]
641pub struct ModifiedChatMessage {
642    pub server_timestamp: u32,
643    pub ordinal: u32,
644    pub deleted: bool,
645}
646
647/// Chat room group header state.
648#[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/// Notification data.
659#[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
671//=============================================================================
672// Message Handler
673//=============================================================================
674
675/// Message handler that decodes and dispatches Steam messages.
676pub struct MessageHandler;
677
678/// Decoded message with job information for request-response correlation.
679#[derive(Debug)]
680pub struct DecodedMessage {
681    /// Event(s) decoded from the message.
682    pub events: Vec<SteamEvent>,
683    /// Job ID target from the header (if this is a response to a tracked
684    /// request).
685    pub job_id_target: Option<u64>,
686    /// Raw body bytes for job completion (zero-copy).
687    pub body: Bytes,
688}
689
690impl MessageHandler {
691    /// Decode a raw message and return events.
692    ///
693    /// For Multi messages, this returns multiple events from the contained
694    /// sub-messages.
695    pub fn decode_message(data: &[u8]) -> Vec<SteamEvent> {
696        Self::decode_packet(data).into_iter().flat_map(|m| m.events).collect()
697    }
698
699    /// Decode a raw packet and return a list of decoded messages.
700    ///
701    /// This handles both single messages and Multi messages (which expand into
702    /// multiple messages). Each returned `DecodedMessage` preserves its own
703    /// events and job ID target.
704    pub fn decode_packet(data: &[u8]) -> Vec<DecodedMessage> {
705        if data.len() < 8 {
706            return vec![];
707        }
708
709        // Read raw EMsg (first 4 bytes, little-endian)
710        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    /// Legacy alias for decode_packet, kept for compatibility if needed but
723    /// returns single struct (incorrect for Multi).
724    ///
725    /// Deprecated: Use `decode_packet` instead.
726    pub fn decode_message_with_job(data: &[u8]) -> DecodedMessage {
727        // This is lossy for Multi messages as it flattens everything into one
728        // DecodedMessage and loses sub-message job IDs. It's kept temporarily.
729        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        // Flatten multiple messages into one
739        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, // Lost for Multi
747            body: Bytes::new(),
748        }
749    }
750
751    /// Decode a single message and return a single event (legacy API).
752    pub fn decode_single(data: &[u8]) -> Option<SteamEvent> {
753        Self::decode_message(data).into_iter().next()
754    }
755
756    /// Decode a protobuf message.
757    #[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    /// Decode a protobuf packet (internal helper).
763    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    /// Decode a protobuf packet.
768    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        // Read header length
778        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        // Parse header to extract job_id_target and target_job_name
785        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        // Extract target_job_name for ServiceMethod routing
791        let target_job_name = parsed_header.as_ref().and_then(|h| h.target_job_name.clone());
792
793        // Get body
794        let body_slice = &data[4 + header_len..];
795
796        // Handle Multi specially as it returns multiple messages
797        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                // Route by target_job_name for proper service method handling
816                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                    // Fallback to legacy handling if no job name
820                    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            // Account events
827            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            // Notification events
833            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            // Playing session state (blocked by another session)
839            EMsg::ClientPlayingSessionState => Self::handle_playing_session_state(body_slice).into_iter().collect(),
840            // Messages that are handled internally or can be safely ignored
841            // These are common messages that don't need to emit events
842            EMsg::ClientFriendsGroupsList | EMsg::ClientVACBanStatus | EMsg::ClientSessionToken | EMsg::ClientServerList | EMsg::ServiceMethodResponse | EMsg::ClientMarketingMessageUpdate2 => {
843                // Silently ignore - these are either handled internally or not needed
844                vec![]
845            }
846            // Response messages complete jobs via job_id_target - no event needed
847            // For truly unknown messages, log them for debugging
848            _ => {
849                if emsg == EMsg::Invalid {
850                    // Unknown message ID - worth logging for debugging
851                    vec![SteamEvent::System(SystemEvent::Debug(format!("Unknown EMsg ID: {}", raw_emsg)))]
852                } else {
853                    // Known message type but unhandled - silently ignore in production
854                    // Uncomment the following for debugging new message types:
855                    // vec![SteamEvent::System(SystemEvent::Debug(format!(
856                    //     "Unhandled message: {:?}",
857                    //     emsg
858                    // )))]
859                    vec![]
860                }
861            }
862        };
863
864        vec![DecodedMessage { events, job_id_target, body: body_bytes }]
865    }
866
867    /// Decode an extended (non-protobuf) message.
868    fn decode_extended_message(emsg: EMsg, _data: &[u8]) -> Vec<SteamEvent> {
869        vec![SteamEvent::System(SystemEvent::Debug(format!("Unhandled extended message: {:?}", emsg)))]
870    }
871
872    /// Handle Multi message - contains multiple sub-messages, optionally gzip
873    /// compressed.
874    ///
875    /// For better performance, sub-messages are processed in parallel using
876    /// rayon when there are 4 or more sub-messages.
877    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                // Gzip compressed - decompress with pre-allocated buffer
888                match Self::decompress_gzip(&message_body, msg.size_unzipped.unwrap_or(4096) as usize) {
889                    Ok(decompressed) => decompressed,
890                    Err(e) => {
891                        // Return error event wrapped in DecodedMessage
892                        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                // Not compressed
901                message_body
902            };
903
904            // Extract sub-message positions first (offset, size)
905            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            // Process sub-messages: use parallel processing for 4+ messages
921            if sub_messages.len() >= 4 {
922                // Parallel processing with rayon
923                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                // Sequential processing for small batches
932                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    /// Decompress gzip data with optional capacity hint for pre-allocation.
945    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                    // Login failed
961                    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 cache for game names to avoid repeated binary searches
997        static GAME_NAME_CACHE: Lazy<Mutex<HashMap<u32, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));
998
999        // Try to resolve game name from static list if possible
1000        // The network often returns empty game_name even when gameid is present
1001        if let Some(game_id) = game_id {
1002            if game_id > 0 {
1003                // AppIDs are u32, safely cast
1004                let id_u32 = game_id as u32;
1005
1006                // Check cache first
1007                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                // Not in cache, lookup in static list
1014                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                    // Update cache
1018                    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                    // Handle default/empty avatar hash (all zeros)
1038                    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                    // Extract steam_player_group from rich presence (lobby/party ID)
1055                    let steam_player_group = rich_presence.get("steam_player_group").filter(|s| s.as_str() != "0").cloned();
1056                    // Extract status from rich presence
1057                    let rich_presence_status = rich_presence.get("status").cloned();
1058                    // Extract match map from rich presence
1059                    let game_map = rich_presence.get("game:map").cloned();
1060                    // Extract game score from rich presence
1061                    let game_score = rich_presence.get("game:score").cloned();
1062                    // Extract num_players from rich presence
1063                    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                        // Prefer gameid (64-bit), fall back to game_played_app_id (32-bit)
1072                        // Steam may send either depending on the game type
1073                        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    /// Echo of our own typing indicator.
1126    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    /// Route service methods by their target_job_name (matches Node.js
1250    /// behavior).
1251    ///
1252    /// This enables dynamic routing of service method calls based on the RPC
1253    /// name from the protobuf header, just like the Node.js steam-user
1254    /// library.
1255    fn handle_service_method_by_name(job_name: &str, body: &[u8]) -> Option<SteamEvent> {
1256        match job_name {
1257            // Friend message notifications
1258            "FriendMessagesClient.IncomingMessage#1" => Self::handle_friend_message_notification(body),
1259
1260            // Message acknowledgment echo
1261            "FriendMessagesClient.NotifyAckMessageEcho#1" => Self::handle_friend_typing_echo(body),
1262
1263            // Steam notifications
1264            "SteamNotificationClient.NotificationsReceived#1" => Self::handle_steam_notification(body),
1265
1266            // Chat room notifications
1267            name if name.starts_with("ChatRoomClient.") => Self::handle_chatroom_notification(name, body),
1268
1269            // Player notifications
1270            name if name.starts_with("PlayerClient.") => Self::handle_player_notification(name, body),
1271
1272            // Unknown service methods - silently ignore
1273            _ => None,
1274        }
1275    }
1276
1277    /// Legacy fallback for service methods without target_job_name.
1278    fn handle_service_method_legacy(body: &[u8]) -> Option<SteamEvent> {
1279        // Try to decode as incoming friend message (legacy behavior)
1280        Self::handle_friend_message_notification(body)
1281    }
1282
1283    /// Handle friend message notifications
1284    /// (FriendMessagesClient.IncomingMessage#1).
1285    ///
1286    /// This handles:
1287    /// - Regular friend messages
1288    /// - Typing indicators
1289    /// - Left conversation notifications
1290    /// - Local echo of sent messages
1291    fn handle_friend_message_notification(body: &[u8]) -> Option<SteamEvent> {
1292        if let Ok(msg) = steam_protos::CFriendMessagesIncomingMessageNotification::decode(body) {
1293            // Check if this is actually a friend message (must have a valid sender)
1294            let steamid_friend = msg.steamid_friend.unwrap_or(0);
1295            if steamid_friend == 0 {
1296                // Not a valid friend message, skip
1297                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            // Prioritize message_no_bbcode if available, similar to node-steam-user
1305            let message = msg.message_no_bbcode.clone().filter(|s| !s.is_empty()).or_else(|| msg.message.clone()).unwrap_or_default();
1306
1307            // Determine the event type based on chat_entry_type and local_echo
1308            match chat_entry_type {
1309                // Typing (EChatEntryType::Typing = 2)
1310                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                // Left Conversation (EChatEntryType::LeftConversation = 6)
1319                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                // Regular message (EChatEntryType::ChatMsg = 1) or other types
1328                _ => {
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        // Only process CS:GO messages (appid 730)
1367        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    /// Route CS:GO GC messages to their respective handlers.
1377    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    /// Handle CS:GO Connection Status message (ClientConnectionStatus = 4009).
1400    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                    // Type 1 is CSOEconItem
1412                    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                    // Type 7 is CSOEconGameAccountClient
1420                    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                                // Prime detection logic from Node.js
1427                                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    /// Handle CS:GO Client Hello message (MatchmakingGC2ClientHello = 9110).
1442    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    /// Handle CS:GO Players Profile message (PlayersProfile = 9128).
1448    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    /// Handle CS:GO Party Invite message (Party_Invite = 9192).
1457    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    /// Handle CS:GO Party Search Results (Party_Search = 9191).
1467    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    /// Build a CsgoClientHello from a matchmaking hello message.
1485    /// Used by both handle_csgo_client_hello and handle_csgo_players_profile.
1486    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    //=========================================================================
1546    // Account Event Handlers
1547    //=========================================================================
1548
1549    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), // MMS invite doesn't seem to have inviter ID in this message?
1553                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    //=========================================================================
1616    // Notification Event Handlers
1617    //=========================================================================
1618
1619    fn handle_user_notifications(body: &[u8]) -> Option<SteamEvent> {
1620        if let Ok(msg) = steam_protos::CMsgClientUserNotifications::decode(body) {
1621            // Check the notification types
1622            // Type 1 = tradeOffers, Type 3 = communityMessages
1623            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                    // Convert account ID to SteamID
1648                    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    //=========================================================================
1683    // Helper function to create test SteamIDs
1684    //=========================================================================
1685
1686    fn test_steam_id() -> SteamID {
1687        SteamID::from_steam_id64(76561198000000000)
1688    }
1689
1690    //=========================================================================
1691    // AuthEvent Tests
1692    //=========================================================================
1693
1694    #[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        // Pattern matching
1704        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    //=========================================================================
1742    // ConnectionEvent Tests
1743    //=========================================================================
1744
1745    #[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    //=========================================================================
1814    // FriendsEvent Tests
1815    //=========================================================================
1816
1817    #[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    //=========================================================================
1876    // ChatEvent Tests
1877    //=========================================================================
1878
1879    #[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        // Test chat_sender helper
1895        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    //=========================================================================
1928    // AppsEvent Tests
1929    //=========================================================================
1930
1931    #[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    //=========================================================================
2013    // ContentEvent Tests
2014    //=========================================================================
2015
2016    #[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    //=========================================================================
2043    // SystemEvent Tests
2044    //=========================================================================
2045
2046    #[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    //=========================================================================
2076    // Helper Method Tests
2077    //=========================================================================
2078
2079    #[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        // FriendMessage has a sender
2164        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        // FriendTyping has a sender
2177        let typing_event = SteamEvent::Chat(ChatEvent::FriendTyping { sender: test_steam_id() });
2178        assert!(typing_event.chat_sender().is_some());
2179
2180        // Non-chat events don't have a sender
2181        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    //=========================================================================
2189    // Clone and Debug Tests
2190    //=========================================================================
2191
2192    #[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    //=========================================================================
2225    // MessageHandler Tests
2226    //=========================================================================
2227
2228    #[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        // Message shorter than 8 bytes should return empty events
2247        let events = MessageHandler::decode_message(&[0, 1, 2, 3]);
2248        assert!(events.is_empty());
2249    }
2250
2251    //=========================================================================
2252    // Pattern Matching Example Tests
2253    //=========================================================================
2254
2255    #[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            // This test ensures all categories can be matched
2269            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        // Test that you can ignore categories easily
2288        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}