use std::{collections::HashMap, io::Read, sync::Mutex, time::Duration};
use bytes::Bytes;
use chrono::{TimeZone, Utc};
use flate2::read::GzDecoder;
use once_cell::sync::Lazy;
use prost::Message;
use steam_enums::{EChatEntryType, ECsgoGCMsg, EFriendRelationship, EGCBaseClientMsg, EMsg, EPersonaState, EResult};
use steamid::SteamID;
use tracing::{error, info};
use super::steam_client::UserPersona;
pub use crate::utils::parsing::{AppChange, AppInfoData, PackageChange, PackageInfoData};
use crate::{services::gc::GCMessage, utils::parsing};
#[cfg(feature = "static-app-list")]
use crate::client::static_app_list::APP_LIST;
#[derive(Debug, Clone)]
pub enum AuthEvent {
LoggedOn { steam_id: SteamID },
LoggedOff { result: EResult },
RefreshToken {
token: String,
account_name: String,
},
WebSession {
session_id: String,
cookies: Vec<String>,
},
GameConnectTokens {
tokens: Vec<Vec<u8>>,
},
}
#[derive(Debug, Clone)]
pub enum ConnectionEvent {
Connected,
Disconnected {
reason: Option<EResult>,
will_reconnect: bool,
},
ReconnectAttempt {
attempt: u32,
max_attempts: u32,
delay: Duration,
},
ReconnectFailed {
reason: Option<EResult>,
attempts: u32,
},
CMList { servers: Vec<String> },
}
#[derive(Debug, Clone)]
pub enum FriendsEvent {
FriendsList { incremental: bool, friends: Vec<FriendEntry> },
PersonaState(Box<UserPersona>),
FriendRelationship { steam_id: SteamID, relationship: EFriendRelationship },
NicknameChanged { steam_id: SteamID, nickname: Option<String> },
}
#[derive(Debug, Clone)]
pub enum ChatEvent {
FriendMessage {
sender: SteamID,
message: String,
chat_entry_type: EChatEntryType,
timestamp: u32,
ordinal: u32,
from_limited_account: bool,
low_priority: bool,
},
FriendMessageEcho { receiver: SteamID, message: String, timestamp: u32, ordinal: u32 },
FriendTyping { sender: SteamID },
FriendTypingEcho { receiver: SteamID },
FriendLeftConversation { sender: SteamID },
FriendLeftConversationEcho { receiver: SteamID },
ChatMessage {
chat_group_id: u64,
chat_id: u64,
sender: SteamID,
message: String,
timestamp: u32,
ordinal: u32,
},
ChatMemberStateChange {
chat_group_id: u64,
steam_id: SteamID,
change: i32, },
ChatRoomGroupRoomsChange { chat_group_id: u64, default_chat_id: u64, chat_rooms: Vec<ChatRoomState> },
ChatMessagesModified { chat_group_id: u64, chat_id: u64, messages: Vec<ModifiedChatMessage> },
ChatRoomGroupHeaderStateChange { chat_group_id: u64, header_state: ChatRoomGroupHeaderState },
OfflineMessagesFetched { friend_id: SteamID, messages: Vec<crate::services::chat::HistoryMessage> },
}
#[derive(Debug, Clone)]
pub enum AppsEvent {
LicenseList { licenses: Vec<LicenseEntry> },
ProductInfoResponse {
apps: HashMap<u32, AppInfoData>,
packages: HashMap<u32, PackageInfoData>,
unknown_apps: Vec<u32>,
unknown_packages: Vec<u32>,
},
AccessTokensResponse {
app_tokens: HashMap<u32, u64>,
package_tokens: HashMap<u32, u64>,
app_denied: Vec<u32>,
package_denied: Vec<u32>,
},
ProductChangesResponse {
current_change_number: u32,
app_changes: Vec<AppChange>,
package_changes: Vec<PackageChange>,
},
GCReceived(GCMessage),
PlayingState {
blocked: bool,
playing_app: u32,
},
}
#[derive(Debug, Clone)]
pub enum CSGOEvent {
Online(CsgoWelcome),
ClientHello(CsgoClientHello),
PlayersProfile(Vec<CsgoClientHello>),
PartyInvite { inviter: SteamID, lobby_id: u64 },
PartySearchResults(Vec<CsgoPartyEntry>),
}
#[derive(Debug, Clone)]
pub struct CsgoPartyEntry {
pub account_id: u32,
pub lobby_id: u32,
pub game_type: u32,
pub loc: u32,
}
#[derive(Debug, Clone)]
pub enum ContentEvent {
RichPresence {
appid: u32,
users: Vec<crate::services::rich_presence::RichPresenceData>,
},
}
#[derive(Debug, Clone)]
pub enum SystemEvent {
Debug(String),
Error(String),
}
#[derive(Debug, Clone)]
pub enum AccountEvent {
EmailInfo {
address: String,
validated: bool,
},
AccountLimitations {
limited: bool,
community_banned: bool,
locked: bool,
can_invite_friends: bool,
},
Wallet {
has_wallet: bool,
currency: i32,
balance: i64,
},
VacBans {
num_bans: u32,
appids: Vec<u32>,
},
AccountInfo {
name: String,
country: String,
authed_machines: u32,
flags: u32,
},
}
#[derive(Debug, Clone)]
pub enum NotificationsEvent {
TradeOffers {
count: u32,
},
OfflineMessages {
count: u32,
friends: Vec<SteamID>,
},
NewItems {
count: u32,
},
NewComments {
count: u32,
owner_comments: u32,
subscription_comments: u32,
},
CommunityMessages {
count: u32,
},
NotificationsReceived(Vec<NotificationData>),
}
#[derive(Debug, Clone)]
pub enum SteamEvent {
Auth(AuthEvent),
Connection(ConnectionEvent),
Friends(FriendsEvent),
Chat(ChatEvent),
Apps(AppsEvent),
CSGO(CSGOEvent),
Content(ContentEvent),
Account(AccountEvent),
Notifications(NotificationsEvent),
System(SystemEvent),
}
impl SteamEvent {
pub fn is_auth(&self) -> bool {
matches!(self, SteamEvent::Auth(_))
}
pub fn is_connection(&self) -> bool {
matches!(self, SteamEvent::Connection(_))
}
pub fn is_friends(&self) -> bool {
matches!(self, SteamEvent::Friends(_))
}
pub fn is_chat(&self) -> bool {
matches!(self, SteamEvent::Chat(_))
}
pub fn is_apps(&self) -> bool {
matches!(self, SteamEvent::Apps(_))
}
pub fn is_content(&self) -> bool {
matches!(self, SteamEvent::Content(_))
}
pub fn is_system(&self) -> bool {
matches!(self, SteamEvent::System(_))
}
pub fn is_account(&self) -> bool {
matches!(self, SteamEvent::Account(_))
}
pub fn is_notifications(&self) -> bool {
matches!(self, SteamEvent::Notifications(_))
}
pub fn is_csgo(&self) -> bool {
matches!(self, SteamEvent::CSGO(_))
}
pub fn chat_sender(&self) -> Option<SteamID> {
match self {
SteamEvent::Chat(ChatEvent::FriendMessage { sender, .. }) => Some(*sender),
SteamEvent::Chat(ChatEvent::FriendTyping { sender }) => Some(*sender),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct FriendEntry {
pub steam_id: SteamID,
pub relationship: EFriendRelationship,
}
#[derive(Debug, Clone, Default)]
pub struct LicenseEntry {
pub package_id: u32,
pub time_created: u32,
pub time_next_process: u32,
pub minute_limit: i32,
pub minutes_used: i32,
pub payment_method: u32,
pub flags: u32,
pub purchase_country_code: String,
pub license_type: u32,
pub territory_code: i32,
pub change_number: i32,
pub owner_id: u32,
pub initial_period: u32,
pub initial_time_unit: u32,
pub renewal_period: u32,
pub renewal_time_unit: u32,
pub access_token: u64,
pub master_package_id: u32,
}
#[derive(Debug, Clone)]
pub struct CsgoWelcome {
pub prime: bool,
pub elevated_state: u32,
pub bonus_xp_usedflags: u32,
pub items: Vec<steam_protos::CSOEconItem>,
}
#[derive(Debug, Clone)]
pub struct CsgoClientHello {
pub account_id: u32,
pub vac_banned: i32,
pub penalty_seconds: u32,
pub penalty_reason: u32,
pub player_level: i32,
pub player_cur_xp: i32,
pub player_xp_bonus_flags: i32,
pub ranking: Option<CsgoRanking>,
pub commendation: Option<CsgoCommendation>,
pub players_online: u32,
pub servers_online: u32,
pub ongoing_matches: u32,
}
#[derive(Debug, Clone)]
pub struct CsgoRanking {
pub rank_id: u32,
pub wins: u32,
pub rank_type_id: u32,
}
#[derive(Debug, Clone)]
pub struct CsgoCommendation {
pub cmd_friendly: u32,
pub cmd_teaching: u32,
pub cmd_leader: u32,
}
#[derive(Debug, Clone)]
pub struct ChatRoomState {
pub chat_id: u64,
pub chat_name: String,
pub voice_allowed: bool,
pub members_in_voice: Vec<SteamID>,
pub time_last_message: u32,
pub last_message: String,
pub steamid_last_message: SteamID,
}
#[derive(Debug, Clone)]
pub struct ModifiedChatMessage {
pub server_timestamp: u32,
pub ordinal: u32,
pub deleted: bool,
}
#[derive(Debug, Clone)]
pub struct ChatRoomGroupHeaderState {
pub name: String,
pub steamid_owner: SteamID,
pub appid: Option<u32>,
pub steamid_clan: Option<SteamID>,
pub avatar_sha: Vec<u8>,
pub default_chat_id: u64,
}
#[derive(Debug, Clone)]
pub struct NotificationData {
pub id: u64,
pub notification_type: i32,
pub body_data: String,
pub read: bool,
pub timestamp: u32,
pub hidden: bool,
pub expiry: Option<u32>,
pub viewed: Option<u32>,
}
pub struct MessageHandler;
#[derive(Debug)]
pub struct DecodedMessage {
pub events: Vec<SteamEvent>,
pub job_id_target: Option<u64>,
pub body: Bytes,
}
impl MessageHandler {
pub fn decode_message(data: &[u8]) -> Vec<SteamEvent> {
Self::decode_packet(data).into_iter().flat_map(|m| m.events).collect()
}
pub fn decode_packet(data: &[u8]) -> Vec<DecodedMessage> {
if data.len() < 8 {
return vec![];
}
let raw_emsg = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
let is_protobuf = (raw_emsg & 0x80000000) != 0;
let emsg = raw_emsg & !0x80000000;
let emsg_type = EMsg::from_i32((raw_emsg & !0x80000000) as i32).unwrap_or(EMsg::Invalid);
if is_protobuf {
Self::decode_protobuf_message_with_job(emsg_type, emsg, &data[4..])
} else {
vec![DecodedMessage { events: Self::decode_extended_message(emsg_type, &data[4..]), job_id_target: None, body: Bytes::new() }]
}
}
pub fn decode_message_with_job(data: &[u8]) -> DecodedMessage {
let mut messages = Self::decode_packet(data);
if messages.is_empty() {
return DecodedMessage { events: vec![], job_id_target: None, body: Bytes::new() };
}
if messages.len() == 1 {
return messages.pop().expect("Messages should not be empty if pop returned None here");
}
let mut all_events = Vec::new();
for msg in messages {
all_events.extend(msg.events);
}
DecodedMessage {
events: all_events,
job_id_target: None, body: Bytes::new(),
}
}
pub fn decode_single(data: &[u8]) -> Option<SteamEvent> {
Self::decode_message(data).into_iter().next()
}
#[allow(dead_code)]
fn decode_protobuf_message(emsg: EMsg, raw_emsg: u32, data: &[u8]) -> Vec<SteamEvent> {
Self::decode_packet_protobuf(emsg, raw_emsg, data).into_iter().flat_map(|m| m.events).collect()
}
fn decode_protobuf_message_with_job(emsg: EMsg, raw_emsg: u32, data: &[u8]) -> Vec<DecodedMessage> {
Self::decode_packet_protobuf(emsg, raw_emsg, data)
}
fn decode_packet_protobuf(emsg: EMsg, raw_emsg: u32, data: &[u8]) -> Vec<DecodedMessage> {
use prost::Message as _;
use crate::protocol::header::CMsgProtoBufHeader;
if data.len() < 4 {
return vec![];
}
let header_len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
if data.len() < 4 + header_len {
return vec![];
}
let header_bytes = &data[4..4 + header_len];
let parsed_header = CMsgProtoBufHeader::decode(header_bytes).ok();
let job_id_target = parsed_header.as_ref().and_then(|h| h.jobid_target).filter(|&id| id != u64::MAX);
let target_job_name = parsed_header.as_ref().and_then(|h| h.target_job_name.clone());
let body_slice = &data[4 + header_len..];
if emsg == EMsg::Multi {
return Self::handle_multi(body_slice);
}
let body_bytes = Bytes::copy_from_slice(body_slice);
let events = match emsg {
EMsg::ClientLogOnResponse => Self::handle_logon_response(body_slice).into_iter().collect(),
EMsg::ClientLoggedOff => Self::handle_logged_off(body_slice).into_iter().collect(),
EMsg::ClientFriendsList => {
info!("[MessageHandler] Received EMsg::ClientFriendsList packet, body size: {} bytes", body_slice.len());
Self::handle_friends_list(body_slice).into_iter().collect()
}
EMsg::ClientPersonaState => Self::handle_persona_state(body_slice),
EMsg::ClientLicenseList => Self::handle_license_list(body_slice).into_iter().collect(),
EMsg::ClientCMList => Self::handle_cm_list(body_slice).into_iter().collect(),
EMsg::ClientFromGC => Self::handle_from_gc(body_slice).into_iter().collect(),
EMsg::ServiceMethod => {
if let Some(ref job_name) = target_job_name {
Self::handle_service_method_by_name(job_name, body_slice).into_iter().collect()
} else {
Self::handle_service_method_legacy(body_slice).into_iter().collect()
}
}
EMsg::ClientPICSProductInfoResponse => Self::handle_pics_product_info(body_slice).into_iter().collect(),
EMsg::ClientPICSAccessTokenResponse => Self::handle_pics_access_tokens(body_slice).into_iter().collect(),
EMsg::ClientPICSChangesSinceResponse => Self::handle_pics_changes(body_slice).into_iter().collect(),
EMsg::ClientEmailAddrInfo => Self::handle_email_info(body_slice).into_iter().collect(),
EMsg::ClientIsLimitedAccount => Self::handle_account_limitations(body_slice).into_iter().collect(),
EMsg::ClientWalletInfoUpdate => Self::handle_wallet_info(body_slice).into_iter().collect(),
EMsg::ClientAccountInfo => Self::handle_account_info(body_slice).into_iter().collect(),
EMsg::ClientGameConnectTokens => Self::handle_game_connect_tokens(body_slice).into_iter().collect(),
EMsg::ClientUserNotifications => Self::handle_user_notifications(body_slice).into_iter().collect(),
EMsg::ClientChatOfflineMessageNotification => Self::handle_offline_messages(body_slice).into_iter().collect(),
EMsg::ClientItemAnnouncements => Self::handle_item_announcements(body_slice).into_iter().collect(),
EMsg::ClientCommentNotifications => Self::handle_comment_notifications(body_slice).into_iter().collect(),
EMsg::ClientMMSInviteToLobby => Self::handle_mms_invite(body_slice).into_iter().collect(),
EMsg::ClientPlayingSessionState => Self::handle_playing_session_state(body_slice).into_iter().collect(),
EMsg::ClientFriendsGroupsList | EMsg::ClientVACBanStatus | EMsg::ClientSessionToken | EMsg::ClientServerList | EMsg::ServiceMethodResponse | EMsg::ClientMarketingMessageUpdate2 => {
vec![]
}
_ => {
if emsg == EMsg::Invalid {
vec![SteamEvent::System(SystemEvent::Debug(format!("Unknown EMsg ID: {}", raw_emsg)))]
} else {
vec![]
}
}
};
vec![DecodedMessage { events, job_id_target, body: body_bytes }]
}
fn decode_extended_message(emsg: EMsg, _data: &[u8]) -> Vec<SteamEvent> {
vec![SteamEvent::System(SystemEvent::Debug(format!("Unhandled extended message: {:?}", emsg)))]
}
fn handle_multi(body: &[u8]) -> Vec<DecodedMessage> {
use rayon::prelude::*;
if let Ok(msg) = steam_protos::CMsgMulti::decode(body) {
let message_body = match msg.message_body {
Some(body) => body,
None => return vec![],
};
let payload = if msg.size_unzipped.unwrap_or(0) > 0 {
match Self::decompress_gzip(&message_body, msg.size_unzipped.unwrap_or(4096) as usize) {
Ok(decompressed) => decompressed,
Err(e) => {
return vec![DecodedMessage {
events: vec![SteamEvent::System(SystemEvent::Error(format!("Failed to decompress Multi: {}", e)))],
job_id_target: None,
body: Bytes::new(),
}];
}
}
} else {
message_body
};
let mut sub_messages: Vec<(usize, usize)> = Vec::new();
let mut offset = 0;
while offset + 4 <= payload.len() {
let sub_size = u32::from_le_bytes([payload[offset], payload[offset + 1], payload[offset + 2], payload[offset + 3]]) as usize;
offset += 4;
if offset + sub_size > payload.len() {
break;
}
sub_messages.push((offset, sub_size));
offset += sub_size;
}
if sub_messages.len() >= 4 {
return sub_messages
.par_iter()
.flat_map(|&(start, size)| {
let sub_msg = &payload[start..start + size];
Self::decode_packet(sub_msg)
})
.collect();
} else {
return sub_messages
.iter()
.flat_map(|&(start, size)| {
let sub_msg = &payload[start..start + size];
Self::decode_packet(sub_msg)
})
.collect();
}
}
vec![]
}
fn decompress_gzip(data: &[u8], capacity_hint: usize) -> Result<Vec<u8>, std::io::Error> {
let mut decoder = GzDecoder::new(data);
let mut decompressed = Vec::with_capacity(capacity_hint);
decoder.read_to_end(&mut decompressed)?;
Ok(decompressed)
}
fn handle_logon_response(body: &[u8]) -> Option<SteamEvent> {
use crate::utils::parsing::parse_logon_response;
match parse_logon_response(body) {
Ok(data) => {
if data.eresult == EResult::OK {
Some(SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: data.steam_id }))
} else {
Some(SteamEvent::Auth(AuthEvent::LoggedOff { result: data.eresult }))
}
}
Err(e) => Some(SteamEvent::System(SystemEvent::Error(format!("Failed to parse logon response: {}", e)))),
}
}
fn handle_logged_off(body: &[u8]) -> Option<SteamEvent> {
use crate::utils::parsing::parse_logged_off;
match parse_logged_off(body) {
Ok(result) => Some(SteamEvent::Auth(AuthEvent::LoggedOff { result })),
Err(e) => Some(SteamEvent::System(SystemEvent::Error(format!("Failed to parse logged off message: {}", e)))),
}
}
fn handle_friends_list(body: &[u8]) -> Option<SteamEvent> {
use crate::utils::parsing::parse_friends_list;
match parse_friends_list(body) {
Ok(data) => {
info!("Received FriendsList event: incremental={}, count={}", data.incremental, data.friends.len());
Some(SteamEvent::Friends(FriendsEvent::FriendsList {
incremental: data.incremental,
friends: data.friends.into_iter().map(|f| FriendEntry { steam_id: f.steam_id, relationship: f.relationship }).collect(),
}))
}
Err(e) => {
error!("Failed to parse friends list: {}", e);
Some(SteamEvent::System(SystemEvent::Error(format!("Failed to parse friends list: {}", e))))
}
}
}
fn resolve_game_name(game_id: Option<u64>) -> Option<String> {
static GAME_NAME_CACHE: Lazy<Mutex<HashMap<u32, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));
if let Some(game_id) = game_id {
if game_id > 0 {
let id_u32 = game_id as u32;
if let Ok(cache) = GAME_NAME_CACHE.lock() {
if let Some(name) = cache.get(&id_u32) {
return Some(name.clone());
}
}
#[cfg(feature = "static-app-list")]
{
if let Ok(idx) = APP_LIST.binary_search_by_key(&id_u32, |&(id, _)| id) {
let name = APP_LIST[idx].1.to_string();
if let Ok(mut cache) = GAME_NAME_CACHE.lock() {
cache.insert(id_u32, name.clone());
}
return Some(name);
}
}
let _ = id_u32;
}
}
None
}
fn handle_persona_state(body: &[u8]) -> Vec<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientPersonaState::decode(body) {
return msg
.friends
.into_iter()
.map(|friend| {
let mut avatar_hash = friend.avatar_hash.as_ref().map(hex::encode);
if let Some(ref hash) = avatar_hash {
if hash == "0000000000000000000000000000000000000000" {
avatar_hash = Some("fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb".to_string());
}
}
let rich_presence: HashMap<String, String> = friend
.rich_presence
.into_iter()
.filter_map(|kv| {
let key = kv.key?;
let value = kv.value?;
Some((key, value))
})
.collect();
let steam_player_group = rich_presence.get("steam_player_group").filter(|s| s.as_str() != "0").cloned();
let rich_presence_status = rich_presence.get("status").cloned();
let game_map = rich_presence.get("game:map").cloned();
let game_score = rich_presence.get("game:score").cloned();
let num_players = rich_presence.get("members:numPlayers").and_then(|s| s.parse::<u32>().ok());
SteamEvent::Friends(FriendsEvent::PersonaState(Box::new(UserPersona {
steam_id: SteamID::from(friend.friendid.unwrap_or(0)),
player_name: friend.player_name.clone().unwrap_or_default(),
persona_state: EPersonaState::from_i32(friend.persona_state.unwrap_or(0) as i32).unwrap_or(EPersonaState::Offline),
persona_state_flags: friend.persona_state_flags.unwrap_or(0),
avatar_hash,
game_id: friend.gameid.or_else(|| friend.game_played_app_id.map(|id| id as u64)),
game_name: Self::resolve_game_name(friend.gameid.or_else(|| friend.game_played_app_id.map(|id| id as u64))),
last_logon: friend.last_logon.and_then(|ts| Utc.timestamp_opt(ts as i64, 0).single()),
last_logoff: friend.last_logoff.and_then(|ts| Utc.timestamp_opt(ts as i64, 0).single()),
last_seen_online: friend.last_seen_online.and_then(|ts| Utc.timestamp_opt(ts as i64, 0).single()),
rich_presence,
steam_player_group,
rich_presence_status,
game_map,
game_score,
num_players,
unread_count: 0,
last_message_time: 0,
})))
})
.collect();
}
vec![]
}
fn handle_license_list(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientLicenseList::decode(body) {
let licenses: Vec<LicenseEntry> = msg
.licenses
.iter()
.map(|l| LicenseEntry {
package_id: l.package_id.unwrap_or(0),
time_created: l.time_created.unwrap_or(0),
time_next_process: l.time_next_process.unwrap_or(0),
minute_limit: l.minute_limit.unwrap_or(0),
minutes_used: l.minutes_used.unwrap_or(0),
payment_method: l.payment_method.unwrap_or(0),
flags: l.flags.unwrap_or(0),
purchase_country_code: l.purchase_country_code.clone().unwrap_or_default(),
license_type: l.license_type.unwrap_or(0),
territory_code: l.territory_code.unwrap_or(0),
change_number: l.change_number.unwrap_or(0),
owner_id: l.owner_id.unwrap_or(0),
initial_period: l.initial_period.unwrap_or(0),
initial_time_unit: l.initial_time_unit.unwrap_or(0),
renewal_period: l.renewal_period.unwrap_or(0),
renewal_time_unit: l.renewal_time_unit.unwrap_or(0),
access_token: l.access_token.unwrap_or(0),
master_package_id: l.master_package_id.unwrap_or(0),
})
.collect();
return Some(SteamEvent::Apps(AppsEvent::LicenseList { licenses }));
}
None
}
fn handle_friend_typing_echo(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CFriendMessagesAckMessageNotification::decode(body) {
if let Some(steamid) = msg.steamid_partner {
return Some(SteamEvent::Chat(ChatEvent::FriendTypingEcho { receiver: SteamID::from(steamid) }));
}
}
None
}
fn handle_chatroom_notification(job_name: &str, body: &[u8]) -> Option<SteamEvent> {
match job_name {
"ChatRoomClient.NotifyIncomingChatMessage#1" => {
if let Ok(msg) = steam_protos::CChatRoomIncomingChatMessageNotification::decode(body) {
return Some(SteamEvent::Chat(ChatEvent::ChatMessage {
chat_group_id: msg.chat_group_id.unwrap_or(0),
chat_id: msg.chat_id.unwrap_or(0),
sender: SteamID::from(msg.steamid_sender.unwrap_or(0)),
message: msg.message.unwrap_or_default(),
timestamp: msg.timestamp.unwrap_or(0),
ordinal: msg.ordinal.unwrap_or(0),
}));
}
}
"ChatRoomClient.NotifyMemberStateChange#1" => {
if let Ok(msg) = steam_protos::CChatRoomMemberStateChangeNotification::decode(body) {
let steamid = msg.member.as_ref().and_then(|m| m.accountid).map(SteamID::from_individual_account_id).unwrap_or_default();
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) }));
}
}
"ChatRoomClient.NotifyChatRoomGroupRoomsChange#1" => {
if let Ok(msg) = steam_protos::CChatRoomChatRoomGroupRoomsChangeNotification::decode(body) {
let chat_rooms = msg
.chat_rooms
.into_iter()
.map(|room| {
let members_in_voice = room.members_in_voice.into_iter().map(SteamID::from_individual_account_id).collect();
ChatRoomState {
chat_id: room.chat_id.unwrap_or(0),
chat_name: room.chat_name.unwrap_or_default(),
voice_allowed: room.voice_allowed.unwrap_or(false),
members_in_voice,
time_last_message: room.time_last_message.unwrap_or(0),
last_message: room.last_message.unwrap_or_default(),
steamid_last_message: SteamID::from_individual_account_id(room.accountid_last_message.unwrap_or(0)),
}
})
.collect();
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 }));
}
}
"ChatRoomClient.NotifyChatMessageModified#1" => {
if let Ok(msg) = steam_protos::CChatRoomChatMessageModifiedNotification::decode(body) {
let messages = msg
.messages
.into_iter()
.map(|m| ModifiedChatMessage {
server_timestamp: m.server_timestamp.unwrap_or(0),
ordinal: m.ordinal.unwrap_or(0),
deleted: m.deleted.unwrap_or(false),
})
.collect();
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 }));
}
}
"ChatRoomClient.NotifyChatRoomHeaderStateChange#1" => {
if let Ok(msg) = steam_protos::CChatRoomChatRoomHeaderStateNotification::decode(body) {
if let Some(header) = msg.header_state {
return Some(SteamEvent::Chat(ChatEvent::ChatRoomGroupHeaderStateChange {
chat_group_id: header.chat_group_id.unwrap_or(0),
header_state: ChatRoomGroupHeaderState {
name: header.chat_name.unwrap_or_default(),
steamid_owner: SteamID::from(header.steamid_owner.unwrap_or(0)),
appid: header.appid,
steamid_clan: header.steamid_clan.map(SteamID::from),
avatar_sha: header.avatar_sha.unwrap_or_default(),
default_chat_id: header.default_chat_id.unwrap_or(0) as u64,
},
}));
}
}
}
_ => {}
}
None
}
fn handle_player_notification(job_name: &str, body: &[u8]) -> Option<SteamEvent> {
if job_name == "PlayerClient.NotifyFriendNicknameChanged#1" {
if let Ok(msg) = steam_protos::CPlayerFriendNicknameChangedNotification::decode(body) {
return Some(SteamEvent::Friends(FriendsEvent::NicknameChanged { steam_id: SteamID::from_individual_account_id(msg.accountid.unwrap_or(0)), nickname: msg.nickname }));
}
}
None
}
fn handle_steam_notification(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CSteamNotificationNotificationsReceivedNotification::decode(body) {
let notifications = msg
.notifications
.into_iter()
.map(|n| NotificationData {
id: n.notification_id.unwrap_or(0),
notification_type: n.notification_type.unwrap_or(0),
body_data: n.body_data.unwrap_or_default(),
read: n.read.unwrap_or(false),
timestamp: n.timestamp.unwrap_or(0),
hidden: n.hidden.unwrap_or(false),
expiry: n.expiry,
viewed: n.viewed,
})
.collect();
return Some(SteamEvent::Notifications(NotificationsEvent::NotificationsReceived(notifications)));
}
None
}
fn handle_cm_list(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientCMList::decode(body) {
return Some(SteamEvent::Connection(ConnectionEvent::CMList { servers: msg.cm_websocket_addresses }));
}
None
}
fn handle_service_method_by_name(job_name: &str, body: &[u8]) -> Option<SteamEvent> {
match job_name {
"FriendMessagesClient.IncomingMessage#1" => Self::handle_friend_message_notification(body),
"FriendMessagesClient.NotifyAckMessageEcho#1" => Self::handle_friend_typing_echo(body),
"SteamNotificationClient.NotificationsReceived#1" => Self::handle_steam_notification(body),
name if name.starts_with("ChatRoomClient.") => Self::handle_chatroom_notification(name, body),
name if name.starts_with("PlayerClient.") => Self::handle_player_notification(name, body),
_ => None,
}
}
fn handle_service_method_legacy(body: &[u8]) -> Option<SteamEvent> {
Self::handle_friend_message_notification(body)
}
fn handle_friend_message_notification(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CFriendMessagesIncomingMessageNotification::decode(body) {
let steamid_friend = msg.steamid_friend.unwrap_or(0);
if steamid_friend == 0 {
return None;
}
let steam_id = SteamID::from_steam_id64(steamid_friend);
let local_echo = msg.local_echo.unwrap_or(false);
let chat_entry_type = msg.chat_entry_type.unwrap_or(1);
let message = msg.message_no_bbcode.clone().filter(|s| !s.is_empty()).or_else(|| msg.message.clone()).unwrap_or_default();
match chat_entry_type {
val if val == EChatEntryType::Typing as i32 => {
if local_echo {
return Some(SteamEvent::Chat(ChatEvent::FriendTypingEcho { receiver: steam_id }));
} else {
return Some(SteamEvent::Chat(ChatEvent::FriendTyping { sender: steam_id }));
}
}
val if val == EChatEntryType::LeftConversation as i32 => {
if local_echo {
return Some(SteamEvent::Chat(ChatEvent::FriendLeftConversationEcho { receiver: steam_id }));
} else {
return Some(SteamEvent::Chat(ChatEvent::FriendLeftConversation { sender: steam_id }));
}
}
_ => {
if local_echo {
return Some(SteamEvent::Chat(ChatEvent::FriendMessageEcho {
receiver: steam_id,
message,
timestamp: msg.rtime32_server_timestamp.unwrap_or(0),
ordinal: msg.ordinal.unwrap_or(0),
}));
} else {
return Some(SteamEvent::Chat(ChatEvent::FriendMessage {
sender: steam_id,
message,
chat_entry_type: EChatEntryType::from_i32(chat_entry_type).unwrap_or(EChatEntryType::ChatMsg),
timestamp: msg.rtime32_server_timestamp.unwrap_or(0),
ordinal: msg.ordinal.unwrap_or(0),
from_limited_account: msg.from_limited_account.unwrap_or(false),
low_priority: msg.low_priority.unwrap_or(false),
}));
}
}
}
}
None
}
fn handle_from_gc(body: &[u8]) -> Vec<SteamEvent> {
let msg = match steam_protos::CMsgGCClient::decode(body) {
Ok(m) => m,
Err(_) => return vec![],
};
let gc_msg = match crate::services::gc::parse_gc_message(&msg) {
Some(m) => m,
None => return vec![],
};
let mut events = vec![SteamEvent::Apps(AppsEvent::GCReceived(gc_msg.clone()))];
if gc_msg.appid == 730 {
if let Some(event) = Self::handle_csgo_gc_message(gc_msg.msg_type, &gc_msg.payload) {
events.push(event);
}
}
events
}
fn handle_csgo_gc_message(msg_type: u32, payload: &[u8]) -> Option<SteamEvent> {
let msg_i32 = msg_type as i32;
if let Some(msg) = ECsgoGCMsg::from_i32(msg_i32) {
match msg {
ECsgoGCMsg::MatchmakingGC2ClientHello => return Self::handle_csgo_client_hello(payload),
ECsgoGCMsg::PlayersProfile => return Self::handle_csgo_players_profile(payload),
ECsgoGCMsg::Party_Search => return Self::handle_csgo_party_search_results(payload),
ECsgoGCMsg::Party_Invite => return Self::handle_csgo_party_invite(payload),
_ => {}
}
}
if let Some(msg) = EGCBaseClientMsg::from_i32(msg_i32) {
if msg == EGCBaseClientMsg::ClientConnectionStatus {
return Self::handle_csgo_welcome(payload);
}
}
None
}
fn handle_csgo_welcome(payload: &[u8]) -> Option<SteamEvent> {
let welcome = steam_protos::CMsgClientWelcome::decode(payload).ok()?;
let mut prime = false;
let mut elevated_state = 0;
let mut bonus_xp_usedflags = 0;
let mut items = Vec::new();
for cache in welcome.outofdate_subscribed_caches {
for object in cache.objects {
match object.type_id {
Some(1) => {
for data in &object.object_data {
if let Ok(item) = steam_protos::CSOEconItem::decode(&**data) {
items.push(item);
}
}
}
Some(7) => {
for data in &object.object_data {
if let Ok(account) = steam_protos::CSOEconGameAccountClient::decode(&**data) {
elevated_state = account.elevated_state.unwrap_or(0);
bonus_xp_usedflags = account.bonus_xp_usedflags.unwrap_or(0);
if (bonus_xp_usedflags & 16) != 0 || elevated_state == 5 {
prime = true;
}
}
}
}
_ => {}
}
}
}
Some(SteamEvent::CSGO(CSGOEvent::Online(CsgoWelcome { prime, elevated_state, bonus_xp_usedflags, items })))
}
fn handle_csgo_client_hello(payload: &[u8]) -> Option<SteamEvent> {
let hello = steam_protos::CMsgGccStrike15V2MatchmakingGc2ClientHello::decode(payload).ok()?;
Some(SteamEvent::CSGO(CSGOEvent::ClientHello(Self::build_csgo_client_hello(&hello))))
}
fn handle_csgo_players_profile(payload: &[u8]) -> Option<SteamEvent> {
let profile_msg = steam_protos::CMsgGccStrike15V2PlayersProfile::decode(payload).ok()?;
let profiles: Vec<CsgoClientHello> = profile_msg.account_profiles.iter().map(Self::build_csgo_client_hello).collect();
Some(SteamEvent::CSGO(CSGOEvent::PlayersProfile(profiles)))
}
fn handle_csgo_party_invite(payload: &[u8]) -> Option<SteamEvent> {
let invite = steam_protos::CMsgGccStrike15V2PartyInvite::decode(payload).ok()?;
Some(SteamEvent::CSGO(CSGOEvent::PartyInvite {
inviter: SteamID::from_individual_account_id(invite.accountid.unwrap_or(0)),
lobby_id: invite.lobbyid.unwrap_or(0) as u64,
}))
}
fn handle_csgo_party_search_results(payload: &[u8]) -> Option<SteamEvent> {
let results = steam_protos::CMsgGccStrike15V2PartySearchResults::decode(payload).ok()?;
let entries: Vec<CsgoPartyEntry> = results
.entries
.into_iter()
.map(|e| CsgoPartyEntry {
account_id: e.accountid.unwrap_or(0),
lobby_id: e.id.unwrap_or(0),
game_type: e.game_type.unwrap_or(0),
loc: e.loc.unwrap_or(0),
})
.collect();
Some(SteamEvent::CSGO(CSGOEvent::PartySearchResults(entries)))
}
pub(crate) fn build_csgo_client_hello(hello: &steam_protos::CMsgGccStrike15V2MatchmakingGc2ClientHello) -> CsgoClientHello {
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) });
let commendation = hello.commendation.as_ref().map(|c| CsgoCommendation {
cmd_friendly: c.cmd_friendly.unwrap_or(0),
cmd_teaching: c.cmd_teaching.unwrap_or(0),
cmd_leader: c.cmd_leader.unwrap_or(0),
});
let global_stats = hello.global_stats.as_ref();
CsgoClientHello {
account_id: hello.account_id.unwrap_or(0),
vac_banned: hello.vac_banned.unwrap_or(0),
penalty_seconds: hello.penalty_seconds.unwrap_or(0),
penalty_reason: hello.penalty_reason.unwrap_or(0),
player_level: hello.player_level.unwrap_or(0),
player_cur_xp: hello.player_cur_xp.unwrap_or(0),
player_xp_bonus_flags: hello.player_xp_bonus_flags.unwrap_or(0),
ranking,
commendation,
players_online: global_stats.and_then(|s| s.players_online).unwrap_or(0),
servers_online: global_stats.and_then(|s| s.servers_online).unwrap_or(0),
ongoing_matches: global_stats.and_then(|s| s.ongoing_matches).unwrap_or(0),
}
}
fn handle_pics_product_info(body: &[u8]) -> Option<SteamEvent> {
parsing::parse_pics_product_info(body).ok().map(|data| {
SteamEvent::Apps(AppsEvent::ProductInfoResponse {
apps: data.apps,
packages: data.packages,
unknown_apps: data.unknown_apps,
unknown_packages: data.unknown_packages,
})
})
}
fn handle_pics_access_tokens(body: &[u8]) -> Option<SteamEvent> {
parsing::parse_pics_access_tokens(body).ok().map(|data| {
SteamEvent::Apps(AppsEvent::AccessTokensResponse {
app_tokens: data.app_tokens,
package_tokens: data.package_tokens,
app_denied: data.app_denied,
package_denied: data.package_denied,
})
})
}
fn handle_pics_changes(body: &[u8]) -> Option<SteamEvent> {
parsing::parse_pics_changes(body).ok().map(|data| {
SteamEvent::Apps(AppsEvent::ProductChangesResponse {
current_change_number: data.current_change_number,
app_changes: data.app_changes,
package_changes: data.package_changes,
})
})
}
fn handle_mms_invite(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientMmsInviteToLobby::decode(body) {
return Some(SteamEvent::CSGO(CSGOEvent::PartyInvite {
inviter: SteamID::from(0), lobby_id: msg.steam_id_lobby.unwrap_or(0),
}));
}
None
}
fn handle_email_info(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientEmailAddrInfo::decode(body) {
return Some(SteamEvent::Account(AccountEvent::EmailInfo { address: msg.email_address.unwrap_or_default(), validated: msg.email_is_validated.unwrap_or(false) }));
}
None
}
fn handle_account_limitations(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientIsLimitedAccount::decode(body) {
return Some(SteamEvent::Account(AccountEvent::AccountLimitations {
limited: msg.bis_limited_account.unwrap_or(false),
community_banned: msg.bis_community_banned.unwrap_or(false),
locked: msg.bis_locked_account.unwrap_or(false),
can_invite_friends: msg.bis_limited_account_allowed_to_invite_friends.unwrap_or(true),
}));
}
None
}
fn handle_wallet_info(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientWalletInfoUpdate::decode(body) {
return Some(SteamEvent::Account(AccountEvent::Wallet {
has_wallet: msg.has_wallet.unwrap_or(false),
currency: msg.currency.unwrap_or(0),
balance: msg.balance64.unwrap_or(msg.balance.unwrap_or(0) as i64),
}));
}
None
}
fn handle_account_info(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientAccountInfo::decode(body) {
return Some(SteamEvent::Account(AccountEvent::AccountInfo {
name: msg.persona_name.unwrap_or_default(),
country: msg.ip_country.unwrap_or_default(),
authed_machines: msg.count_authed_computers.unwrap_or(0) as u32,
flags: msg.account_flags.unwrap_or(0),
}));
}
None
}
fn handle_game_connect_tokens(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientGameConnectTokens::decode(body) {
return Some(SteamEvent::Auth(AuthEvent::GameConnectTokens { tokens: msg.tokens }));
}
None
}
fn handle_playing_session_state(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientPlayingSessionState::decode(body) {
return Some(SteamEvent::Apps(AppsEvent::PlayingState { blocked: msg.playing_blocked.unwrap_or(false), playing_app: msg.playing_app.unwrap_or(0) }));
}
None
}
fn handle_user_notifications(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientUserNotifications::decode(body) {
for notif in &msg.notifications {
let notif_type = notif.user_notification_type.unwrap_or(0);
let count = notif.count.unwrap_or(0);
match notif_type {
1 => {
return Some(SteamEvent::Notifications(NotificationsEvent::TradeOffers { count }));
}
3 => {
return Some(SteamEvent::Notifications(NotificationsEvent::CommunityMessages { count }));
}
_ => {}
}
}
}
None
}
fn handle_offline_messages(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientOfflineMessageNotification::decode(body) {
let friends: Vec<SteamID> = msg
.friends_with_offline_messages
.iter()
.map(|&account_id| {
SteamID::from_individual_account_id(account_id)
})
.collect();
return Some(SteamEvent::Notifications(NotificationsEvent::OfflineMessages { count: msg.offline_messages.unwrap_or(0), friends }));
}
None
}
fn handle_item_announcements(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientItemAnnouncements::decode(body) {
return Some(SteamEvent::Notifications(NotificationsEvent::NewItems { count: msg.count_new_items.unwrap_or(0) }));
}
None
}
fn handle_comment_notifications(body: &[u8]) -> Option<SteamEvent> {
if let Ok(msg) = steam_protos::CMsgClientCommentNotifications::decode(body) {
return Some(SteamEvent::Notifications(NotificationsEvent::NewComments {
count: msg.count_new_comments.unwrap_or(0),
owner_comments: msg.count_new_comments_owner.unwrap_or(0),
subscription_comments: msg.count_new_comments_subscriptions.unwrap_or(0),
}));
}
None
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
fn test_steam_id() -> SteamID {
SteamID::from_steam_id64(76561198000000000)
}
#[test]
fn test_auth_event_logged_on() {
let steam_id = test_steam_id();
let event = SteamEvent::Auth(AuthEvent::LoggedOn { steam_id });
assert!(event.is_auth());
assert!(!event.is_connection());
assert!(!event.is_chat());
match event {
SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: sid }) => {
assert_eq!(sid.steam_id64(), 76561198000000000);
}
_ => panic!("Expected LoggedOn event"),
}
}
#[test]
fn test_auth_event_logged_off() {
let event = SteamEvent::Auth(AuthEvent::LoggedOff { result: EResult::LoggedInElsewhere });
assert!(event.is_auth());
match event {
SteamEvent::Auth(AuthEvent::LoggedOff { result }) => {
assert_eq!(result, EResult::LoggedInElsewhere);
}
_ => panic!("Expected LoggedOff event"),
}
}
#[test]
fn test_auth_event_refresh_token() {
let event = SteamEvent::Auth(AuthEvent::RefreshToken { token: "test_token_123".to_string(), account_name: "test_user".to_string() });
assert!(event.is_auth());
match event {
SteamEvent::Auth(AuthEvent::RefreshToken { token, account_name }) => {
assert_eq!(token, "test_token_123");
assert_eq!(account_name, "test_user");
}
_ => panic!("Expected RefreshToken event"),
}
}
#[test]
fn test_connection_event_connected() {
let event = SteamEvent::Connection(ConnectionEvent::Connected);
assert!(event.is_connection());
assert!(!event.is_auth());
assert!(matches!(event, SteamEvent::Connection(ConnectionEvent::Connected)));
}
#[test]
fn test_connection_event_disconnected() {
let event = SteamEvent::Connection(ConnectionEvent::Disconnected { reason: Some(EResult::NoConnection), will_reconnect: true });
assert!(event.is_connection());
match event {
SteamEvent::Connection(ConnectionEvent::Disconnected { reason, will_reconnect }) => {
assert_eq!(reason, Some(EResult::NoConnection));
assert!(will_reconnect);
}
_ => panic!("Expected Disconnected event"),
}
}
#[test]
fn test_connection_event_reconnect_attempt() {
let event = SteamEvent::Connection(ConnectionEvent::ReconnectAttempt { attempt: 3, max_attempts: 10, delay: Duration::from_secs(5) });
assert!(event.is_connection());
match event {
SteamEvent::Connection(ConnectionEvent::ReconnectAttempt { attempt, max_attempts, delay }) => {
assert_eq!(attempt, 3);
assert_eq!(max_attempts, 10);
assert_eq!(delay, Duration::from_secs(5));
}
_ => panic!("Expected ReconnectAttempt event"),
}
}
#[test]
fn test_connection_event_reconnect_failed() {
let event = SteamEvent::Connection(ConnectionEvent::ReconnectFailed { reason: Some(EResult::ServiceUnavailable), attempts: 10 });
match event {
SteamEvent::Connection(ConnectionEvent::ReconnectFailed { reason, attempts }) => {
assert_eq!(reason, Some(EResult::ServiceUnavailable));
assert_eq!(attempts, 10);
}
_ => panic!("Expected ReconnectFailed event"),
}
}
#[test]
fn test_connection_event_cm_list() {
let servers = vec!["cm1.steampowered.com:443".to_string(), "cm2.steampowered.com:443".to_string()];
let event = SteamEvent::Connection(ConnectionEvent::CMList { servers: servers.clone() });
match event {
SteamEvent::Connection(ConnectionEvent::CMList { servers: s }) => {
assert_eq!(s.len(), 2);
assert_eq!(s[0], "cm1.steampowered.com:443");
}
_ => panic!("Expected CMList event"),
}
}
#[test]
fn test_friends_event_friends_list() {
let friends = vec![FriendEntry { steam_id: test_steam_id(), relationship: EFriendRelationship::Friend }];
let event = SteamEvent::Friends(FriendsEvent::FriendsList { incremental: false, friends });
assert!(event.is_friends());
assert!(!event.is_chat());
match event {
SteamEvent::Friends(FriendsEvent::FriendsList { incremental, friends }) => {
assert!(!incremental);
assert_eq!(friends.len(), 1);
assert_eq!(friends[0].relationship, EFriendRelationship::Friend);
}
_ => panic!("Expected FriendsList event"),
}
}
#[test]
fn test_friends_event_persona_state() {
let persona = UserPersona {
steam_id: test_steam_id(),
player_name: "TestPlayer".to_string(),
persona_state: EPersonaState::Online,
avatar_hash: Some("abc123".to_string()),
game_name: Some("Counter-Strike 2".to_string()),
game_id: Some(730),
..Default::default()
};
let event = SteamEvent::Friends(FriendsEvent::PersonaState(Box::new(persona)));
assert!(event.is_friends());
match event {
SteamEvent::Friends(FriendsEvent::PersonaState(p)) => {
assert_eq!(p.player_name, "TestPlayer");
assert_eq!(p.persona_state, EPersonaState::Online);
assert_eq!(p.game_id, Some(730));
}
_ => panic!("Expected PersonaState event"),
}
}
#[test]
fn test_friends_event_relationship() {
let event = SteamEvent::Friends(FriendsEvent::FriendRelationship { steam_id: test_steam_id(), relationship: EFriendRelationship::Blocked });
match event {
SteamEvent::Friends(FriendsEvent::FriendRelationship { steam_id, relationship }) => {
assert_eq!(steam_id.steam_id64(), 76561198000000000);
assert_eq!(relationship, EFriendRelationship::Blocked);
}
_ => panic!("Expected FriendRelationship event"),
}
}
#[test]
fn test_chat_event_friend_message() {
let event = SteamEvent::Chat(ChatEvent::FriendMessage {
sender: test_steam_id(),
message: "Hello, World!".to_string(),
chat_entry_type: EChatEntryType::ChatMsg,
timestamp: 1702000000,
ordinal: 1,
from_limited_account: false,
low_priority: false,
});
assert!(event.is_chat());
assert!(!event.is_friends());
assert!(event.chat_sender().is_some());
assert_eq!(event.chat_sender().unwrap_or_default().steam_id64(), 76561198000000000);
match event {
SteamEvent::Chat(ChatEvent::FriendMessage { sender, message, chat_entry_type, timestamp, ordinal, from_limited_account, low_priority }) => {
assert_eq!(sender.steam_id64(), 76561198000000000);
assert_eq!(message, "Hello, World!");
assert_eq!(chat_entry_type, EChatEntryType::ChatMsg);
assert_eq!(timestamp, 1702000000);
assert_eq!(ordinal, 1);
assert!(!from_limited_account);
assert!(!low_priority);
}
_ => panic!("Expected FriendMessage event"),
}
}
#[test]
fn test_chat_event_friend_typing() {
let event = SteamEvent::Chat(ChatEvent::FriendTyping { sender: test_steam_id() });
assert!(event.is_chat());
assert!(event.chat_sender().is_some());
match event {
SteamEvent::Chat(ChatEvent::FriendTyping { sender }) => {
assert_eq!(sender.steam_id64(), 76561198000000000);
}
_ => panic!("Expected FriendTyping event"),
}
}
#[test]
fn test_apps_event_license_list() {
let licenses = vec![LicenseEntry {
package_id: 12345,
time_created: 1600000000,
license_type: 1,
flags: 0,
access_token: 0,
..Default::default()
}];
let event = SteamEvent::Apps(AppsEvent::LicenseList { licenses });
assert!(event.is_apps());
match event {
SteamEvent::Apps(AppsEvent::LicenseList { licenses }) => {
assert_eq!(licenses.len(), 1);
assert_eq!(licenses[0].package_id, 12345);
}
_ => panic!("Expected LicenseList event"),
}
}
#[test]
fn test_apps_event_product_info_response() {
let mut apps = HashMap::new();
apps.insert(730, AppInfoData { app_id: 730, change_number: 12345, missing_token: false, app_info: None });
let event = SteamEvent::Apps(AppsEvent::ProductInfoResponse { apps, packages: HashMap::new(), unknown_apps: vec![99999], unknown_packages: vec![] });
assert!(event.is_apps());
match event {
SteamEvent::Apps(AppsEvent::ProductInfoResponse { apps, packages, unknown_apps, unknown_packages }) => {
assert_eq!(apps.len(), 1);
assert!(apps.contains_key(&730));
assert_eq!(apps[&730].change_number, 12345);
assert!(packages.is_empty());
assert_eq!(unknown_apps, vec![99999]);
assert!(unknown_packages.is_empty());
}
_ => panic!("Expected ProductInfoResponse event"),
}
}
#[test]
fn test_apps_event_access_tokens_response() {
let mut app_tokens = HashMap::new();
app_tokens.insert(730, 123456789);
let event = SteamEvent::Apps(AppsEvent::AccessTokensResponse { app_tokens, package_tokens: HashMap::new(), app_denied: vec![440], package_denied: vec![] });
match event {
SteamEvent::Apps(AppsEvent::AccessTokensResponse { app_tokens, package_tokens, app_denied, package_denied }) => {
assert_eq!(app_tokens[&730], 123456789);
assert!(package_tokens.is_empty());
assert_eq!(app_denied, vec![440]);
assert!(package_denied.is_empty());
}
_ => panic!("Expected AccessTokensResponse event"),
}
}
#[test]
fn test_apps_event_product_changes_response() {
let app_changes = vec![AppChange { app_id: 730, change_number: 99999, needs_token: false }];
let event = SteamEvent::Apps(AppsEvent::ProductChangesResponse { current_change_number: 100000, app_changes, package_changes: vec![] });
match event {
SteamEvent::Apps(AppsEvent::ProductChangesResponse { current_change_number, app_changes, package_changes }) => {
assert_eq!(current_change_number, 100000);
assert_eq!(app_changes.len(), 1);
assert_eq!(app_changes[0].app_id, 730);
assert!(package_changes.is_empty());
}
_ => panic!("Expected ProductChangesResponse event"),
}
}
#[test]
fn test_content_event_rich_presence() {
let users = vec![crate::services::rich_presence::RichPresenceData {
steam_id: test_steam_id(),
appid: 730,
data: {
let mut map = HashMap::new();
map.insert("status".to_string(), "In Game".to_string());
map
},
}];
let event = SteamEvent::Content(ContentEvent::RichPresence { appid: 730, users });
assert!(event.is_content());
match event {
SteamEvent::Content(ContentEvent::RichPresence { appid, users }) => {
assert_eq!(appid, 730);
assert_eq!(users.len(), 1);
assert_eq!(users[0].data.get("status"), Some(&"In Game".to_string()));
}
_ => panic!("Expected RichPresence event"),
}
}
#[test]
fn test_system_event_debug() {
let event = SteamEvent::System(SystemEvent::Debug("Test debug message".to_string()));
assert!(event.is_system());
assert!(!event.is_auth());
match event {
SteamEvent::System(SystemEvent::Debug(msg)) => {
assert_eq!(msg, "Test debug message");
}
_ => panic!("Expected Debug event"),
}
}
#[test]
fn test_system_event_error() {
let event = SteamEvent::System(SystemEvent::Error("Something went wrong".to_string()));
assert!(event.is_system());
match event {
SteamEvent::System(SystemEvent::Error(msg)) => {
assert_eq!(msg, "Something went wrong");
}
_ => panic!("Expected Error event"),
}
}
#[test]
fn test_all_is_category_methods() {
let events = vec![
(SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: SteamID::new() }), "auth"),
(SteamEvent::Connection(ConnectionEvent::Connected), "connection"),
(SteamEvent::Friends(FriendsEvent::FriendsList { incremental: false, friends: vec![] }), "friends"),
(SteamEvent::Chat(ChatEvent::FriendTyping { sender: SteamID::new() }), "chat"),
(SteamEvent::Apps(AppsEvent::LicenseList { licenses: vec![] }), "apps"),
(SteamEvent::Content(ContentEvent::RichPresence { appid: 0, users: vec![] }), "content"),
(SteamEvent::System(SystemEvent::Debug("".to_string())), "system"),
];
for (event, expected_category) in events {
match expected_category {
"auth" => {
assert!(event.is_auth());
assert!(!event.is_connection());
assert!(!event.is_friends());
assert!(!event.is_chat());
assert!(!event.is_apps());
assert!(!event.is_content());
assert!(!event.is_system());
}
"connection" => {
assert!(!event.is_auth());
assert!(event.is_connection());
assert!(!event.is_friends());
assert!(!event.is_chat());
assert!(!event.is_apps());
assert!(!event.is_content());
assert!(!event.is_system());
}
"friends" => {
assert!(!event.is_auth());
assert!(!event.is_connection());
assert!(event.is_friends());
assert!(!event.is_chat());
assert!(!event.is_apps());
assert!(!event.is_content());
assert!(!event.is_system());
}
"chat" => {
assert!(!event.is_auth());
assert!(!event.is_connection());
assert!(!event.is_friends());
assert!(event.is_chat());
assert!(!event.is_apps());
assert!(!event.is_content());
assert!(!event.is_system());
}
"apps" => {
assert!(!event.is_auth());
assert!(!event.is_connection());
assert!(!event.is_friends());
assert!(!event.is_chat());
assert!(event.is_apps());
assert!(!event.is_content());
assert!(!event.is_system());
}
"content" => {
assert!(!event.is_auth());
assert!(!event.is_connection());
assert!(!event.is_friends());
assert!(!event.is_chat());
assert!(!event.is_apps());
assert!(event.is_content());
assert!(!event.is_system());
}
"system" => {
assert!(!event.is_auth());
assert!(!event.is_connection());
assert!(!event.is_friends());
assert!(!event.is_chat());
assert!(!event.is_apps());
assert!(!event.is_content());
assert!(event.is_system());
}
_ => panic!("Unknown category"),
}
}
}
#[test]
fn test_chat_sender_helper() {
let msg_event = SteamEvent::Chat(ChatEvent::FriendMessage {
sender: test_steam_id(),
message: "test".to_string(),
chat_entry_type: EChatEntryType::ChatMsg,
timestamp: 0,
ordinal: 0,
from_limited_account: false,
low_priority: false,
});
assert!(msg_event.chat_sender().is_some());
assert_eq!(msg_event.chat_sender().unwrap_or_default().steam_id64(), 76561198000000000);
let typing_event = SteamEvent::Chat(ChatEvent::FriendTyping { sender: test_steam_id() });
assert!(typing_event.chat_sender().is_some());
let auth_event = SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: SteamID::new() });
assert!(auth_event.chat_sender().is_none());
let conn_event = SteamEvent::Connection(ConnectionEvent::Connected);
assert!(conn_event.chat_sender().is_none());
}
#[test]
fn test_events_are_cloneable() {
let original = SteamEvent::Chat(ChatEvent::FriendMessage {
sender: test_steam_id(),
message: "Clone me!".to_string(),
chat_entry_type: EChatEntryType::ChatMsg,
timestamp: 123,
ordinal: 1,
from_limited_account: false,
low_priority: false,
});
let cloned = original.clone();
match (original, cloned) {
(SteamEvent::Chat(ChatEvent::FriendMessage { message: m1, .. }), SteamEvent::Chat(ChatEvent::FriendMessage { message: m2, .. })) => {
assert_eq!(m1, m2);
assert_eq!(m1, "Clone me!");
}
_ => panic!("Clone failed"),
}
}
#[test]
fn test_events_are_debuggable() {
let event = SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: test_steam_id() });
let debug_str = format!("{:?}", event);
assert!(debug_str.contains("Auth"));
assert!(debug_str.contains("LoggedOn"));
}
#[test]
fn test_decompress_gzip() {
use std::io::Write;
use flate2::{write::GzEncoder, Compression};
let original = b"Hello, Steam Multi Message!";
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(original).unwrap_or_default();
let compressed = encoder.finish().unwrap_or_default();
let decompressed = MessageHandler::decompress_gzip(&compressed, original.len()).unwrap_or_default();
assert_eq!(decompressed, original);
}
#[test]
fn test_decode_message_too_short() {
let events = MessageHandler::decode_message(&[0, 1, 2, 3]);
assert!(events.is_empty());
}
#[test]
fn test_exhaustive_category_matching() {
let events = vec![
SteamEvent::Auth(AuthEvent::LoggedOn { steam_id: SteamID::new() }),
SteamEvent::Connection(ConnectionEvent::Connected),
SteamEvent::Friends(FriendsEvent::FriendsList { incremental: false, friends: vec![] }),
SteamEvent::Chat(ChatEvent::FriendTyping { sender: SteamID::new() }),
SteamEvent::Apps(AppsEvent::LicenseList { licenses: vec![] }),
SteamEvent::Content(ContentEvent::RichPresence { appid: 0, users: vec![] }),
SteamEvent::System(SystemEvent::Debug("test".to_string())),
];
for event in events {
let category = match event {
SteamEvent::Auth(_) => "auth",
SteamEvent::Connection(_) => "connection",
SteamEvent::Friends(_) => "friends",
SteamEvent::Chat(_) => "chat",
SteamEvent::Apps(_) => "apps",
SteamEvent::Content(_) => "content",
SteamEvent::System(_) => "system",
SteamEvent::Account(_) => "account",
SteamEvent::Notifications(_) => "notifications",
SteamEvent::CSGO(_) => "csgo",
};
assert!(!category.is_empty());
}
}
#[test]
fn test_selective_category_matching() {
let event = SteamEvent::Chat(ChatEvent::FriendMessage {
sender: test_steam_id(),
message: "Hello".to_string(),
chat_entry_type: EChatEntryType::ChatMsg,
timestamp: 0,
ordinal: 0,
from_limited_account: false,
low_priority: false,
});
let mut handled = false;
if let SteamEvent::Chat(ChatEvent::FriendMessage { message, .. }) = event {
assert_eq!(message, "Hello");
handled = true;
}
assert!(handled);
}
}