1use std::{collections::HashMap, io::Read, sync::Mutex, time::Duration};
19
20use bytes::Bytes;
21use chrono::{TimeZone, Utc};
22use flate2::read::GzDecoder;
23use once_cell::sync::Lazy;
24use prost::Message;
25use steam_enums::{EChatEntryType, ECsgoGCMsg, EFriendRelationship, EGCBaseClientMsg, EMsg, EPersonaState, EResult};
26use steamid::SteamID;
27use tracing::{error, info};
28
29use super::steam_client::UserPersona;
30pub use crate::utils::parsing::{AppChange, AppInfoData, PackageChange, PackageInfoData};
31use crate::{services::gc::GCMessage, utils::parsing};
32#[cfg(feature = "static-app-list")]
33use crate::client::static_app_list::APP_LIST;
34
35#[derive(Debug, Clone)]
41pub enum AuthEvent {
42 LoggedOn { steam_id: SteamID },
44
45 LoggedOff { result: EResult },
47
48 RefreshToken {
51 token: String,
53 account_name: String,
55 },
56
57 WebSession {
68 session_id: String,
70 cookies: Vec<String>,
72 },
73
74 GameConnectTokens {
76 tokens: Vec<Vec<u8>>,
78 },
79}
80
81#[derive(Debug, Clone)]
83pub enum ConnectionEvent {
84 Connected,
86
87 Disconnected {
89 reason: Option<EResult>,
92 will_reconnect: bool,
94 },
95
96 ReconnectAttempt {
98 attempt: u32,
100 max_attempts: u32,
102 delay: Duration,
104 },
105
106 ReconnectFailed {
108 reason: Option<EResult>,
110 attempts: u32,
112 },
113
114 CMList { servers: Vec<String> },
116}
117
118#[derive(Debug, Clone)]
120pub enum FriendsEvent {
121 FriendsList { incremental: bool, friends: Vec<FriendEntry> },
123
124 PersonaState(Box<UserPersona>),
126
127 FriendRelationship { steam_id: SteamID, relationship: EFriendRelationship },
129
130 NicknameChanged { steam_id: SteamID, nickname: Option<String> },
132}
133
134#[derive(Debug, Clone)]
136pub enum ChatEvent {
137 FriendMessage {
139 sender: SteamID,
140 message: String,
141 chat_entry_type: EChatEntryType,
142 timestamp: u32,
143 ordinal: u32,
144 from_limited_account: bool,
146 low_priority: bool,
148 },
149
150 FriendMessageEcho { receiver: SteamID, message: String, timestamp: u32, ordinal: u32 },
152
153 FriendTyping { sender: SteamID },
155
156 FriendTypingEcho { receiver: SteamID },
158
159 FriendLeftConversation { sender: SteamID },
161
162 FriendLeftConversationEcho { receiver: SteamID },
164
165 ChatMessage {
167 chat_group_id: u64,
168 chat_id: u64,
169 sender: SteamID,
170 message: String,
171 timestamp: u32,
172 ordinal: u32,
173 },
175
176 ChatMemberStateChange {
178 chat_group_id: u64,
179 steam_id: SteamID,
180 change: i32, },
182
183 ChatRoomGroupRoomsChange { chat_group_id: u64, default_chat_id: u64, chat_rooms: Vec<ChatRoomState> },
185
186 ChatMessagesModified { chat_group_id: u64, chat_id: u64, messages: Vec<ModifiedChatMessage> },
188
189 ChatRoomGroupHeaderStateChange { chat_group_id: u64, header_state: ChatRoomGroupHeaderState },
191
192 OfflineMessagesFetched { friend_id: SteamID, messages: Vec<crate::services::chat::HistoryMessage> },
194}
195
196#[derive(Debug, Clone)]
198pub enum AppsEvent {
199 LicenseList { licenses: Vec<LicenseEntry> },
201
202 ProductInfoResponse {
204 apps: HashMap<u32, AppInfoData>,
206 packages: HashMap<u32, PackageInfoData>,
208 unknown_apps: Vec<u32>,
210 unknown_packages: Vec<u32>,
212 },
213
214 AccessTokensResponse {
216 app_tokens: HashMap<u32, u64>,
218 package_tokens: HashMap<u32, u64>,
220 app_denied: Vec<u32>,
222 package_denied: Vec<u32>,
224 },
225
226 ProductChangesResponse {
228 current_change_number: u32,
230 app_changes: Vec<AppChange>,
232 package_changes: Vec<PackageChange>,
234 },
235
236 GCReceived(GCMessage),
238
239 PlayingState {
247 blocked: bool,
250 playing_app: u32,
253 },
254}
255
256#[derive(Debug, Clone)]
258pub enum CSGOEvent {
259 Online(CsgoWelcome),
264
265 ClientHello(CsgoClientHello),
270
271 PlayersProfile(Vec<CsgoClientHello>),
276
277 PartyInvite { inviter: SteamID, lobby_id: u64 },
279 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#[derive(Debug, Clone)]
293pub enum ContentEvent {
294 RichPresence {
296 appid: u32,
298 users: Vec<crate::services::rich_presence::RichPresenceData>,
300 },
301}
302
303#[derive(Debug, Clone)]
305pub enum SystemEvent {
306 Debug(String),
308
309 Error(String),
311}
312
313#[derive(Debug, Clone)]
315pub enum AccountEvent {
316 EmailInfo {
318 address: String,
320 validated: bool,
322 },
323
324 AccountLimitations {
326 limited: bool,
328 community_banned: bool,
330 locked: bool,
332 can_invite_friends: bool,
334 },
335
336 Wallet {
338 has_wallet: bool,
340 currency: i32,
342 balance: i64,
344 },
345
346 VacBans {
348 num_bans: u32,
350 appids: Vec<u32>,
352 },
353
354 AccountInfo {
356 name: String,
358 country: String,
360 authed_machines: u32,
362 flags: u32,
364 },
365}
366
367#[derive(Debug, Clone)]
369pub enum NotificationsEvent {
370 TradeOffers {
372 count: u32,
374 },
375
376 OfflineMessages {
378 count: u32,
380 friends: Vec<SteamID>,
382 },
383
384 NewItems {
386 count: u32,
388 },
389
390 NewComments {
392 count: u32,
394 owner_comments: u32,
396 subscription_comments: u32,
398 },
399
400 CommunityMessages {
402 count: u32,
404 },
405
406 NotificationsReceived(Vec<NotificationData>),
408}
409
410#[derive(Debug, Clone)]
435pub enum SteamEvent {
436 Auth(AuthEvent),
438
439 Connection(ConnectionEvent),
441
442 Friends(FriendsEvent),
444
445 Chat(ChatEvent),
447
448 Apps(AppsEvent),
450
451 CSGO(CSGOEvent),
453
454 Content(ContentEvent),
456
457 Account(AccountEvent),
459
460 Notifications(NotificationsEvent),
462
463 System(SystemEvent),
465}
466
467impl SteamEvent {
472 pub fn is_auth(&self) -> bool {
474 matches!(self, SteamEvent::Auth(_))
475 }
476
477 pub fn is_connection(&self) -> bool {
479 matches!(self, SteamEvent::Connection(_))
480 }
481
482 pub fn is_friends(&self) -> bool {
484 matches!(self, SteamEvent::Friends(_))
485 }
486
487 pub fn is_chat(&self) -> bool {
489 matches!(self, SteamEvent::Chat(_))
490 }
491
492 pub fn is_apps(&self) -> bool {
494 matches!(self, SteamEvent::Apps(_))
495 }
496
497 pub fn is_content(&self) -> bool {
499 matches!(self, SteamEvent::Content(_))
500 }
501
502 pub fn is_system(&self) -> bool {
504 matches!(self, SteamEvent::System(_))
505 }
506
507 pub fn is_account(&self) -> bool {
509 matches!(self, SteamEvent::Account(_))
510 }
511
512 pub fn is_notifications(&self) -> bool {
514 matches!(self, SteamEvent::Notifications(_))
515 }
516
517 pub fn is_csgo(&self) -> bool {
519 matches!(self, SteamEvent::CSGO(_))
520 }
521
522 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#[derive(Debug, Clone)]
538pub struct FriendEntry {
539 pub steam_id: SteamID,
540 pub relationship: EFriendRelationship,
541}
542
543#[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#[derive(Debug, Clone)]
568pub struct CsgoWelcome {
569 pub prime: bool,
571 pub elevated_state: u32,
573 pub bonus_xp_usedflags: u32,
575 pub items: Vec<steam_protos::CSOEconItem>,
577}
578
579#[derive(Debug, Clone)]
581pub struct CsgoClientHello {
582 pub account_id: u32,
584 pub vac_banned: i32,
586 pub penalty_seconds: u32,
588 pub penalty_reason: u32,
590 pub player_level: i32,
592 pub player_cur_xp: i32,
594 pub player_xp_bonus_flags: i32,
596 pub ranking: Option<CsgoRanking>,
598 pub commendation: Option<CsgoCommendation>,
600 pub players_online: u32,
602 pub servers_online: u32,
603 pub ongoing_matches: u32,
604}
605
606#[derive(Debug, Clone)]
608pub struct CsgoRanking {
609 pub rank_id: u32,
611 pub wins: u32,
613 pub rank_type_id: u32,
616}
617
618#[derive(Debug, Clone)]
620pub struct CsgoCommendation {
621 pub cmd_friendly: u32,
623 pub cmd_teaching: u32,
625 pub cmd_leader: u32,
627}
628
629#[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#[derive(Debug, Clone)]
643pub struct ModifiedChatMessage {
644 pub server_timestamp: u32,
645 pub ordinal: u32,
646 pub deleted: bool,
647}
648
649#[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#[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
673pub struct MessageHandler;
679
680#[derive(Debug)]
682pub struct DecodedMessage {
683 pub events: Vec<SteamEvent>,
685 pub job_id_target: Option<u64>,
688 pub body: Bytes,
690}
691
692impl MessageHandler {
693 pub fn decode_message(data: &[u8]) -> Vec<SteamEvent> {
698 Self::decode_packet(data).into_iter().flat_map(|m| m.events).collect()
699 }
700
701 pub fn decode_packet(data: &[u8]) -> Vec<DecodedMessage> {
707 if data.len() < 8 {
708 return vec![];
709 }
710
711 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 pub fn decode_message_with_job(data: &[u8]) -> DecodedMessage {
729 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 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, body: Bytes::new(),
750 }
751 }
752
753 pub fn decode_single(data: &[u8]) -> Option<SteamEvent> {
755 Self::decode_message(data).into_iter().next()
756 }
757
758 #[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 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 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 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 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 let target_job_name = parsed_header.as_ref().and_then(|h| h.target_job_name.clone());
794
795 let body_slice = &data[4 + header_len..];
797
798 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 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 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 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 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 EMsg::ClientPlayingSessionState => Self::handle_playing_session_state(body_slice).into_iter().collect(),
842 EMsg::ClientFriendsGroupsList | EMsg::ClientVACBanStatus | EMsg::ClientSessionToken | EMsg::ClientServerList | EMsg::ServiceMethodResponse | EMsg::ClientMarketingMessageUpdate2 => {
845 vec![]
847 }
848 _ => {
851 if emsg == EMsg::Invalid {
852 vec![SteamEvent::System(SystemEvent::Debug(format!("Unknown EMsg ID: {}", raw_emsg)))]
854 } else {
855 vec![]
862 }
863 }
864 };
865
866 vec![DecodedMessage { events, job_id_target, body: body_bytes }]
867 }
868
869 fn decode_extended_message(emsg: EMsg, _data: &[u8]) -> Vec<SteamEvent> {
871 vec![SteamEvent::System(SystemEvent::Debug(format!("Unhandled extended message: {:?}", emsg)))]
872 }
873
874 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 match Self::decompress_gzip(&message_body, msg.size_unzipped.unwrap_or(4096) as usize) {
891 Ok(decompressed) => decompressed,
892 Err(e) => {
893 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 message_body
904 };
905
906 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 if sub_messages.len() >= 4 {
924 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 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 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 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 GAME_NAME_CACHE: Lazy<Mutex<HashMap<u32, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));
1000
1001 if let Some(game_id) = game_id {
1004 if game_id > 0 {
1005 let id_u32 = game_id as u32;
1007
1008 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 #[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 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 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 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 let steam_player_group = rich_presence.get("steam_player_group").filter(|s| s.as_str() != "0").cloned();
1063 let rich_presence_status = rich_presence.get("status").cloned();
1065 let game_map = rich_presence.get("game:map").cloned();
1067 let game_score = rich_presence.get("game:score").cloned();
1069 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 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 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 fn handle_service_method_by_name(job_name: &str, body: &[u8]) -> Option<SteamEvent> {
1263 match job_name {
1264 "FriendMessagesClient.IncomingMessage#1" => Self::handle_friend_message_notification(body),
1266
1267 "FriendMessagesClient.NotifyAckMessageEcho#1" => Self::handle_friend_typing_echo(body),
1269
1270 "SteamNotificationClient.NotificationsReceived#1" => Self::handle_steam_notification(body),
1272
1273 name if name.starts_with("ChatRoomClient.") => Self::handle_chatroom_notification(name, body),
1275
1276 name if name.starts_with("PlayerClient.") => Self::handle_player_notification(name, body),
1278
1279 _ => None,
1281 }
1282 }
1283
1284 fn handle_service_method_legacy(body: &[u8]) -> Option<SteamEvent> {
1286 Self::handle_friend_message_notification(body)
1288 }
1289
1290 fn handle_friend_message_notification(body: &[u8]) -> Option<SteamEvent> {
1299 if let Ok(msg) = steam_protos::CFriendMessagesIncomingMessageNotification::decode(body) {
1300 let steamid_friend = msg.steamid_friend.unwrap_or(0);
1302 if steamid_friend == 0 {
1303 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 let message = msg.message_no_bbcode.clone().filter(|s| !s.is_empty()).or_else(|| msg.message.clone()).unwrap_or_default();
1313
1314 match chat_entry_type {
1316 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 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 _ => {
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 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 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 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 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 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 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 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 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 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 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 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 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), 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 fn handle_user_notifications(body: &[u8]) -> Option<SteamEvent> {
1627 if let Ok(msg) = steam_protos::CMsgClientUserNotifications::decode(body) {
1628 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 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 fn test_steam_id() -> SteamID {
1694 SteamID::from_steam_id64(76561198000000000)
1695 }
1696
1697 #[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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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 let typing_event = SteamEvent::Chat(ChatEvent::FriendTyping { sender: test_steam_id() });
2185 assert!(typing_event.chat_sender().is_some());
2186
2187 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 #[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 #[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 let events = MessageHandler::decode_message(&[0, 1, 2, 3]);
2255 assert!(events.is_empty());
2256 }
2257
2258 #[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 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 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}