diff --git a/docs/api/steam.client.rst b/docs/api/steam.client.rst index 30f750a..0f206f4 100644 --- a/docs/api/steam.client.rst +++ b/docs/api/steam.client.rst @@ -12,4 +12,5 @@ client steam.client.builtins steam.client.mixins steam.client.gc + steam.client.user diff --git a/docs/api/steam.client.user.rst b/docs/api/steam.client.user.rst new file mode 100644 index 0000000..be11618 --- /dev/null +++ b/docs/api/steam.client.user.rst @@ -0,0 +1,7 @@ +user +==== + +.. automodule:: steam.client.user + :members: + :show-inheritance: + diff --git a/steam/client/builtins/user.py b/steam/client/builtins/user.py index ea75c68..45c1120 100644 --- a/steam/client/builtins/user.py +++ b/steam/client/builtins/user.py @@ -1,3 +1,5 @@ +from weakref import WeakValueDictionary +from steam.client.user import SteamUser from steam.enums import EPersonaState from steam.enums.emsg import EMsg from steam.core.msg import MsgProto @@ -5,14 +7,32 @@ from steam.util import proto_fill_from_dict class User(object): persona_state = EPersonaState.Online #: current persona state + user = None #: :class:`.SteamUser` instance once logged on def __init__(self, *args, **kwargs): super(User, self).__init__(*args, **kwargs) + self._user_cache = WeakValueDictionary() + + self.on(self.EVENT_DISCONNECTED, self.__handle_disconnect) self.on(self.EVENT_LOGGED_ON, self.__handle_set_persona) + self.on(EMsg.ClientPersonaState, self.__handle_persona_state) + + def __handle_disconnect(self): + self.user = None def __handle_set_persona(self): self.change_status(persona_state=self.persona_state) + self.user = self.get_user(self.steam_id) + + def __handle_persona_state(self, message): + for friend in message.body.friends: + steamid = friend.friendid + + if steamid in self._user_cache: + suser = self._user_cache[steamid] + suser._pstate = friend + suser._pstate_ready.set() def change_status(self, **kwargs): """ @@ -35,3 +55,36 @@ class User(object): message = MsgProto(EMsg.ClientChangeStatus) proto_fill_from_dict(message.body, kwargs) self.send(message) + + def request_persona_state(self, steam_ids): + """Request persona state data + + :param steam_ids: list of steam ids + :type steam_ids: :class:`list` + """ + m = MsgProto(EMsg.ClientRequestFriendData) + m.body.persona_state_requested = 4294967295 # request all possible flags + m.body.friends.extend(steam_ids) + self.send_job(m) + + def get_user(self, steam_id, fetch_persona_state=True): + """Get :class:`.SteamUser` instance for ``steam id`` + + :param steam_id: steam id + :type steam_id: :class:`int`, :class:`.SteamID` + :param fetch_persona_state: whether to request person state when necessary + :type fetch_persona_state: :class:`bool` + :return: SteamUser instance + :rtype: :class:`.SteamUser` + """ + steam_id = int(steam_id) + suser = self._user_cache.get(steam_id, None) + + if suser is None: + suser = SteamUser(steam_id, self) + self._user_cache[steam_id] = suser + + if fetch_persona_state: + self.request_persona_state([steam_id]) + + return suser diff --git a/steam/client/mixins/friends.py b/steam/client/mixins/friends.py index 7e1255a..cb05db0 100644 --- a/steam/client/mixins/friends.py +++ b/steam/client/mixins/friends.py @@ -1,7 +1,7 @@ import logging from eventemitter import EventEmitter from steam.steamid import SteamID, intBase -from steam.enums import EResult, EFriendRelationship, EPersonaState +from steam.enums import EResult, EFriendRelationship from steam.enums.emsg import EMsg from steam.core.msg import MsgProto @@ -15,12 +15,9 @@ class Friends(object): class SteamFriendlist(EventEmitter): """SteamFriendlist is an object that keeps state of user's friend list. - Think of it as a list of :class:`SteamUser`. - You can iterate over it, check if it contains a particular steam id, or get :class:`SteamUser` for a steamid. - - .. note:: - persona state is not updated immediatelly for new user entries - + It's essentially a :class:`list` of :class:`.SteamUser`. + You can iterate over it, check if it contains a particular ``steam id``, + or get :class:`.SteamUser` for a ``steam id``. """ EVENT_READY = 'ready' """Friend list is ready for use @@ -51,14 +48,8 @@ class SteamFriendlist(EventEmitter): :param steam_id: steam id :param type: :class:`.SteamID` """ - EVENT_PERSONA_STATE_UPDATED = 'persona_state_updated' - """Upon persona state changes for a user - - :param user: steam user instance - :type user: :class:`.SteamUser` - """ - ready = False #: indicates whether friend list is available + ready = False #: indicates whether friend list is ready for use def __init__(self, client, logger_name='SteamFriendList'): self._LOG = logging.getLogger(logger_name) @@ -67,7 +58,6 @@ class SteamFriendlist(EventEmitter): self._steam.on(EMsg.ClientAddFriendResponse, self._handle_add_friend_result) self._steam.on(EMsg.ClientFriendsList, self._handle_friends_list) - self._steam.on(EMsg.ClientPersonaState, self._handle_persona_state) self._steam.on(self._steam.EVENT_DISCONNECTED, self._handle_disconnect) def emit(self, event, *args): @@ -89,54 +79,42 @@ class SteamFriendlist(EventEmitter): if incremental == False: self._fr.clear() - pstate_check = set() + steamids_to_check = set() # update internal friends list for friend in message.body.friends: - steamid = friend.ulfriendid + steamid = SteamID(friend.ulfriendid) + + if steamid.type != steamid.EType.Individual: + continue + + suser = self._steam.get_user(steamid, False) rel = EFriendRelationship(friend.efriendrelationship) if steamid not in self._fr: - suser = SteamUser(steamid, rel) self._fr[suser] = suser + suser.relationship = rel + steamids_to_check.add(steamid) - if rel in (2,4): - if incremental == False: - pstate_check.add(steamid) - + if rel in (2,4): # RequestRecipient = 2, RequestInitiator = 4 if rel == EFriendRelationship.RequestRecipient: self.emit(self.EVENT_FRIEND_INVITE, suser) else: - oldrel = self._fr[steamid]._data['relationship'] - self._fr[steamid]._data['relationship'] = rel + oldrel, suser.relationship = suser.relationship, rel if rel == EFriendRelationship.No: - self.emit(self.EVENT_FRIEND_REMOVED, self._fr.pop(steamid)) + self.emit(self.EVENT_FRIEND_REMOVED, self._fr.pop(suder)) elif oldrel in (2,4) and rel == EFriendRelationship.Friend: - self.emit(self.EVENT_FRIEND_NEW, self._fr[steamid]) + self.emit(self.EVENT_FRIEND_NEW, suser) # request persona state for any new entries - if pstate_check: - m = MsgProto(EMsg.ClientRequestFriendData) - m.body.persona_state_requested = 4294967295 # request all possible flags - m.body.friends.extend(pstate_check) - self._steam.send(m) + if steamids_to_check: + self._steam.request_persona_state(steamids_to_check) if not self.ready: self.ready = True self.emit(self.EVENT_READY) - def _handle_persona_state(self, message): - for friend in message.body.friends: - steamid = friend.friendid - - if steamid == self._steam.steam_id: - continue - - if steamid in self._fr: - self._fr[steamid]._data['pstate'] = friend - self.emit('persona_state_updated', self._fr[steamid]) - def __repr__(self): return "<%s %d users>" % ( self.__class__.__name__, @@ -176,7 +154,7 @@ class SteamFriendlist(EventEmitter): else: m.body.accountname_or_email_to_add = steamid_or_accountname_or_email - self._steam.send(m) + self._steam.send_job(m) def remove(self, steamid): """ @@ -185,102 +163,5 @@ class SteamFriendlist(EventEmitter): :param steamid: their steamid :type steamid: :class:`int`, :class:`.SteamID`, :class:`SteamUser` """ - m = MsgProto(EMsg.ClientRemoveFriend) - m.body.friendid = steamid - self._steam.send(m) - -class SteamUser(intBase): - def __new__(cls, steam64, *args, **kwargs): - return super(SteamUser, cls).__new__(cls, steam64) - - def __init__(self, steam64, rel): - self._data = { - 'relationship': EFriendRelationship(rel) - } - - def __repr__(self): - return "<%s (%s) %s %s>" % ( - self.__class__.__name__, - int(self) if self.name is None else repr(self.name), - self.relationship.name, - self.state.name, - ) - - @property - def steamid(self): - """SteamID instance - - :rtype: :class:`.SteamID` - """ - return SteamID(int(self)) - - @property - def relationship(self): - """Current relationship with the steam user - - :rtype: :class:`.EFriendRelationship` - """ - return self._data['relationship'] - - def get_ps(self, field_name): - """Get a value for field in ``CMsgClientPersonaState.Friend`` - - :param field_name: see example list below - :type field_name: :class:`str` - :return: value for the field, or ``None`` if not available - :rtype: :class:`None`, :class:`int`, :class:`str`, :class:`bytes`, :class:`bool` + self._steam.send(MsgProto(EMsg.ClientRemoveFriend), {'friendid': steamid}) - Examples: - - game_played_app_id - - last_logoff - - last_logon - - game_name - - avatar_hash - - facebook_id - - """ - pstate = self._data.get('pstate', None) - if pstate is None or not pstate.HasField(field_name): - return None - return getattr(pstate, field_name) - - @property - def name(self): - """Name of the steam user, or ``None`` if it's not available - - :rtype: :class:`str`, :class:`None` - """ - return self.get_ps('player_name') - - @property - def state(self): - """State of the steam user - - :rtype: :class:`.EPersonaState` - """ - state = self.get_ps('persona_state') - if state: - return EPersonaState(state) - return EPersonaState.Offline - - def get_avatar_url(self, size=2): - """Get url to the steam avatar - - :param size: possible values are ``0``, ``1``, or ``2`` corresponding to small, medium, large - :type size: :class:`int` - :return: url to avatar - :rtype: :class:`str` - """ - ahash = self.get_ps('avatar_hash') - if ahash is None: - return None - - sizes = { - 0: '', - 1: '_medium', - 2: '_full', - } - url = "http://cdn.akamai.steamstatic.com/steamcommunity/public/images/avatars/%s/%s%s.jpg" - ahash = binascii.hexlify(persona_state_value.avatar_hash).decode('ascii') - - return url % (ahash[:2], ahash, sizes[size]) diff --git a/steam/client/user.py b/steam/client/user.py new file mode 100644 index 0000000..04d3bf3 --- /dev/null +++ b/steam/client/user.py @@ -0,0 +1,86 @@ +from datetime import datetime +from binascii import hexlify +from gevent.event import Event +from steam.steamid import SteamID +from steam.enums import EFriendRelationship, EPersonaState + +class SteamUser(object): + """Holds various functionality and data related to a steam user + """ + _pstate = None + steam_id = SteamID() + relationship = EFriendRelationship.No #: friendship status + + def __new__(cls, steam_id, *args, **kwargs): + return super(SteamUser, cls).__new__(cls, steam_id) + + def __init__(self, steam_id, steam): + self._pstate_ready = Event() + self._steam = steam + self.steam_id = SteamID(steam_id) + + def __repr__(self): + return "<%s(%s, %s)>" % ( + self.__class__.__name__, + str(self.steam_id), + self.state, + ) + + def get_ps(self, field_name, wait_pstate=True): + if not wait_pstate or self._pstate_ready.wait(timeout=30): + if self._pstate and self._pstate.HasField(field_name): + return getattr(self._pstate, field_name) + return None + + @property + def last_logon(self): + """:rtype: :class:`datetime`, :class:`None`""" + ts = self.get_ps('last_logon') + return datetime.utcfromtimestamp(ts) if ts else None + + @property + def last_logoff(self): + """:rtype: :class:`datetime`, :class:`None`""" + ts = self.get_ps('last_logoff') + return datetime.utcfromtimestamp(ts) if ts else None + + @property + def name(self): + """Name of the steam user, or ``None`` if it's not available + + :rtype: :class:`str`, :class:`None` + """ + return self.get_ps('player_name') + + @property + def state(self): + """Personsa state (e.g. Online, Offline, Away, Busy, etc) + + :rtype: :class:`.EPersonaState` + """ + state = self.get_ps('persona_state', False) + return EPersonaState(state) if state else EPersonaState.Offline + + def get_avatar_url(self, size=2): + """Get URL to avatar picture + + :param size: possible values are ``0``, ``1``, or ``2`` corresponding to small, medium, large + :type size: :class:`int` + :return: url to avatar + :rtype: :class:`str` + """ + hashbytes = self.get_ps('avatar_hash') + + if hashbytes != "\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000": + ahash = hexlify(hashbytes).decode('ascii') + else: + ahash = 'fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb' + + sizes = { + 0: '', + 1: '_medium', + 2: '_full', + } + url = "http://cdn.akamai.steamstatic.com/steamcommunity/public/images/avatars/%s/%s%s.jpg" + + return url % (ahash[:2], ahash, sizes[size])