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::{services::gc::GCMessage, utils::parsing};
32#[cfg(feature = "static-app-list")]
33use crate::client::static_app_list::APP_LIST;
34
35//=============================================================================
36// Event Category Enums
37//=============================================================================
38
39/// Authentication events.
40#[derive(Debug, Clone)]
41pub enum AuthEvent {
42    /// Client logged on successfully.
43    LoggedOn { steam_id: SteamID },
44
45    /// Client was logged off.
46    LoggedOff { result: EResult },
47
48    /// Received a new refresh token (from password authentication).
49    /// This can be saved and used for future logins without password.
50    RefreshToken {
51        /// The refresh token.
52        token: String,
53        /// The account name.
54        account_name: String,
55    },
56
57    /// Web session established.
58    ///
59    /// Emitted after successful login when web cookies are available.
60    /// These cookies can be used to authenticate with Steam web APIs
61    /// (e.g., steamcommunity.com, store.steampowered.com).
62    ///
63    /// This event is emitted when:
64    /// - Login is successful and we have a refresh token that can generate web
65    ///   cookies
66    /// - The `get_web_session()` method is called explicitly after login
67    WebSession {
68        /// The session ID cookie value.
69        session_id: String,
70        /// Cookies in "name=value" format, suitable for HTTP Cookie headers.
71        cookies: Vec<String>,
72    },
73
74    /// Received game connect tokens.
75    GameConnectTokens {
76        /// The tokens.
77        tokens: Vec<Vec<u8>>,
78    },
79}
80
81/// Connection lifecycle events.
82#[derive(Debug, Clone)]
83pub enum ConnectionEvent {
84    /// WebSocket connection established.
85    Connected,
86
87    /// WebSocket connection closed.
88    Disconnected {
89        /// The result code indicating why disconnected.
90        /// `None` if the connection was closed cleanly without an error.
91        reason: Option<EResult>,
92        /// Whether reconnection will be attempted.
93        will_reconnect: bool,
94    },
95
96    /// Client is attempting to reconnect after a disconnection.
97    ReconnectAttempt {
98        /// Current attempt number (1-indexed).
99        attempt: u32,
100        /// Maximum attempts configured.
101        max_attempts: u32,
102        /// Delay before this attempt was made.
103        delay: Duration,
104    },
105
106    /// Reconnection has permanently failed after max attempts.
107    ReconnectFailed {
108        /// Original disconnect reason.
109        reason: Option<EResult>,
110        /// Number of attempts made.
111        attempts: u32,
112    },
113
114    /// Received CM server list.
115    CMList { servers: Vec<String> },
116}
117
118/// Friends and social events.
119#[derive(Debug, Clone)]
120pub enum FriendsEvent {
121    /// Received friend list.
122    FriendsList { incremental: bool, friends: Vec<FriendEntry> },
123
124    /// A friend's persona state changed.
125    PersonaState(Box<UserPersona>),
126
127    /// Friend relationship changed.
128    FriendRelationship { steam_id: SteamID, relationship: EFriendRelationship },
129
130    /// A friend's nickname was changed.
131    NicknameChanged { steam_id: SteamID, nickname: Option<String> },
132}
133
134/// Chat and messaging events.
135#[derive(Debug, Clone)]
136pub enum ChatEvent {
137    /// Received a friend message.
138    FriendMessage {
139        sender: SteamID,
140        message: String,
141        chat_entry_type: EChatEntryType,
142        timestamp: u32,
143        ordinal: u32,
144        /// Whether the message is from a limited account.
145        from_limited_account: bool,
146        /// Whether this is a low priority message.
147        low_priority: bool,
148    },
149
150    /// Echo of a message we sent (received when local_echo is true).
151    FriendMessageEcho { receiver: SteamID, message: String, timestamp: u32, ordinal: u32 },
152
153    /// Friend typing indicator.
154    FriendTyping { sender: SteamID },
155
156    /// Echo of our own typing indicator.
157    FriendTypingEcho { receiver: SteamID },
158
159    /// Friend left the conversation.
160    FriendLeftConversation { sender: SteamID },
161
162    /// Echo of us leaving the conversation.
163    FriendLeftConversationEcho { receiver: SteamID },
164
165    /// Received a group chat message.
166    ChatMessage {
167        chat_group_id: u64,
168        chat_id: u64,
169        sender: SteamID,
170        message: String,
171        timestamp: u32,
172        ordinal: u32,
173        // Mentions and server messages could be added here in the future
174    },
175
176    /// A member's state in a chat room group changed.
177    ChatMemberStateChange {
178        chat_group_id: u64,
179        steam_id: SteamID,
180        change: i32, // EChatRoomMemberStateChange
181    },
182
183    /// Rooms in a chat room group changed.
184    ChatRoomGroupRoomsChange { chat_group_id: u64, default_chat_id: u64, chat_rooms: Vec<ChatRoomState> },
185
186    /// Chat messages in a room were modified (e.g. deleted).
187    ChatMessagesModified { chat_group_id: u64, chat_id: u64, messages: Vec<ModifiedChatMessage> },
188
189    /// A chat room group's header state changed.
190    ChatRoomGroupHeaderStateChange { chat_group_id: u64, header_state: ChatRoomGroupHeaderState },
191
192    /// Fetched offline messages history.
193    OfflineMessagesFetched { friend_id: SteamID, messages: Vec<crate::services::chat::HistoryMessage> },
194}
195
196/// App and game events.
197#[derive(Debug, Clone)]
198pub enum AppsEvent {
199    /// Received licenses list.
200    LicenseList { licenses: Vec<LicenseEntry> },
201
202    /// PICS product info response.
203    ProductInfoResponse {
204        /// App info data keyed by app ID.
205        apps: HashMap<u32, AppInfoData>,
206        /// Package info data keyed by package ID.
207        packages: HashMap<u32, PackageInfoData>,
208        /// Unknown/unavailable app IDs.
209        unknown_apps: Vec<u32>,
210        /// Unknown/unavailable package IDs.
211        unknown_packages: Vec<u32>,
212    },
213
214    /// PICS access tokens response.
215    AccessTokensResponse {
216        /// App access tokens keyed by app ID.
217        app_tokens: HashMap<u32, u64>,
218        /// Package access tokens keyed by package ID.
219        package_tokens: HashMap<u32, u64>,
220        /// App IDs for which tokens were denied.
221        app_denied: Vec<u32>,
222        /// Package IDs for which tokens were denied.
223        package_denied: Vec<u32>,
224    },
225
226    /// PICS product changes response.
227    ProductChangesResponse {
228        /// Current change number.
229        current_change_number: u32,
230        /// App IDs that have changed.
231        app_changes: Vec<AppChange>,
232        /// Package IDs that have changed.
233        package_changes: Vec<PackageChange>,
234    },
235
236    /// Received a Game Coordinator message.
237    GCReceived(GCMessage),
238
239    /// Playing session state changed.
240    ///
241    /// Emitted when:
242    /// - Right after logon, only if a game is being played elsewhere (blocked
243    ///   is true)
244    /// - Whenever a game starts/stops being played on another session
245    /// - Whenever you start/stop playing a game on this session
246    PlayingState {
247        /// True if playing is blocked because this account is playing a game in
248        /// another location
249        blocked: bool,
250        /// The app ID currently being played (elsewhere if blocked, or by this
251        /// session if not)
252        playing_app: u32,
253    },
254}
255
256/// CS:GO specific events.
257#[derive(Debug, Clone)]
258pub enum CSGOEvent {
259    /// CS:GO/CS2 Online event (Welcome message).
260    ///
261    /// Emitted when the CS:GO Game Coordinator sends a welcome message,
262    /// indicating the client is "online" in CS:GO/CS2.
263    Online(CsgoWelcome),
264
265    /// CS:GO/CS2 Client Hello event.
266    ///
267    /// Emitted when the CS:GO Game Coordinator sends a client hello response,
268    /// containing player profile data (rank, level, commendations, etc.).
269    ClientHello(CsgoClientHello),
270
271    /// CS:GO/CS2 Players Profile event.
272    ///
273    /// Emitted when the CS:GO Game Coordinator sends a players profile
274    /// response, usually after a `ClientRequestPlayersProfile` message.
275    PlayersProfile(Vec<CsgoClientHello>),
276
277    /// Received a party invite.
278    PartyInvite { inviter: SteamID, lobby_id: u64 },
279    /// Received party search results.
280    PartySearchResults(Vec<CsgoPartyEntry>),
281}
282
283#[derive(Debug, Clone)]
284pub struct CsgoPartyEntry {
285    pub account_id: u32,
286    pub lobby_id: u32,
287    pub game_type: u32,
288    pub loc: u32,
289}
290
291/// Content delivery events.
292#[derive(Debug, Clone)]
293pub enum ContentEvent {
294    /// Received rich presence info for users.
295    RichPresence {
296        /// The app ID.
297        appid: u32,
298        /// Rich presence data for each user.
299        users: Vec<crate::services::rich_presence::RichPresenceData>,
300    },
301}
302
303/// System/debug events.
304#[derive(Debug, Clone)]
305pub enum SystemEvent {
306    /// Debug/log message.
307    Debug(String),
308
309    /// Error occurred.
310    Error(String),
311}
312
313/// Account events (email, limitations, wallet, VAC).
314#[derive(Debug, Clone)]
315pub enum AccountEvent {
316    /// Email address information.
317    EmailInfo {
318        /// The email address associated with this account.
319        address: String,
320        /// Whether the email has been validated.
321        validated: bool,
322    },
323
324    /// Account limitations status.
325    AccountLimitations {
326        /// Whether this is a limited account.
327        limited: bool,
328        /// Whether the account is community banned.
329        community_banned: bool,
330        /// Whether the account is locked.
331        locked: bool,
332        /// Whether the account can invite friends.
333        can_invite_friends: bool,
334    },
335
336    /// Wallet balance update.
337    Wallet {
338        /// Whether the account has a wallet.
339        has_wallet: bool,
340        /// Currency code.
341        currency: i32,
342        /// Balance in cents.
343        balance: i64,
344    },
345
346    /// VAC ban status.
347    VacBans {
348        /// Number of VAC bans.
349        num_bans: u32,
350        /// App IDs from which the account is banned.
351        appids: Vec<u32>,
352    },
353
354    /// Account info update.
355    AccountInfo {
356        /// Persona name.
357        name: String,
358        /// Country code.
359        country: String,
360        /// Number of authorized machines.
361        authed_machines: u32,
362        /// Account flags.
363        flags: u32,
364    },
365}
366
367/// Notification events (trade offers, messages, items, comments).
368#[derive(Debug, Clone)]
369pub enum NotificationsEvent {
370    /// Trade offers notification.
371    TradeOffers {
372        /// Number of pending trade offers.
373        count: u32,
374    },
375
376    /// Offline messages notification.
377    OfflineMessages {
378        /// Number of offline messages.
379        count: u32,
380        /// Friends who have sent offline messages.
381        friends: Vec<SteamID>,
382    },
383
384    /// New items notification.
385    NewItems {
386        /// Number of new items.
387        count: u32,
388    },
389
390    /// New comments notification.
391    NewComments {
392        /// Total new comments.
393        count: u32,
394        /// Comments on your items.
395        owner_comments: u32,
396        /// Comments in subscribed discussions.
397        subscription_comments: u32,
398    },
399
400    /// Community messages (moderator messages).
401    CommunityMessages {
402        /// Number of unread community messages.
403        count: u32,
404    },
405
406    /// Notifications received.
407    NotificationsReceived(Vec<NotificationData>),
408}
409
410//=============================================================================
411// Top-Level Event Enum
412//=============================================================================
413
414/// Steam client events, categorized by domain.
415///
416/// # Example
417/// ```rust,ignore
418/// match event {
419///     SteamEvent::Auth(auth) => match auth {
420///         AuthEvent::LoggedOn { steam_id } => println!("Logged in as {}", steam_id),
421///         AuthEvent::LoggedOff { result } => println!("Logged off: {:?}", result),
422///         _ => {}
423///     },
424///     SteamEvent::Chat(chat) => match chat {
425///         ChatEvent::FriendMessage { sender, message, .. } => {
426///             println!("{}: {}", sender, message);
427///         }
428///         _ => {}
429///     },
430///     // Ignore other categories
431///     _ => {}
432/// }
433/// ```
434#[derive(Debug, Clone)]
435pub enum SteamEvent {
436    /// Authentication events (login, logout, tokens).
437    Auth(AuthEvent),
438
439    /// Connection lifecycle (connect, disconnect, reconnect).
440    Connection(ConnectionEvent),
441
442    /// Friends and social (friends list, personas).
443    Friends(FriendsEvent),
444
445    /// Chat and messaging (friend messages, typing).
446    Chat(ChatEvent),
447
448    /// Apps and games (licenses, product info, GC).
449    Apps(AppsEvent),
450
451    /// CS:GO specific events.
452    CSGO(CSGOEvent),
453
454    /// Content delivery (rich presence).
455    Content(ContentEvent),
456
457    /// Account events (email, limitations, wallet, VAC).
458    Account(AccountEvent),
459
460    /// Notification events (trade offers, messages, items).
461    Notifications(NotificationsEvent),
462
463    /// System events (debug, errors).
464    System(SystemEvent),
465}
466
467//=============================================================================
468// Helper Methods for SteamEvent
469//=============================================================================
470
471impl SteamEvent {
472    /// Check if this is an authentication event.
473    pub fn is_auth(&self) -> bool {
474        matches!(self, SteamEvent::Auth(_))
475    }
476
477    /// Check if this is a connection event.
478    pub fn is_connection(&self) -> bool {
479        matches!(self, SteamEvent::Connection(_))
480    }
481
482    /// Check if this is a friends event.
483    pub fn is_friends(&self) -> bool {
484        matches!(self, SteamEvent::Friends(_))
485    }
486
487    /// Check if this is a chat event.
488    pub fn is_chat(&self) -> bool {
489        matches!(self, SteamEvent::Chat(_))
490    }
491
492    /// Check if this is an apps event.
493    pub fn is_apps(&self) -> bool {
494        matches!(self, SteamEvent::Apps(_))
495    }
496
497    /// Check if this is a content event.
498    pub fn is_content(&self) -> bool {
499        matches!(self, SteamEvent::Content(_))
500    }
501
502    /// Check if this is a system event.
503    pub fn is_system(&self) -> bool {
504        matches!(self, SteamEvent::System(_))
505    }
506
507    /// Check if this is an account event.
508    pub fn is_account(&self) -> bool {
509        matches!(self, SteamEvent::Account(_))
510    }
511
512    /// Check if this is a notifications event.
513    pub fn is_notifications(&self) -> bool {
514        matches!(self, SteamEvent::Notifications(_))
515    }
516
517    /// Check if this is a CS:GO event.
518    pub fn is_csgo(&self) -> bool {
519        matches!(self, SteamEvent::CSGO(_))
520    }
521
522    /// Get the sender SteamID if this is a chat message.
523    pub fn chat_sender(&self) -> Option<SteamID> {
524        match self {
525            SteamEvent::Chat(ChatEvent::FriendMessage { sender, .. }) => Some(*sender),
526            SteamEvent::Chat(ChatEvent::FriendTyping { sender }) => Some(*sender),
527            _ => None,
528        }
529    }
530}
531
532//=============================================================================
533// Data Structures
534//=============================================================================
535
536/// Friend entry from friends list.
537#[derive(Debug, Clone)]
538pub struct FriendEntry {
539    pub steam_id: SteamID,
540    pub relationship: EFriendRelationship,
541}
542
543/// License entry.
544#[derive(Debug, Clone, Default)]
545pub struct LicenseEntry {
546    pub package_id: u32,
547    pub time_created: u32,
548    pub time_next_process: u32,
549    pub minute_limit: i32,
550    pub minutes_used: i32,
551    pub payment_method: u32,
552    pub flags: u32,
553    pub purchase_country_code: String,
554    pub license_type: u32,
555    pub territory_code: i32,
556    pub change_number: i32,
557    pub owner_id: u32,
558    pub initial_period: u32,
559    pub initial_time_unit: u32,
560    pub renewal_period: u32,
561    pub renewal_time_unit: u32,
562    pub access_token: u64,
563    pub master_package_id: u32,
564}
565
566/// CS:GO Welcome data.
567#[derive(Debug, Clone)]
568pub struct CsgoWelcome {
569    /// Whether the account has Prime status.
570    pub prime: bool,
571    /// Elevated state (5 = bought prime).
572    pub elevated_state: u32,
573    /// Bonus XP used flags (16 = prestige earned/prime).
574    pub bonus_xp_usedflags: u32,
575    /// Inventory items.
576    pub items: Vec<steam_protos::CSOEconItem>,
577}
578
579/// CS:GO Client Hello data (GC message 9110 response).
580#[derive(Debug, Clone)]
581pub struct CsgoClientHello {
582    /// The account ID.
583    pub account_id: u32,
584    /// Whether the account is VAC banned (0 = not banned).
585    pub vac_banned: i32,
586    /// Penalty cooldown seconds remaining.
587    pub penalty_seconds: u32,
588    /// Penalty reason code.
589    pub penalty_reason: u32,
590    /// Player CS:GO profile level.
591    pub player_level: i32,
592    /// Player current XP (subtract 327680000 for actual XP).
593    pub player_cur_xp: i32,
594    /// Player XP bonus flags.
595    pub player_xp_bonus_flags: i32,
596    /// Competitive ranking info.
597    pub ranking: Option<CsgoRanking>,
598    /// Player commendation counts.
599    pub commendation: Option<CsgoCommendation>,
600    /// Global statistics (players online, servers online, etc.).
601    pub players_online: u32,
602    pub servers_online: u32,
603    pub ongoing_matches: u32,
604}
605
606/// CS:GO player ranking information.
607#[derive(Debug, Clone)]
608pub struct CsgoRanking {
609    /// Rank ID (1-18 for competitive, higher for Premier).
610    pub rank_id: u32,
611    /// Number of competitive wins.
612    pub wins: u32,
613    /// Rank type (6 = competitive, 7 = wingman, 10 = danger zone, 11 =
614    /// premier).
615    pub rank_type_id: u32,
616}
617
618/// CS:GO player commendation counts.
619#[derive(Debug, Clone)]
620pub struct CsgoCommendation {
621    /// Friendly commendations.
622    pub cmd_friendly: u32,
623    /// Teaching commendations.
624    pub cmd_teaching: u32,
625    /// Leader commendations.
626    pub cmd_leader: u32,
627}
628
629/// Chat room state.
630#[derive(Debug, Clone)]
631pub struct ChatRoomState {
632    pub chat_id: u64,
633    pub chat_name: String,
634    pub voice_allowed: bool,
635    pub members_in_voice: Vec<SteamID>,
636    pub time_last_message: u32,
637    pub last_message: String,
638    pub steamid_last_message: SteamID,
639}
640
641/// A modified chat message.
642#[derive(Debug, Clone)]
643pub struct ModifiedChatMessage {
644    pub server_timestamp: u32,
645    pub ordinal: u32,
646    pub deleted: bool,
647}
648
649/// Chat room group header state.
650#[derive(Debug, Clone)]
651pub struct ChatRoomGroupHeaderState {
652    pub name: String,
653    pub steamid_owner: SteamID,
654    pub appid: Option<u32>,
655    pub steamid_clan: Option<SteamID>,
656    pub avatar_sha: Vec<u8>,
657    pub default_chat_id: u64,
658}
659
660/// Notification data.
661#[derive(Debug, Clone)]
662pub struct NotificationData {
663    pub id: u64,
664    pub notification_type: i32,
665    pub body_data: String,
666    pub read: bool,
667    pub timestamp: u32,
668    pub hidden: bool,
669    pub expiry: Option<u32>,
670    pub viewed: Option<u32>,
671}
672
673//=============================================================================
674// Message Handler
675//=============================================================================
676
677/// Message handler that decodes and dispatches Steam messages.
678pub struct MessageHandler;
679
680/// Decoded message with job information for request-response correlation.
681#[derive(Debug)]
682pub struct DecodedMessage {
683    /// Event(s) decoded from the message.
684    pub events: Vec<SteamEvent>,
685    /// Job ID target from the header (if this is a response to a tracked
686    /// request).
687    pub job_id_target: Option<u64>,
688    /// Raw body bytes for job completion (zero-copy).
689    pub body: Bytes,
690}
691
692impl MessageHandler {
693    /// Decode a raw message and return events.
694    ///
695    /// For Multi messages, this returns multiple events from the contained
696    /// sub-messages.
697    pub fn decode_message(data: &[u8]) -> Vec<SteamEvent> {
698        Self::decode_packet(data).into_iter().flat_map(|m| m.events).collect()
699    }
700
701    /// Decode a raw packet and return a list of decoded messages.
702    ///
703    /// This handles both single messages and Multi messages (which expand into
704    /// multiple messages). Each returned `DecodedMessage` preserves its own
705    /// events and job ID target.
706    pub fn decode_packet(data: &[u8]) -> Vec<DecodedMessage> {
707        if data.len() < 8 {
708            return vec![];
709        }
710
711        // Read raw EMsg (first 4 bytes, little-endian)
712        let raw_emsg = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
713        let is_protobuf = (raw_emsg & 0x80000000) != 0;
714        let emsg = raw_emsg & !0x80000000;
715        let emsg_type = EMsg::from_i32((raw_emsg & !0x80000000) as i32).unwrap_or(EMsg::Invalid);
716
717        if is_protobuf {
718            Self::decode_protobuf_message_with_job(emsg_type, emsg, &data[4..])
719        } else {
720            vec![DecodedMessage { events: Self::decode_extended_message(emsg_type, &data[4..]), job_id_target: None, body: Bytes::new() }]
721        }
722    }
723
724    /// Legacy alias for decode_packet, kept for compatibility if needed but
725    /// returns single struct (incorrect for Multi).
726    ///
727    /// Deprecated: Use `decode_packet` instead.
728    pub fn decode_message_with_job(data: &[u8]) -> DecodedMessage {
729        // This is lossy for Multi messages as it flattens everything into one
730        // DecodedMessage and loses sub-message job IDs. It's kept temporarily.
731        let mut messages = Self::decode_packet(data);
732        if messages.is_empty() {
733            return DecodedMessage { events: vec![], job_id_target: None, body: Bytes::new() };
734        }
735
736        if messages.len() == 1 {
737            return messages.pop().expect("Messages should not be empty if pop returned None here");
738        }
739
740        // Flatten multiple messages into one
741        let mut all_events = Vec::new();
742        for msg in messages {
743            all_events.extend(msg.events);
744        }
745
746        DecodedMessage {
747            events: all_events,
748            job_id_target: None, // Lost for Multi
749            body: Bytes::new(),
750        }
751    }
752
753    /// Decode a single message and return a single event (legacy API).
754    pub fn decode_single(data: &[u8]) -> Option<SteamEvent> {
755        Self::decode_message(data).into_iter().next()
756    }
757
758    /// Decode a protobuf message.
759    #[allow(dead_code)]
760    fn decode_protobuf_message(emsg: EMsg, raw_emsg: u32, data: &[u8]) -> Vec<SteamEvent> {
761        Self::decode_packet_protobuf(emsg, raw_emsg, data).into_iter().flat_map(|m| m.events).collect()
762    }
763
764    /// Decode a protobuf packet (internal helper).
765    fn decode_protobuf_message_with_job(emsg: EMsg, raw_emsg: u32, data: &[u8]) -> Vec<DecodedMessage> {
766        Self::decode_packet_protobuf(emsg, raw_emsg, data)
767    }
768
769    /// Decode a protobuf packet.
770    fn decode_packet_protobuf(emsg: EMsg, raw_emsg: u32, data: &[u8]) -> Vec<DecodedMessage> {
771        use prost::Message as _;
772
773        use crate::protocol::header::CMsgProtoBufHeader;
774
775        if data.len() < 4 {
776            return vec![];
777        }
778
779        // Read header length
780        let header_len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
781
782        if data.len() < 4 + header_len {
783            return vec![];
784        }
785
786        // Parse header to extract job_id_target and target_job_name
787        let header_bytes = &data[4..4 + header_len];
788        let parsed_header = CMsgProtoBufHeader::decode(header_bytes).ok();
789
790        let job_id_target = parsed_header.as_ref().and_then(|h| h.jobid_target).filter(|&id| id != u64::MAX);
791
792        // Extract target_job_name for ServiceMethod routing
793        let target_job_name = parsed_header.as_ref().and_then(|h| h.target_job_name.clone());
794
795        // Get body
796        let body_slice = &data[4 + header_len..];
797
798        // Handle Multi specially as it returns multiple messages
799        if emsg == EMsg::Multi {
800            return Self::handle_multi(body_slice);
801        }
802
803        let body_bytes = Bytes::copy_from_slice(body_slice);
804
805        let events = match emsg {
806            EMsg::ClientLogOnResponse => Self::handle_logon_response(body_slice).into_iter().collect(),
807            EMsg::ClientLoggedOff => Self::handle_logged_off(body_slice).into_iter().collect(),
808            EMsg::ClientFriendsList => {
809                info!("[MessageHandler] Received EMsg::ClientFriendsList packet, body size: {} bytes", body_slice.len());
810                Self::handle_friends_list(body_slice).into_iter().collect()
811            }
812            EMsg::ClientPersonaState => Self::handle_persona_state(body_slice),
813            EMsg::ClientLicenseList => Self::handle_license_list(body_slice).into_iter().collect(),
814            EMsg::ClientCMList => Self::handle_cm_list(body_slice).into_iter().collect(),
815            EMsg::ClientFromGC => Self::handle_from_gc(body_slice).into_iter().collect(),
816            EMsg::ServiceMethod => {
817                // Route by target_job_name for proper service method handling
818                if let Some(ref job_name) = target_job_name {
819                    Self::handle_service_method_by_name(job_name, body_slice).into_iter().collect()
820                } else {
821                    // Fallback to legacy handling if no job name
822                    Self::handle_service_method_legacy(body_slice).into_iter().collect()
823                }
824            }
825            EMsg::ClientPICSProductInfoResponse => Self::handle_pics_product_info(body_slice).into_iter().collect(),
826            EMsg::ClientPICSAccessTokenResponse => Self::handle_pics_access_tokens(body_slice).into_iter().collect(),
827            EMsg::ClientPICSChangesSinceResponse => Self::handle_pics_changes(body_slice).into_iter().collect(),
828            // Account events
829            EMsg::ClientEmailAddrInfo => Self::handle_email_info(body_slice).into_iter().collect(),
830            EMsg::ClientIsLimitedAccount => Self::handle_account_limitations(body_slice).into_iter().collect(),
831            EMsg::ClientWalletInfoUpdate => Self::handle_wallet_info(body_slice).into_iter().collect(),
832            EMsg::ClientAccountInfo => Self::handle_account_info(body_slice).into_iter().collect(),
833            EMsg::ClientGameConnectTokens => Self::handle_game_connect_tokens(body_slice).into_iter().collect(),
834            // Notification events
835            EMsg::ClientUserNotifications => Self::handle_user_notifications(body_slice).into_iter().collect(),
836            EMsg::ClientChatOfflineMessageNotification => Self::handle_offline_messages(body_slice).into_iter().collect(),
837            EMsg::ClientItemAnnouncements => Self::handle_item_announcements(body_slice).into_iter().collect(),
838            EMsg::ClientCommentNotifications => Self::handle_comment_notifications(body_slice).into_iter().collect(),
839            EMsg::ClientMMSInviteToLobby => Self::handle_mms_invite(body_slice).into_iter().collect(),
840            // Playing session state (blocked by another session)
841            EMsg::ClientPlayingSessionState => Self::handle_playing_session_state(body_slice).into_iter().collect(),
842            // Messages that are handled internally or can be safely ignored
843            // These are common messages that don't need to emit events
844            EMsg::ClientFriendsGroupsList | EMsg::ClientVACBanStatus | EMsg::ClientSessionToken | EMsg::ClientServerList | EMsg::ServiceMethodResponse | EMsg::ClientMarketingMessageUpdate2 => {
845                // Silently ignore - these are either handled internally or not needed
846                vec![]
847            }
848            // Response messages complete jobs via job_id_target - no event needed
849            // For truly unknown messages, log them for debugging
850            _ => {
851                if emsg == EMsg::Invalid {
852                    // Unknown message ID - worth logging for debugging
853                    vec![SteamEvent::System(SystemEvent::Debug(format!("Unknown EMsg ID: {}", raw_emsg)))]
854                } else {
855                    // Known message type but unhandled - silently ignore in production
856                    // Uncomment the following for debugging new message types:
857                    // vec![SteamEvent::System(SystemEvent::Debug(format!(
858                    //     "Unhandled message: {:?}",
859                    //     emsg
860                    // )))]
861                    vec![]
862                }
863            }
864        };
865
866        vec![DecodedMessage { events, job_id_target, body: body_bytes }]
867    }
868
869    /// Decode an extended (non-protobuf) message.
870    fn decode_extended_message(emsg: EMsg, _data: &[u8]) -> Vec<SteamEvent> {
871        vec![SteamEvent::System(SystemEvent::Debug(format!("Unhandled extended message: {:?}", emsg)))]
872    }
873
874    /// Handle Multi message - contains multiple sub-messages, optionally gzip
875    /// compressed.
876    ///
877    /// For better performance, sub-messages are processed in parallel using
878    /// rayon when there are 4 or more sub-messages.
879    fn handle_multi(body: &[u8]) -> Vec<DecodedMessage> {
880        use rayon::prelude::*;
881
882        if let Ok(msg) = steam_protos::CMsgMulti::decode(body) {
883            let message_body = match msg.message_body {
884                Some(body) => body,
885                None => return vec![],
886            };
887
888            let payload = if msg.size_unzipped.unwrap_or(0) > 0 {
889                // Gzip compressed - decompress with pre-allocated buffer
890                match Self::decompress_gzip(&message_body, msg.size_unzipped.unwrap_or(4096) as usize) {
891                    Ok(decompressed) => decompressed,
892                    Err(e) => {
893                        // Return error event wrapped in DecodedMessage
894                        return vec![DecodedMessage {
895                            events: vec![SteamEvent::System(SystemEvent::Error(format!("Failed to decompress Multi: {}", e)))],
896                            job_id_target: None,
897                            body: Bytes::new(),
898                        }];
899                    }
900                }
901            } else {
902                // Not compressed
903                message_body
904            };
905
906            // Extract sub-message positions first (offset, size)
907            let mut sub_messages: Vec<(usize, usize)> = Vec::new();
908            let mut offset = 0;
909
910            while offset + 4 <= payload.len() {
911                let sub_size = u32::from_le_bytes([payload[offset], payload[offset + 1], payload[offset + 2], payload[offset + 3]]) as usize;
912                offset += 4;
913
914                if offset + sub_size > payload.len() {
915                    break;
916                }
917
918                sub_messages.push((offset, sub_size));
919                offset += sub_size;
920            }
921
922            // Process sub-messages: use parallel processing for 4+ messages
923            if sub_messages.len() >= 4 {
924                // Parallel processing with rayon
925                return sub_messages
926                    .par_iter()
927                    .flat_map(|&(start, size)| {
928                        let sub_msg = &payload[start..start + size];
929                        Self::decode_packet(sub_msg)
930                    })
931                    .collect();
932            } else {
933                // Sequential processing for small batches
934                return sub_messages
935                    .iter()
936                    .flat_map(|&(start, size)| {
937                        let sub_msg = &payload[start..start + size];
938                        Self::decode_packet(sub_msg)
939                    })
940                    .collect();
941            }
942        }
943        vec![]
944    }
945
946    /// Decompress gzip data with optional capacity hint for pre-allocation.
947    fn decompress_gzip(data: &[u8], capacity_hint: usize) -> Result<Vec<u8>, std::io::Error> {
948        let mut decoder = GzDecoder::new(data);
949        let mut decompressed = Vec::with_capacity(capacity_hint);
950        decoder.read_to_end(&mut decompressed)?;
951        Ok(decompressed)
952    }
953
954    fn handle_logon_response(body: &[u8]) -> Option<SteamEvent> {
955        use crate::utils::parsing::parse_logon_response;
956
957        match parse_logon_response(body) {
958            Ok(data) => {
959                if data.eresult == EResult::OK {
960                    Some(SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: data.steam_id }))
961                } else {
962                    // Login failed
963                    Some(SteamEvent::Auth(AuthEvent::LoggedOff { result: data.eresult }))
964                }
965            }
966            Err(e) => Some(SteamEvent::System(SystemEvent::Error(format!("Failed to parse logon response: {}", e)))),
967        }
968    }
969
970    fn handle_logged_off(body: &[u8]) -> Option<SteamEvent> {
971        use crate::utils::parsing::parse_logged_off;
972
973        match parse_logged_off(body) {
974            Ok(result) => Some(SteamEvent::Auth(AuthEvent::LoggedOff { result })),
975            Err(e) => Some(SteamEvent::System(SystemEvent::Error(format!("Failed to parse logged off message: {}", e)))),
976        }
977    }
978
979    fn handle_friends_list(body: &[u8]) -> Option<SteamEvent> {
980        use crate::utils::parsing::parse_friends_list;
981
982        match parse_friends_list(body) {
983            Ok(data) => {
984                info!("Received FriendsList event: incremental={}, count={}", data.incremental, data.friends.len());
985                Some(SteamEvent::Friends(FriendsEvent::FriendsList {
986                    incremental: data.incremental,
987                    friends: data.friends.into_iter().map(|f| FriendEntry { steam_id: f.steam_id, relationship: f.relationship }).collect(),
988                }))
989            }
990            Err(e) => {
991                error!("Failed to parse friends list: {}", e);
992                Some(SteamEvent::System(SystemEvent::Error(format!("Failed to parse friends list: {}", e))))
993            }
994        }
995    }
996
997    fn resolve_game_name(game_id: Option<u64>) -> Option<String> {
998        // Static cache for game names to avoid repeated binary searches
999        static GAME_NAME_CACHE: Lazy<Mutex<HashMap<u32, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));
1000
1001        // Try to resolve game name from static list if possible
1002        // The network often returns empty game_name even when gameid is present
1003        if let Some(game_id) = game_id {
1004            if game_id > 0 {
1005                // AppIDs are u32, safely cast
1006                let id_u32 = game_id as u32;
1007
1008                // Check cache first
1009                if let Ok(cache) = GAME_NAME_CACHE.lock() {
1010                    if let Some(name) = cache.get(&id_u32) {
1011                        return Some(name.clone());
1012                    }
1013                }
1014
1015                // Not in cache, lookup in bundled static list (feature-gated).
1016                #[cfg(feature = "static-app-list")]
1017                {
1018                    if let Ok(idx) = APP_LIST.binary_search_by_key(&id_u32, |&(id, _)| id) {
1019                        let name = APP_LIST[idx].1.to_string();
1020
1021                        // Update cache
1022                        if let Ok(mut cache) = GAME_NAME_CACHE.lock() {
1023                            cache.insert(id_u32, name.clone());
1024                        }
1025
1026                        return Some(name);
1027                    }
1028                }
1029                // Reference `id_u32` so the binding isn't unused without the feature.
1030                let _ = id_u32;
1031            }
1032        }
1033        None
1034    }
1035
1036    fn handle_persona_state(body: &[u8]) -> Vec<SteamEvent> {
1037        if let Ok(msg) = steam_protos::CMsgClientPersonaState::decode(body) {
1038            return msg
1039                .friends
1040                .into_iter()
1041                .map(|friend| {
1042                    let mut avatar_hash = friend.avatar_hash.as_ref().map(hex::encode);
1043
1044                    // Handle default/empty avatar hash (all zeros)
1045                    if let Some(ref hash) = avatar_hash {
1046                        if hash == "0000000000000000000000000000000000000000" {
1047                            avatar_hash = Some("fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb".to_string());
1048                        }
1049                    }
1050
1051                    let rich_presence: HashMap<String, String> = friend
1052                        .rich_presence
1053                        .into_iter()
1054                        .filter_map(|kv| {
1055                            let key = kv.key?;
1056                            let value = kv.value?;
1057                            Some((key, value))
1058                        })
1059                        .collect();
1060
1061                    // Extract steam_player_group from rich presence (lobby/party ID)
1062                    let steam_player_group = rich_presence.get("steam_player_group").filter(|s| s.as_str() != "0").cloned();
1063                    // Extract status from rich presence
1064                    let rich_presence_status = rich_presence.get("status").cloned();
1065                    // Extract match map from rich presence
1066                    let game_map = rich_presence.get("game:map").cloned();
1067                    // Extract game score from rich presence
1068                    let game_score = rich_presence.get("game:score").cloned();
1069                    // Extract num_players from rich presence
1070                    let num_players = rich_presence.get("members:numPlayers").and_then(|s| s.parse::<u32>().ok());
1071
1072                    SteamEvent::Friends(FriendsEvent::PersonaState(Box::new(UserPersona {
1073                        steam_id: SteamID::from(friend.friendid.unwrap_or(0)),
1074                        player_name: friend.player_name.clone().unwrap_or_default(),
1075                        persona_state: EPersonaState::from_i32(friend.persona_state.unwrap_or(0) as i32).unwrap_or(EPersonaState::Offline),
1076                        persona_state_flags: friend.persona_state_flags.unwrap_or(0),
1077                        avatar_hash,
1078                        // Prefer gameid (64-bit), fall back to game_played_app_id (32-bit)
1079                        // Steam may send either depending on the game type
1080                        game_id: friend.gameid.or_else(|| friend.game_played_app_id.map(|id| id as u64)),
1081                        game_name: Self::resolve_game_name(friend.gameid.or_else(|| friend.game_played_app_id.map(|id| id as u64))),
1082                        last_logon: friend.last_logon.and_then(|ts| Utc.timestamp_opt(ts as i64, 0).single()),
1083                        last_logoff: friend.last_logoff.and_then(|ts| Utc.timestamp_opt(ts as i64, 0).single()),
1084                        last_seen_online: friend.last_seen_online.and_then(|ts| Utc.timestamp_opt(ts as i64, 0).single()),
1085                        rich_presence,
1086                        steam_player_group,
1087                        rich_presence_status,
1088                        game_map,
1089                        game_score,
1090                        num_players,
1091                        unread_count: 0,
1092                        last_message_time: 0,
1093                    })))
1094                })
1095                .collect();
1096        }
1097        vec![]
1098    }
1099
1100    fn handle_license_list(body: &[u8]) -> Option<SteamEvent> {
1101        if let Ok(msg) = steam_protos::CMsgClientLicenseList::decode(body) {
1102            let licenses: Vec<LicenseEntry> = msg
1103                .licenses
1104                .iter()
1105                .map(|l| LicenseEntry {
1106                    package_id: l.package_id.unwrap_or(0),
1107                    time_created: l.time_created.unwrap_or(0),
1108                    time_next_process: l.time_next_process.unwrap_or(0),
1109                    minute_limit: l.minute_limit.unwrap_or(0),
1110                    minutes_used: l.minutes_used.unwrap_or(0),
1111                    payment_method: l.payment_method.unwrap_or(0),
1112                    flags: l.flags.unwrap_or(0),
1113                    purchase_country_code: l.purchase_country_code.clone().unwrap_or_default(),
1114                    license_type: l.license_type.unwrap_or(0),
1115                    territory_code: l.territory_code.unwrap_or(0),
1116                    change_number: l.change_number.unwrap_or(0),
1117                    owner_id: l.owner_id.unwrap_or(0),
1118                    initial_period: l.initial_period.unwrap_or(0),
1119                    initial_time_unit: l.initial_time_unit.unwrap_or(0),
1120                    renewal_period: l.renewal_period.unwrap_or(0),
1121                    renewal_time_unit: l.renewal_time_unit.unwrap_or(0),
1122                    access_token: l.access_token.unwrap_or(0),
1123                    master_package_id: l.master_package_id.unwrap_or(0),
1124                })
1125                .collect();
1126
1127            return Some(SteamEvent::Apps(AppsEvent::LicenseList { licenses }));
1128        }
1129        None
1130    }
1131
1132    /// Echo of our own typing indicator.
1133    fn handle_friend_typing_echo(body: &[u8]) -> Option<SteamEvent> {
1134        if let Ok(msg) = steam_protos::CFriendMessagesAckMessageNotification::decode(body) {
1135            if let Some(steamid) = msg.steamid_partner {
1136                return Some(SteamEvent::Chat(ChatEvent::FriendTypingEcho { receiver: SteamID::from(steamid) }));
1137            }
1138        }
1139        None
1140    }
1141
1142    fn handle_chatroom_notification(job_name: &str, body: &[u8]) -> Option<SteamEvent> {
1143        match job_name {
1144            "ChatRoomClient.NotifyIncomingChatMessage#1" => {
1145                if let Ok(msg) = steam_protos::CChatRoomIncomingChatMessageNotification::decode(body) {
1146                    return Some(SteamEvent::Chat(ChatEvent::ChatMessage {
1147                        chat_group_id: msg.chat_group_id.unwrap_or(0),
1148                        chat_id: msg.chat_id.unwrap_or(0),
1149                        sender: SteamID::from(msg.steamid_sender.unwrap_or(0)),
1150                        message: msg.message.unwrap_or_default(),
1151                        timestamp: msg.timestamp.unwrap_or(0),
1152                        ordinal: msg.ordinal.unwrap_or(0),
1153                    }));
1154                }
1155            }
1156            "ChatRoomClient.NotifyMemberStateChange#1" => {
1157                if let Ok(msg) = steam_protos::CChatRoomMemberStateChangeNotification::decode(body) {
1158                    let steamid = msg.member.as_ref().and_then(|m| m.accountid).map(SteamID::from_individual_account_id).unwrap_or_default();
1159                    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) }));
1160                }
1161            }
1162            "ChatRoomClient.NotifyChatRoomGroupRoomsChange#1" => {
1163                if let Ok(msg) = steam_protos::CChatRoomChatRoomGroupRoomsChangeNotification::decode(body) {
1164                    let chat_rooms = msg
1165                        .chat_rooms
1166                        .into_iter()
1167                        .map(|room| {
1168                            let members_in_voice = room.members_in_voice.into_iter().map(SteamID::from_individual_account_id).collect();
1169                            ChatRoomState {
1170                                chat_id: room.chat_id.unwrap_or(0),
1171                                chat_name: room.chat_name.unwrap_or_default(),
1172                                voice_allowed: room.voice_allowed.unwrap_or(false),
1173                                members_in_voice,
1174                                time_last_message: room.time_last_message.unwrap_or(0),
1175                                last_message: room.last_message.unwrap_or_default(),
1176                                steamid_last_message: SteamID::from_individual_account_id(room.accountid_last_message.unwrap_or(0)),
1177                            }
1178                        })
1179                        .collect();
1180                    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 }));
1181                }
1182            }
1183            "ChatRoomClient.NotifyChatMessageModified#1" => {
1184                if let Ok(msg) = steam_protos::CChatRoomChatMessageModifiedNotification::decode(body) {
1185                    let messages = msg
1186                        .messages
1187                        .into_iter()
1188                        .map(|m| ModifiedChatMessage {
1189                            server_timestamp: m.server_timestamp.unwrap_or(0),
1190                            ordinal: m.ordinal.unwrap_or(0),
1191                            deleted: m.deleted.unwrap_or(false),
1192                        })
1193                        .collect();
1194                    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 }));
1195                }
1196            }
1197            "ChatRoomClient.NotifyChatRoomHeaderStateChange#1" => {
1198                if let Ok(msg) = steam_protos::CChatRoomChatRoomHeaderStateNotification::decode(body) {
1199                    if let Some(header) = msg.header_state {
1200                        return Some(SteamEvent::Chat(ChatEvent::ChatRoomGroupHeaderStateChange {
1201                            chat_group_id: header.chat_group_id.unwrap_or(0),
1202                            header_state: ChatRoomGroupHeaderState {
1203                                name: header.chat_name.unwrap_or_default(),
1204                                steamid_owner: SteamID::from(header.steamid_owner.unwrap_or(0)),
1205                                appid: header.appid,
1206                                steamid_clan: header.steamid_clan.map(SteamID::from),
1207                                avatar_sha: header.avatar_sha.unwrap_or_default(),
1208                                default_chat_id: header.default_chat_id.unwrap_or(0) as u64,
1209                            },
1210                        }));
1211                    }
1212                }
1213            }
1214            _ => {}
1215        }
1216        None
1217    }
1218
1219    fn handle_player_notification(job_name: &str, body: &[u8]) -> Option<SteamEvent> {
1220        if job_name == "PlayerClient.NotifyFriendNicknameChanged#1" {
1221            if let Ok(msg) = steam_protos::CPlayerFriendNicknameChangedNotification::decode(body) {
1222                return Some(SteamEvent::Friends(FriendsEvent::NicknameChanged { steam_id: SteamID::from_individual_account_id(msg.accountid.unwrap_or(0)), nickname: msg.nickname }));
1223            }
1224        }
1225        None
1226    }
1227
1228    fn handle_steam_notification(body: &[u8]) -> Option<SteamEvent> {
1229        if let Ok(msg) = steam_protos::CSteamNotificationNotificationsReceivedNotification::decode(body) {
1230            let notifications = msg
1231                .notifications
1232                .into_iter()
1233                .map(|n| NotificationData {
1234                    id: n.notification_id.unwrap_or(0),
1235                    notification_type: n.notification_type.unwrap_or(0),
1236                    body_data: n.body_data.unwrap_or_default(),
1237                    read: n.read.unwrap_or(false),
1238                    timestamp: n.timestamp.unwrap_or(0),
1239                    hidden: n.hidden.unwrap_or(false),
1240                    expiry: n.expiry,
1241                    viewed: n.viewed,
1242                })
1243                .collect();
1244            return Some(SteamEvent::Notifications(NotificationsEvent::NotificationsReceived(notifications)));
1245        }
1246        None
1247    }
1248
1249    fn handle_cm_list(body: &[u8]) -> Option<SteamEvent> {
1250        if let Ok(msg) = steam_protos::CMsgClientCMList::decode(body) {
1251            return Some(SteamEvent::Connection(ConnectionEvent::CMList { servers: msg.cm_websocket_addresses }));
1252        }
1253        None
1254    }
1255
1256    /// Route service methods by their target_job_name (matches Node.js
1257    /// behavior).
1258    ///
1259    /// This enables dynamic routing of service method calls based on the RPC
1260    /// name from the protobuf header, just like the Node.js steam-user
1261    /// library.
1262    fn handle_service_method_by_name(job_name: &str, body: &[u8]) -> Option<SteamEvent> {
1263        match job_name {
1264            // Friend message notifications
1265            "FriendMessagesClient.IncomingMessage#1" => Self::handle_friend_message_notification(body),
1266
1267            // Message acknowledgment echo
1268            "FriendMessagesClient.NotifyAckMessageEcho#1" => Self::handle_friend_typing_echo(body),
1269
1270            // Steam notifications
1271            "SteamNotificationClient.NotificationsReceived#1" => Self::handle_steam_notification(body),
1272
1273            // Chat room notifications
1274            name if name.starts_with("ChatRoomClient.") => Self::handle_chatroom_notification(name, body),
1275
1276            // Player notifications
1277            name if name.starts_with("PlayerClient.") => Self::handle_player_notification(name, body),
1278
1279            // Unknown service methods - silently ignore
1280            _ => None,
1281        }
1282    }
1283
1284    /// Legacy fallback for service methods without target_job_name.
1285    fn handle_service_method_legacy(body: &[u8]) -> Option<SteamEvent> {
1286        // Try to decode as incoming friend message (legacy behavior)
1287        Self::handle_friend_message_notification(body)
1288    }
1289
1290    /// Handle friend message notifications
1291    /// (FriendMessagesClient.IncomingMessage#1).
1292    ///
1293    /// This handles:
1294    /// - Regular friend messages
1295    /// - Typing indicators
1296    /// - Left conversation notifications
1297    /// - Local echo of sent messages
1298    fn handle_friend_message_notification(body: &[u8]) -> Option<SteamEvent> {
1299        if let Ok(msg) = steam_protos::CFriendMessagesIncomingMessageNotification::decode(body) {
1300            // Check if this is actually a friend message (must have a valid sender)
1301            let steamid_friend = msg.steamid_friend.unwrap_or(0);
1302            if steamid_friend == 0 {
1303                // Not a valid friend message, skip
1304                return None;
1305            }
1306
1307            let steam_id = SteamID::from_steam_id64(steamid_friend);
1308            let local_echo = msg.local_echo.unwrap_or(false);
1309            let chat_entry_type = msg.chat_entry_type.unwrap_or(1);
1310
1311            // Prioritize message_no_bbcode if available, similar to node-steam-user
1312            let message = msg.message_no_bbcode.clone().filter(|s| !s.is_empty()).or_else(|| msg.message.clone()).unwrap_or_default();
1313
1314            // Determine the event type based on chat_entry_type and local_echo
1315            match chat_entry_type {
1316                // Typing (EChatEntryType::Typing = 2)
1317                val if val == EChatEntryType::Typing as i32 => {
1318                    if local_echo {
1319                        return Some(SteamEvent::Chat(ChatEvent::FriendTypingEcho { receiver: steam_id }));
1320                    } else {
1321                        return Some(SteamEvent::Chat(ChatEvent::FriendTyping { sender: steam_id }));
1322                    }
1323                }
1324
1325                // Left Conversation (EChatEntryType::LeftConversation = 6)
1326                val if val == EChatEntryType::LeftConversation as i32 => {
1327                    if local_echo {
1328                        return Some(SteamEvent::Chat(ChatEvent::FriendLeftConversationEcho { receiver: steam_id }));
1329                    } else {
1330                        return Some(SteamEvent::Chat(ChatEvent::FriendLeftConversation { sender: steam_id }));
1331                    }
1332                }
1333
1334                // Regular message (EChatEntryType::ChatMsg = 1) or other types
1335                _ => {
1336                    if local_echo {
1337                        return Some(SteamEvent::Chat(ChatEvent::FriendMessageEcho {
1338                            receiver: steam_id,
1339                            message,
1340                            timestamp: msg.rtime32_server_timestamp.unwrap_or(0),
1341                            ordinal: msg.ordinal.unwrap_or(0),
1342                        }));
1343                    } else {
1344                        return Some(SteamEvent::Chat(ChatEvent::FriendMessage {
1345                            sender: steam_id,
1346                            message,
1347                            chat_entry_type: EChatEntryType::from_i32(chat_entry_type).unwrap_or(EChatEntryType::ChatMsg),
1348                            timestamp: msg.rtime32_server_timestamp.unwrap_or(0),
1349                            ordinal: msg.ordinal.unwrap_or(0),
1350                            from_limited_account: msg.from_limited_account.unwrap_or(false),
1351                            low_priority: msg.low_priority.unwrap_or(false),
1352                        }));
1353                    }
1354                }
1355            }
1356        }
1357        None
1358    }
1359
1360    fn handle_from_gc(body: &[u8]) -> Vec<SteamEvent> {
1361        let msg = match steam_protos::CMsgGCClient::decode(body) {
1362            Ok(m) => m,
1363            Err(_) => return vec![],
1364        };
1365
1366        let gc_msg = match crate::services::gc::parse_gc_message(&msg) {
1367            Some(m) => m,
1368            None => return vec![],
1369        };
1370
1371        let mut events = vec![SteamEvent::Apps(AppsEvent::GCReceived(gc_msg.clone()))];
1372
1373        // Only process CS:GO messages (appid 730)
1374        if gc_msg.appid == 730 {
1375            if let Some(event) = Self::handle_csgo_gc_message(gc_msg.msg_type, &gc_msg.payload) {
1376                events.push(event);
1377            }
1378        }
1379
1380        events
1381    }
1382
1383    /// Route CS:GO GC messages to their respective handlers.
1384    fn handle_csgo_gc_message(msg_type: u32, payload: &[u8]) -> Option<SteamEvent> {
1385        let msg_i32 = msg_type as i32;
1386
1387        if let Some(msg) = ECsgoGCMsg::from_i32(msg_i32) {
1388            match msg {
1389                ECsgoGCMsg::MatchmakingGC2ClientHello => return Self::handle_csgo_client_hello(payload),
1390                ECsgoGCMsg::PlayersProfile => return Self::handle_csgo_players_profile(payload),
1391                ECsgoGCMsg::Party_Search => return Self::handle_csgo_party_search_results(payload),
1392                ECsgoGCMsg::Party_Invite => return Self::handle_csgo_party_invite(payload),
1393                _ => {}
1394            }
1395        }
1396
1397        if let Some(msg) = EGCBaseClientMsg::from_i32(msg_i32) {
1398            if msg == EGCBaseClientMsg::ClientConnectionStatus {
1399                return Self::handle_csgo_welcome(payload);
1400            }
1401        }
1402
1403        None
1404    }
1405
1406    /// Handle CS:GO Connection Status message (ClientConnectionStatus = 4009).
1407    fn handle_csgo_welcome(payload: &[u8]) -> Option<SteamEvent> {
1408        let welcome = steam_protos::CMsgClientWelcome::decode(payload).ok()?;
1409
1410        let mut prime = false;
1411        let mut elevated_state = 0;
1412        let mut bonus_xp_usedflags = 0;
1413        let mut items = Vec::new();
1414
1415        for cache in welcome.outofdate_subscribed_caches {
1416            for object in cache.objects {
1417                match object.type_id {
1418                    // Type 1 is CSOEconItem
1419                    Some(1) => {
1420                        for data in &object.object_data {
1421                            if let Ok(item) = steam_protos::CSOEconItem::decode(&**data) {
1422                                items.push(item);
1423                            }
1424                        }
1425                    }
1426                    // Type 7 is CSOEconGameAccountClient
1427                    Some(7) => {
1428                        for data in &object.object_data {
1429                            if let Ok(account) = steam_protos::CSOEconGameAccountClient::decode(&**data) {
1430                                elevated_state = account.elevated_state.unwrap_or(0);
1431                                bonus_xp_usedflags = account.bonus_xp_usedflags.unwrap_or(0);
1432
1433                                // Prime detection logic from Node.js
1434                                if (bonus_xp_usedflags & 16) != 0 || elevated_state == 5 {
1435                                    prime = true;
1436                                }
1437                            }
1438                        }
1439                    }
1440                    _ => {}
1441                }
1442            }
1443        }
1444
1445        Some(SteamEvent::CSGO(CSGOEvent::Online(CsgoWelcome { prime, elevated_state, bonus_xp_usedflags, items })))
1446    }
1447
1448    /// Handle CS:GO Client Hello message (MatchmakingGC2ClientHello = 9110).
1449    fn handle_csgo_client_hello(payload: &[u8]) -> Option<SteamEvent> {
1450        let hello = steam_protos::CMsgGccStrike15V2MatchmakingGc2ClientHello::decode(payload).ok()?;
1451        Some(SteamEvent::CSGO(CSGOEvent::ClientHello(Self::build_csgo_client_hello(&hello))))
1452    }
1453
1454    /// Handle CS:GO Players Profile message (PlayersProfile = 9128).
1455    fn handle_csgo_players_profile(payload: &[u8]) -> Option<SteamEvent> {
1456        let profile_msg = steam_protos::CMsgGccStrike15V2PlayersProfile::decode(payload).ok()?;
1457
1458        let profiles: Vec<CsgoClientHello> = profile_msg.account_profiles.iter().map(Self::build_csgo_client_hello).collect();
1459
1460        Some(SteamEvent::CSGO(CSGOEvent::PlayersProfile(profiles)))
1461    }
1462
1463    /// Handle CS:GO Party Invite message (Party_Invite = 9192).
1464    fn handle_csgo_party_invite(payload: &[u8]) -> Option<SteamEvent> {
1465        let invite = steam_protos::CMsgGccStrike15V2PartyInvite::decode(payload).ok()?;
1466
1467        Some(SteamEvent::CSGO(CSGOEvent::PartyInvite {
1468            inviter: SteamID::from_individual_account_id(invite.accountid.unwrap_or(0)),
1469            lobby_id: invite.lobbyid.unwrap_or(0) as u64,
1470        }))
1471    }
1472
1473    /// Handle CS:GO Party Search Results (Party_Search = 9191).
1474    fn handle_csgo_party_search_results(payload: &[u8]) -> Option<SteamEvent> {
1475        let results = steam_protos::CMsgGccStrike15V2PartySearchResults::decode(payload).ok()?;
1476
1477        let entries: Vec<CsgoPartyEntry> = results
1478            .entries
1479            .into_iter()
1480            .map(|e| CsgoPartyEntry {
1481                account_id: e.accountid.unwrap_or(0),
1482                lobby_id: e.id.unwrap_or(0),
1483                game_type: e.game_type.unwrap_or(0),
1484                loc: e.loc.unwrap_or(0),
1485            })
1486            .collect();
1487
1488        Some(SteamEvent::CSGO(CSGOEvent::PartySearchResults(entries)))
1489    }
1490
1491    /// Build a CsgoClientHello from a matchmaking hello message.
1492    /// Used by both handle_csgo_client_hello and handle_csgo_players_profile.
1493    pub(crate) fn build_csgo_client_hello(hello: &steam_protos::CMsgGccStrike15V2MatchmakingGc2ClientHello) -> CsgoClientHello {
1494        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) });
1495
1496        let commendation = hello.commendation.as_ref().map(|c| CsgoCommendation {
1497            cmd_friendly: c.cmd_friendly.unwrap_or(0),
1498            cmd_teaching: c.cmd_teaching.unwrap_or(0),
1499            cmd_leader: c.cmd_leader.unwrap_or(0),
1500        });
1501
1502        let global_stats = hello.global_stats.as_ref();
1503
1504        CsgoClientHello {
1505            account_id: hello.account_id.unwrap_or(0),
1506            vac_banned: hello.vac_banned.unwrap_or(0),
1507            penalty_seconds: hello.penalty_seconds.unwrap_or(0),
1508            penalty_reason: hello.penalty_reason.unwrap_or(0),
1509            player_level: hello.player_level.unwrap_or(0),
1510            player_cur_xp: hello.player_cur_xp.unwrap_or(0),
1511            player_xp_bonus_flags: hello.player_xp_bonus_flags.unwrap_or(0),
1512            ranking,
1513            commendation,
1514            players_online: global_stats.and_then(|s| s.players_online).unwrap_or(0),
1515            servers_online: global_stats.and_then(|s| s.servers_online).unwrap_or(0),
1516            ongoing_matches: global_stats.and_then(|s| s.ongoing_matches).unwrap_or(0),
1517        }
1518    }
1519
1520    fn handle_pics_product_info(body: &[u8]) -> Option<SteamEvent> {
1521        parsing::parse_pics_product_info(body).ok().map(|data| {
1522            SteamEvent::Apps(AppsEvent::ProductInfoResponse {
1523                apps: data.apps,
1524                packages: data.packages,
1525                unknown_apps: data.unknown_apps,
1526                unknown_packages: data.unknown_packages,
1527            })
1528        })
1529    }
1530
1531    fn handle_pics_access_tokens(body: &[u8]) -> Option<SteamEvent> {
1532        parsing::parse_pics_access_tokens(body).ok().map(|data| {
1533            SteamEvent::Apps(AppsEvent::AccessTokensResponse {
1534                app_tokens: data.app_tokens,
1535                package_tokens: data.package_tokens,
1536                app_denied: data.app_denied,
1537                package_denied: data.package_denied,
1538            })
1539        })
1540    }
1541
1542    fn handle_pics_changes(body: &[u8]) -> Option<SteamEvent> {
1543        parsing::parse_pics_changes(body).ok().map(|data| {
1544            SteamEvent::Apps(AppsEvent::ProductChangesResponse {
1545                current_change_number: data.current_change_number,
1546                app_changes: data.app_changes,
1547                package_changes: data.package_changes,
1548            })
1549        })
1550    }
1551
1552    //=========================================================================
1553    // Account Event Handlers
1554    //=========================================================================
1555
1556    fn handle_mms_invite(body: &[u8]) -> Option<SteamEvent> {
1557        if let Ok(msg) = steam_protos::CMsgClientMmsInviteToLobby::decode(body) {
1558            return Some(SteamEvent::CSGO(CSGOEvent::PartyInvite {
1559                inviter: SteamID::from(0), // MMS invite doesn't seem to have inviter ID in this message?
1560                lobby_id: msg.steam_id_lobby.unwrap_or(0),
1561            }));
1562        }
1563        None
1564    }
1565
1566    fn handle_email_info(body: &[u8]) -> Option<SteamEvent> {
1567        if let Ok(msg) = steam_protos::CMsgClientEmailAddrInfo::decode(body) {
1568            return Some(SteamEvent::Account(AccountEvent::EmailInfo { address: msg.email_address.unwrap_or_default(), validated: msg.email_is_validated.unwrap_or(false) }));
1569        }
1570        None
1571    }
1572
1573    fn handle_account_limitations(body: &[u8]) -> Option<SteamEvent> {
1574        if let Ok(msg) = steam_protos::CMsgClientIsLimitedAccount::decode(body) {
1575            return Some(SteamEvent::Account(AccountEvent::AccountLimitations {
1576                limited: msg.bis_limited_account.unwrap_or(false),
1577                community_banned: msg.bis_community_banned.unwrap_or(false),
1578                locked: msg.bis_locked_account.unwrap_or(false),
1579                can_invite_friends: msg.bis_limited_account_allowed_to_invite_friends.unwrap_or(true),
1580            }));
1581        }
1582        None
1583    }
1584
1585    fn handle_wallet_info(body: &[u8]) -> Option<SteamEvent> {
1586        if let Ok(msg) = steam_protos::CMsgClientWalletInfoUpdate::decode(body) {
1587            return Some(SteamEvent::Account(AccountEvent::Wallet {
1588                has_wallet: msg.has_wallet.unwrap_or(false),
1589                currency: msg.currency.unwrap_or(0),
1590                balance: msg.balance64.unwrap_or(msg.balance.unwrap_or(0) as i64),
1591            }));
1592        }
1593        None
1594    }
1595
1596    fn handle_account_info(body: &[u8]) -> Option<SteamEvent> {
1597        if let Ok(msg) = steam_protos::CMsgClientAccountInfo::decode(body) {
1598            return Some(SteamEvent::Account(AccountEvent::AccountInfo {
1599                name: msg.persona_name.unwrap_or_default(),
1600                country: msg.ip_country.unwrap_or_default(),
1601                authed_machines: msg.count_authed_computers.unwrap_or(0) as u32,
1602                flags: msg.account_flags.unwrap_or(0),
1603            }));
1604        }
1605        None
1606    }
1607
1608    fn handle_game_connect_tokens(body: &[u8]) -> Option<SteamEvent> {
1609        if let Ok(msg) = steam_protos::CMsgClientGameConnectTokens::decode(body) {
1610            return Some(SteamEvent::Auth(AuthEvent::GameConnectTokens { tokens: msg.tokens }));
1611        }
1612        None
1613    }
1614
1615    fn handle_playing_session_state(body: &[u8]) -> Option<SteamEvent> {
1616        if let Ok(msg) = steam_protos::CMsgClientPlayingSessionState::decode(body) {
1617            return Some(SteamEvent::Apps(AppsEvent::PlayingState { blocked: msg.playing_blocked.unwrap_or(false), playing_app: msg.playing_app.unwrap_or(0) }));
1618        }
1619        None
1620    }
1621
1622    //=========================================================================
1623    // Notification Event Handlers
1624    //=========================================================================
1625
1626    fn handle_user_notifications(body: &[u8]) -> Option<SteamEvent> {
1627        if let Ok(msg) = steam_protos::CMsgClientUserNotifications::decode(body) {
1628            // Check the notification types
1629            // Type 1 = tradeOffers, Type 3 = communityMessages
1630            for notif in &msg.notifications {
1631                let notif_type = notif.user_notification_type.unwrap_or(0);
1632                let count = notif.count.unwrap_or(0);
1633
1634                match notif_type {
1635                    1 => {
1636                        return Some(SteamEvent::Notifications(NotificationsEvent::TradeOffers { count }));
1637                    }
1638                    3 => {
1639                        return Some(SteamEvent::Notifications(NotificationsEvent::CommunityMessages { count }));
1640                    }
1641                    _ => {}
1642                }
1643            }
1644        }
1645        None
1646    }
1647
1648    fn handle_offline_messages(body: &[u8]) -> Option<SteamEvent> {
1649        if let Ok(msg) = steam_protos::CMsgClientOfflineMessageNotification::decode(body) {
1650            let friends: Vec<SteamID> = msg
1651                .friends_with_offline_messages
1652                .iter()
1653                .map(|&account_id| {
1654                    // Convert account ID to SteamID
1655                    SteamID::from_individual_account_id(account_id)
1656                })
1657                .collect();
1658
1659            return Some(SteamEvent::Notifications(NotificationsEvent::OfflineMessages { count: msg.offline_messages.unwrap_or(0), friends }));
1660        }
1661        None
1662    }
1663
1664    fn handle_item_announcements(body: &[u8]) -> Option<SteamEvent> {
1665        if let Ok(msg) = steam_protos::CMsgClientItemAnnouncements::decode(body) {
1666            return Some(SteamEvent::Notifications(NotificationsEvent::NewItems { count: msg.count_new_items.unwrap_or(0) }));
1667        }
1668        None
1669    }
1670
1671    fn handle_comment_notifications(body: &[u8]) -> Option<SteamEvent> {
1672        if let Ok(msg) = steam_protos::CMsgClientCommentNotifications::decode(body) {
1673            return Some(SteamEvent::Notifications(NotificationsEvent::NewComments {
1674                count: msg.count_new_comments.unwrap_or(0),
1675                owner_comments: msg.count_new_comments_owner.unwrap_or(0),
1676                subscription_comments: msg.count_new_comments_subscriptions.unwrap_or(0),
1677            }));
1678        }
1679        None
1680    }
1681}
1682
1683#[cfg(test)]
1684mod tests {
1685    use std::time::Duration;
1686
1687    use super::*;
1688
1689    //=========================================================================
1690    // Helper function to create test SteamIDs
1691    //=========================================================================
1692
1693    fn test_steam_id() -> SteamID {
1694        SteamID::from_steam_id64(76561198000000000)
1695    }
1696
1697    //=========================================================================
1698    // AuthEvent Tests
1699    //=========================================================================
1700
1701    #[test]
1702    fn test_auth_event_logged_on() {
1703        let steam_id = test_steam_id();
1704        let event = SteamEvent::Auth(AuthEvent::LoggedOn { steam_id });
1705
1706        assert!(event.is_auth());
1707        assert!(!event.is_connection());
1708        assert!(!event.is_chat());
1709
1710        // Pattern matching
1711        match event {
1712            SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: sid }) => {
1713                assert_eq!(sid.steam_id64(), 76561198000000000);
1714            }
1715            _ => panic!("Expected LoggedOn event"),
1716        }
1717    }
1718
1719    #[test]
1720    fn test_auth_event_logged_off() {
1721        let event = SteamEvent::Auth(AuthEvent::LoggedOff { result: EResult::LoggedInElsewhere });
1722
1723        assert!(event.is_auth());
1724
1725        match event {
1726            SteamEvent::Auth(AuthEvent::LoggedOff { result }) => {
1727                assert_eq!(result, EResult::LoggedInElsewhere);
1728            }
1729            _ => panic!("Expected LoggedOff event"),
1730        }
1731    }
1732
1733    #[test]
1734    fn test_auth_event_refresh_token() {
1735        let event = SteamEvent::Auth(AuthEvent::RefreshToken { token: "test_token_123".to_string(), account_name: "test_user".to_string() });
1736
1737        assert!(event.is_auth());
1738
1739        match event {
1740            SteamEvent::Auth(AuthEvent::RefreshToken { token, account_name }) => {
1741                assert_eq!(token, "test_token_123");
1742                assert_eq!(account_name, "test_user");
1743            }
1744            _ => panic!("Expected RefreshToken event"),
1745        }
1746    }
1747
1748    //=========================================================================
1749    // ConnectionEvent Tests
1750    //=========================================================================
1751
1752    #[test]
1753    fn test_connection_event_connected() {
1754        let event = SteamEvent::Connection(ConnectionEvent::Connected);
1755
1756        assert!(event.is_connection());
1757        assert!(!event.is_auth());
1758
1759        assert!(matches!(event, SteamEvent::Connection(ConnectionEvent::Connected)));
1760    }
1761
1762    #[test]
1763    fn test_connection_event_disconnected() {
1764        let event = SteamEvent::Connection(ConnectionEvent::Disconnected { reason: Some(EResult::NoConnection), will_reconnect: true });
1765
1766        assert!(event.is_connection());
1767
1768        match event {
1769            SteamEvent::Connection(ConnectionEvent::Disconnected { reason, will_reconnect }) => {
1770                assert_eq!(reason, Some(EResult::NoConnection));
1771                assert!(will_reconnect);
1772            }
1773            _ => panic!("Expected Disconnected event"),
1774        }
1775    }
1776
1777    #[test]
1778    fn test_connection_event_reconnect_attempt() {
1779        let event = SteamEvent::Connection(ConnectionEvent::ReconnectAttempt { attempt: 3, max_attempts: 10, delay: Duration::from_secs(5) });
1780
1781        assert!(event.is_connection());
1782
1783        match event {
1784            SteamEvent::Connection(ConnectionEvent::ReconnectAttempt { attempt, max_attempts, delay }) => {
1785                assert_eq!(attempt, 3);
1786                assert_eq!(max_attempts, 10);
1787                assert_eq!(delay, Duration::from_secs(5));
1788            }
1789            _ => panic!("Expected ReconnectAttempt event"),
1790        }
1791    }
1792
1793    #[test]
1794    fn test_connection_event_reconnect_failed() {
1795        let event = SteamEvent::Connection(ConnectionEvent::ReconnectFailed { reason: Some(EResult::ServiceUnavailable), attempts: 10 });
1796
1797        match event {
1798            SteamEvent::Connection(ConnectionEvent::ReconnectFailed { reason, attempts }) => {
1799                assert_eq!(reason, Some(EResult::ServiceUnavailable));
1800                assert_eq!(attempts, 10);
1801            }
1802            _ => panic!("Expected ReconnectFailed event"),
1803        }
1804    }
1805
1806    #[test]
1807    fn test_connection_event_cm_list() {
1808        let servers = vec!["cm1.steampowered.com:443".to_string(), "cm2.steampowered.com:443".to_string()];
1809        let event = SteamEvent::Connection(ConnectionEvent::CMList { servers: servers.clone() });
1810
1811        match event {
1812            SteamEvent::Connection(ConnectionEvent::CMList { servers: s }) => {
1813                assert_eq!(s.len(), 2);
1814                assert_eq!(s[0], "cm1.steampowered.com:443");
1815            }
1816            _ => panic!("Expected CMList event"),
1817        }
1818    }
1819
1820    //=========================================================================
1821    // FriendsEvent Tests
1822    //=========================================================================
1823
1824    #[test]
1825    fn test_friends_event_friends_list() {
1826        let friends = vec![FriendEntry { steam_id: test_steam_id(), relationship: EFriendRelationship::Friend }];
1827
1828        let event = SteamEvent::Friends(FriendsEvent::FriendsList { incremental: false, friends });
1829
1830        assert!(event.is_friends());
1831        assert!(!event.is_chat());
1832
1833        match event {
1834            SteamEvent::Friends(FriendsEvent::FriendsList { incremental, friends }) => {
1835                assert!(!incremental);
1836                assert_eq!(friends.len(), 1);
1837                assert_eq!(friends[0].relationship, EFriendRelationship::Friend);
1838            }
1839            _ => panic!("Expected FriendsList event"),
1840        }
1841    }
1842
1843    #[test]
1844    fn test_friends_event_persona_state() {
1845        let persona = UserPersona {
1846            steam_id: test_steam_id(),
1847            player_name: "TestPlayer".to_string(),
1848            persona_state: EPersonaState::Online,
1849            avatar_hash: Some("abc123".to_string()),
1850            game_name: Some("Counter-Strike 2".to_string()),
1851            game_id: Some(730),
1852            ..Default::default()
1853        };
1854
1855        let event = SteamEvent::Friends(FriendsEvent::PersonaState(Box::new(persona)));
1856
1857        assert!(event.is_friends());
1858
1859        match event {
1860            SteamEvent::Friends(FriendsEvent::PersonaState(p)) => {
1861                assert_eq!(p.player_name, "TestPlayer");
1862                assert_eq!(p.persona_state, EPersonaState::Online);
1863                assert_eq!(p.game_id, Some(730));
1864            }
1865            _ => panic!("Expected PersonaState event"),
1866        }
1867    }
1868
1869    #[test]
1870    fn test_friends_event_relationship() {
1871        let event = SteamEvent::Friends(FriendsEvent::FriendRelationship { steam_id: test_steam_id(), relationship: EFriendRelationship::Blocked });
1872
1873        match event {
1874            SteamEvent::Friends(FriendsEvent::FriendRelationship { steam_id, relationship }) => {
1875                assert_eq!(steam_id.steam_id64(), 76561198000000000);
1876                assert_eq!(relationship, EFriendRelationship::Blocked);
1877            }
1878            _ => panic!("Expected FriendRelationship event"),
1879        }
1880    }
1881
1882    //=========================================================================
1883    // ChatEvent Tests
1884    //=========================================================================
1885
1886    #[test]
1887    fn test_chat_event_friend_message() {
1888        let event = SteamEvent::Chat(ChatEvent::FriendMessage {
1889            sender: test_steam_id(),
1890            message: "Hello, World!".to_string(),
1891            chat_entry_type: EChatEntryType::ChatMsg,
1892            timestamp: 1702000000,
1893            ordinal: 1,
1894            from_limited_account: false,
1895            low_priority: false,
1896        });
1897
1898        assert!(event.is_chat());
1899        assert!(!event.is_friends());
1900
1901        // Test chat_sender helper
1902        assert!(event.chat_sender().is_some());
1903        assert_eq!(event.chat_sender().unwrap_or_default().steam_id64(), 76561198000000000);
1904
1905        match event {
1906            SteamEvent::Chat(ChatEvent::FriendMessage { sender, message, chat_entry_type, timestamp, ordinal, from_limited_account, low_priority }) => {
1907                assert_eq!(sender.steam_id64(), 76561198000000000);
1908                assert_eq!(message, "Hello, World!");
1909                assert_eq!(chat_entry_type, EChatEntryType::ChatMsg);
1910                assert_eq!(timestamp, 1702000000);
1911                assert_eq!(ordinal, 1);
1912                assert!(!from_limited_account);
1913                assert!(!low_priority);
1914            }
1915            _ => panic!("Expected FriendMessage event"),
1916        }
1917    }
1918
1919    #[test]
1920    fn test_chat_event_friend_typing() {
1921        let event = SteamEvent::Chat(ChatEvent::FriendTyping { sender: test_steam_id() });
1922
1923        assert!(event.is_chat());
1924        assert!(event.chat_sender().is_some());
1925
1926        match event {
1927            SteamEvent::Chat(ChatEvent::FriendTyping { sender }) => {
1928                assert_eq!(sender.steam_id64(), 76561198000000000);
1929            }
1930            _ => panic!("Expected FriendTyping event"),
1931        }
1932    }
1933
1934    //=========================================================================
1935    // AppsEvent Tests
1936    //=========================================================================
1937
1938    #[test]
1939    fn test_apps_event_license_list() {
1940        let licenses = vec![LicenseEntry {
1941            package_id: 12345,
1942            time_created: 1600000000,
1943            license_type: 1,
1944            flags: 0,
1945            access_token: 0,
1946            ..Default::default()
1947        }];
1948
1949        let event = SteamEvent::Apps(AppsEvent::LicenseList { licenses });
1950
1951        assert!(event.is_apps());
1952
1953        match event {
1954            SteamEvent::Apps(AppsEvent::LicenseList { licenses }) => {
1955                assert_eq!(licenses.len(), 1);
1956                assert_eq!(licenses[0].package_id, 12345);
1957            }
1958            _ => panic!("Expected LicenseList event"),
1959        }
1960    }
1961
1962    #[test]
1963    fn test_apps_event_product_info_response() {
1964        let mut apps = HashMap::new();
1965        apps.insert(730, AppInfoData { app_id: 730, change_number: 12345, missing_token: false, app_info: None });
1966
1967        let event = SteamEvent::Apps(AppsEvent::ProductInfoResponse { apps, packages: HashMap::new(), unknown_apps: vec![99999], unknown_packages: vec![] });
1968
1969        assert!(event.is_apps());
1970
1971        match event {
1972            SteamEvent::Apps(AppsEvent::ProductInfoResponse { apps, packages, unknown_apps, unknown_packages }) => {
1973                assert_eq!(apps.len(), 1);
1974                assert!(apps.contains_key(&730));
1975                assert_eq!(apps[&730].change_number, 12345);
1976                assert!(packages.is_empty());
1977                assert_eq!(unknown_apps, vec![99999]);
1978                assert!(unknown_packages.is_empty());
1979            }
1980            _ => panic!("Expected ProductInfoResponse event"),
1981        }
1982    }
1983
1984    #[test]
1985    fn test_apps_event_access_tokens_response() {
1986        let mut app_tokens = HashMap::new();
1987        app_tokens.insert(730, 123456789);
1988
1989        let event = SteamEvent::Apps(AppsEvent::AccessTokensResponse { app_tokens, package_tokens: HashMap::new(), app_denied: vec![440], package_denied: vec![] });
1990
1991        match event {
1992            SteamEvent::Apps(AppsEvent::AccessTokensResponse { app_tokens, package_tokens, app_denied, package_denied }) => {
1993                assert_eq!(app_tokens[&730], 123456789);
1994                assert!(package_tokens.is_empty());
1995                assert_eq!(app_denied, vec![440]);
1996                assert!(package_denied.is_empty());
1997            }
1998            _ => panic!("Expected AccessTokensResponse event"),
1999        }
2000    }
2001
2002    #[test]
2003    fn test_apps_event_product_changes_response() {
2004        let app_changes = vec![AppChange { app_id: 730, change_number: 99999, needs_token: false }];
2005
2006        let event = SteamEvent::Apps(AppsEvent::ProductChangesResponse { current_change_number: 100000, app_changes, package_changes: vec![] });
2007
2008        match event {
2009            SteamEvent::Apps(AppsEvent::ProductChangesResponse { current_change_number, app_changes, package_changes }) => {
2010                assert_eq!(current_change_number, 100000);
2011                assert_eq!(app_changes.len(), 1);
2012                assert_eq!(app_changes[0].app_id, 730);
2013                assert!(package_changes.is_empty());
2014            }
2015            _ => panic!("Expected ProductChangesResponse event"),
2016        }
2017    }
2018
2019    //=========================================================================
2020    // ContentEvent Tests
2021    //=========================================================================
2022
2023    #[test]
2024    fn test_content_event_rich_presence() {
2025        let users = vec![crate::services::rich_presence::RichPresenceData {
2026            steam_id: test_steam_id(),
2027            appid: 730,
2028            data: {
2029                let mut map = HashMap::new();
2030                map.insert("status".to_string(), "In Game".to_string());
2031                map
2032            },
2033        }];
2034
2035        let event = SteamEvent::Content(ContentEvent::RichPresence { appid: 730, users });
2036
2037        assert!(event.is_content());
2038
2039        match event {
2040            SteamEvent::Content(ContentEvent::RichPresence { appid, users }) => {
2041                assert_eq!(appid, 730);
2042                assert_eq!(users.len(), 1);
2043                assert_eq!(users[0].data.get("status"), Some(&"In Game".to_string()));
2044            }
2045            _ => panic!("Expected RichPresence event"),
2046        }
2047    }
2048
2049    //=========================================================================
2050    // SystemEvent Tests
2051    //=========================================================================
2052
2053    #[test]
2054    fn test_system_event_debug() {
2055        let event = SteamEvent::System(SystemEvent::Debug("Test debug message".to_string()));
2056
2057        assert!(event.is_system());
2058        assert!(!event.is_auth());
2059
2060        match event {
2061            SteamEvent::System(SystemEvent::Debug(msg)) => {
2062                assert_eq!(msg, "Test debug message");
2063            }
2064            _ => panic!("Expected Debug event"),
2065        }
2066    }
2067
2068    #[test]
2069    fn test_system_event_error() {
2070        let event = SteamEvent::System(SystemEvent::Error("Something went wrong".to_string()));
2071
2072        assert!(event.is_system());
2073
2074        match event {
2075            SteamEvent::System(SystemEvent::Error(msg)) => {
2076                assert_eq!(msg, "Something went wrong");
2077            }
2078            _ => panic!("Expected Error event"),
2079        }
2080    }
2081
2082    //=========================================================================
2083    // Helper Method Tests
2084    //=========================================================================
2085
2086    #[test]
2087    fn test_all_is_category_methods() {
2088        let events = vec![
2089            (SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: SteamID::new() }), "auth"),
2090            (SteamEvent::Connection(ConnectionEvent::Connected), "connection"),
2091            (SteamEvent::Friends(FriendsEvent::FriendsList { incremental: false, friends: vec![] }), "friends"),
2092            (SteamEvent::Chat(ChatEvent::FriendTyping { sender: SteamID::new() }), "chat"),
2093            (SteamEvent::Apps(AppsEvent::LicenseList { licenses: vec![] }), "apps"),
2094            (SteamEvent::Content(ContentEvent::RichPresence { appid: 0, users: vec![] }), "content"),
2095            (SteamEvent::System(SystemEvent::Debug("".to_string())), "system"),
2096        ];
2097
2098        for (event, expected_category) in events {
2099            match expected_category {
2100                "auth" => {
2101                    assert!(event.is_auth());
2102                    assert!(!event.is_connection());
2103                    assert!(!event.is_friends());
2104                    assert!(!event.is_chat());
2105                    assert!(!event.is_apps());
2106                    assert!(!event.is_content());
2107                    assert!(!event.is_system());
2108                }
2109                "connection" => {
2110                    assert!(!event.is_auth());
2111                    assert!(event.is_connection());
2112                    assert!(!event.is_friends());
2113                    assert!(!event.is_chat());
2114                    assert!(!event.is_apps());
2115                    assert!(!event.is_content());
2116                    assert!(!event.is_system());
2117                }
2118                "friends" => {
2119                    assert!(!event.is_auth());
2120                    assert!(!event.is_connection());
2121                    assert!(event.is_friends());
2122                    assert!(!event.is_chat());
2123                    assert!(!event.is_apps());
2124                    assert!(!event.is_content());
2125                    assert!(!event.is_system());
2126                }
2127                "chat" => {
2128                    assert!(!event.is_auth());
2129                    assert!(!event.is_connection());
2130                    assert!(!event.is_friends());
2131                    assert!(event.is_chat());
2132                    assert!(!event.is_apps());
2133                    assert!(!event.is_content());
2134                    assert!(!event.is_system());
2135                }
2136                "apps" => {
2137                    assert!(!event.is_auth());
2138                    assert!(!event.is_connection());
2139                    assert!(!event.is_friends());
2140                    assert!(!event.is_chat());
2141                    assert!(event.is_apps());
2142                    assert!(!event.is_content());
2143                    assert!(!event.is_system());
2144                }
2145                "content" => {
2146                    assert!(!event.is_auth());
2147                    assert!(!event.is_connection());
2148                    assert!(!event.is_friends());
2149                    assert!(!event.is_chat());
2150                    assert!(!event.is_apps());
2151                    assert!(event.is_content());
2152                    assert!(!event.is_system());
2153                }
2154                "system" => {
2155                    assert!(!event.is_auth());
2156                    assert!(!event.is_connection());
2157                    assert!(!event.is_friends());
2158                    assert!(!event.is_chat());
2159                    assert!(!event.is_apps());
2160                    assert!(!event.is_content());
2161                    assert!(event.is_system());
2162                }
2163                _ => panic!("Unknown category"),
2164            }
2165        }
2166    }
2167
2168    #[test]
2169    fn test_chat_sender_helper() {
2170        // FriendMessage has a sender
2171        let msg_event = SteamEvent::Chat(ChatEvent::FriendMessage {
2172            sender: test_steam_id(),
2173            message: "test".to_string(),
2174            chat_entry_type: EChatEntryType::ChatMsg,
2175            timestamp: 0,
2176            ordinal: 0,
2177            from_limited_account: false,
2178            low_priority: false,
2179        });
2180        assert!(msg_event.chat_sender().is_some());
2181        assert_eq!(msg_event.chat_sender().unwrap_or_default().steam_id64(), 76561198000000000);
2182
2183        // FriendTyping has a sender
2184        let typing_event = SteamEvent::Chat(ChatEvent::FriendTyping { sender: test_steam_id() });
2185        assert!(typing_event.chat_sender().is_some());
2186
2187        // Non-chat events don't have a sender
2188        let auth_event = SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: SteamID::new() });
2189        assert!(auth_event.chat_sender().is_none());
2190
2191        let conn_event = SteamEvent::Connection(ConnectionEvent::Connected);
2192        assert!(conn_event.chat_sender().is_none());
2193    }
2194
2195    //=========================================================================
2196    // Clone and Debug Tests
2197    //=========================================================================
2198
2199    #[test]
2200    fn test_events_are_cloneable() {
2201        let original = SteamEvent::Chat(ChatEvent::FriendMessage {
2202            sender: test_steam_id(),
2203            message: "Clone me!".to_string(),
2204            chat_entry_type: EChatEntryType::ChatMsg,
2205            timestamp: 123,
2206            ordinal: 1,
2207            from_limited_account: false,
2208            low_priority: false,
2209        });
2210
2211        let cloned = original.clone();
2212
2213        match (original, cloned) {
2214            (SteamEvent::Chat(ChatEvent::FriendMessage { message: m1, .. }), SteamEvent::Chat(ChatEvent::FriendMessage { message: m2, .. })) => {
2215                assert_eq!(m1, m2);
2216                assert_eq!(m1, "Clone me!");
2217            }
2218            _ => panic!("Clone failed"),
2219        }
2220    }
2221
2222    #[test]
2223    fn test_events_are_debuggable() {
2224        let event = SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: test_steam_id() });
2225
2226        let debug_str = format!("{:?}", event);
2227        assert!(debug_str.contains("Auth"));
2228        assert!(debug_str.contains("LoggedOn"));
2229    }
2230
2231    //=========================================================================
2232    // MessageHandler Tests
2233    //=========================================================================
2234
2235    #[test]
2236    fn test_decompress_gzip() {
2237        use std::io::Write;
2238
2239        use flate2::{write::GzEncoder, Compression};
2240
2241        let original = b"Hello, Steam Multi Message!";
2242
2243        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
2244        encoder.write_all(original).unwrap_or_default();
2245        let compressed = encoder.finish().unwrap_or_default();
2246
2247        let decompressed = MessageHandler::decompress_gzip(&compressed, original.len()).unwrap_or_default();
2248        assert_eq!(decompressed, original);
2249    }
2250
2251    #[test]
2252    fn test_decode_message_too_short() {
2253        // Message shorter than 8 bytes should return empty events
2254        let events = MessageHandler::decode_message(&[0, 1, 2, 3]);
2255        assert!(events.is_empty());
2256    }
2257
2258    //=========================================================================
2259    // Pattern Matching Example Tests
2260    //=========================================================================
2261
2262    #[test]
2263    fn test_exhaustive_category_matching() {
2264        let events = vec![
2265            SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: SteamID::new() }),
2266            SteamEvent::Connection(ConnectionEvent::Connected),
2267            SteamEvent::Friends(FriendsEvent::FriendsList { incremental: false, friends: vec![] }),
2268            SteamEvent::Chat(ChatEvent::FriendTyping { sender: SteamID::new() }),
2269            SteamEvent::Apps(AppsEvent::LicenseList { licenses: vec![] }),
2270            SteamEvent::Content(ContentEvent::RichPresence { appid: 0, users: vec![] }),
2271            SteamEvent::System(SystemEvent::Debug("test".to_string())),
2272        ];
2273
2274        for event in events {
2275            // This test ensures all categories can be matched
2276            let category = match event {
2277                SteamEvent::Auth(_) => "auth",
2278                SteamEvent::Connection(_) => "connection",
2279                SteamEvent::Friends(_) => "friends",
2280                SteamEvent::Chat(_) => "chat",
2281                SteamEvent::Apps(_) => "apps",
2282                SteamEvent::Content(_) => "content",
2283                SteamEvent::System(_) => "system",
2284                SteamEvent::Account(_) => "account",
2285                SteamEvent::Notifications(_) => "notifications",
2286                SteamEvent::CSGO(_) => "csgo",
2287            };
2288            assert!(!category.is_empty());
2289        }
2290    }
2291
2292    #[test]
2293    fn test_selective_category_matching() {
2294        // Test that you can ignore categories easily
2295        let event = SteamEvent::Chat(ChatEvent::FriendMessage {
2296            sender: test_steam_id(),
2297            message: "Hello".to_string(),
2298            chat_entry_type: EChatEntryType::ChatMsg,
2299            timestamp: 0,
2300            ordinal: 0,
2301            from_limited_account: false,
2302            low_priority: false,
2303        });
2304
2305        let mut handled = false;
2306
2307        if let SteamEvent::Chat(ChatEvent::FriendMessage { message, .. }) = event {
2308            assert_eq!(message, "Hello");
2309            handled = true;
2310        }
2311
2312        assert!(handled);
2313    }
2314}