diff --git a/CHANGES.md b/CHANGES.md index 0f4aae0..a9dacbf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,8 @@ This release brings some breaking changes - Removed imports from 'steam' namespace - Replaced builtin CM server list with automatic discovery via WebAPI or DNS +- Removed `SteamClient.unifed_messages` +- UM/ServiceMethods are now handled in the `SteamClient` instance. See `SteamClient.send_um()` - Removed `steam.client.mixins` package - Renamed `medium` param to `backend` on `SteamAuthenticator` - Added `WebAuth.cli_login()`, handles all steps of the login process @@ -14,11 +16,9 @@ This release brings some breaking changes - Added `rich_presence` property to `SteamUser` - Fixed `create_emergency_codes()` not returning codes - Fixed `validate_phone_number()` returning no data -- Added protos for new chat via unified messages - Updated protobufs - Removed `SteamClient.change_email()` - Removed `SteamClient.create_account()` -- `SteanClient.unified_messages` now expose errors by returning a tuple `(result, error)` - `get_product_info()` now replaces invalid unicode chars - Updated `SteamID.is_valid` - Updated various Enums diff --git a/steam/client/__init__.py b/steam/client/__init__.py index 95ed9b2..3142295 100644 --- a/steam/client/__init__.py +++ b/steam/client/__init__.py @@ -21,13 +21,14 @@ from getpass import getpass import logging import six -from steam.core.crypto import sha1_hash from eventemitter import EventEmitter -from steam.enums.emsg import EMsg from steam.enums import EResult, EOSType, EPersonaState -from steam.core.msg import MsgProto +from steam.enums.emsg import EMsg from steam.core.cm import CMClient +from steam.core.msg import MsgProto +from steam.core.crypto import sha1_hash from steam.steamid import SteamID +from steam.exceptions import SteamError from steam.client.builtins import BuiltinBase from steam.util import ip_from_int, ip_to_int, proto_fill_from_dict @@ -59,7 +60,6 @@ class SteamClient(CMClient, BuiltinBase): self._LOG = logging.getLogger("SteamClient") # register listners - self.on(None, self._handle_jobs) self.on(self.EVENT_DISCONNECTED, self._handle_disconnect) self.on(self.EVENT_RECONNECT, self._handle_disconnect) self.on(EMsg.ClientNewLoginKey, self._handle_login_key) @@ -94,6 +94,27 @@ class SteamClient(CMClient, BuiltinBase): self.logged_on = False CMClient.disconnect(self, *args, **kwargs) + def _parse_message(self, message): + result = CMClient._parse_message(self, message) + + if result is None: + return + + emsg, msg = result + + # emit job events + if msg.proto: + jobid = msg.header.jobid_target + else: + jobid = msg.header.targetJobID + + if jobid not in (-1, 18446744073709551615): + self.emit("job_%d" % jobid, msg) + + # emit UMs + if emsg in (EMsg.ServiceMethodResponse, EMsg.ServiceMethodSendToClient): + self.emit(msg.header.target_job_name, msg) + def _bootstrap_cm_list_from_file(self): if not self.credential_location or self.cm_servers.last_updated > 0: return @@ -154,18 +175,6 @@ class SteamClient(CMClient, BuiltinBase): except IOError as e: self._LOG.error("saving %s: %s" % (filepath, str(e))) - def _handle_jobs(self, event, *args): - if isinstance(event, EMsg): - message = args[0] - - if message.proto: - jobid = message.header.jobid_target - else: - jobid = message.header.targetJobID - - if jobid not in (-1, 18446744073709551615): - self.emit("job_%d" % jobid, *args) - def _handle_disconnect(self, *args): self.logged_on = False self.current_jobid = 0 @@ -514,7 +523,7 @@ class SteamClient(CMClient, BuiltinBase): message = MsgProto(EMsg.ClientLogon) message.header.steamid = SteamID(type='Individual', universe='Public') message.body.protocol_version = 65580 - message.body.client_package_version = 1771 + message.body.client_package_version = 1561159470 message.body.client_os_type = EOSType.Windows10 message.body.client_language = "english" message.body.should_remember_password = True @@ -571,6 +580,7 @@ class SteamClient(CMClient, BuiltinBase): message = MsgProto(EMsg.ClientLogon) message.header.steamid = SteamID(type='AnonUser', universe='Public') + message.body.client_package_version = 1561159470 message.body.protocol_version = 65580 self.send(message) @@ -669,4 +679,3 @@ class SteamClient(CMClient, BuiltinBase): result = self.login(username, password, None, auth_code, two_factor_code) return result - diff --git a/steam/client/builtins/gameservers.py b/steam/client/builtins/gameservers.py index d0fcaec..8b07876 100644 --- a/steam/client/builtins/gameservers.py +++ b/steam/client/builtins/gameservers.py @@ -36,6 +36,7 @@ Filter code What it does """ from steam.steamid import SteamID from steam.core.msg import MsgProto +from steam.enums import EResult from steam.enums.emsg import EMsg from steam.util import ip_to_int, ip_from_int, proto_to_dict @@ -50,7 +51,6 @@ class GameServers(object): class SteamGameServers(object): def __init__(self, steam): self._s = steam - self._um = steam.unified_messages def query(self, filter_text, max_servers=10, timeout=30, **kw): r""" @@ -117,7 +117,7 @@ class SteamGameServers(object): :type timeout: int :returns: list of servers, see below. (``None`` is returned steam doesn't respond) :rtype: :class:`list`, :class:`None` - :raises: :class:`.UnifiedMessageError` + :raises: :class:`.SteamError` Sample response: @@ -143,20 +143,20 @@ class SteamGameServers(object): 'version': '1.35.4.0'} ] """ - resp, error = self._um.send_and_wait("GameServers.GetServerList#1", - { - "filter": filter_text, - "limit": max_servers, - }, - timeout=20, - ) - - if error: - raise error + resp = self._s.send_um_and_wait("GameServers.GetServerList#1", + { + "filter": filter_text, + "limit": max_servers, + }, + timeout=20, + ) + if resp is None: return None + if resp.header.eresult != EResult.OK: + raise SteamError(resp.header.error_message, resp.header.eresult) - resp = proto_to_dict(resp) + resp = proto_to_dict(resp.body) if not resp: return [] @@ -175,7 +175,7 @@ class SteamGameServers(object): :type timeout: int :return: map of ips to steamids :rtype: dict - :raises: :class:`.UnifiedMessageError` + :raises: :class:`.SteamError` Sample response: @@ -183,16 +183,16 @@ class SteamGameServers(object): {SteamID(id=123456, type='AnonGameServer', universe='Public', instance=1234): '1.2.3.4:27060'} """ - resp, error = self._um.send_and_wait("GameServers.GetServerIPsBySteamID#1", - {"server_steamids": server_steam_ids}, - timeout=timeout, - ) - if error: - raise error + resp = self._s.send_um_and_wait("GameServers.GetServerIPsBySteamID#1", + {"server_steamids": server_steam_ids}, + timeout=timeout, + ) if resp is None: return None + if resp.header.eresult != EResult.OK: + raise SteamError(resp.header.error_message, resp.header.eresult) - return {SteamID(server.steamid): server.addr for server in resp.servers} + return {SteamID(server.steamid): server.addr for server in resp.body.servers} def get_steamids_from_ips(self, server_ips, timeout=30): """Resolve SteamIDs from IPs @@ -203,7 +203,7 @@ class SteamGameServers(object): :type timeout: int :return: map of steamids to ips :rtype: dict - :raises: :class:`.UnifiedMessageError` + :raises: :class:`.SteamError` Sample response: @@ -211,14 +211,13 @@ class SteamGameServers(object): {'1.2.3.4:27060': SteamID(id=123456, type='AnonGameServer', universe='Public', instance=1234)} """ - resp, error = self._um.send_and_wait("GameServers.GetServerSteamIDsByIP#1", - {"server_ips": server_ips}, - timeout=timeout, - ) - - if error: - raise error + resp = self._s.send_um_and_wait("GameServers.GetServerSteamIDsByIP#1", + {"server_ips": server_ips}, + timeout=timeout, + ) if resp is None: return None + if resp.header.eresult != EResult.OK: + raise SteamError(resp.header.error_message, resp.header.eresult) - return {server.addr: SteamID(server.steamid) for server in resp.servers} + return {server.addr: SteamID(server.steamid) for server in resp.body.servers} diff --git a/steam/client/builtins/unified_messages.py b/steam/client/builtins/unified_messages.py index af20dec..4e94bba 100644 --- a/steam/client/builtins/unified_messages.py +++ b/steam/client/builtins/unified_messages.py @@ -1,161 +1,79 @@ """ -:class:`SteamUnifiedMessages` provides a simply API to send and receive unified messages. +Methods to call service methods, also known as unified messages Example code: .. code:: python # the easy way - response = client.unified_messages.send_and_wait('Player.GetGameBadgeLevels#1', { + response = client.send_um_and_wait('Player.GetGameBadgeLevels#1', { 'property': 1, 'something': 'value', }) + print(response.body) + # the other way - jobid = client.unified_message.send('Player.GetGameBadgeLevels#1', {'something': 1}) - response, = client.unified_message.wait_event(jobid) + jobid = client.send_um('Player.GetGameBadgeLevels#1', {'something': 1}) + response = client.wait_event(jobid) + +The backend might error out, but we still get response. Here is how to check for error: + +.. code:: python + + if response.header.eresult != EResult.OK: + print(response.header.error_message) - # i know what im doing, alright? - message = client.unified_message.get('Player.GetGameBadgeLevels#1') - message.something = 1 - response = client.unified_message.send_and_wait(message) """ -import logging -from eventemitter import EventEmitter from steam.core.msg import MsgProto, get_um -from steam.enums import EResult from steam.enums.emsg import EMsg -from steam.util import WeakRefKeyDict, proto_fill_from_dict +from steam.util import proto_fill_from_dict class UnifiedMessages(object): def __init__(self, *args, **kwargs): super(UnifiedMessages, self).__init__(*args, **kwargs) - name = "%s.unified_messages" % self.__class__.__name__ - self.unified_messages = SteamUnifiedMessages(self, name) #: instance of :class:`SteamUnifiedMessages` - - -class UnifiedMessageError(Exception): - def __init__(self, message, eresult=EResult.Invalid): - self.eresult = eresult - self.message = message - - def __repr__(self): - return "%s(%s, %s)" % (self.__class__.__name__, self.eresult, repr(self.message)) - - def __str__(self): - return "(%s) %s" % (self.eresult, self.message) - - - -class SteamUnifiedMessages(EventEmitter): - """Simple API for send/recv of unified messages - - Incoming messages are emitted as events once with their ``jobid`` - and once with their method name (e.g. ``Player.GetGameBadgeLevels#1``) - """ - def __init__(self, steam, logger_name=None): - self._LOG = logging.getLogger(logger_name if logger_name else self.__class__.__name__) - self._steam = steam - self._data = WeakRefKeyDict() - - steam.on(EMsg.ServiceMethod, self._handle_service_method) - steam.on(EMsg.ClientServiceMethodResponse, self._handle_client_service_method) - - def emit(self, event, *args): - if event is not None: - self._LOG.debug("Emit event: %s" % repr(event)) - EventEmitter.emit(self, event, *args) - - def _handle_service_method(self, message): - self.emit(message.header.target_job_name, message.body) - - def _handle_client_service_method(self, message): - method_name = message.body.method_name - proto = get_um(method_name, response=True) - - if proto is None: - self._LOG.error("Unable to find proto for %s" % repr(method_name)) - return - - error = None - if message.header.eresult != EResult.OK: - error = UnifiedMessageError(message.header.error_message, - EResult(message.header.eresult), - ) - - resp = proto() - resp.ParseFromString(message.body.serialized_method_response) - - self.emit(method_name, resp, error) - - jobid = message.header.jobid_target - if jobid not in (-1, 18446744073709551615): - self.emit("job_%d" % jobid, resp, error) - - def get(self, method_name): - """Get request proto instance for given methed name - - :param method_name: name for the method (e.g. ``Player.GetGameBadgeLevels#1``) - :type method_name: :class:`str` - :return: proto message instance, or :class:`None` if not found - """ - proto = get_um(method_name) - if proto is None: - return None - message = proto() - self._data[message] = method_name - return message - - def send(self, message, params=None): + def send_um(self, method_name, params=None): """Send service method request - :param message: - proto message instance (use :meth:`SteamUnifiedMessages.get`) - or method name (e.g. ``Player.GetGameBadgeLevels#1``) - :type message: :class:`str`, proto message instance + :param method_name: method name (e.g. ``Player.GetGameBadgeLevels#1``) + :type method_name: :class:`str` :param params: message parameters - :type params: :class:`dict` - :return: ``jobid`` event identifier + :type params: :class:`dict` + :return: ``job_id`` identifier :rtype: :class:`str` Listen for ``jobid`` on this object to catch the response. - - .. note:: - If you listen for ``jobid`` on the client instance you will get the encapsulated message """ - if isinstance(message, str): - message = self.get(message) - if message not in self._data: - raise ValueError("Supplied message is invalid. Use 'get' method.") + proto = get_um(method_name) - if params: - proto_fill_from_dict(message, params) + if proto is None: + raise ValueError("Failed to find method named: %s" % method_name) - capsule = MsgProto(EMsg.ClientServiceMethod) - capsule.body.method_name = self._data[message] - capsule.body.serialized_method = message.SerializeToString() + message = MsgProto(EMsg.ServiceMethodCallFromClient) + message.header.target_job_name = method_name + message.body = proto() + + if params: + proto_fill_from_dict(message.body, params) - return self._steam.send_job(capsule) + return self.send_job(message) - def send_and_wait(self, message, params=None, timeout=10, raises=False): + def send_um_and_wait(self, method_name, params=None, timeout=10, raises=False): """Send service method request and wait for response - :param message: - proto message instance (use :meth:`SteamUnifiedMessages.get`) - or method name (e.g. ``Player.GetGameBadgeLevels#1``) - :type message: :class:`str`, proto message instance + :param method_name: method name (e.g. ``Player.GetGameBadgeLevels#1``) + :type method_name: :class:`str` :param params: message parameters - :type params: :class:`dict` + :type params: :class:`dict` :param timeout: (optional) seconds to wait - :type timeout: :class:`int` + :type timeout: :class:`int` :param raises: (optional) On timeout if :class:`False` return :class:`None`, else raise :class:`gevent.Timeout` - :type raises: :class:`bool` - :return: response proto message instance - :rtype: (proto message, :class:`.UnifiedMessageError`) + :type raises: :class:`bool` + :return: response message + :rtype: proto message instance :raises: :class:`gevent.Timeout` """ - job_id = self.send(message, params) - resp = self.wait_event(job_id, timeout, raises=raises) - return (None, None) if resp is None else resp + job_id = self.send_um(method_name, params) + return self.wait_msg(job_id, timeout, raises=raises) diff --git a/steam/client/cdn.py b/steam/client/cdn.py index 31020ef..337dbd4 100644 --- a/steam/client/cdn.py +++ b/steam/client/cdn.py @@ -562,8 +562,10 @@ class CDNClient(object): :type item_id: int :returns: manifest instance :rtype: :class:`.CDNDepotManifest` + :raises: steam error + :rtype: :class:`.SteamError` """ - resp, error = self.steam.unified_messages.send_and_wait('PublishedFile.GetDetails#1', { + resp = self.steam.send_um_and_wait('PublishedFile.GetDetails#1', { 'publishedfileids': [item_id], 'includetags': False, 'includeadditionalpreviews': False, @@ -576,10 +578,10 @@ class CDNClient(object): 'language': 0 }, timeout=7) - if error: - raise error + if resp.header.eresult != EResult.OK: + raise SteamError(resp.header.error_message, resp.header.eresult) - wf = None if resp is None else resp.publishedfiledetails[0] + wf = None if resp is None else resp.body.publishedfiledetails[0] if wf is None or wf.result != EResult.OK: raise ValueError("Failed getting workshop file info: %s" % repr( diff --git a/steam/core/cm.py b/steam/core/cm.py index 02a7a2d..2102c6f 100644 --- a/steam/core/cm.py +++ b/steam/core/cm.py @@ -263,7 +263,7 @@ class CMClient(EventEmitter): msg = Msg(emsg, message, extended=True) except Exception as e: self._LOG.fatal("Failed to deserialize message: %s (is_proto: %s)", - str(emsg), + repr(emsg), is_proto(emsg_id) ) self._LOG.exception(e) @@ -275,6 +275,7 @@ class CMClient(EventEmitter): self._LOG.debug("Incoming: %s", repr(msg)) self.emit(emsg, msg) + return emsg, msg def __handle_encrypt_request(self, req): self._LOG.debug("Securing channel") diff --git a/steam/core/msg/__init__.py b/steam/core/msg/__init__.py index 0a254f3..c68a79a 100644 --- a/steam/core/msg/__init__.py +++ b/steam/core/msg/__init__.py @@ -2,7 +2,9 @@ import fnmatch from steam.core.msg.unified import get_um from steam.core.msg.structs import get_struct from steam.core.msg.headers import MsgHdr, ExtendedMsgHdr, MsgHdrProtoBuf, GCMsgHdr, GCMsgHdrProto +from steam.enums import EResult from steam.enums.emsg import EMsg +from steam.exceptions import SteamError from steam.protobufs import steammessages_base_pb2 from steam.protobufs import steammessages_clientserver_pb2 from steam.protobufs import steammessages_clientserver_2_pb2 @@ -144,12 +146,12 @@ class MsgProto(object): self.header = self._header.proto self.msg = msg - if msg == EMsg.ServiceMethod: - proto = get_um(self.header.target_job_name) + if msg in (EMsg.ServiceMethodResponse, EMsg.ServiceMethodSendToClient): + proto = get_um(self.header.target_job_name, response=True) if proto: self.body = proto() else: - self.body = '!! Failed to resolve UM for: %s !!' % repr(self.header.target_job_name) + self.body = '!! Failed to resolve UM: %s !!' % repr(self.header.target_job_name) else: proto = get_cmsg(msg) diff --git a/steam/exceptions.py b/steam/exceptions.py new file mode 100644 index 0000000..76f23f8 --- /dev/null +++ b/steam/exceptions.py @@ -0,0 +1,7 @@ + +from steam.enums import EResult + +class SteamError(Exception): + def __init__(self, message, eresult=EResult.Fail): + Exception.__init__(self, message) + self.eresult = EResult(eresult) #: :class:`.EResult` diff --git a/steam/guard.py b/steam/guard.py index d601e85..60ec847 100644 --- a/steam/guard.py +++ b/steam/guard.py @@ -142,15 +142,16 @@ class SteamAuthenticator(object): if not backend.logged_on: raise SteamAuthenticatorError("SteamClient instance not logged in") - resp, error = backend.unified_messages.send_and_wait("TwoFactor.%s#1" % action, - params, timeout=10) + resp = backend.send_um_and_wait("TwoFactor.%s#1" % action, + params, timeout=10) - if error: - raise SteamAuthenticatorError("Failed: %s" % str(error)) if resp is None: raise SteamAuthenticatorError("Failed. Request timeout") + if resp.header.eresult != EResult.OK: + raise SteamAuthenticatorError("Failed: %s (%s)" % str(resp.header.error_message, + repr(resp.header.eresult))) - resp = proto_to_dict(resp) + resp = proto_to_dict(resp.body) if action == 'AddAuthenticator': for key in ['shared_secret', 'identity_secret', 'secret_1']: