├── steamctl ├── utils │ ├── __init__.py │ ├── web.py │ ├── apps.py │ ├── prompt.py │ ├── versions_report.py │ ├── tqdm.py │ ├── format.py │ └── storage.py ├── commands │ ├── __init__.py │ ├── steamid │ │ ├── __init__.py │ │ └── cmds.py │ ├── clean │ │ ├── __init__.py │ │ └── cmds.py │ ├── assistant │ │ ├── __init__.py │ │ ├── discovery_queue.py │ │ └── card_idler.py │ ├── ugc │ │ ├── __init__.py │ │ └── gcmds.py │ ├── hlmaster │ │ ├── __init__.py │ │ └── cmds.py │ ├── cloud │ │ ├── __init__.py │ │ └── gcmds.py │ ├── apps │ │ ├── enums.py │ │ ├── __init__.py │ │ └── gcmds.py │ ├── authenticator │ │ ├── cmd_code.py │ │ ├── __init__.py │ │ └── cmds.py │ ├── workshop │ │ ├── __init__.py │ │ ├── gcmds.py │ │ └── cmds.py │ ├── webapi │ │ ├── __init__.py │ │ └── cmds.py │ └── depot │ │ ├── __init__.py │ │ └── gcmds.py ├── __init__.py ├── __main__.py ├── argparser.py └── clients.py ├── .gitignore ├── preview_authenticator.jpg ├── requirements.txt ├── .github └── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── Makefile ├── LICENSE ├── setup.py └── README.rst /steamctl/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /steamctl/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | e3 4 | build 5 | dist 6 | steam 7 | *.pyc 8 | *.egg-info 9 | -------------------------------------------------------------------------------- /preview_authenticator.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ValvePython/steamctl/HEAD/preview_authenticator.jpg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | steam[client]~=1.4,>=1.4.3 2 | appdirs 3 | argcomplete 4 | tqdm 5 | arrow 6 | pyqrcode 7 | vpk>=1.3.2 8 | beautifulsoup4 9 | -------------------------------------------------------------------------------- /steamctl/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.9.5" 2 | __author__ = "Rossen Georgiev" 3 | __appname__ = "steamctl" 4 | 5 | version_info = tuple(map(int, __version__.split('.'))) 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Ask a question in the discussion section 4 | url: https://github.com/ValvePython/steamctl/discussions 5 | about: Please ask and answer questions here. 6 | - name: Ask a question via IRC (libera.chat) 7 | url: https://web.libera.chat/#steamre 8 | about: Please ask and answer questions here. 9 | -------------------------------------------------------------------------------- /steamctl/commands/steamid/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from steamctl.argparser import register_command 3 | 4 | epilog = """\ 5 | Example usage: 6 | {prog} steamid 4 7 | """ 8 | 9 | 10 | @register_command('steamid', help='Parse SteamID representations', epilog=epilog) 11 | def cmd_parser(cp): 12 | cp.add_argument('s_input', metavar='') 13 | cp.set_defaults(_cmd_func=__name__ + '.cmds:cmd_steamid') 14 | -------------------------------------------------------------------------------- /steamctl/utils/web.py: -------------------------------------------------------------------------------- 1 | 2 | import requests 3 | from steam import __version__ as steam_ver 4 | from steamctl import __version__ 5 | 6 | 7 | def make_requests_session(): 8 | """ 9 | :returns: requests session 10 | :rtype: :class:`requests.Session` 11 | """ 12 | session = requests.Session() 13 | 14 | version = __import__('steam').__version__ 15 | ua = "python-steamctl/{} python-steam/{} {}".format( 16 | __version__, 17 | steam_ver, 18 | session.headers['User-Agent'], 19 | ) 20 | session.headers['User-Agent'] = ua 21 | 22 | return session 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | define HELPBODY 2 | Available commands: 3 | 4 | make help - this thing. 5 | 6 | make init - install python dependancies 7 | 8 | make dist - build source distribution 9 | mage register - register in pypi 10 | make upload - upload to pypi 11 | 12 | endef 13 | 14 | export HELPBODY 15 | help: 16 | @echo "$$HELPBODY" 17 | 18 | init: 19 | pip install -r requirements.txt 20 | 21 | clean: 22 | rm -rf dist steamctl.egg-info build 23 | 24 | dist: clean 25 | python setup.py sdist 26 | python setup.py bdist_wheel 27 | 28 | upload: dist 29 | twine upload -r pypi dist/* 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE REQUEST]" 5 | labels: feature-request, needs-review 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug, needs-review 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Description** 11 | A clear and concise description of what the bug is. 12 | 13 | **Steps to Reproduce the behavior** 14 | (Include debug logs if possible and relevant) 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **What actually happend** 20 | Description of what actually happend 21 | 22 | **Logs** 23 |
steamctl -l debug 24 | (Include logs related ot the issue. Use `steamctl -l debug` to get detailed log) 25 | ``` 26 | PASTE LOG HERE 27 | ``` 28 |
29 | 30 | **Versions Report** 31 |
steamctl --versions-report 32 | (Run steamctl --versions-report and paste the output below) 33 | 34 | ```yaml 35 | PASTE HERE 36 | ``` 37 |
38 | -------------------------------------------------------------------------------- /steamctl/utils/apps.py: -------------------------------------------------------------------------------- 1 | 2 | from time import time 3 | from steamctl.utils.storage import SqliteDict, UserCacheFile 4 | from steamctl.utils.web import make_requests_session 5 | from steam import webapi 6 | 7 | webapi._make_requests_session = make_requests_session 8 | 9 | def get_app_names(): 10 | papps = SqliteDict(UserCacheFile("app_names.sqlite3")) 11 | 12 | try: 13 | last = int(papps[-7]) # use a key that will never be used 14 | except KeyError: 15 | last = 0 16 | 17 | if last < time(): 18 | resp = webapi.get('ISteamApps', 'GetAppList', version=2) 19 | apps = resp.get('applist', {}).get('apps', []) 20 | 21 | if not apps and len(papps) == 0: 22 | raise RuntimeError("Failed to fetch apps") 23 | 24 | for app in apps: 25 | papps[int(app['appid'])] = app['name'] 26 | 27 | papps[-7] = str(int(time()) + 86400) 28 | papps.commit() 29 | 30 | return papps 31 | 32 | -------------------------------------------------------------------------------- /steamctl/utils/prompt.py: -------------------------------------------------------------------------------- 1 | 2 | import re 3 | 4 | def pmt_confirmation(text, default_yes=None): 5 | 6 | while True: 7 | response = input("{} [{}/{}]: ".format( 8 | text, 9 | 'YES' if default_yes == True else 'yes', 10 | 'NO' if default_yes == False else 'no', 11 | )).strip() 12 | 13 | if not response: 14 | if default_yes is not None: 15 | return default_yes 16 | else: 17 | continue 18 | elif response.lower() in ('y', 'yes'): 19 | return True 20 | elif response.lower() in ('n', 'no'): 21 | return False 22 | 23 | def pmt_input(text, regex=None, nmprefix='Invalid input.'): 24 | while True: 25 | response = input("{} ".format(text.rstrip(' '))) 26 | 27 | if regex and not re.search(regex, response): 28 | print(nmprefix, '', end='') 29 | continue 30 | 31 | return response 32 | 33 | -------------------------------------------------------------------------------- /steamctl/commands/clean/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from steamctl.argparser import register_command 3 | 4 | @register_command('clear', help='Remove data stored on disk') 5 | def setup_arg_parser(cp): 6 | 7 | def print_help(*args, **kwargs): 8 | cp.print_help() 9 | 10 | cp.set_defaults(_cmd_func=print_help) 11 | sub_cp = cp.add_subparsers(metavar='', 12 | dest='subcommand', 13 | title='List of sub-commands', 14 | description='', 15 | ) 16 | 17 | scp_c = sub_cp.add_parser("cache", help="Remove all cache files") 18 | scp_c.set_defaults(_cmd_func=__name__ + '.cmds:cmd_clear_cache') 19 | scp_c = sub_cp.add_parser("credentials", help="Remove all credentials and saved logins") 20 | scp_c.set_defaults(_cmd_func=__name__ + '.cmds:cmd_clear_credentials') 21 | scp_c = sub_cp.add_parser("all", help="Remove all cache and data files") 22 | scp_c.set_defaults(_cmd_func=__name__ + '.cmds:cmd_clear_all') 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Rossen Georgiev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /steamctl/commands/clean/cmds.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | from steamctl.utils.storage import UserDataDirectory, UserCacheDirectory 4 | from steamctl.utils.format import print_table, fmt_duration 5 | 6 | 7 | _LOG = logging.getLogger(__name__) 8 | 9 | def cmd_clear_cache(args): 10 | _LOG.debug("Removing all cache files") 11 | 12 | cache_dir = UserCacheDirectory() 13 | 14 | if cache_dir.exists(): 15 | cache_dir.remove() 16 | else: 17 | _LOG.debug("Cache dir doesn't exist. Nothing to do") 18 | 19 | def cmd_clear_credentials(args): 20 | _LOG.debug("Removing all stored credentials") 21 | 22 | data_dir = UserDataDirectory('client') 23 | 24 | for entry in data_dir.iter_files('*.key'): 25 | entry.secure_remove() 26 | for entry in data_dir.iter_files('*_sentry.bin'): 27 | entry.secure_remove() 28 | 29 | def cmd_clear_all(args): 30 | _LOG.debug("Removing all files stored by this application") 31 | 32 | cmd_clear_cache(args) 33 | cmd_clear_credentials(args) 34 | 35 | data_dir = UserDataDirectory() 36 | 37 | if data_dir.exists(): 38 | data_dir.remove() 39 | else: 40 | _LOG.debug("Data dir doesn't exist. Nothing to do") 41 | 42 | 43 | -------------------------------------------------------------------------------- /steamctl/commands/steamid/cmds.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | 4 | _LOG = logging.getLogger(__name__) 5 | 6 | def cmd_steamid(args): 7 | from steam.steamid import SteamID 8 | 9 | if args.s_input.startswith('http'): 10 | _LOG.debug("Input is URL. Making online request to resolve SteamID") 11 | s = SteamID.from_url(args.s_input) or SteamID() 12 | else: 13 | s = SteamID(args.s_input) 14 | 15 | lines = [ 16 | "SteamID: {s.as_64}", 17 | "Account ID: {s.as_32}", 18 | "Type: {s.type} ({stype})", 19 | "Universe: {s.universe} ({suniverse})", 20 | "Instance: {s.instance}", 21 | "Steam2: {s.as_steam2}", 22 | "Steam2Legacy: {s.as_steam2_zero}", 23 | "Steam3: {s.as_steam3}", 24 | ] 25 | 26 | if s.community_url: 27 | lines += ["Community URL: {s.community_url}"] 28 | 29 | if s.invite_url: 30 | lines += ["Invite URL: {s.invite_url}"] 31 | 32 | lines += ["Valid: {is_valid}"] 33 | 34 | print("\n".join(lines).format(s=s, 35 | stype=str(s.type), 36 | suniverse=str(s.universe), 37 | is_valid=str(s.is_valid()), 38 | )) 39 | -------------------------------------------------------------------------------- /steamctl/commands/assistant/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from steamctl.argparser import register_command 3 | 4 | 5 | epilog = """\ 6 | """ 7 | 8 | @register_command('assistant', help='Helpful automation', epilog=epilog) 9 | def cmd_parser(cp): 10 | def print_help(*args, **kwargs): 11 | cp.print_help() 12 | 13 | cp.set_defaults(_cmd_func=print_help) 14 | 15 | sub_cp = cp.add_subparsers(metavar='', 16 | dest='subcommand', 17 | title='List of sub-commands', 18 | description='', 19 | ) 20 | 21 | scp_i = sub_cp.add_parser("idle-games", help="Idle up to 32 games for game time") 22 | scp_i.set_defaults(_cmd_func=__name__ + '.card_idler:cmd_assistant_idle_games') 23 | scp_i.add_argument('app_ids', nargs='+', metavar='AppID', type=int, help='App ID(s) to idle') 24 | 25 | scp_i = sub_cp.add_parser("idle-cards", help="Automatic idling for game cards") 26 | scp_i.set_defaults(_cmd_func=__name__ + '.card_idler:cmd_assistant_idle_cards') 27 | 28 | scp_i = sub_cp.add_parser("discovery-queue", help="Explore a single discovery queue") 29 | scp_i.set_defaults(_cmd_func=__name__ + '.discovery_queue:cmd_assistant_discovery_queue') 30 | -------------------------------------------------------------------------------- /steamctl/commands/ugc/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from steamctl.argparser import register_command 3 | from steamctl.utils.storage import UserDataFile, UserCacheFile 4 | from argcomplete import warn 5 | 6 | 7 | epilog = """\ 8 | """ 9 | 10 | @register_command('ugc', help='Info and download of user generated content', epilog=epilog) 11 | def cmd_parser(cp): 12 | def print_help(*args, **kwargs): 13 | cp.print_help() 14 | 15 | cp.set_defaults(_cmd_func=print_help) 16 | 17 | sub_cp = cp.add_subparsers(metavar='', 18 | dest='subcommand', 19 | title='List of sub-commands', 20 | description='', 21 | ) 22 | 23 | scp_i = sub_cp.add_parser("info", help="Get details for UGC") 24 | scp_i.add_argument('ugc', type=int, help='UGC ID') 25 | scp_i.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_ugc_info') 26 | 27 | scp_dl = sub_cp.add_parser("download", help="Download UGC") 28 | scp_dl.add_argument('-o', '--output', type=str, default='', help='Path to directory for the downloaded files (default: cwd)') 29 | scp_dl.add_argument('-nd', '--no-directories', action='store_true', help='Do not create directories') 30 | scp_dl.add_argument('-np', '--no-progress', action='store_true', help='Do not create directories') 31 | scp_dl.add_argument('ugc', type=int, help='UGC ID') 32 | scp_dl.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_ugc_download') 33 | -------------------------------------------------------------------------------- /steamctl/utils/versions_report.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | 4 | def versions_report(output=sys.stdout): 5 | from builtins import print 6 | from functools import partial 7 | 8 | print = partial(print, file=output) 9 | 10 | # steamctl version 11 | from steamctl import __version__, __appname__ 12 | print("{}: {}".format(__appname__, __version__)) 13 | 14 | # dependecy versions 15 | print("\nDependencies:") 16 | 17 | import pkg_resources 18 | 19 | installed_pkgs = {pkg.project_name.lower(): pkg.version for pkg in pkg_resources.working_set} 20 | 21 | for dep in [ 22 | 'steam', 23 | 'appdirs', 24 | 'argcomplete', 25 | 'tqdm', 26 | 'arrow', 27 | 'pyqrcode', 28 | 'beautifulsoup4', 29 | 'vpk', 30 | 'vdf', 31 | 'gevent-eventemitter', 32 | 'gevent', 33 | 'greenlet', 34 | 'pyyaml', 35 | 'pycryptodomex', 36 | 'protobuf', 37 | ]: 38 | print("{:>20}:".format(dep), installed_pkgs.get(dep.lower(), "Not Installed")) 39 | 40 | # python runtime 41 | print("\nPython runtime:") 42 | print(" executable:", sys.executable) 43 | print(" version:", sys.version.replace('\n', '')) 44 | print(" platform:", sys.platform) 45 | 46 | # system info 47 | import platform 48 | 49 | print("\nSystem info:") 50 | print(" system:", platform.system()) 51 | print(" machine:", platform.machine()) 52 | print(" release:", platform.release()) 53 | print(" version:", platform.version()) 54 | -------------------------------------------------------------------------------- /steamctl/commands/hlmaster/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from steamctl.argparser import register_command 3 | 4 | @register_command('hlmaster', help='Query master server and server information') 5 | def setup_arg_parser(cp): 6 | 7 | def print_help(*args, **kwargs): 8 | cp.print_help() 9 | 10 | cp.set_defaults(_cmd_func=print_help) 11 | sub_cp = cp.add_subparsers(metavar='', 12 | dest='subcommand', 13 | title='List of sub-commands', 14 | description='', 15 | ) 16 | 17 | scp_query = sub_cp.add_parser("query", help="Query HL Master for servers") 18 | scp_query.add_argument('filter', type=str) 19 | scp_query.add_argument('--ip-only', action='store_true', help='Show short info about each server') 20 | scp_query.add_argument('-n', '--num-servers', default=20, type=int, help="Number of result to return (Default: 20)") 21 | scp_query.add_argument('-m', '--master', default=None, type=str, help="Master server (default: hl2master.steampowered.com:27011)") 22 | scp_query.set_defaults(_cmd_func=__name__ + '.cmds:cmd_hlmaster_query') 23 | 24 | scp_info = sub_cp.add_parser("info", help="Query info from a goldsrc or source server") 25 | scp_info.add_argument('server', type=str) 26 | scp_info.add_argument('-i', '--info', action='store_true', help='Show server info') 27 | scp_info.add_argument('-r', '--rules', action='store_true', help='Show server rules') 28 | scp_info.add_argument('-p', '--players', action='store_true', help='Show player list') 29 | scp_info.add_argument('-s', '--short', action='store_true', help='Print server info in short form') 30 | scp_info.set_defaults(_cmd_func=__name__ + '.cmds:cmd_hlmaster_info') 31 | -------------------------------------------------------------------------------- /steamctl/utils/tqdm.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import logging 4 | from tqdm import tqdm as _tqdm 5 | 6 | 7 | class TQDMHandler(logging.Handler): 8 | def __init__(self, tqdm_instance): 9 | self._tqdm = tqdm_instance 10 | logging.Handler.__init__(self) 11 | def emit(self, record): 12 | message = self.format(record).rstrip() 13 | self._tqdm.write(message, sys.stderr) 14 | def flush(self): 15 | pass 16 | 17 | class tqdm(_tqdm): 18 | _xclosed = False 19 | _xloghandler = None 20 | _hooked = False 21 | 22 | def __init__(self, *args, **kwargs): 23 | _tqdm.__init__(self, *args, **kwargs) 24 | 25 | if not tqdm._hooked: 26 | tqdm._hooked = True 27 | 28 | self._xloghandler = TQDMHandler(self) 29 | 30 | log = logging.getLogger() 31 | self._xoldhandler = log.handlers.pop() 32 | self._xloghandler.level = self._xoldhandler.level 33 | self._xloghandler.formatter = self._xoldhandler.formatter 34 | log.addHandler(self._xloghandler) 35 | 36 | self._xrootlog = log 37 | 38 | def write(self, s, file=sys.stdout): 39 | super().write(s, file) 40 | 41 | def close(self): 42 | _xclosed = True 43 | _tqdm.close(self) 44 | 45 | if self._xloghandler: 46 | self._xrootlog.removeHandler(self._xloghandler) 47 | self._xrootlog.addHandler(self._xoldhandler) 48 | 49 | def gevent_refresh_loop(self): 50 | from gevent import sleep 51 | while not self._xclosed: 52 | self.refresh() 53 | sleep(0.5) 54 | 55 | class fake_tqdm(object): 56 | def __init__(self, *args, **kwargs): 57 | self.n = 0 58 | def write(self, s): 59 | print(s) 60 | def update(self, n): 61 | self.n += n 62 | def refresh(self): 63 | pass 64 | def close(self): 65 | pass 66 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | from codecs import open 5 | from os import path 6 | import sys 7 | 8 | here = path.abspath(path.dirname(__file__)) 9 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 10 | long_description = f.read() 11 | with open(path.join(here, 'steamctl/__init__.py'), encoding='utf-8') as f: 12 | __version__ = f.readline().split('"')[1] 13 | __author__ = f.readline().split('"')[1] 14 | 15 | install_requires = [ 16 | 'steam[client]~=1.4,>=1.4.3', 17 | 'appdirs', 18 | 'argcomplete', 19 | 'tqdm', 20 | 'arrow', 21 | 'pyqrcode', 22 | 'vpk>=1.3.2', 23 | 'beautifulsoup4', 24 | ] 25 | 26 | setup( 27 | name='steamctl', 28 | version=__version__, 29 | description='Take control of Steam from your terminal', 30 | long_description=long_description, 31 | url='https://github.com/ValvePython/steamctl', 32 | author=__author__, 33 | author_email='rossen@rgp.io', 34 | license='MIT', 35 | classifiers=[ 36 | 'Development Status :: 4 - Beta', 37 | 'Environment :: Console', 38 | 'Natural Language :: English', 39 | 'License :: OSI Approved :: MIT License', 40 | 'Operating System :: OS Independent', 41 | 'Programming Language :: Python :: 3.5', 42 | 'Programming Language :: Python :: 3.6', 43 | 'Programming Language :: Python :: 3.7', 44 | 'Programming Language :: Python :: 3.8', 45 | 'Programming Language :: Python :: 3.9', 46 | ], 47 | keywords='steam steamctl steamid steamcommunity authenticator workshop', 48 | packages=['steamctl'] + ['steamctl.'+x for x in find_packages(where='steamctl')], 49 | entry_points={ 50 | 'console_scripts': ['steamctl = steamctl.__main__:main'], 51 | }, 52 | python_requires='~=3.4', 53 | install_requires=install_requires, 54 | zip_safe=True, 55 | ) 56 | -------------------------------------------------------------------------------- /steamctl/commands/cloud/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from steamctl.argparser import register_command 3 | from steamctl.utils.storage import UserDataFile, UserCacheFile 4 | from argcomplete import warn 5 | 6 | 7 | epilog = """\ 8 | 9 | Examples: 10 | 11 | Listing and downloading personal game files, such as saved files for Black Mesa (362890): 12 | {prog} cloud list 362890 13 | {prog} cloud download -o savefiles 362890 14 | 15 | Listing all screenshots on the account. App 760 is where Steam stores screenshots. 16 | {prog} cloud list 760 17 | 18 | Downloading all screenshots to a directory called "screenshots": 19 | {prog} cloud download -o screenshots 760 20 | 21 | """ 22 | 23 | @register_command('cloud', help='Manage Steam Cloud files (e.g. save files, settings, etc)', epilog=epilog) 24 | def cmd_parser(cp): 25 | def print_help(*args, **kwargs): 26 | cp.print_help() 27 | 28 | cp.set_defaults(_cmd_func=print_help) 29 | 30 | sub_cp = cp.add_subparsers(metavar='', 31 | dest='subcommand', 32 | title='List of sub-commands', 33 | description='', 34 | ) 35 | 36 | scp_l = sub_cp.add_parser("list", help="List files for app") 37 | scp_l.add_argument('--long', action='store_true', help='Shows extra info for every file') 38 | # fexcl = scp_l.add_mutually_exclusive_group() 39 | # fexcl.add_argument('-n', '--name', type=str, help='Wildcard for matching filepath') 40 | # fexcl.add_argument('-re', '--regex', type=str, help='Reguar expression for matching filepath') 41 | scp_l.add_argument('app_id', metavar='AppID', type=int, help='AppID to query') 42 | scp_l.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_cloud_list') 43 | 44 | scp_la = sub_cp.add_parser("list_apps", help="List all apps with cloud files") 45 | scp_la.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_cloud_list_apps') 46 | 47 | scp_dl = sub_cp.add_parser("download", help="Download files for app") 48 | scp_dl.add_argument('-o', '--output', type=str, default='', help='Path to directory for the downloaded files (default: cwd)') 49 | scp_dl.add_argument('-np', '--no-progress', action='store_true', help='Do not create directories') 50 | scp_dl.add_argument('app_id', metavar='AppID', type=int, help='AppID to query') 51 | scp_dl.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_cloud_download') 52 | -------------------------------------------------------------------------------- /steamctl/commands/apps/enums.py: -------------------------------------------------------------------------------- 1 | from steam.enums.base import SteamIntEnum 2 | 3 | class EPaymentMethod(SteamIntEnum): 4 | NONE = 0 5 | ActivationCode = 1 6 | CreditCard = 2 7 | Giropay = 3 8 | PayPal = 4 9 | Ideal = 5 10 | PaySafeCard = 6 11 | Sofort = 7 12 | GuestPass = 8 13 | WebMoney = 9 14 | MoneyBookers = 10 15 | AliPay = 11 16 | Yandex = 12 17 | Kiosk = 13 18 | Qiwi = 14 19 | GameStop = 15 20 | HardwarePromo = 16 21 | MoPay = 17 22 | BoletoBancario = 18 23 | BoaCompraGold = 19 24 | BancoDoBrasilOnline = 20 25 | ItauOnline = 21 26 | BradescoOnline = 22 27 | Pagseguro = 23 28 | VisaBrazil = 24 29 | AmexBrazil = 25 30 | Aura = 26 31 | Hipercard = 27 32 | MastercardBrazil = 28 33 | DinersCardBrazil = 29 34 | AuthorizedDevice = 30 35 | MOLPoints = 31 36 | ClickAndBuy = 32 37 | Beeline = 33 38 | Konbini = 34 39 | EClubPoints = 35 40 | CreditCardJapan = 36 41 | BankTransferJapan = 37 42 | # PayEasyJapan = 38 removed "renamed to PayEasy" 43 | PayEasy = 38 44 | Zong = 39 45 | CultureVoucher = 40 46 | BookVoucher = 41 47 | HappymoneyVoucher = 42 48 | ConvenientStoreVoucher = 43 49 | GameVoucher = 44 50 | Multibanco = 45 51 | Payshop = 46 52 | # Maestro = 47 removed "renamed to MaestroBoaCompra" 53 | MaestroBoaCompra = 47 54 | OXXO = 48 55 | ToditoCash = 49 56 | Carnet = 50 57 | SPEI = 51 58 | ThreePay = 52 59 | IsBank = 53 60 | Garanti = 54 61 | Akbank = 55 62 | YapiKredi = 56 63 | Halkbank = 57 64 | BankAsya = 58 65 | Finansbank = 59 66 | DenizBank = 60 67 | PTT = 61 68 | CashU = 62 69 | AutoGrant = 64 70 | WebMoneyJapan = 65 71 | OneCard = 66 72 | PSE = 67 73 | Exito = 68 74 | Efecty = 69 75 | Paloto = 70 76 | PinValidda = 71 77 | MangirKart = 72 78 | BancoCreditoDePeru = 73 79 | BBVAContinental = 74 80 | SafetyPay = 75 81 | PagoEfectivo = 76 82 | Trustly = 77 83 | UnionPay = 78 84 | BitCoin = 79 85 | Wallet = 128 86 | Valve = 129 87 | # SteamPressMaster = 130 removed "renamed to MasterComp" 88 | MasterComp = 130 89 | # StorePromotion = 131 removed "renamed to Promotional" 90 | Promotional = 131 91 | MasterSubscription = 134 92 | Payco = 135 93 | MobileWalletJapan = 136 94 | OEMTicket = 256 95 | Split = 512 96 | Complimentary = 1024 97 | 98 | class EPackageStatus(SteamIntEnum): 99 | Available = 0 100 | Preorder = 1 101 | Unavailable = 2 102 | Invalid = 3 103 | 104 | -------------------------------------------------------------------------------- /steamctl/utils/format.py: -------------------------------------------------------------------------------- 1 | 2 | from math import log 3 | from functools import reduce 4 | import dateutil 5 | import arrow 6 | 7 | def print_table(rows, column_names=None): 8 | """Taken a list of columns and prints a table where column are spaced automatically""" 9 | 10 | # calculates the max width for every column 11 | widths = list(reduce(lambda a, b: [max(ac, bc) for ac, bc in zip(a, b)], 12 | map(lambda row: map(len, row), rows))) 13 | 14 | justify_right = [False] * len(widths) 15 | 16 | if column_names: 17 | # allows for justifying column to right, or left, by prepending > or < to column name 18 | cleaned_columns = [] 19 | for i, name in enumerate(column_names): 20 | if name[0:1] in ('<', '>'): 21 | if name[0:1] == '>': 22 | justify_right[i] = True 23 | cleaned_columns.append(name[1:]) 24 | else: 25 | cleaned_columns.append(name) 26 | 27 | column_names = cleaned_columns 28 | 29 | # recalcualte width including column names (could be longer than column values) 30 | widths = [max(a, b) for a, b in zip(widths, map(len, column_names))] 31 | 32 | print(' | '.join((column.ljust(widths[i]) for i, column in enumerate(column_names))).rstrip(' ')) 33 | 34 | sep = '-' * (widths[0] + 1) 35 | 36 | for width in widths[1:-1]: 37 | sep += '|' + ('-' * (width+2)) 38 | 39 | if len(widths) > 1: 40 | sep += '|' + ('-' * (min(widths[-1]+2, 50))) # limit last column seperator width 41 | 42 | print(sep.rstrip(' ')) 43 | 44 | for row in rows: 45 | print(' | '.join((getattr(column, 'rjust' if justify_right[i] else 'ljust')(widths[i]) 46 | for i, column in enumerate(row))).rstrip(' ')) 47 | 48 | def fmt_size(size, decimal_places=0): 49 | """Format size in bytes into friendly format""" 50 | 51 | suffixes = 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' 52 | power = 0 53 | 54 | if size > 0: 55 | power = int(log(size, 1000)) 56 | 57 | if power: 58 | size = size / (1000 ** power) 59 | 60 | return ("{:."+str(decimal_places)+"f} {}").format(size, suffixes[power]) 61 | 62 | def fmt_duration(seconds): 63 | hours, seconds = divmod(seconds, 3600) 64 | minutes, seconds = divmod(seconds, 60) 65 | 66 | if hours and minutes: 67 | return "{:.0f}h {:.0f}m {:.0f}s".format(hours, minutes, seconds) 68 | elif minutes: 69 | return "{:.0f}m {:.0f}s".format(minutes, seconds) 70 | else: 71 | return "{:.0f}s".format(seconds) 72 | 73 | def fmt_datetime(timestamp, utc=False): 74 | if utc: 75 | return arrow.get(timestamp).strftime('%Y-%m-%d %H:%M:%S UTC') 76 | else: 77 | return arrow.get(timestamp).to(dateutil.tz.gettz()).strftime('%Y-%m-%d %H:%M:%S %Z') 78 | -------------------------------------------------------------------------------- /steamctl/commands/authenticator/cmd_code.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import struct 3 | from time import time 4 | from base64 import b64decode, b32encode 5 | from hashlib import sha1 6 | import hmac 7 | from steamctl import __appname__ 8 | from steamctl.utils.storage import UserDataFile 9 | 10 | _LOG = logging.getLogger(__name__) 11 | 12 | def generate_twofactor_code_for_time(shared_secret, timestamp): 13 | """Generate Steam 2FA code for timestamp 14 | 15 | :param shared_secret: authenticator shared secret 16 | :type shared_secret: bytes 17 | :param timestamp: timestamp to use, if left out uses current time 18 | :type timestamp: int 19 | :return: steam two factor code 20 | :rtype: str 21 | """ 22 | hashed = hmac.new(bytes(shared_secret), struct.pack('>Q', int(timestamp)//30), sha1).digest() 23 | 24 | start = ord(hashed[19:20]) & 0xF 25 | codeint = struct.unpack('>I', hashed[start:start+4])[0] & 0x7fffffff 26 | 27 | charset = '23456789BCDFGHJKMNPQRTVWXY' 28 | code = '' 29 | 30 | for _ in range(5): 31 | codeint, i = divmod(codeint, len(charset)) 32 | code += charset[i] 33 | 34 | return code 35 | 36 | def cmd_authenticator_code(args): 37 | account = args.account.lower().strip() 38 | secrets = UserDataFile('authenticator/{}.json'.format(account)).read_json() 39 | 40 | if not secrets: 41 | print("No authenticator for %r" % account) 42 | return 1 # error 43 | 44 | print(generate_twofactor_code_for_time(b64decode(secrets['shared_secret']), time())) 45 | 46 | def cmd_authenticator_qrcode(args): 47 | account = args.account.lower().strip() 48 | secrets = UserDataFile('authenticator/{}.json'.format(account)).read_json() 49 | 50 | if not secrets: 51 | print("No authenticator for %r" % account) 52 | return 1 # error 53 | 54 | import pyqrcode 55 | 56 | if args.invert: 57 | FG, BG = '0', '1' 58 | else: 59 | FG, BG = '1', '0' 60 | 61 | charmap = { 62 | (BG, BG): '█', 63 | (FG, FG): ' ', 64 | (BG, FG): '▀', 65 | (FG, BG): '▄', 66 | } 67 | 68 | if args.compat: 69 | uri = 'otpauth://totp/steamctl:{user}?secret={secret}&issuer=Steam&digits=5' 70 | else: 71 | uri = 'otpauth://steam/steamctl:{user}?secret={secret}&issuer=Steam' 72 | 73 | 74 | uri = uri.format(user=secrets['account_name'], 75 | secret=b32encode(b64decode(secrets['shared_secret'])).decode('ascii'), 76 | ) 77 | 78 | qrlines = pyqrcode.create(uri, error='M').text(1).split('\n')[:-1] 79 | 80 | print("Suggested 2FA App: Aegis, andOTP") 81 | print("Scan the QR code below:") 82 | 83 | for y in range(0, len(qrlines), 2): 84 | for x in range(0, len(qrlines[y])): 85 | print(charmap[(qrlines[y][x], FG if y+1 >= len(qrlines) else qrlines[y+1][x])], end='') 86 | print() 87 | 88 | -------------------------------------------------------------------------------- /steamctl/__main__.py: -------------------------------------------------------------------------------- 1 | # PYTHON_ARGCOMPLETE_OK 2 | from __future__ import print_function 3 | 4 | import sys 5 | import logging 6 | import pkgutil 7 | import importlib 8 | import argcomplete 9 | 10 | import steamctl.commands 11 | from steamctl import __appname__ 12 | from steamctl.argparser import generate_parser, nested_print_usage 13 | 14 | _LOG = logging.getLogger(__appname__) 15 | 16 | def main(): 17 | # setup login config, before loading subparsers 18 | parser = generate_parser(pre=True) 19 | args, _ = parser.parse_known_args() 20 | 21 | logging.basicConfig( 22 | format='[%(levelname)s] %(name)s: %(message)s' if args.log_level == 'debug' else '[%(levelname)s] %(message)s', 23 | level=100 if args.log_level == 'quiet' else getattr(logging, args.log_level.upper()) 24 | ) 25 | 26 | # load subcommands 27 | for _, modname, ispkg in pkgutil.iter_modules(steamctl.commands.__path__): 28 | if ispkg: 29 | try: 30 | importlib.import_module('steamctl.commands.' + modname) 31 | except ImportError as exp: 32 | _LOG.error(str(exp)) 33 | 34 | # reload parser, and enable auto completion 35 | parser = generate_parser() 36 | argcomplete.autocomplete(parser) 37 | args, unknown_args = parser.parse_known_args() 38 | 39 | if unknown_args: 40 | _LOG.debug("Unknown args: %s", unknown_args) 41 | nested_print_usage(parser, args) 42 | print("%s: unrecognized arguments: %s" % (parser.prog, ' '.join(unknown_args)), file=sys.stderr) 43 | sys.exit(1) 44 | 45 | # process subcommand 46 | cmd_func = args._cmd_func 47 | 48 | # attempt to load submodule where the subcommand is located 49 | if isinstance(cmd_func, str): 50 | from importlib import import_module 51 | subpkg, func = cmd_func.split(':', 1) 52 | cmd_func = getattr(import_module(subpkg), func) 53 | 54 | _LOG.debug("Parsed args: %s", vars(args)) 55 | 56 | # execute subcommand 57 | if cmd_func: 58 | try: 59 | rcode = cmd_func(args=args) 60 | except KeyboardInterrupt: 61 | _LOG.debug('Interrupted with KeyboardInterrupt') 62 | rcode = 1 63 | except Exception as exp: 64 | from steam.exceptions import SteamError 65 | 66 | if isinstance(exp, SteamError): 67 | if args.log_level == 'debug': 68 | _LOG.exception(exp) 69 | else: 70 | _LOG.error(str(exp)) 71 | 72 | rcode = 1 73 | 74 | # unhandled exceptions 75 | else: 76 | raise 77 | else: 78 | _LOG.debug('_cmd_func attribute is missing') 79 | rcode = 1 80 | 81 | # ensure that we always output an appropriet return code 82 | if rcode is not None: 83 | sys.exit(rcode) 84 | 85 | if __name__ == '__main__': 86 | main() 87 | -------------------------------------------------------------------------------- /steamctl/commands/hlmaster/cmds.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | import gevent.socket 4 | from gevent.pool import Pool 5 | from gevent.queue import Queue 6 | from steam import game_servers as gs 7 | from steamctl.utils.format import print_table, fmt_duration 8 | gs.socket = gevent.socket 9 | 10 | 11 | # HELPERS 12 | _LOG = logging.getLogger(__name__) 13 | 14 | def parse_host(text): 15 | host, port = text.split(':', 1) 16 | return host, int(port) 17 | 18 | def get_info_short(host, port): 19 | shost = host + ':' + str(port) 20 | try: 21 | data = gs.a2s_info((host, port), timeout=1) 22 | except Exception as exp: 23 | return "{:<21} | Error: {}".format(shost, str(exp)) 24 | else: 25 | return "{shost:<21} | {name:<63} | {game} | {players:>2}/{max_players:>2} | {map:<20} | {_ping:>4.0f} ms".format( 26 | shost=shost, 27 | **data, 28 | ) 29 | 30 | # COMMANDS 31 | 32 | def cmd_hlmaster_query(args): 33 | task_pool = Pool(40) 34 | results = Queue() 35 | 36 | def run_query(args): 37 | try: 38 | for ip, port in gs.query_master(args.filter, max_servers=args.num_servers): 39 | if args.ip_only: 40 | print("%s:%s" % (ip, port)) 41 | else: 42 | results.put(task_pool.spawn(get_info_short, ip, port)) 43 | except Exception as exp: 44 | _LOG.error("query_master: Error: %s", str(exp)) 45 | 46 | results.put(StopIteration) 47 | 48 | task_pool.spawn(run_query, args) 49 | 50 | for result in results: 51 | print(result.get()) 52 | 53 | def cmd_hlmaster_info(args): 54 | host, port = parse_host(args.server) 55 | 56 | flags = [args.info, args.players, args.rules].count(True) 57 | 58 | if args.info or flags == 0: 59 | if flags > 1: 60 | print('--- {:-<60}'.format("Server Info ")) 61 | 62 | if args.short: 63 | print(get_info_short(host, port)) 64 | else: 65 | try: 66 | data = gs.a2s_info((host, port)) 67 | except Exception as exp: 68 | print("Error: {}".format(exp)) 69 | else: 70 | for pair in sorted(data.items()): 71 | print("{} = {}".format(*pair)) 72 | 73 | if args.players: 74 | if flags > 1: 75 | print('--- {:-<60}'.format("Players ")) 76 | 77 | try: 78 | plist = gs.a2s_players((host, port)) 79 | except Exception as exp: 80 | print("Error: {}".format(exp)) 81 | else: 82 | print_table([[ 83 | player['name'], 84 | str(player['score']), 85 | fmt_duration(player['duration']), 86 | ] for player in plist], 87 | ['Name', '>Score', '>Duration'], 88 | ) 89 | 90 | if args.rules: 91 | if flags > 1: 92 | print('--- {:-<60}'.format("Rules ")) 93 | 94 | try: 95 | rules = gs.a2s_rules((host, port)) 96 | except Exception as exp: 97 | print("Error: {}".format(exp)) 98 | else: 99 | for rule in rules.items(): 100 | print("{} = {}".format(*rule)) 101 | -------------------------------------------------------------------------------- /steamctl/commands/assistant/discovery_queue.py: -------------------------------------------------------------------------------- 1 | import gevent 2 | import gevent.monkey 3 | gevent.monkey.patch_socket() 4 | gevent.monkey.patch_select() 5 | gevent.monkey.patch_ssl() 6 | 7 | import logging 8 | from gevent.pool import Pool 9 | from contextlib import contextmanager 10 | from steamctl.clients import CachingSteamClient 11 | from steamctl.utils.web import make_requests_session 12 | from steam.client import EMsg, EResult 13 | 14 | import steam.client.builtins.web 15 | steam.client.builtins.web.make_requests_session = make_requests_session 16 | 17 | LOG = logging.getLogger(__name__) 18 | 19 | 20 | class SteamClient(CachingSteamClient): 21 | _LOG = logging.getLogger("SteamClient") 22 | 23 | def __init__(self, *args, **kwargs): 24 | CachingSteamClient.__init__(self, *args, **kwargs) 25 | 26 | self.on(self.EVENT_DISCONNECTED, self.__handle_disconnected) 27 | self.on(self.EVENT_RECONNECT, self.__handle_reconnect) 28 | self.on(EMsg.ClientItemAnnouncements, self.__handle_item_notification) 29 | 30 | def connect(self, *args, **kwargs): 31 | self._LOG.info("Connecting to Steam...") 32 | return CachingSteamClient.connect(self, *args, **kwargs) 33 | 34 | def __handle_disconnected(self): 35 | self._LOG.info("Disconnected from Steam") 36 | 37 | def __handle_reconnect(self, delay): 38 | if delay: 39 | self._LOG.info("Attemping reconnect in %s second(s)..", delay) 40 | 41 | def __handle_item_notification(self, msg): 42 | if msg.body.count_new_items == 100: 43 | self._LOG.info("Notification: over %s new items", msg.body.count_new_items) 44 | else: 45 | self._LOG.info("Notification: %s new item(s)", msg.body.count_new_items) 46 | 47 | @contextmanager 48 | def init_client(args): 49 | s = SteamClient() 50 | s.login_from_args(args) 51 | yield s 52 | s.disconnect() 53 | 54 | def cmd_assistant_discovery_queue(args): 55 | with init_client(args) as s: 56 | web = s.get_web_session() 57 | 58 | if not web: 59 | LOG.error("Failed to get web session") 60 | return 1 # error 61 | 62 | sessionid = web.cookies.get('sessionid', domain='store.steampowered.com') 63 | 64 | LOG.info("Generating new discovery queue...") 65 | 66 | try: 67 | data = web.post('https://store.steampowered.com/explore/generatenewdiscoveryqueue', {'sessionid': sessionid, 'queuetype': 0}).json() 68 | except Exception as exp: 69 | LOG.debug("Exception: %s", str(exp)) 70 | data = None 71 | 72 | if not isinstance(data, dict) or not data.get('queue', None): 73 | LOG.error("Invalid/empty discovery response") 74 | return 1 # error 75 | 76 | def explore_app(appid): 77 | for delay in (1,3,5,8,14): 78 | resp = web.post('https://store.steampowered.com/app/10', {'appid_to_clear_from_queue': appid, 'sessionid': sessionid}) 79 | 80 | if resp.status_code == 200: 81 | return True 82 | 83 | LOG.warning('Failed to explore app %s, retrying in %s second(s)', appid, delay) 84 | s.sleep(delay) 85 | 86 | return False 87 | 88 | pool = Pool(6) 89 | 90 | result = pool.imap(explore_app, data['queue']) 91 | 92 | if all(result): 93 | LOG.info("Discovery queue explored successfully") 94 | else: 95 | LOG.error("Failed to explore some apps, try again") 96 | return 1 #error 97 | 98 | -------------------------------------------------------------------------------- /steamctl/commands/authenticator/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | from steamctl.argparser import register_command 4 | from steamctl.utils.storage import UserDataDirectory 5 | 6 | epilog = """\ 7 | """ 8 | 9 | @register_command('authenticator', help='Manage Steam authenticators', epilog=epilog) 10 | def cmd_parser(cp): 11 | def print_help(*args, **kwargs): 12 | cp.print_help() 13 | 14 | cp.set_defaults(_cmd_func=print_help) 15 | 16 | sub_cp = cp.add_subparsers(metavar='', 17 | dest='subcommand', 18 | title='List of sub-commands', 19 | description='', 20 | ) 21 | 22 | scp = sub_cp.add_parser("add", help="Add authentictor to a Steam account") 23 | scp.add_argument('--force', action='store_true', 24 | help='Overwrite existing authenticator.' 25 | ) 26 | scp.add_argument('--from-secret', type=str, 27 | help='Provide the authenticator secret directly. Need to be base64 encoded.' 28 | ) 29 | scp.add_argument('account', type=str, help='Account name') 30 | scp.set_defaults(_cmd_func=__name__ + '.cmds:cmd_authenticator_add') 31 | 32 | scp = sub_cp.add_parser("remove", help="Remove an authenticator") 33 | scp.add_argument('--force', action='store_true', 34 | help='Delete local secrets only. Does not attempt to remove from account. ' 35 | 'This may lead to losing access to the account if the authenticator ' 36 | 'is still attached to an account.' 37 | ) 38 | scp.add_argument('account', type=str, help='Account name') 39 | scp.set_defaults(_cmd_func=__name__ + '.cmds:cmd_authenticator_remove') 40 | 41 | scp = sub_cp.add_parser("list", help="List all authenticators") 42 | scp.add_argument('--utc', action='store_true', help='Show datetime in UTC') 43 | scp.set_defaults(_cmd_func=__name__ + '.cmds:cmd_authenticator_list') 44 | 45 | def account_autocomplete(prefix, parsed_args, **kwargs): 46 | return [userfile.filename[:-5] 47 | for userfile in UserDataDirectory('authenticator').iter_files('*.json')] 48 | 49 | scp = sub_cp.add_parser("status", help="Query Steam Guard status for account") 50 | scp.add_argument('account', type=str, help='Account name').completer = account_autocomplete 51 | scp.set_defaults(_cmd_func=__name__ + '.cmds:cmd_authenticator_status') 52 | 53 | scp = sub_cp.add_parser("code", help="Generate auth code") 54 | scp.add_argument('account', type=str, help='Account name').completer = account_autocomplete 55 | scp.set_defaults(_cmd_func=__name__ + '.cmd_code:cmd_authenticator_code') 56 | 57 | scp = sub_cp.add_parser("qrcode", help="Generate QR code") 58 | scp.add_argument('--compat', action='store_true', 59 | help='Alternative QR code mode (e.g. otpauth://totp/....). ' 60 | 'The default mode is custom format (otpath://steam/...), and requires custom Steam support in your 2FA app. ' 61 | 'Apps with support: Aegis and andOTP.' 62 | ) 63 | scp.add_argument('--invert', action='store_true', 64 | help='Invert QR code colors. Try if app fails to scan the code.' 65 | ) 66 | scp.add_argument('account', type=str, help='Account name').completer = account_autocomplete 67 | scp.set_defaults(_cmd_func=__name__ + '.cmd_code:cmd_authenticator_qrcode') 68 | 69 | -------------------------------------------------------------------------------- /steamctl/commands/ugc/gcmds.py: -------------------------------------------------------------------------------- 1 | import gevent 2 | import gevent.monkey 3 | gevent.monkey.patch_socket() 4 | gevent.monkey.patch_select() 5 | gevent.monkey.patch_ssl() 6 | 7 | import os 8 | import sys 9 | import logging 10 | from io import open 11 | from contextlib import contextmanager 12 | from steam import webapi 13 | from steam.exceptions import SteamError 14 | from steam.client import EResult, EMsg, MsgProto, SteamID 15 | from steamctl.clients import CachingSteamClient 16 | from steamctl.utils.storage import ensure_dir, sanitizerelpath 17 | from steamctl.utils.web import make_requests_session 18 | from steamctl.utils.format import fmt_size 19 | from steamctl.utils.tqdm import tqdm, fake_tqdm 20 | from steamctl.commands.webapi import get_webapi_key 21 | 22 | webapi._make_requests_session = make_requests_session 23 | 24 | LOG = logging.getLogger(__name__) 25 | 26 | 27 | class UGCSteamClient(CachingSteamClient): 28 | def get_ugc_details(self, ugc_id): 29 | if 0 > ugc_id > 2**64: 30 | raise SteamError("Invalid UGC ID") 31 | 32 | result = self.send_job_and_wait(MsgProto(EMsg.ClientUFSGetUGCDetails), {'hcontent': ugc_id}, timeout=5) 33 | 34 | if not result or result.eresult != EResult.OK: 35 | raise SteamError("Failed getting UGC details", EResult(result.eresult) if result else EResult.Timeout) 36 | 37 | return result 38 | 39 | @contextmanager 40 | def init_client(args): 41 | s = UGCSteamClient() 42 | s.login_from_args(args) 43 | yield s 44 | s.disconnect() 45 | 46 | def cmd_ugc_info(args): 47 | with init_client(args) as s: 48 | ugcdetails = s.get_ugc_details(args.ugc) 49 | user = s.get_user(ugcdetails.steamid_creator) 50 | 51 | print("File URL:", ugcdetails.url) 52 | print("Filename:", ugcdetails.filename) 53 | print("File size:", fmt_size(ugcdetails.file_size)) 54 | print("SHA1:", ugcdetails.file_encoded_sha1) 55 | print("App ID:", ugcdetails.app_id) 56 | print("Creator:", user.name) 57 | print("Creator Profile:", SteamID(ugcdetails.steamid_creator).community_url) 58 | 59 | 60 | def cmd_ugc_download(args): 61 | with init_client(args) as s: 62 | ugcdetails = s.get_ugc_details(args.ugc) 63 | 64 | return download_via_url(args, ugcdetails.url, ugcdetails.filename) 65 | 66 | 67 | def download_via_url(args, url, filename): 68 | sess = make_requests_session() 69 | fstream = sess.get(url, stream=True) 70 | total_size = int(fstream.headers.get('Content-Length', 0)) 71 | 72 | relpath = sanitizerelpath(filename) 73 | 74 | if args.no_directories: 75 | relpath = os.path.basename(relpath) 76 | 77 | relpath = os.path.join(args.output, relpath) 78 | 79 | filepath = os.path.abspath(relpath) 80 | ensure_dir(filepath) 81 | 82 | with open(filepath, 'wb') as fp: 83 | if not args.no_progress and sys.stderr.isatty(): 84 | pbar = tqdm(total=total_size, mininterval=0.5, maxinterval=1, miniters=1024**3*10, unit='B', unit_scale=True) 85 | gevent.spawn(pbar.gevent_refresh_loop) 86 | else: 87 | pbar = fake_tqdm() 88 | 89 | # LOG.info('Downloading to {} ({})'.format( 90 | # relpath, 91 | # fmt_size(total_size) if total_size else 'Unknown size', 92 | # )) 93 | 94 | for chunk in iter(lambda: fstream.raw.read(8388608), b''): 95 | fp.write(chunk) 96 | pbar.update(len(chunk)) 97 | 98 | pbar.close() 99 | 100 | -------------------------------------------------------------------------------- /steamctl/commands/apps/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | import argparse 3 | from steamctl.argparser import register_command 4 | from steamctl.utils.storage import UserDataFile, UserCacheFile 5 | from argcomplete import warn 6 | 7 | 8 | epilog = """\ 9 | 10 | """ 11 | 12 | @register_command('apps', help='Get information about apps', epilog=epilog) 13 | def cmd_parser(cp): 14 | def print_help(*args, **kwargs): 15 | cp.print_help() 16 | 17 | cp.set_defaults(_cmd_func=print_help) 18 | 19 | sub_cp = cp.add_subparsers(metavar='', 20 | dest='subcommand', 21 | title='List of sub-commands', 22 | description='', 23 | ) 24 | 25 | # ---- activate_key 26 | scp_l = sub_cp.add_parser("activate_key", help="Activate key(s) on account") 27 | scp_l.add_argument('keys', metavar='GameKey', nargs='+', type=str, help='Key(s) to activate') 28 | scp_l.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_apps_activate_key') 29 | 30 | # -------------- SUBCOMMAND ---------------------- 31 | 32 | # ---- licenses 33 | lcp = sub_cp.add_parser("licenses", help="Manage licenses", description="Manage licenses") 34 | 35 | def print_help(*args, **kwargs): 36 | lcp.print_help() 37 | 38 | lcp.set_defaults(_cmd_func=print_help) 39 | 40 | lsub_cp = lcp.add_subparsers(metavar='', 41 | dest='subcommand2', 42 | title='List of sub-commands', 43 | description='', 44 | ) 45 | 46 | # ---- licenses list 47 | scp_l = lsub_cp.add_parser("list", help="List owned or all apps") 48 | scp_l.add_argument('--app', type=int, nargs='*', help='Only licenses granting these app ids') 49 | 50 | def completer_billingtype(prefix, parsed_args, **kwargs): 51 | from steam.enums import EBillingType 52 | return [bt.name for bt in EBillingType] 53 | 54 | scp_l.add_argument('--billingtype', type=str, nargs='*', metavar='BT', 55 | help='Only licenses of billing type (e.g. ActivationCode, Complimentary, FreeOnDemand)', 56 | ).completer = completer_billingtype 57 | 58 | scp_l.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_apps_licenses_list') 59 | 60 | scp_l = lsub_cp.add_parser("add", help="Add free package license(s)") 61 | scp_l.add_argument('pkg_ids', metavar='PackageID', nargs='+', type=int, help='Package ID to add') 62 | scp_l.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_apps_licenses_add') 63 | 64 | scp_l = lsub_cp.add_parser("remove", help="Remove free package license(s)") 65 | scp_l.add_argument('pkg_ids', metavar='PackageID', nargs='+', type=int, help='Package ID to remove') 66 | scp_l.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_apps_licenses_remove') 67 | 68 | # -------------- END SUBCOMMAND ---------------------- 69 | 70 | # ---- add 71 | scp_l = sub_cp.add_parser("add", help="Add free app(s)") 72 | scp_l.add_argument('app_ids', metavar='AppID', nargs='+', type=int, help='App ID to add') 73 | scp_l.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_apps_add') 74 | 75 | # ---- list 76 | scp_l = sub_cp.add_parser("list", help="List owned or all apps") 77 | scp_l.add_argument('--all', action='store_true', help='List all apps on Steam') 78 | scp_l.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_apps_list') 79 | 80 | # ---- product_info 81 | scp_l = sub_cp.add_parser("product_info", help="Show product info for app") 82 | scp_l.add_argument('--skip-licenses', action='store_true', help='Skip license check') 83 | scp_l.add_argument('app_ids', nargs='+', metavar='AppID', type=int, help='AppID to query') 84 | scp_l.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_apps_product_info') 85 | 86 | # ---- item_def 87 | scp_l = sub_cp.add_parser("item_def", help="Get item definitions for app") 88 | scp_l.add_argument('app_id', metavar='AppID', type=int, help='AppID to query') 89 | scp_l.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_apps_item_def') 90 | -------------------------------------------------------------------------------- /steamctl/commands/workshop/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from steamctl.argparser import register_command 3 | from steamctl.utils.storage import UserDataFile, UserCacheFile 4 | from argcomplete import warn 5 | 6 | 7 | epilog = """\ 8 | 9 | Examples: 10 | 11 | Search for any item in the workshop: 12 | {prog} workshop search dust 2 13 | 14 | Search for Dota 2 custom games: 15 | {prog} workshop search --appid 570 --tag 'Custom Game' auto chess 16 | 17 | Downlaod workshop files, such as Dota 2 custom maps or CSGO maps: 18 | {prog} workshop download 12345678 19 | 20 | """ 21 | 22 | @register_command('workshop', help='Search and download workshop items', epilog=epilog) 23 | def cmd_parser(cp): 24 | def print_help(*args, **kwargs): 25 | cp.print_help() 26 | 27 | cp.set_defaults(_cmd_func=print_help) 28 | 29 | sub_cp = cp.add_subparsers(metavar='', 30 | dest='subcommand', 31 | title='List of sub-commands', 32 | description='', 33 | ) 34 | 35 | scp_q = sub_cp.add_parser("search", help="Search the workshop") 36 | scp_q.add_argument('--apikey', type=str, help='WebAPI key to use') 37 | scp_q.add_argument('-a', '--appid', type=int, help='Filter by AppID') 38 | scp_q.add_argument('-t', '--tag', type=str, action='append', help='Filter by tags') 39 | scp_q.add_argument('-d', '--downloable', action='store_true', help='Show only downloable results') 40 | scp_q.add_argument('--match_all_tags', action='store_true', help='All tags must match') 41 | scp_q.add_argument('-n', '--numresults', type=int, default=20, help='Number of results (default: 20)') 42 | scp_q.add_argument('search_text', nargs='+', metavar='search_text', type=str, help='Text to search in the workshop') 43 | scp_q.set_defaults(_cmd_func=__name__ + '.cmds:cmd_workshop_search') 44 | 45 | scp_i = sub_cp.add_parser("info", help="Get all details for a workshop item") 46 | scp_i.add_argument('--apikey', type=str, help='WebAPI key to use') 47 | scp_i.add_argument('-a', '--appid', type=int, help='Filter by AppID') 48 | scp_i.add_argument('id', metavar='id', type=str, help='Workshop ID') 49 | scp_i.set_defaults(_cmd_func=__name__ + '.cmds:cmd_workshop_info') 50 | 51 | scp_dl = sub_cp.add_parser("download", help="Download a workshop item") 52 | scp_dl.add_argument('--apikey', type=str, help='WebAPI key to use') 53 | scp_dl.add_argument('--cell_id', type=int, help='Cell ID to use for download') 54 | scp_dl.add_argument('-o', '--output', type=str, default='', help='Path to directory for the downloaded files (default: cwd)') 55 | scp_dl.add_argument('-nd', '--no-directories', action='store_true', help='Do not create directories') 56 | scp_dl.add_argument('-np', '--no-progress', action='store_true', help='Do not create directories') 57 | scp_dl.add_argument('id', type=int, help='Workshop item ID') 58 | scp_dl.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_workshop_download') 59 | 60 | scp_s = sub_cp.add_parser("subscribe", help="Subscribe to workshop items") 61 | scp_s.add_argument('--apikey', type=str, help='WebAPI key to use') 62 | scp_s.add_argument('workshop_ids', metavar='workshop_id', type=int, nargs='+', help='Workshop ID') 63 | scp_s.set_defaults(_cmd_func=__name__ + '.cmds:cmd_workshop_subscribe') 64 | 65 | scp_s = sub_cp.add_parser("unsubscribe", help="Unsubscribe to workshop items") 66 | scp_s.add_argument('--apikey', type=str, help='WebAPI key to use') 67 | scp_s.add_argument('workshop_ids', metavar='workshop_id', type=int, nargs='+', help='Workshop ID') 68 | scp_s.set_defaults(_cmd_func=__name__ + '.cmds:cmd_workshop_unsubscribe') 69 | 70 | scp_s = sub_cp.add_parser("favorite", help="Favourite workshop items") 71 | scp_s.add_argument('--apikey', type=str, help='WebAPI key to use') 72 | scp_s.add_argument('workshop_ids', metavar='workshop_id', type=int, nargs='+', help='Workshop ID') 73 | scp_s.set_defaults(_cmd_func=__name__ + '.cmds:cmd_workshop_favorite') 74 | 75 | scp_s = sub_cp.add_parser("unfavorite", help="Unfavourite workshop items") 76 | scp_s.add_argument('--apikey', type=str, help='WebAPI key to use') 77 | scp_s.add_argument('workshop_ids', metavar='workshop_id', type=int, nargs='+', help='Workshop ID') 78 | scp_s.set_defaults(_cmd_func=__name__ + '.cmds:cmd_workshop_unfavorite') 79 | -------------------------------------------------------------------------------- /steamctl/argparser.py: -------------------------------------------------------------------------------- 1 | 2 | from steamctl import __appname__, __version__ 3 | from types import FunctionType 4 | from collections import OrderedDict 5 | import os 6 | import sys 7 | import argparse 8 | import argcomplete 9 | 10 | epilog = """\ 11 | Tab Completion 12 | 13 | Additional steps are needed to activate bash tab completion. 14 | See https://argcomplete.readthedocs.io/en/latest/#global-completion 15 | 16 | To enable globally run: 17 | activate-global-python-argcomplete 18 | 19 | To enable for the current session run: 20 | eval "$(register-python-argcomplete steamctl)" 21 | 22 | The above code can be added to .bashrc to persist between sessions for the user. 23 | """ 24 | 25 | class ActionVersionsReport(argparse.Action): 26 | def __init__(self, *args, **kwargs): 27 | super().__init__(nargs=0, help='show detailed versions report and exit', **kwargs) 28 | 29 | def __call__(self, *args, **kwargs): 30 | from steamctl.utils.versions_report import versions_report 31 | versions_report() 32 | sys.exit(0) 33 | 34 | _subcommands = OrderedDict() 35 | 36 | def register_command(command, **kwargs): 37 | if isinstance(command, FunctionType): 38 | raise ValueError("Subcommand name not specified") 39 | if command in _subcommands: 40 | raise ValueError("There is already a subcommand registered with name: {}".format(command)) 41 | 42 | def func_wrap(func): 43 | _subcommands[command] = func, kwargs 44 | 45 | return func_wrap 46 | 47 | def generate_parser(pre=False): 48 | # pre parser only handles a couple of arguements to handle basics 49 | # full parse is generated once all modules have been loaded 50 | if pre: 51 | parser = argparse.ArgumentParser( 52 | formatter_class=argparse.RawDescriptionHelpFormatter, 53 | add_help=False, 54 | ) 55 | else: 56 | parser = argparse.ArgumentParser( 57 | formatter_class=argparse.RawDescriptionHelpFormatter, 58 | epilog=epilog, 59 | ) 60 | 61 | parser.prog = __appname__ 62 | 63 | parser.add_argument('--version', action='version', version="{} {}".format(__appname__, __version__)) 64 | parser.add_argument('--versions-report', action=ActionVersionsReport) 65 | parser.add_argument( 66 | '-l', '--log_level', choices=['quiet','info','debug'], 67 | default=os.getenv('STEAMCTL_LOGLEVEL', 'info'), 68 | help='Set logging level' 69 | ) 70 | 71 | # return pre parser 72 | if pre: 73 | return parser 74 | 75 | # fully configure argument parser from here on 76 | def print_help(*args, **kwargs): 77 | parser.print_help() 78 | 79 | parser.add_argument('--anonymous', action='store_true', help='Anonymous Steam login') 80 | parser.add_argument( 81 | '--user', type=str, default=os.getenv('STEAMCTL_USER', None), 82 | help='Username for Steam login' 83 | ) 84 | parser.add_argument( 85 | '--password', type=str, default=os.getenv('STEAMCTL_PASSWORD', None), 86 | help='Password for Steam login' 87 | ) 88 | parser.set_defaults(_cmd_func=print_help) 89 | 90 | if _subcommands: 91 | subparsers = parser.add_subparsers( 92 | metavar='', 93 | dest='command', 94 | title='List of commands', 95 | description='', 96 | ) 97 | 98 | for subcommand, (func, kwargs) in sorted(_subcommands.items(), key=lambda x: x[0]): 99 | # lets description and epilog maintain identation 100 | kwargs.setdefault('formatter_class', argparse.RawDescriptionHelpFormatter) 101 | 102 | if '{prog}' in kwargs.get('epilog', ''): 103 | kwargs['epilog'] = kwargs['epilog'].format(prog=parser.prog) 104 | if 'description' not in kwargs: 105 | kwargs['description'] = kwargs['help'] 106 | 107 | sp = subparsers.add_parser(subcommand, **kwargs) 108 | func(sp) 109 | 110 | return parser 111 | 112 | 113 | def nested_print_usage(parser, args): 114 | parser.print_usage() 115 | 116 | for action in parser._actions: 117 | if isinstance(action, argparse._SubParsersAction): 118 | if getattr(args, action.dest): 119 | nested_print_usage(action.choices[getattr(args, action.dest)], args) 120 | -------------------------------------------------------------------------------- /steamctl/commands/workshop/gcmds.py: -------------------------------------------------------------------------------- 1 | import gevent 2 | import gevent.monkey 3 | gevent.monkey.patch_socket() 4 | gevent.monkey.patch_select() 5 | gevent.monkey.patch_ssl() 6 | 7 | from gevent.pool import Pool as GPool 8 | 9 | import os 10 | import sys 11 | import logging 12 | from io import open 13 | from steam import webapi 14 | from steam.exceptions import SteamError, ManifestError 15 | from steam.enums import EResult 16 | from steamctl.utils.storage import ensure_dir, sanitizerelpath 17 | from steamctl.utils.web import make_requests_session 18 | from steamctl.utils.format import fmt_size 19 | from steamctl.utils.tqdm import tqdm, fake_tqdm 20 | from steamctl.commands.webapi import get_webapi_key 21 | from steamctl.commands.ugc.gcmds import download_via_url 22 | 23 | webapi._make_requests_session = make_requests_session 24 | 25 | LOG = logging.getLogger(__name__) 26 | 27 | def cmd_workshop_download(args): 28 | apikey = args.apikey or get_webapi_key() 29 | 30 | if not apikey: 31 | LOG.error("No WebAPI key set. See: steamctl webapi -h") 32 | return 1 #error 33 | 34 | params = { 35 | 'key': apikey, 36 | 'publishedfileids': [args.id], 37 | } 38 | 39 | 40 | try: 41 | pubfile = webapi.get('IPublishedFileService', 'GetDetails', 42 | params=params, 43 | )['response']['publishedfiledetails'][0] 44 | except Exception as exp: 45 | LOG.error("Query failed: %s", str(exp)) 46 | if getattr(exp, 'response', None): 47 | LOG.error("Response body: %s", exp.response.text) 48 | return 1 # error 49 | 50 | if pubfile['result'] != EResult.OK: 51 | LOG.error("Error accessing %s: %r", pubfile['publishedfileid'], EResult(pubfile['result'])) 52 | return 1 # error 53 | 54 | LOG.info("Workshop item: (%s) %s" % (pubfile['publishedfileid'], pubfile['title'].strip())) 55 | LOG.info("App: (%s) %s" % (pubfile['consumer_appid'], pubfile['app_name'])) 56 | 57 | if pubfile.get('file_url'): 58 | # reuse 'ugc download' function 59 | return download_via_url(args, pubfile['file_url'], pubfile['filename']) 60 | elif pubfile.get('hcontent_file'): 61 | return download_via_steampipe(args, pubfile) 62 | else: 63 | LOG.error("This workshop file is not downloable") 64 | return 1 65 | 66 | 67 | def download_via_steampipe(args, pubfile): 68 | from steamctl.clients import CachingSteamClient 69 | 70 | s = CachingSteamClient() 71 | if args.cell_id is not None: 72 | s.cell_id = args.cell_id 73 | cdn = s.get_cdnclient() 74 | 75 | 76 | key = pubfile['consumer_appid'], pubfile['consumer_appid'], pubfile['hcontent_file'] 77 | manifest = cdn.get_cached_manifest(*key) 78 | 79 | # only login if we dont have depot decryption key 80 | if ( 81 | not manifest 82 | or ( 83 | manifest.filenames_encrypted 84 | and int(pubfile['consumer_appid']) not in cdn.depot_keys 85 | ) 86 | ): 87 | result = s.login_from_args(args) 88 | 89 | if result == EResult.OK: 90 | LOG.info("Login to Steam successful") 91 | else: 92 | LOG.error("Failed to login: %r" % result) 93 | return 1 # error 94 | 95 | if not manifest or manifest.filenames_encrypted: 96 | try: 97 | manifest_code = cdn.get_manifest_request_code(*key) 98 | manifest = cdn.get_manifest(*key, manifest_request_code=manifest_code) 99 | except ManifestError as exc: 100 | LOG.error(str(exc)) 101 | return 1 # error 102 | 103 | manifest.name = pubfile['title'] 104 | 105 | LOG.debug("Got manifest: %r", manifest) 106 | LOG.info("File manifest acquired (%s)", pubfile['hcontent_file']) 107 | 108 | if not args.no_progress and sys.stderr.isatty(): 109 | pbar = tqdm(total=manifest.size_original, mininterval=0.5, maxinterval=1, miniters=1024**3*10, unit='B', unit_scale=True) 110 | gevent.spawn(pbar.gevent_refresh_loop) 111 | else: 112 | pbar = fake_tqdm() 113 | 114 | tasks = GPool(4) 115 | 116 | for mfile in manifest: 117 | if not mfile.is_file: 118 | continue 119 | tasks.spawn(mfile.download_to, args.output, 120 | no_make_dirs=args.no_directories, 121 | pbar=pbar) 122 | 123 | # wait on all downloads to finish 124 | tasks.join() 125 | 126 | # clean and exit 127 | pbar.close() 128 | cdn.save_cache() 129 | s.disconnect() 130 | 131 | LOG.info("Download complete.") 132 | -------------------------------------------------------------------------------- /steamctl/commands/cloud/gcmds.py: -------------------------------------------------------------------------------- 1 | import gevent 2 | import gevent.monkey 3 | gevent.monkey.patch_socket() 4 | gevent.monkey.patch_select() 5 | gevent.monkey.patch_ssl() 6 | 7 | from gevent.pool import Pool as GPool 8 | 9 | import re 10 | import os 11 | import sys 12 | import logging 13 | from contextlib import contextmanager 14 | from steam.exceptions import SteamError 15 | from steam.client import EResult, EMsg, MsgProto, SteamID 16 | from steamctl.clients import CachingSteamClient 17 | from steamctl.utils.web import make_requests_session 18 | from steamctl.utils.tqdm import tqdm, fake_tqdm 19 | from steamctl.utils.format import fmt_size 20 | from steamctl.utils.storage import ensure_dir, sanitizerelpath 21 | from steamctl.utils.apps import get_app_names 22 | 23 | LOG = logging.getLogger(__name__) 24 | 25 | 26 | @contextmanager 27 | def init_client(args): 28 | s = CachingSteamClient() 29 | s.login_from_args(args) 30 | yield s 31 | s.disconnect() 32 | 33 | 34 | def get_cloud_files(s, app_id): 35 | job_id = s.send_um('Cloud.EnumerateUserFiles#1', 36 | {'appid': app_id, 37 | 'extended_details': True, 38 | }) 39 | 40 | files = [] 41 | total_files, n_files, total_size = None, 0, 0 42 | 43 | while total_files != n_files: 44 | msg = s.wait_msg(job_id, timeout=10) 45 | 46 | if not msg: 47 | raise SteamError("Failed listing UFS files", EResult.Timeout) 48 | if msg.header.eresult != EResult.OK: 49 | raise SteamError("Failed listing UFS files", EResult(msg.header.eresult)) 50 | 51 | total_files = msg.body.total_files 52 | n_files += len(msg.body.files) 53 | 54 | for entry in msg.body.files: 55 | files.append(entry) 56 | total_size += entry.file_size 57 | 58 | return files, total_files, total_size 59 | 60 | def cmd_cloud_list(args): 61 | with init_client(args) as s: 62 | files, n_files, total_size = get_cloud_files(s, args.app_id) 63 | 64 | for entry in files: 65 | if not args.long: 66 | print(entry.filename) 67 | else: 68 | print("{} - size:{:,d} sha1:{}".format( 69 | entry.filename, 70 | entry.file_size, 71 | entry.file_sha, 72 | ) 73 | ) 74 | 75 | def cmd_cloud_list_apps(args): 76 | with init_client(args) as s: 77 | msg = s.send_um_and_wait('Cloud.EnumerateUserApps#1', timeout=10) 78 | 79 | if msg is None or msg.body is None: 80 | return 1 # error 81 | 82 | app_names = get_app_names() 83 | 84 | for app in msg.body.apps: 85 | print("{} - {} - Files: {} Size: {}".format( 86 | app.appid, 87 | app_names.get(app.appid, f'Unknown App {app.appid}'), 88 | app.totalcount, 89 | fmt_size(app.totalsize), 90 | ) 91 | ) 92 | 93 | def download_file(args, sess, file, pbar_size, pbar_files): 94 | fstream = sess.get(file.url, stream=True) 95 | filename = file.filename 96 | 97 | if fstream.status_code != 200: 98 | LOG.error("Failed to download: {}".format(filename)) 99 | return 1 # error 100 | 101 | relpath = sanitizerelpath(filename) 102 | # ensure there is a / after %vars%, and replace % with _ 103 | relpath = re.sub(r'^%([A-Za-z0-9]+)%', r'_\1_/', relpath) 104 | 105 | relpath = os.path.join(args.output, relpath) 106 | 107 | filepath = os.path.abspath(relpath) 108 | ensure_dir(filepath) 109 | 110 | with open(filepath, 'wb') as fp: 111 | for chunk in iter(lambda: fstream.raw.read(8388608), b''): 112 | fp.write(chunk) 113 | pbar_size.update(len(chunk)) 114 | 115 | pbar_files.update(1) 116 | 117 | def cmd_cloud_download(args): 118 | with init_client(args) as s: 119 | files, total_files, total_size = get_cloud_files(s, args.app_id) 120 | 121 | if not args.no_progress and sys.stderr.isatty(): 122 | pbar = tqdm(desc='Data ', mininterval=0.5, maxinterval=1, miniters=1024**3*10, total=total_size, unit='B', unit_scale=True) 123 | pbar2 = tqdm(desc='Files', mininterval=0.5, maxinterval=1, miniters=10, total=total_files, position=1, unit=' file', unit_scale=False) 124 | gevent.spawn(pbar.gevent_refresh_loop) 125 | gevent.spawn(pbar2.gevent_refresh_loop) 126 | else: 127 | pbar = fake_tqdm() 128 | pbar2 = fake_tqdm() 129 | 130 | tasks = GPool(6) 131 | sess = make_requests_session() 132 | 133 | for entry in files: 134 | tasks.spawn(download_file, args, sess, entry, pbar, pbar2) 135 | 136 | tasks.join() 137 | 138 | pbar.refresh() 139 | pbar2.refresh() 140 | pbar.close() 141 | -------------------------------------------------------------------------------- /steamctl/commands/webapi/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | from steamctl.argparser import register_command 4 | from steamctl.utils.storage import UserDataFile, UserCacheFile 5 | from argcomplete import warn 6 | 7 | 8 | def get_webapi_key(): 9 | return UserDataFile('apikey.txt').read_text() 10 | 11 | epilog = """\ 12 | Steam Web API Key: 13 | 14 | To get a key login with a Steam account and visit https://steamcommunity.com/dev/apikey 15 | You can save the key and have it applied automatically by running: 16 | {prog} webapi set-key KEY 17 | 18 | Alternatively, you can provide for one-off calls: 19 | {prog} webapi call --apikey YOURKEY ISteamNews.GetNewsForApp appid=570 count=1 20 | 21 | Examples: 22 | 23 | List all available Web API endpoints: 24 | {prog} webapi list 25 | 26 | Search for partial of the name: 27 | {prog} webapi list cmlist 28 | 29 | View endpoint parameters by supplying the --verbose flag: 30 | {prog} webapi list -v cmlist 31 | 32 | Call an Web API endpoint: 33 | {prog} webapi call ISteamNews.GetNewsForApp appid=570 count=1 34 | """ 35 | 36 | @register_command('webapi', help='Access to WebAPI', epilog=epilog) 37 | def cmd_parser(cp): 38 | def print_help(*args, **kwargs): 39 | cp.print_help() 40 | 41 | cp.set_defaults(_cmd_func=print_help) 42 | 43 | sub_cp = cp.add_subparsers(metavar='', 44 | dest='subcommand', 45 | title='List of sub-commands', 46 | description='', 47 | ) 48 | 49 | scp_key = sub_cp.add_parser("set-key", help="Set WebAPI key") 50 | scp_key.add_argument('key', nargs='?', type=str, help='WebAPI key to save') 51 | scp_key.set_defaults(_cmd_func=__name__ + '.cmds:cmd_webapi_set') 52 | 53 | scp_key = sub_cp.add_parser("clear-key", help="Remove saved key") 54 | scp_key.set_defaults(_cmd_func=__name__ + '.cmds:cmd_webapi_clear') 55 | 56 | scp_list = sub_cp.add_parser("list", help="List all available WebAPI endpoints") 57 | scp_list.add_argument('--apikey', type=str, help='WebAPI key to use') 58 | scp_list.add_argument('--format', choices=['text', 'json', 'json_line', 'vdf', 'xml'], default='text', help='Output format') 59 | scp_list.add_argument('-v' , '--verbose', action='store_true', help='List endpoint parameters') 60 | scp_list.add_argument('search', nargs='?', type=str, help='Text to search in the endpoint name. Only works with \'text\' format.') 61 | scp_list.set_defaults(_cmd_func=__name__ + '.cmds:cmd_webapi_list') 62 | 63 | def endpoint_autocomplete(prefix, parsed_args, **kwargs): 64 | interfaces = UserCacheFile('webapi_interfaces.json').read_json() 65 | 66 | if not interfaces: 67 | warn("To enable endpoint tab completion run: steamctl webapi list") 68 | return [] 69 | 70 | return ('{}.{}'.format(a['name'], b['name']) for a in interfaces for b in a['methods']) 71 | 72 | def parameter_autocomplete(prefix, parsed_args, **kwargs): 73 | interfaces = UserCacheFile('webapi_interfaces.json').read_json() 74 | 75 | if not interfaces: 76 | warn("To enable endpoint tab completion run: steamctl webapi list") 77 | return [] 78 | 79 | parameters = [] 80 | ainterface, amethod = parsed_args.endpoint.split('.', 1) 81 | 82 | for interface in filter(lambda a: a['name'] == ainterface, interfaces): 83 | for method in filter(lambda b: b['name'] == amethod, interface['methods']): 84 | for param in method['parameters']: 85 | if param['name'][-3:] == '[0]': 86 | param['name'] = param['name'][:-3] 87 | 88 | parameters.append(param['name'] + '=') 89 | break 90 | 91 | return parameters 92 | 93 | 94 | scp_call = sub_cp.add_parser("call", help="Call WebAPI endpoint") 95 | scp_call.add_argument('--apikey', type=str, help='WebAPI key to use') 96 | scp_call.add_argument('--format', choices=['json', 'json_line', 'vdf', 'xml'], default='json', 97 | help='Output format') 98 | scp_call.add_argument('--method', choices=['GET', 'POST'], type=str, 99 | help='HTTP method to use') 100 | scp_call.add_argument('--version', type=int, 101 | help='Method version') 102 | scp_call.add_argument('endpoint', type=str, 103 | help='WebAPI endpoint name (eg ISteamWebAPIUtil.GetSupportedAPIList)')\ 104 | .completer = endpoint_autocomplete 105 | scp_call.add_argument('params', metavar='KEY=VAL', nargs='*', 106 | type=lambda x: x.split('=', 1), default={}, 107 | help='param=value pairs to pass to endpoint')\ 108 | .completer = parameter_autocomplete 109 | scp_call.set_defaults(_cmd_func=__name__ + '.cmds:cmd_webapi_call') 110 | -------------------------------------------------------------------------------- /steamctl/commands/webapi/cmds.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | import sys 4 | import json 5 | from steam import webapi 6 | from steamctl.utils.storage import UserDataFile, UserCacheFile 7 | from steamctl.utils.web import make_requests_session 8 | from steamctl.commands.webapi import get_webapi_key 9 | 10 | webapi._make_requests_session = make_requests_session 11 | 12 | LOG = logging.getLogger(__name__) 13 | 14 | def cmd_webapi_set(args): 15 | keyfile = UserDataFile('apikey.txt') 16 | 17 | if args.key: 18 | keyfile.write_text(args.key) 19 | 20 | if keyfile.exists(): 21 | print("Current key:", keyfile.read_text()) 22 | else: 23 | print("Current key: NOT SET") 24 | 25 | def cmd_webapi_clear(args): 26 | UserDataFile('apikey.txt').remove() 27 | 28 | def cmd_webapi_list(args): 29 | params = {} 30 | params.setdefault('key', args.apikey or get_webapi_key()) 31 | 32 | if args.format !='text': 33 | params['format'] = 'json' if args.format == 'json_line' else args.format 34 | params['raw'] = True 35 | 36 | try: 37 | resp = webapi.get('ISteamWebAPIUtil', 'GetSupportedAPIList', params=params) 38 | 39 | if args.format == 'text': 40 | interfaces = resp['apilist']['interfaces'] 41 | UserCacheFile('webapi_interfaces.json').write_json(interfaces) 42 | except Exception as exp: 43 | LOG.error("GetSupportedAPIList failed: %s", str(exp)) 44 | if getattr(exp, 'response', None): 45 | LOG.error("Response body: %s", exp.response.text) 46 | return 1 # error 47 | 48 | if args.format != 'text': 49 | if args.format == 'json': 50 | json.dump(json.loads(resp), sys.stdout, indent=4, sort_keys=True) 51 | print('') 52 | else: 53 | print(resp) 54 | return 55 | 56 | for interface in interfaces: 57 | for method in interface['methods']: 58 | if args.search: 59 | if args.search.lower() not in "{}.{}".format(interface['name'], method['name']).lower(): 60 | continue 61 | 62 | out = "{:>4} {}.{} v{} {}".format( 63 | method['httpmethod'], 64 | interface['name'], 65 | method['name'], 66 | method['version'], 67 | ('- ' + method['description']) if 'description' in method else '', 68 | ) 69 | 70 | print(out) 71 | 72 | if args.verbose: 73 | for param in method.get('parameters', []): 74 | name = param['name'] 75 | if name[-3:] == '[0]': 76 | name = name[:-3] 77 | 78 | print(" {:<10} {}{:<10} {}".format( 79 | param['type'], 80 | ' ' if param['optional'] else '*', 81 | name, 82 | ('- ' + param['description']) if 'description' in param else '', 83 | )) 84 | 85 | print('') 86 | 87 | def cmd_webapi_call(args): 88 | # load key=value pairs. Stuff thats start with [ is a list, so parse as json 89 | try: 90 | params = {k: (json.loads(v) if v[0:1] == '[' else v) for k, v in args.params} 91 | except Exception as exp: 92 | LOG.error("Error parsing params: %s", str(exp)) 93 | return 1 # error 94 | 95 | apicall = webapi.get 96 | version = args.version or 1 97 | 98 | if args.method == 'POST': 99 | apicall = webapi.post 100 | 101 | webapi_map = {} 102 | 103 | # load cache webapi_interfaces if available 104 | for interface in (UserCacheFile('webapi_interfaces.json').read_json() or {}): 105 | for method in interface['methods']: 106 | key = "{}.{}".format(interface['name'], method['name']) 107 | 108 | if key not in webapi_map or webapi_map[key][1] < method['version']: 109 | webapi_map[key] = method['httpmethod'], method['version'] 110 | 111 | # if --method or --version are unset, take them the cache 112 | # This will the call POST if needed with specifying explicity 113 | # This will prever the highest version of a method 114 | if args.endpoint in webapi_map: 115 | if args.method is None: 116 | if webapi_map[args.endpoint][0] == 'POST': 117 | apicall = webapi.post 118 | if args.version is None: 119 | version = webapi_map[args.endpoint][1] 120 | 121 | # drop reserved words. these have special meaning for steam.webapi 122 | for reserved in ('key', 'format', 'raw', 'http_timeout', 'apihost', 'https'): 123 | params.pop(reserved, None) 124 | 125 | # load key if available 126 | params.setdefault('key', args.apikey or get_webapi_key()) 127 | 128 | if args.format !='text': 129 | params['format'] = 'json' if args.format == 'json_line' else args.format 130 | params['raw'] = True 131 | 132 | try: 133 | interface, method = args.endpoint.split('.', 1) 134 | resp = apicall(interface, method, version, params=params) 135 | except Exception as exp: 136 | LOG.error("%s failed: %s", args.endpoint, str(exp)) 137 | if getattr(exp, 'response', None): 138 | LOG.error("Response body: %s", exp.response.text) 139 | return 1 # error 140 | 141 | # by default we print json, other formats are shown as returned from api 142 | if args.format == 'json': 143 | json.dump(json.loads(resp.rstrip('\n\t\x00 ')), sys.stdout, indent=4, sort_keys=True) 144 | print('') 145 | else: 146 | print(resp) 147 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | | |pypi| |pypipy| |license| 2 | | |sonar_maintainability| |sonar_reliability| |sonar_security| 3 | 4 | steamctl 5 | -------- 6 | 7 | ``steamctl`` is an open-source CLI utility similar to ``steamcmd``. It provides access to a number of Steam features and data from the command line. While it is possible to download apps and content from Steam, `steamctl` is not a game launcher. 8 | 9 | Install 10 | ------- 11 | 12 | .. code:: bash 13 | 14 | pip install steamctl 15 | 16 | Install directly from ``github``: 17 | 18 | .. code:: bash 19 | 20 | pip install git+https://github.com/ValvePython/steamctl#egg=steamctl 21 | 22 | 23 | Command list 24 | ------------- 25 | 26 | 27 | .. code:: text 28 | 29 | apps Get information about apps 30 | |- activate_key Activate key(s) on account 31 | |- licenses Manage licenses 32 | | |- list List all licenses on account 33 | | |- add Add free package license(s) 34 | | \- remove Remove free package license(s) 35 | |- list List owned or all apps 36 | |- product_info Show product info for app 37 | \- item_def Get item definitions for app 38 | 39 | assistant Helpful automation 40 | |- idle-games Idle up to 32 games for game time 41 | |- idle-cards Automatic idling for game cards 42 | \- discovery-queue Explore a single discovery queue 43 | 44 | authenticator Manage Steam authenticators 45 | |- add Add authentictor to a Steam account 46 | |- remove Remove an authenticator 47 | |- list List all authenticators 48 | |- status Query Steam Guard status for account 49 | |- code Generate auth code 50 | \- qrcode Generate QR code 51 | 52 | clear Remove data stored on disk 53 | |- cache Remove all cache and data files 54 | |- credentials Remove all credentials and saved logins 55 | \- all Remove all cache files 56 | 57 | cloud Manage Steam Cloud files (e.g. save files, settings, etc) 58 | |- list List files for app 59 | |- list_apps List all apps with cloud files 60 | \- download Download files for app 61 | 62 | depot List and download from Steam depots 63 | |- info View info about a depot(s) 64 | |- list List files from depot(s) 65 | |- download Download depot files 66 | |- diff Compare files between manifest(s) and filesystem 67 | \- decrypt_gid Decrypt manifest gid 68 | 69 | hlmaster Query master server and server information 70 | |- query Query HL Master for servers 71 | \- info Query info from a goldsrc or source server 72 | 73 | steamid Parse SteamID representations 74 | 75 | ugc Info and download of user generated content 76 | |- info Get details for UGC 77 | \- download Download UGC 78 | 79 | webapi Access to WebAPI 80 | |- set-key Set WebAPI key 81 | |- clear-key Remove saved key 82 | |- list List all available WebAPI endpoints 83 | \- call Call WebAPI endpoint 84 | 85 | workshop Search and download workshop items 86 | |- search Search the workshop 87 | |- info Get all details for a workshop item 88 | |- download Download a workshop item 89 | |- subscribe Subscribe to workshop items 90 | |- unsubscribe Unsubscribe to workshop items 91 | |- favorite Favourite workshop items 92 | \- unfavorite Unfavourite workshop items 93 | 94 | Previews 95 | -------- 96 | 97 | ``steamctl authenticator`` (No root required, and transferable token. Steamapp, ``steamctl``, and aegis, with the same token) 98 | 99 | .. image:: https://raw.githubusercontent.com/ValvePython/steamctl/master/preview_authenticator.jpg 100 | :alt: preview: steamctl authenticator 101 | 102 | (video) ``steamctl depot`` 103 | 104 | .. image:: https://asciinema.org/a/323966.png 105 | :target: https://asciinema.org/a/323966 106 | :alt: asciinema preview: steamctl depot 107 | 108 | (video) ``steamctl workshop`` 109 | 110 | .. image:: https://asciinema.org/a/253277.png 111 | :target: https://asciinema.org/a/253277 112 | :alt: asciinema preview: steamctl workshop 113 | 114 | (video) ``steamctl webapi`` 115 | 116 | .. image:: https://asciinema.org/a/323976.png 117 | :target: https://asciinema.org/a/323976 118 | :alt: asciinema preview: steamctl workshop 119 | 120 | (video) ``steamctl hlmaster`` 121 | 122 | .. image:: https://asciinema.org/a/253275.png 123 | :target: https://asciinema.org/a/253275 124 | :alt: asciinema preview: steamctl hlmaster 125 | 126 | 127 | 128 | .. |pypi| image:: https://img.shields.io/pypi/v/steamctl.svg?style=flat&label=latest 129 | :target: https://pypi.org/project/steamctl/ 130 | :alt: Latest version released on PyPi 131 | 132 | .. |pypipy| image:: https://img.shields.io/pypi/pyversions/steamctl.svg?label=%20&logo=python&logoColor=white 133 | :alt: PyPI - Python Version 134 | 135 | .. |license| image:: https://img.shields.io/pypi/l/steamctl.svg?style=flat&label=license 136 | :target: https://pypi.org/project/steamctl/ 137 | :alt: MIT License 138 | 139 | .. |sonar_maintainability| image:: https://sonarcloud.io/api/project_badges/measure?project=ValvePython_steamctl&metric=sqale_rating 140 | :target: https://sonarcloud.io/dashboard?id=ValvePython_steamctl 141 | :alt: SonarCloud Rating 142 | 143 | .. |sonar_reliability| image:: https://sonarcloud.io/api/project_badges/measure?project=ValvePython_steamctl&metric=reliability_rating 144 | :target: https://sonarcloud.io/dashboard?id=ValvePython_steamctl 145 | :alt: SonarCloud Rating 146 | 147 | .. |sonar_security| image:: https://sonarcloud.io/api/project_badges/measure?project=ValvePython_steamctl&metric=security_rating 148 | :target: https://sonarcloud.io/dashboard?id=ValvePython_steamctl 149 | :alt: SonarCloud Rating 150 | -------------------------------------------------------------------------------- /steamctl/utils/storage.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import re 4 | import json 5 | import logging 6 | from time import time 7 | from io import open 8 | from contextlib import contextmanager 9 | import fnmatch 10 | import shutil 11 | from collections import UserDict 12 | import sqlite3 13 | from appdirs import AppDirs 14 | from steamctl import __appname__ 15 | 16 | _LOG = logging.getLogger(__name__) 17 | _appdirs = AppDirs(__appname__) 18 | 19 | def ensure_dir(path, mode=0o750): 20 | dirpath = os.path.dirname(path) 21 | 22 | if not os.path.exists(dirpath): 23 | _LOG.debug("Making dirs: %s", dirpath) 24 | os.makedirs(dirpath, mode) 25 | 26 | def normpath(path): 27 | if os.sep == '/': 28 | path = path.replace('\\', '/') 29 | return os.path.normpath(path) 30 | 31 | def sanitizerelpath(path): 32 | return re.sub(r'^((\.\.)?[\\/])*', '', normpath(path)) 33 | 34 | 35 | class FileBase(object): 36 | _root_path = None 37 | 38 | def __init__(self, relpath, mode='r'): 39 | self.mode = mode 40 | self.relpath = relpath 41 | self.path = normpath(os.path.join(self._root_path, relpath)) 42 | self.filename = os.path.basename(self.path) 43 | 44 | def __repr__(self): 45 | return "%s(%r, mode=%r)" % ( 46 | self.__class__.__name__, 47 | self.relpath, 48 | self.mode, 49 | ) 50 | 51 | def exists(self): 52 | return os.path.exists(self.path) 53 | 54 | def mkdir(self): 55 | ensure_dir(self.path, 0o700) 56 | 57 | def older_than(seconds=0, minutes=0, hours=0, days=0): 58 | delta = seconds + (minutes*60) + (hours*3600) + (days*86400) 59 | ts = os.path.getmtime(self.path) 60 | return ts + delta > time() 61 | 62 | def open(self, mode): 63 | _LOG.debug("Opening file (%s): %s", mode, self.path) 64 | self.mkdir() 65 | return open(self.path, mode) 66 | 67 | def read_text(self): 68 | if self.exists(): 69 | with self.open('r') as fp: 70 | return fp.read() 71 | 72 | def write_text(self, data): 73 | with self.open('w') as fp: 74 | fp.write(data) 75 | 76 | def read_json(self): 77 | if self.exists(): 78 | with self.open('r') as fp: 79 | return json.load(fp) 80 | 81 | def write_json(self, data, pretty=True): 82 | with self.open('w') as fp: 83 | if pretty: 84 | json.dump(data, fp, indent=4, sort_keys=True) 85 | else: 86 | json.dump(data, fp) 87 | 88 | def remove(self): 89 | _LOG.debug("Removing file: %s", self.path) 90 | 91 | if self.exists(): 92 | os.remove(self.path) 93 | 94 | def secure_remove(self): 95 | _LOG.debug("Securely removing file: %s", self.path) 96 | 97 | if self.exists(): 98 | with open(self.path, 'r+b') as fp: 99 | size = fp.seek(0, 2) 100 | 101 | fp.seek(0) 102 | chunk = b'0' * 4096 103 | 104 | while fp.tell() + 4096 < size: 105 | fp.write(chunk) 106 | fp.write(chunk[:max(size - fp.tell(), 0)]) 107 | 108 | fp.flush() 109 | os.fsync(fp.fileno()) 110 | 111 | os.remove(self.path) 112 | 113 | def __enter__(self): 114 | self._fp = self.open(self.mode) 115 | return self._fp 116 | 117 | def __exit__(self, exc_type, exc_value, traceback): 118 | self._fp.close() 119 | 120 | class UserDataFile(FileBase): 121 | _root_path = _appdirs.user_data_dir 122 | 123 | class UserCacheFile(FileBase): 124 | _root_path = _appdirs.user_cache_dir 125 | 126 | class DirectoryBase(object): 127 | _root_path = None 128 | _file_type = None 129 | 130 | def __init__(self, path='.'): 131 | self.path = normpath(os.path.join(self._root_path, path)) 132 | 133 | if self.exists() and not os.path.isdir(self.path): 134 | raise ValueError("Path is not a directory: %s" % self.path) 135 | 136 | def mkdir(self): 137 | ensure_dir(self.path + os.sep, 0o700) 138 | 139 | def exists(self): 140 | return os.path.exists(self.path) 141 | 142 | def remove(self): 143 | _LOG.debug("Removing directory: %s", self.path) 144 | shutil.rmtree(self.path) 145 | 146 | def iter_files(self, pattern=None, recurse=False): 147 | if not os.path.exists(self.path): 148 | return 149 | 150 | for root, dirs, files in os.walk(self.path): 151 | if not recurse and self.path != root: 152 | break 153 | 154 | if pattern: 155 | files = fnmatch.filter(files, pattern) 156 | 157 | yield from (self._file_type(os.path.join(root, filename)) for filename in files) 158 | 159 | class UserDataDirectory(DirectoryBase): 160 | _root_path = _appdirs.user_data_dir 161 | _file_type = UserDataFile 162 | 163 | class UserCacheDirectory(DirectoryBase): 164 | _root_path = _appdirs.user_cache_dir 165 | _file_type = UserCacheFile 166 | 167 | 168 | class SqliteDict(UserDict): 169 | def __init__(self, path=':memory:'): 170 | if isinstance(path, FileBase): 171 | path.mkdir() 172 | path = path.path 173 | 174 | self.path = path 175 | self._db = sqlite3.connect(path) 176 | self._db.execute('CREATE TABLE IF NOT EXISTS kv (key INTEGER PRIMARY KEY, value TEXT)') 177 | self._db.commit() 178 | 179 | def __repr__(self): 180 | return "%s(path=%r)" % ( 181 | self.__class__.__name__, 182 | self.path, 183 | ) 184 | 185 | def __len__(self): 186 | return self._db.execute('SELECT count(*) FROM kv').fetchone()[0] 187 | 188 | def __contain__(self, key): 189 | return self.get(key) is not None 190 | 191 | def get(self, key, default=None): 192 | row = self._db.execute('SELECT value FROM kv WHERE key = ?', (key,)).fetchone() 193 | return row[0] if row else default 194 | 195 | def __getitem__(self, key): 196 | val = self.get(key) 197 | 198 | if val is None: 199 | raise KeyError(key) 200 | else: 201 | if val and val[0] == '{' and val[-1] == '}': 202 | val = json.loads(val) 203 | return val 204 | 205 | def __setitem__(self, key, val): 206 | if isinstance(val, str): 207 | pass 208 | elif isinstance(val, dict): 209 | val = json.dumps(val) 210 | else: 211 | raise TypeError("Only str or dict types are allowed") 212 | 213 | self._db.execute("REPLACE INTO kv VALUES (?, ?)", (key, val)) 214 | 215 | def items(self): 216 | for item in self._db.execute("SELECT key, value FROM kv ORDER BY key ASC"): 217 | yield item 218 | 219 | def commit(self): 220 | self._db.commit() 221 | 222 | def __del__(self): 223 | self.commit() 224 | 225 | try: 226 | self._db.close(do_log=False, force=True) 227 | except Exception: 228 | pass 229 | -------------------------------------------------------------------------------- /steamctl/commands/workshop/cmds.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from steam import webapi 4 | from steam.enums import EResult 5 | from steamctl.utils.web import make_requests_session 6 | from steamctl.utils.format import print_table, fmt_size 7 | from steamctl.commands.webapi import get_webapi_key 8 | 9 | webapi._make_requests_session = make_requests_session 10 | 11 | _LOG = logging.getLogger(__name__) 12 | 13 | 14 | def check_apikey(args): 15 | apikey = args.apikey or get_webapi_key() 16 | 17 | if not apikey: 18 | _LOG.error("No WebAPI key set. See: steamctl webapi -h") 19 | return 1 #error 20 | 21 | return apikey 22 | 23 | 24 | def cmd_workshop_search(args): 25 | apikey = check_apikey(args) 26 | 27 | maxpages, _ = divmod(args.numresults, 100) 28 | firstpage = True 29 | 30 | rows = [] 31 | names = {} 32 | 33 | for page in range(max(maxpages, 1)): 34 | params = { 35 | 'key': apikey, 36 | 'search_text': ' '.join(args.search_text), 37 | 'numperpage': min(args.numresults, 100), 38 | 'page': page + 1, 39 | 'return_details': True, 40 | 'return_tags': True, 41 | 'query_type': 9, 42 | 'days': 5, 43 | } 44 | 45 | if args.appid: 46 | params['appid'] = args.appid 47 | if args.tag: 48 | params['requiredtags'] = args.tag 49 | if args.match_all_tags: 50 | params['match_all_tags'] = True 51 | 52 | try: 53 | results = webapi.get('IPublishedFileService', 'QueryFiles', 54 | params=params, 55 | )['response'].get('publishedfiledetails', []) 56 | 57 | if not results: 58 | if firstpage: 59 | _LOG.error("No results found") 60 | return 1 # error 61 | else: 62 | break 63 | 64 | firstpage = False 65 | 66 | users = webapi.get('ISteamUser', 'GetPlayerSummaries', 2, 67 | params={ 68 | 'key': apikey, 69 | 'steamids': ','.join(set((a['creator'] 70 | for a in results 71 | if (a['result'] == EResult.OK 72 | and a['creator'] not in names) 73 | ))) 74 | }, 75 | )['response']['players'] 76 | except Exception as exp: 77 | _LOG.error("Query failed: %s", str(exp)) 78 | if getattr(exp, 'response', None): 79 | _LOG.error("Response body: %s", exp.response.text) 80 | return 1 # error 81 | 82 | names.update({user['steamid']: user['personaname'].strip() for user in users}) 83 | 84 | def make_row(item): 85 | size = int(item.get('file_size', 0)) 86 | 87 | if item.get('file_url'): 88 | dl = 'URL' 89 | elif item.get('hcontent_file'): 90 | dl = 'SP' 91 | else: 92 | dl = '' 93 | 94 | return [ 95 | item['publishedfileid'], 96 | item['title'].strip(), 97 | names.get(item['creator'], ''), 98 | str(item['consumer_appid']), 99 | item['app_name'], 100 | '{:,d}'.format(item['views']), 101 | '{:,d}'.format(item['favorited']), 102 | fmt_size(size) if size else '', 103 | dl, 104 | ', '.join(map(lambda x: x['tag'], item.get('tags', []))) 105 | ] 106 | 107 | for item in results: 108 | if item['result'] == EResult.OK: 109 | if args.downloable and not (item.get('file_url') or item.get('hcontent_file')): 110 | continue 111 | rows.append(make_row(item)) 112 | 113 | print_table(rows, 114 | ['ID', 'Title', 'Creator', 'AppID', 'App Name', '>Views', '>Favs', '>Size', 'DL', 'Tags'], 115 | ) 116 | 117 | def cmd_workshop_info(args): 118 | apikey = check_apikey(args) 119 | 120 | params = { 121 | 'key': apikey, 122 | 'publishedfileids': [args.id], 123 | 'includetags': 1, 124 | 'includeadditionalpreviews': 1, 125 | 'includechildren': 1, 126 | 'includekvtags': 1, 127 | 'includevotes': 1, 128 | 'includeforsaledata': 1, 129 | 'includemetadata': 1, 130 | 'return_playtime_stats': 1, 131 | 'strip_description_bbcode': 1, 132 | } 133 | 134 | try: 135 | result = webapi.get('IPublishedFileService', 'GetDetails', 136 | params=params, 137 | )['response']['publishedfiledetails'][0] 138 | except Exception as exp: 139 | _LOG.error("Query failed: %s", str(exp)) 140 | if getattr(exp, 'response', None): 141 | _LOG.error("Response body: %s", exp.response.text) 142 | return 1 # error 143 | 144 | if result['result'] != EResult.OK: 145 | _LOG.error("Query result: %r", EResult(result['result'])) 146 | return 1 # error 147 | 148 | print(json.dumps(result, indent=2)) 149 | 150 | 151 | def webapi_sub_helper(args, cmd): 152 | apikey = check_apikey(args) 153 | 154 | subscribe = not cmd.startswith('un') 155 | 156 | if cmd.endswith('subscribe'): 157 | list_type = 1 158 | elif cmd.endswith('favorite'): 159 | list_type = 2 160 | 161 | try: 162 | workshop_items = webapi.get('IPublishedFileService', 'GetDetails', 163 | params={ 164 | 'key': apikey, 165 | 'publishedfileids': list(set(args.workshop_ids)), 166 | 'includetags': 0, 167 | 'includeadditionalpreviews': 0, 168 | 'includechildren': 0, 169 | 'includekvtags': 0, 170 | 'includevotes': 0, 171 | 'includeforsaledata': 0, 172 | 'includemetadata': 0, 173 | 'return_playtime_stats': 0, 174 | 'strip_description_bbcode': 0, 175 | }, 176 | )['response']['publishedfiledetails'] 177 | except Exception as exp: 178 | _LOG.debug("webapi request failed: %s", str(exp)) 179 | _LOG.error("Failed fetching workshop details") 180 | return 1 # error 181 | 182 | 183 | has_error = False 184 | 185 | for item in workshop_items: 186 | title = item['title'] 187 | workshop_id = item['publishedfileid'] 188 | 189 | if item['result'] != EResult.OK: 190 | _LOG.error("Error for %s: %s", workshop_id, EResult(item['result'])) 191 | continue 192 | 193 | try: 194 | result = webapi.post('IPublishedFileService', 'Subscribe' if subscribe else "Unsubscribe", 195 | params={ 196 | 'key': apikey, 197 | 'publishedfileid': workshop_id, 198 | 'list_type': list_type, 199 | 'notify_client': 1, 200 | }, 201 | ) 202 | except Exception as exp: 203 | _LOG.debug("webapi request failed: %s", str(exp)) 204 | _LOG.error("%s failed for %s - %s", cmd.capitalize(), workshop_id, title) 205 | has_error = True 206 | else: 207 | _LOG.info("%sd %s - %s", cmd.capitalize(), workshop_id, title) 208 | 209 | if has_error: 210 | return 1 # error 211 | 212 | def cmd_workshop_subscribe(args): 213 | return webapi_sub_helper(args, 'subscribe') 214 | 215 | def cmd_workshop_unsubscribe(args): 216 | return webapi_sub_helper(args, 'unsubscribe') 217 | 218 | def cmd_workshop_favorite(args): 219 | return webapi_sub_helper(args, 'favorite') 220 | 221 | def cmd_workshop_unfavorite(args): 222 | return webapi_sub_helper(args, 'unfavorite') 223 | -------------------------------------------------------------------------------- /steamctl/commands/assistant/card_idler.py: -------------------------------------------------------------------------------- 1 | import gevent 2 | import gevent.monkey 3 | gevent.monkey.patch_socket() 4 | gevent.monkey.patch_select() 5 | gevent.monkey.patch_ssl() 6 | 7 | import os 8 | import re 9 | import sys 10 | import math 11 | import random 12 | import logging 13 | from io import open 14 | from itertools import count 15 | from contextlib import contextmanager 16 | from collections import namedtuple 17 | from steamctl.clients import CachingSteamClient 18 | from steamctl.utils.web import make_requests_session 19 | from steam.client import EMsg, EResult 20 | from bs4 import BeautifulSoup 21 | 22 | import steam.client.builtins.web 23 | steam.client.builtins.web.make_requests_session = make_requests_session 24 | 25 | LOG = logging.getLogger(__name__) 26 | 27 | 28 | class IdleClient(CachingSteamClient): 29 | _LOG = logging.getLogger("IdleClient") 30 | 31 | def __init__(self, *args, **kwargs): 32 | CachingSteamClient.__init__(self, *args, **kwargs) 33 | 34 | self.wakeup = gevent.event.Event() 35 | self.newcards = gevent.event.Event() 36 | self.playing_blocked = gevent.event.Event() 37 | 38 | self.on(self.EVENT_DISCONNECTED, self.__handle_disconnected) 39 | self.on(self.EVENT_RECONNECT, self.__handle_reconnect) 40 | self.on(EMsg.ClientItemAnnouncements, self.__handle_item_notification) 41 | self.on(EMsg.ClientPlayingSessionState, self.__handle_playing_session) 42 | 43 | def connect(self, *args, **kwargs): 44 | self.wakeup.clear() 45 | self._LOG.info("Connecting to Steam...") 46 | return CachingSteamClient.connect(self, *args, **kwargs) 47 | 48 | def __handle_disconnected(self): 49 | self._LOG.info("Disconnected from Steam") 50 | self.wakeup.set() 51 | 52 | def __handle_reconnect(self, delay): 53 | if delay: 54 | self._LOG.info("Attemping reconnect in %s second(s)..", delay) 55 | 56 | def __handle_item_notification(self, msg): 57 | if msg.body.count_new_items == 100: 58 | self._LOG.info("Notification: over %s new items", msg.body.count_new_items) 59 | else: 60 | self._LOG.info("Notification: %s new item(s)", msg.body.count_new_items) 61 | self.newcards.set() 62 | self.wakeup.set() 63 | 64 | def __handle_playing_session(self, msg): 65 | if msg.body.playing_blocked: 66 | self.playing_blocked.set() 67 | else: 68 | self.playing_blocked.clear() 69 | self.wakeup.set() 70 | 71 | @contextmanager 72 | def init_client(args): 73 | s = IdleClient() 74 | s.login_from_args(args) 75 | yield s 76 | s.disconnect() 77 | 78 | 79 | Game = namedtuple('Game', 'appid name cards_left playtime') 80 | 81 | def get_remaining_cards(s): 82 | # introduced delay in case account takes longer to login 83 | if not s.licenses: 84 | s.wait_event(EMsg.ClientLicenseList, raises=False, timeout=5) 85 | 86 | web = s.get_web_session() 87 | 88 | if not web: 89 | LOG.error("Failed to get web session") 90 | return 91 | 92 | 93 | games = [] 94 | n_pages = 1 95 | 96 | for n in count(1): 97 | LOG.debug("Loading badge page %s", n) 98 | resp = web.get('https://steamcommunity.com/profiles/{}/badges?sort=p&p={}'.format( 99 | s.steam_id.as_64, 100 | n, 101 | )) 102 | 103 | if resp.status_code != 200: 104 | LOG.error("Error fetching badges: HTTP %s", resp.status_code) 105 | return 106 | 107 | page = BeautifulSoup(resp.content, 'html.parser') 108 | 109 | if n_pages == 1: 110 | elms = page.select('.profile_paging') 111 | 112 | if elms: 113 | m = re.search('of (\S+) badges', elms[0].get_text(strip=True)) 114 | if m: 115 | n_badges = int(re.sub('[^0-9]', '', m.group(1))) 116 | n_pages = math.ceil(n_badges / 150) 117 | 118 | for badge in page.select('div.badge_row'): 119 | status = badge.select('.progress_info_bold') 120 | 121 | if not status: 122 | continue 123 | 124 | m = re.match('(\d+) card drops?', status[0].get_text(strip=True)) 125 | 126 | if not m: 127 | continue 128 | 129 | cards_left = int(m.group(1)) 130 | name = badge.select('.badge_title')[0].get_text('\x00', True).split('\x00', 1)[0] 131 | appid = int(re.search('gamecards/(\d+)/', badge.select('[href*=gamecards]')[0].get('href')).group(1)) 132 | playtime = float((badge.select('.badge_title_stats_playtime')[0].get_text(strip=True) or '0').split(' ', 1)[0]) 133 | 134 | games.append(Game(appid, name, cards_left, playtime)) 135 | 136 | if n == n_pages: 137 | break 138 | 139 | return games 140 | 141 | def cmd_assistant_idle_cards(args): 142 | with init_client(args) as s: 143 | while True: 144 | # ensure we are connected and logged in 145 | if not s.connected: 146 | s.reconnect() 147 | continue 148 | 149 | if not s.logged_on: 150 | if not s.relogin_available: 151 | return 1 # error 152 | 153 | result = s.relogin() 154 | 155 | if result != EResult.OK: 156 | LOG.warning("Login failed: %s", repr(EResult(result))) 157 | 158 | continue 159 | 160 | s.wakeup.clear() 161 | 162 | # wait out any active sessions 163 | if s.playing_blocked.is_set(): 164 | LOG.info("Another Steam session is playing right now. Waiting for it to finish...") 165 | s.wakeup.wait(timeout=3600) 166 | continue 167 | 168 | # check badges for cards 169 | LOG.info("Checking for games with cards left..") 170 | games = get_remaining_cards(s) 171 | 172 | if not games: 173 | LOG.info("No games with card left were found. Idling..") 174 | s.wakeup.wait(timeout=60 if games is None else 600) 175 | continue 176 | 177 | n_games = len(games) 178 | n_cards = sum(map(lambda game: game.cards_left, games)) 179 | 180 | LOG.info("%s card(s) left across %s game(s)", n_cards, n_games) 181 | 182 | # pick games to idle 183 | if len(games) > 32: 184 | random.shuffle(games) 185 | 186 | # only 32 can be idled at a single time 187 | games = sorted(games[:32], key=lambda game: game.playtime, reverse=True) 188 | LOG.info("Playing: %s", ', '.join(map(lambda game: "{} ({:.1f} hrs)".format(game.appid, game.playtime), games))) 189 | 190 | # play games 191 | games_to_play = list(map(lambda game: game.appid, games)) 192 | s.newcards.clear() 193 | 194 | for timeout in [15, 15, 30, 30, 60, 60, 120, 120, 240, 360, 360]: 195 | s.games_played(games_to_play) 196 | s.playing_blocked.wait(timeout=2) 197 | s.wakeup.clear() 198 | s.wakeup.wait(timeout=timeout) 199 | s.games_played([]) 200 | s.sleep(1) 201 | 202 | if s.newcards.is_set(): 203 | break 204 | 205 | 206 | def cmd_assistant_idle_games(args): 207 | with init_client(args) as s: 208 | while True: 209 | # ensure we are connected and logged in 210 | if not s.connected: 211 | s.reconnect() 212 | continue 213 | 214 | if not s.logged_on: 215 | if not s.relogin_available: 216 | return 1 # error 217 | 218 | result = s.relogin() 219 | 220 | if result != EResult.OK: 221 | LOG.warning("Login failed: %s", repr(EResult(result))) 222 | 223 | continue 224 | 225 | s.wakeup.clear() 226 | 227 | # wait out any active sessions 228 | if s.playing_blocked.is_set(): 229 | LOG.info("Another Steam session is playing right now. Waiting for it to finish...") 230 | s.wakeup.wait(timeout=3600) 231 | continue 232 | 233 | # check requested app ids against the license list 234 | app_ids = args.app_ids 235 | # TODO 236 | 237 | # idle games 238 | LOG.info("Idling apps: %s", ', '.join(map(str, app_ids))) 239 | s.games_played(app_ids) 240 | s.playing_blocked.wait(timeout=2) 241 | s.wakeup.clear() 242 | s.wakeup.wait() 243 | s.games_played([]) 244 | s.sleep(1) 245 | 246 | 247 | 248 | 249 | 250 | 251 | -------------------------------------------------------------------------------- /steamctl/commands/depot/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | import argparse 3 | from steamctl.argparser import register_command 4 | from steamctl.utils.storage import UserDataFile, UserCacheFile 5 | from argcomplete import warn 6 | 7 | 8 | epilog = """\ 9 | 10 | Examples: 11 | 12 | Show manifest info for specific manifest: 13 | {prog} depot info --app 570 --depot 570 --manifest 7280959080077824592 14 | 15 | Show manifest info from a file: 16 | {prog} depot info -f /Steam/depotcache/381450_8619474727384971127.manifest 17 | 18 | List manifest files: 19 | {prog} depot list -f /Steam/depotcache/381450_8619474727384971127.manifest 20 | 21 | List files from all manifest for app: 22 | {prog} depot list --app 570 23 | 24 | Download files from a manifest to a directory called 'temp': 25 | {prog} depot download --app 570 --depot 570 --manifest 7280959080077824592 -o ./temp 26 | 27 | Download all files for an app to a directory called 'temp': 28 | {prog} depot download --app 570 -o ./temp 29 | 30 | """ 31 | 32 | @register_command('depot', help='List and download from Steam depots', epilog=epilog) 33 | def cmd_parser(cp): 34 | def print_help(*args, **kwargs): 35 | cp.print_help() 36 | 37 | cp.set_defaults(_cmd_func=print_help) 38 | 39 | sub_cp = cp.add_subparsers(metavar='', 40 | dest='subcommand', 41 | title='List of sub-commands', 42 | description='', 43 | ) 44 | 45 | # ---- info 46 | scp_i = sub_cp.add_parser("info", help="View info about a depot(s)") 47 | scp_i.add_argument('--cell_id', type=int, help='Cell ID to use for download') 48 | scp_i.add_argument('-os', choices=['any', 'windows', 'windows64', 'linux', 'linux64', 'macos'], 49 | default='any', 50 | help='Operating system (Default: any)') 51 | scp_i.add_argument('-f', '--file', type=argparse.FileType('rb'), action='append', nargs='+', help='Path to a manifest file') 52 | scp_i.add_argument('-a', '--app', type=int, help='App ID') 53 | scp_i.add_argument('-d', '--depot', type=int, help='Depot ID') 54 | scp_i.add_argument('-m', '--manifest', type=int, help='Manifest GID') 55 | scp_i.add_argument('-b', '--branch', type=str, help='Branch name', default='public') 56 | scp_i.add_argument('-p', '--password', type=str, help='Branch password') 57 | scp_i.add_argument('--skip-depot', type=int, nargs='+', help='Depot IDs to skip') 58 | scp_i.add_argument('--skip-login', action='store_true', help='Skip login to Steam') 59 | scp_i.add_argument('--skip-licenses', action='store_true', help='Skip checking for licenses') 60 | scp_i.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_depot_info') 61 | 62 | # ---- list 63 | scp_l = sub_cp.add_parser("list", help="List files from depot(s)") 64 | scp_l.add_argument('--cell_id', type=int, help='Cell ID to use for download') 65 | scp_l.add_argument('-os', choices=['any', 'windows', 'windows64', 'linux', 'linux64', 'macos'], 66 | default='any', 67 | help='Operating system (Default: any)') 68 | scp_l.add_argument('-f', '--file', type=argparse.FileType('rb'), action='append', nargs='+', help='Path to a manifest file') 69 | scp_l.add_argument('-a', '--app', type=int, help='App ID') 70 | scp_l.add_argument('-d', '--depot', type=int, help='Depot ID') 71 | scp_l.add_argument('-m', '--manifest', type=int, help='Manifest GID') 72 | scp_l.add_argument('-b', '--branch', type=str, help='Branch name', default='public') 73 | scp_l.add_argument('-p', '--password', type=str, help='Branch password') 74 | scp_l.add_argument('--skip-depot', type=int, nargs='+', help='Depot IDs to skip') 75 | scp_l.add_argument('--skip-login', action='store_true', help='Skip login to Steam') 76 | scp_l.add_argument('--skip-licenses', action='store_true', help='Skip checking for licenses') 77 | scp_l.add_argument('--long', action='store_true', help='Shows extra info for every file') 78 | scp_l.add_argument('--vpk', action='store_true', help='Include files inside VPK files') 79 | fexcl = scp_l.add_mutually_exclusive_group() 80 | fexcl.add_argument('-n', '--name', type=str, help='Wildcard for matching filepath') 81 | fexcl.add_argument('-re', '--regex', type=str, help='Reguar expression for matching filepath') 82 | scp_l.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_depot_list') 83 | 84 | # ---- download 85 | scp_dl = sub_cp.add_parser("download", help="Download depot files") 86 | scp_dl.add_argument('--cell_id', type=int, help='Cell ID to use for download') 87 | scp_dl.add_argument('-os', choices=['any', 'windows', 'windows64', 'linux', 'linux64', 'macos'], 88 | default='any', 89 | help='Operating system (Default: any)') 90 | scp_dl.add_argument('-o', '--output', type=str, default='', help='Path to directory for the downloaded files (default: cwd)') 91 | scp_dl.add_argument('-nd', '--no-directories', action='store_true', help='Do not create directories') 92 | scp_dl.add_argument('-np', '--no-progress', action='store_true', help='Do not create directories') 93 | scp_dl.add_argument('-f', '--file', type=argparse.FileType('rb'), action='append', nargs='+', help='Path to a manifest file') 94 | scp_dl.add_argument('-a', '--app', type=int, help='App ID') 95 | scp_dl.add_argument('-d', '--depot', type=int, help='Depot ID') 96 | scp_dl.add_argument('-m', '--manifest', type=int, help='Manifest GID') 97 | scp_dl.add_argument('-b', '--branch', type=str, help='Branch name', default='public') 98 | scp_dl.add_argument('-p', '--password', type=str, help='Branch password') 99 | scp_dl.add_argument('--skip-depot', type=int, nargs='+', help='Depot IDs to skip') 100 | scp_dl.add_argument('--skip-login', action='store_true', help='Skip login to Steam') 101 | scp_dl.add_argument('--skip-licenses', action='store_true', help='Skip checking for licenses') 102 | scp_dl.add_argument('--vpk', action='store_true', help='Include files inside VPK files') 103 | scp_dl.add_argument('--skip-verify', action='store_true', help='Do not verify existing files, simply redownload') 104 | fexcl = scp_dl.add_mutually_exclusive_group() 105 | fexcl.add_argument('-n', '--name', type=str, help='Wildcard for matching filepath') 106 | fexcl.add_argument('-re', '--regex', type=str, help='Reguar expression for matching filepath') 107 | scp_dl.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_depot_download') 108 | 109 | # ---- diff 110 | scp_df = sub_cp.add_parser("diff", help="Compare files between manifest(s) and filesystem") 111 | scp_df.add_argument('--cell_id', type=int, help='Cell ID to use for download') 112 | scp_df.add_argument('-os', choices=['any', 'windows', 'windows64', 'linux', 'linux64', 'macos'], 113 | default='any', 114 | help='Operating system (Default: any)') 115 | scp_df.add_argument('-f', '--file', type=argparse.FileType('rb'), action='append', nargs='+', help='Path to a manifest file') 116 | scp_df.add_argument('-a', '--app', type=int, help='App ID') 117 | scp_df.add_argument('-d', '--depot', type=int, help='Depot ID') 118 | scp_df.add_argument('-m', '--manifest', type=int, help='Manifest GID') 119 | scp_df.add_argument('-b', '--branch', type=str, help='Branch name', default='public') 120 | scp_df.add_argument('-p', '--password', type=str, help='Branch password') 121 | scp_df.add_argument('--skip-depot', type=int, nargs='+', help='Depot IDs to skip') 122 | scp_df.add_argument('--skip-login', action='store_true', help='Skip login to Steam') 123 | scp_df.add_argument('--skip-licenses', action='store_true', help='Skip checking for licenses') 124 | fexcl = scp_df.add_mutually_exclusive_group() 125 | fexcl.add_argument('-n', '--name', type=str, help='Wildcard for matching filepath') 126 | fexcl.add_argument('-re', '--regex', type=str, help='Reguar expression for matching filepath') 127 | scp_df.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_depot_diff') 128 | scp_df.add_argument('--hide-missing', action='store_true', help='Do not show manifest files are not found on filesystem') 129 | scp_df.add_argument('--hide-mismatch', action='store_true', help='Do not show manifest files mismatch (size, chucksum) with filesystem ones ') 130 | scp_df.add_argument('--show-extra', action='store_true', help='Show files that exist on the filesystem, but not in the manifest(s)') 131 | scp_df.add_argument('TARGETDIR', nargs='?', default='.', type=str, help='Directory to compare to (default: current)') 132 | 133 | # ---- decrypt_gid 134 | scp_l = sub_cp.add_parser("decrypt_gid", help="Decrypt manifest gid") 135 | scp_l.add_argument('-a', '--app', type=int, help='App ID') 136 | scp_l.add_argument('-p', '--password', type=str, help='Branch password') 137 | scp_l.add_argument('-k', '--key', type=str, help='Decryption key for gid (hex encoded)') 138 | scp_l.add_argument('manifest_gid', type=str, nargs='+', help='Encrypted manifest gid (hex encoded)') 139 | scp_l.set_defaults(_cmd_func=__name__ + '.gcmds:cmd_depot_decrypt_gid') 140 | -------------------------------------------------------------------------------- /steamctl/commands/apps/gcmds.py: -------------------------------------------------------------------------------- 1 | import gevent 2 | import gevent.monkey 3 | gevent.monkey.patch_socket() 4 | gevent.monkey.patch_select() 5 | gevent.monkey.patch_ssl() 6 | 7 | import sys 8 | import json 9 | import codecs 10 | import logging 11 | import functools 12 | from time import time 13 | from binascii import hexlify 14 | from contextlib import contextmanager 15 | from steam.exceptions import SteamError 16 | from steam.enums import EResult, EPurchaseResultDetail 17 | from steam.client import EMsg 18 | from steam.utils import chunks 19 | from steamctl.clients import CachingSteamClient 20 | from steamctl.utils.web import make_requests_session 21 | from steamctl.utils.format import fmt_datetime 22 | from steam.enums import ELicenseType, ELicenseFlags, EBillingType, EType 23 | from steam.core.msg import MsgProto 24 | from steamctl.commands.apps.enums import EPaymentMethod, EPackageStatus 25 | from steamctl.utils.apps import get_app_names 26 | 27 | LOG = logging.getLogger(__name__) 28 | 29 | @contextmanager 30 | def init_client(args): 31 | s = CachingSteamClient() 32 | s.login_from_args(args) 33 | yield s 34 | s.disconnect() 35 | 36 | def cmd_apps_activate_key(args): 37 | with init_client(args) as s: 38 | for key in args.keys: 39 | print("-- Activating: {}".format(key)) 40 | result, detail, receipt = s.register_product_key(key) 41 | 42 | detail = EPurchaseResultDetail(detail) 43 | 44 | print(f"Result: {result.name} ({result:d}) Detail: {detail.name} ({detail:d})") 45 | 46 | products = [product.get('ItemDescription', '') for product in receipt.get('lineitems', {}).values()] 47 | 48 | if result == EResult.OK: 49 | print("Products:", ', '.join(products) if products else "None") 50 | else: 51 | return 1 # error 52 | 53 | def cmd_apps_product_info(args): 54 | with init_client(args) as s: 55 | s.check_for_changes() 56 | 57 | if not args.skip_licenses: 58 | if not s.licenses and s.steam_id.type != s.steam_id.EType.AnonUser: 59 | s.wait_event(EMsg.ClientLicenseList, raises=False, timeout=10) 60 | 61 | cdn = s.get_cdnclient() 62 | cdn.load_licenses() 63 | 64 | for app_id in args.app_ids: 65 | if app_id not in cdn.licensed_app_ids: 66 | LOG.error("No license available for App ID: %s (%s)", app_id, EResult.AccessDenied) 67 | return 1 #error 68 | 69 | data = s.get_product_info(apps=args.app_ids) 70 | 71 | if not data: 72 | LOG.error("No results") 73 | return 1 # error 74 | 75 | data = data['apps'] 76 | 77 | for k, v in data.items(): 78 | json.dump(v, sys.stdout, indent=4, sort_keys=True) 79 | 80 | def cmd_apps_list(args): 81 | app_names = get_app_names() 82 | 83 | if args.all: 84 | for app_id, name in app_names.items(): 85 | if app_id >= 0: 86 | print(app_id, name) 87 | 88 | else: 89 | with init_client(args) as s: 90 | if not s.licenses and s.steam_id.type != s.steam_id.EType.AnonUser: 91 | s.wait_event(EMsg.ClientLicenseList, raises=False, timeout=10) 92 | 93 | cdn = s.get_cdnclient() 94 | cdn.load_licenses() 95 | 96 | for app_id in sorted(cdn.licensed_app_ids): 97 | print(app_id, app_names.get(app_id, 'Unknown App {}'.format(app_id))) 98 | 99 | 100 | def cmd_apps_item_def(args): 101 | app_id = args.app_id 102 | 103 | # special case apps 104 | if app_id in (440, 570, 620, 730, 205790): 105 | LOG.error("The app's item schema cannot be retrieved via this method") 106 | 107 | if app_id == 440: 108 | LOG.error("Use: steamctl webapi call IEconItems_440.GetSchemaItems") 109 | if app_id == 570: 110 | LOG.error("Use: steamctl webapi call IEconItems_570.GetSchemaURL") 111 | LOG.error(" steamctl depot download -a 570 --vpk --name '*pak01_dir.vpk:*/items_game.txt' --no-directories -o dota_items") 112 | if app_id == 620: 113 | LOG.error("Use: steamctl webapi call IEconItems_620.GetSchema") 114 | if app_id == 730: 115 | LOG.error("Use: steamctl webapi call IEconItems_730.GetSchema") 116 | LOG.error(" steamctl depot download -a 730 --regex 'items_game(_cdn)?\.txt$' --no-directories --output csgo_item_def") 117 | if app_id == 205790: 118 | LOG.error("Use: steamctl webapi call IEconItems_205790.GetSchemaURL") 119 | 120 | return 1 # error 121 | 122 | # regular case 123 | with init_client(args) as s: 124 | resp = s.send_um_and_wait("Inventory.GetItemDefMeta#1", {'appid': app_id}) 125 | 126 | if resp.header.eresult != EResult.OK: 127 | LOG.error("Request failed: %r", EResult(resp.header.eresult)) 128 | return 1 # error 129 | 130 | digest = resp.body.digest 131 | 132 | sess = make_requests_session() 133 | resp = sess.get('https://api.steampowered.com/IGameInventory/GetItemDefArchive/v1/', 134 | params={'appid': app_id, 'digest': resp.body.digest}, 135 | stream=True) 136 | 137 | if resp.status_code != 200: 138 | LOG.error("Request failed: HTTP %s", resp.status_code) 139 | return 1 # error 140 | 141 | resp.raw.read = functools.partial(resp.raw.read, decode_content=True) 142 | reader = codecs.getreader('utf-8')(resp.raw, 'replace') 143 | 144 | for chunk in iter(lambda: reader.read(8096), ''): 145 | if chunk[-1] == '\x00': 146 | chunk = chunk[:-1] 147 | 148 | sys.stdout.write(chunk) 149 | 150 | def cmd_apps_add(args): 151 | with init_client(args) as s: 152 | resp = s.send_job_and_wait( 153 | MsgProto(EMsg.ClientRequestFreeLicense), 154 | { "appids": args.app_ids }, 155 | timeout=15 156 | ) 157 | 158 | if not resp or resp.eresult != EResult.OK: 159 | LOG.error("Failed to add app(s): %r", EResult.Timeout if resp is None else EResult(resp.eresult)) 160 | return 1 # error 161 | 162 | if resp.granted_appids: 163 | app_names = get_app_names() 164 | print("Apps added:") 165 | for app_id in resp.granted_appids: 166 | app_name = app_names.get(app_id, f'Unknown App {app_id}') 167 | print(f"+ {app_id}: {app_name}") 168 | 169 | # ---- licenses section 170 | 171 | def cmd_apps_licenses_list(args): 172 | with init_client(args) as s: 173 | app_names = get_app_names() 174 | 175 | if s.steam_id.type == EType.AnonUser: 176 | from steam.protobufs.steammessages_clientserver_pb2 import CMsgClientLicenseList 177 | s.licenses = { 178 | 17906: CMsgClientLicenseList.License(package_id=17906, license_type=1) 179 | } 180 | 181 | # ensure that the license list has loaded 182 | if not s.licenses: 183 | s.wait_event(EMsg.ClientLicenseList) 184 | 185 | for chunk in chunks(list(s.licenses), 100): 186 | packages = s.get_product_info(packages=chunk)['packages'] 187 | 188 | for pkg_id in chunk: 189 | license = s.licenses[pkg_id] 190 | info = packages[pkg_id] 191 | 192 | # skip licenses not granting specified app ids 193 | if args.app and set(info['appids'].values()).isdisjoint(args.app): 194 | continue 195 | 196 | # skip licenses not matching specified billingtype(s) 197 | if args.billingtype and EBillingType(info['billingtype']).name not in args.billingtype: 198 | continue 199 | 200 | print(f"License: { pkg_id }") 201 | print(f" Type: { ELicenseType(license.license_type).name } ({license.license_type})") 202 | print(f" Created: { fmt_datetime(license.time_created) }") 203 | print(f" Purchase country: { license.purchase_country_code }") 204 | print(f" Payment method: { EPaymentMethod(license.payment_method).name } ({license.payment_method})") 205 | 206 | flags = ', '.join((flag.name for flag in ELicenseFlags if flag & license.flags)) 207 | 208 | print(f" Flags: { flags }") 209 | print(f" Change number: { license.change_number }") 210 | print(f" SteamDB: https://steamdb.info/sub/{ pkg_id }/") 211 | if 'billingtype' in info: 212 | print(f" Billing Type: { EBillingType(info['billingtype']).name } ({info['billingtype']})") 213 | if 'status' in info: 214 | print(f" Status: { EPackageStatus(info['status']).name } ({info['status']})") 215 | 216 | if info.get('extended', None): 217 | print(" Extended:") 218 | for key, val in info['extended'].items(): 219 | print(f" {key}: {val}") 220 | 221 | if info.get('appids', None): 222 | print(" Apps:") 223 | for app_id in info['appids'].values(): 224 | app_name = app_names.get(app_id, f'Unknown App {app_id}') 225 | print(f" {app_id}: {app_name}") 226 | 227 | def cmd_apps_licenses_add(args): 228 | with init_client(args) as s: 229 | web = s.get_web_session() 230 | app_names = get_app_names() 231 | 232 | for pkg_id in args.pkg_ids: 233 | if pkg_id in s.licenses: 234 | print(f'Already owned package: {pkg_id}') 235 | continue 236 | 237 | resp = web.post(f'https://store.steampowered.com/checkout/addfreelicense/{pkg_id}', 238 | data={'ajax': 'true', 'sessionid': web.cookies.get('sessionid', domain='store.steampowered.com')}, 239 | ) 240 | 241 | if resp.status_code != 200: 242 | print(f'Failed to activate: {pkg_id}') 243 | 244 | try: 245 | error = EPurchaseResultDetail(resp.json()['purchaseresultdetail']) 246 | LOG.error(f'Result: {error!r}') 247 | except: 248 | LOG.error(f'Request failed with HTTP {resp.status_code}') 249 | continue 250 | 251 | if pkg_id not in s.licenses: 252 | s.wait_event(EMsg.ClientLicenseList, timeout=2) 253 | 254 | if pkg_id in s.licenses: 255 | print(f'Activated package: {pkg_id}') 256 | 257 | for app_id in s.get_product_info(packages=[pkg_id])['packages'][pkg_id]['appids'].values(): 258 | app_name = app_names.get(app_id, f'Unknown App {app_id}') 259 | print(f" + {app_id}: {app_name}") 260 | else: 261 | # this shouldn't happen 262 | print(f'Activated package: {pkg_id} (BUT, NO LICENSE ON ACCOUNT?)') 263 | 264 | def cmd_apps_licenses_remove(args): 265 | with init_client(args) as s: 266 | web = s.get_web_session() 267 | app_names = get_app_names() 268 | 269 | for pkg_id in args.pkg_ids: 270 | if pkg_id not in s.licenses: 271 | print(f'No license for: {pkg_id}') 272 | continue 273 | 274 | info = s.get_product_info(packages=[pkg_id])['packages'][pkg_id] 275 | 276 | resp = web.post(f'https://store.steampowered.com/account/removelicense', 277 | data={'packageid': pkg_id, 'sessionid': web.cookies.get('sessionid', domain='store.steampowered.com')}, 278 | ) 279 | 280 | if resp.status_code != 200: 281 | LOG.error(f'Request failed with HTTP code {resp.status_code}') 282 | return 1 # error 283 | 284 | if resp.json()['success']: 285 | print(f"Removed package: {pkg_id}") 286 | 287 | for app_id in info['appids'].values(): 288 | app_name = app_names.get(app_id, f'Unknown App {app_id}') 289 | print(f" - {app_id}: {app_name}") 290 | else: 291 | print(f"Failed to remove: {pkg_id}") 292 | -------------------------------------------------------------------------------- /steamctl/commands/authenticator/cmds.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | from getpass import getpass 4 | from time import time 5 | from base64 import b64decode 6 | from steamctl import __appname__ 7 | from steamctl.utils.storage import UserDataFile, UserDataDirectory 8 | from steamctl.utils.prompt import pmt_confirmation, pmt_input 9 | from steamctl.utils.web import make_requests_session 10 | from steamctl.utils.format import print_table, fmt_datetime 11 | from steam import webapi, webauth 12 | from steam.enums import EResult 13 | from steam.guard import SteamAuthenticator, SteamAuthenticatorError 14 | 15 | # patch session method 16 | webapi._make_requests_session = make_requests_session 17 | 18 | _LOG = logging.getLogger(__name__) 19 | 20 | class BetterMWA(webauth.MobileWebAuth): 21 | def __init__(self, username): 22 | webauth.MobileWebAuth.__init__(self, username) 23 | 24 | def bcli_login(self, password=None, auto_twofactor=False, sa_instance=None): 25 | email_code = twofactor_code = '' 26 | 27 | while True: 28 | try: 29 | if not password: 30 | raise webauth.LoginIncorrect 31 | return self.login(password, captcha, email_code, twofactor_code) 32 | except (webauth.LoginIncorrect, webauth.CaptchaRequired) as exp: 33 | email_code = twofactor_code = '' 34 | 35 | if auto_twofactor and sa_instance: 36 | twofactor_code = sa_instance.get_code() 37 | 38 | if isinstance(exp, webauth.LoginIncorrect): 39 | prompt = ("Enter password for %s: " if not password else 40 | "Invalid password for %s. Enter password: ") 41 | password = getpass(prompt % repr(self.username)) 42 | if isinstance(exp, webauth.CaptchaRequired): 43 | if captcha: 44 | print("Login error: %s" % str(exp)) 45 | if not pmt_confirmation("Try again?", default_yes=True): 46 | raise EOFError 47 | self.refresh_captcha() 48 | 49 | if self.captcha_url: 50 | prompt = "Solve CAPTCHA at %s\nCAPTCHA code: " % self.captcha_url 51 | captcha = input(prompt) 52 | continue 53 | 54 | captcha = '' 55 | except webauth.EmailCodeRequired: 56 | prompt = ("Enter email code: " if not email_code else 57 | "Incorrect code. Enter email code: ") 58 | email_code, twofactor_code = input(prompt), '' 59 | except webauth.TwoFactorCodeRequired as exp: 60 | if auto_twofactor: 61 | print("Steam did not accept 2FA code") 62 | raise EOFError 63 | 64 | if sa_instance: 65 | print("Authenticator available. Leave blank to use it, or manually enter code") 66 | 67 | prompt = ("Enter 2FA code: " if not twofactor_code else 68 | "Incorrect code. Enter 2FA code: ") 69 | 70 | code = input(prompt) 71 | 72 | if sa_instance and not code: 73 | code = sa_instance.get_code() 74 | 75 | email_code, twofactor_code = '', code 76 | 77 | 78 | def cmd_authenticator_add(args): 79 | account = args.account.lower().strip() 80 | secrets_file = UserDataFile('authenticator/{}.json'.format(account)) 81 | sa = None 82 | 83 | if secrets_file.exists(): 84 | if not args.force: 85 | print("There is already an authenticator for that account. Use --force to overwrite") 86 | return 1 # error 87 | sa = SteamAuthenticator(secrets_file.read_json()) 88 | 89 | if args.from_secret: 90 | secret = b64decode(args.from_secret) 91 | if len(secret) != 20: 92 | print("Provided secret length is not 20 bytes (got %s)" % len(secret)) 93 | return 1 # error 94 | 95 | sa = SteamAuthenticator({ 96 | 'account_name': account, 97 | 'shared_secret': args.from_secret, 98 | 'token_gid': 'Imported secret', 99 | 'server_time': int(time()), 100 | }) 101 | print("To import a secret, we need to login to Steam to verify") 102 | else: 103 | print("To add an authenticator, first we need to login to Steam") 104 | print("Account name:", account) 105 | 106 | wa = BetterMWA(account) 107 | try: 108 | wa.bcli_login(sa_instance=sa, auto_twofactor=bool(args.from_secret)) 109 | except (KeyboardInterrupt, EOFError): 110 | print("Login interrupted") 111 | return 1 # error 112 | 113 | print("Login successful. Checking status...") 114 | 115 | if sa: 116 | sa.backend = wa 117 | else: 118 | sa = SteamAuthenticator(backend=wa) 119 | 120 | status = sa.status() 121 | _LOG.debug("Authenticator status: %s", status) 122 | 123 | if args.from_secret: 124 | if status['state'] == 1: 125 | sa.secrets['token_gid'] = status['token_gid'] 126 | sa.secrets['server_time'] = status['time_created'] 127 | sa.secrets['state'] = status['state'] 128 | secrets_file.write_json(sa.secrets) 129 | print("Authenticator added successfully") 130 | return 131 | else: 132 | print("No authenticator on account, but we logged in with 2FA? This is impossible") 133 | return 1 # error 134 | 135 | if status['state'] == 1: 136 | print("This account already has an authenticator.") 137 | print("You need to remove it first, before proceeding") 138 | return 1 # error 139 | 140 | if not status['email_validated']: 141 | print("Account needs a verified email address") 142 | return 1 # error 143 | 144 | if not status['authenticator_allowed']: 145 | print("This account is now allowed to have authenticator") 146 | return 1 # error 147 | 148 | # check phone number, and add one if its missing 149 | if not sa.has_phone_number(): 150 | print("No phone number on this account. This is required.") 151 | 152 | if pmt_confirmation("Do you want to add a phone number?", default_yes=True): 153 | print("Phone number need to include country code and no spaces.") 154 | 155 | while True: 156 | phnum = pmt_input("Enter phone number:", regex=r'^(\+|00)[0-9]+$') 157 | 158 | resp = sa.validate_phone_number(phnum) 159 | _LOG.debug("Phone number validation for %r: %s", phnum, resp) 160 | 161 | if not resp.get('is_valid', False): 162 | print("That number is not valid for Steam.") 163 | continue 164 | 165 | if not sa.add_phone_number(phnum): 166 | print("Failed to add phone number!") 167 | continue 168 | 169 | print("Phone number added. Confirmation SMS sent.") 170 | 171 | while not sa.confirm_phone_number(pmt_input("Enter SMS code:", regex='^[0-9]+$')): 172 | print("Code was incorrect. Try again.") 173 | 174 | break 175 | else: 176 | # user declined adding a phone number, we cant proceed 177 | return 1 # error 178 | 179 | # being adding authenticator setup 180 | sa.add() 181 | 182 | _LOG.debug("Authenticator secrets obtained. Saving to disk") 183 | 184 | secrets_file.write_json(sa.secrets) 185 | 186 | # Setup Steam app in conjuction 187 | if pmt_confirmation("Do you want to use Steam Mobile app too? (Needed for trades)", default_yes=False): 188 | print("Great! Go and setup Steam Guard in your app.") 189 | print("Once completed, generate a code and enter it below.") 190 | 191 | showed_fail_info = False 192 | fail_counter = 0 193 | 194 | while True: 195 | code = pmt_input("Steam Guard code:", regex='^[23456789BCDFGHJKMNPQRTVWXYbcdfghjkmnpqrtvwxy]{5}$') 196 | 197 | # code match 198 | if sa.get_code() == code.upper(): 199 | break # success 200 | # code do not match 201 | else: 202 | fail_counter += 1 203 | 204 | if fail_counter >= 3 and not showed_fail_info: 205 | showed_fail_info = True 206 | print("The codes do not match. This can be caused by:") 207 | print("* The code was not entered correctly") 208 | print("* Your system time is not synchronized") 209 | print("* Steam has made changes to their backend") 210 | 211 | if not pmt_confirmation("Code mismatch. Try again?", default_yes=True): 212 | _LOG.debug("Removing secrets file") 213 | secrets_file.secure_remove() 214 | return 1 # failed, exit 215 | 216 | # only setup steamctl 2fa 217 | else: 218 | print("Authenticator secrets obtained. SMS code for finalization sent.") 219 | 220 | while True: 221 | code = pmt_input("Enter SMS code:", regex='^[0-9]+$') 222 | try: 223 | sa.finalize(code) 224 | except SteamAuthenticatorError as exp: 225 | print("Finalization error: %s", exp) 226 | continue 227 | else: 228 | break 229 | 230 | # finish line 231 | print("Authenticator added successfully!") 232 | print("Get a code: {} authenticator code {}".format(__appname__, account)) 233 | print("Or QR code: {} authenticator qrcode {}".format(__appname__, account)) 234 | 235 | 236 | def cmd_authenticator_remove(args): 237 | account = args.account.lower().strip() 238 | secrets_file = UserDataFile('authenticator/{}.json'.format(account)) 239 | secrets = secrets_file.read_json() 240 | 241 | if not secrets: 242 | print("No authenticator found for %r" % account) 243 | return 1 #error 244 | 245 | if args.force: 246 | secrets_file.secure_remove() 247 | print("Forceful removal of %r successful" % account) 248 | return 249 | 250 | print("To remove an authenticator, first we need to login to Steam") 251 | print("Account name:", account) 252 | 253 | wa = BetterMWA(account) 254 | sa = SteamAuthenticator(secrets, backend=wa) 255 | 256 | try: 257 | wa.bcli_login(sa_instance=sa) 258 | except (KeyboardInterrupt, EOFError): 259 | print("Login interrupted") 260 | return 1 # error 261 | 262 | print("Login successful.") 263 | print("Steam Guard will be set to email, after removal.") 264 | 265 | while True: 266 | if not pmt_confirmation("Proceed with removing Steam Authenticator?"): 267 | break 268 | else: 269 | try: 270 | sa.remove() 271 | except SteamAuthenticatorError as exp: 272 | print("Removal error: %s" % exp) 273 | continue 274 | except (EOFError, KeyboardInterrupt): 275 | break 276 | else: 277 | secrets_file.secure_remove() 278 | print("Removal successful!") 279 | return 280 | 281 | print("Removal cancelled.") 282 | 283 | def cmd_authenticator_list(args): 284 | rows = [] 285 | 286 | for secrets_file in UserDataDirectory('authenticator').iter_files('*.json'): 287 | secrets = secrets_file.read_json() 288 | rows.append([ 289 | secrets_file.filename, 290 | secrets['account_name'], 291 | secrets['token_gid'], 292 | fmt_datetime(int(secrets['server_time']), utc=args.utc), 293 | 'Created via steamctl' if 'serial_number' in secrets else 'Imported from secret' 294 | ]) 295 | 296 | if rows: 297 | print_table(rows, 298 | ['Filename', 'Account', 'Token GID', 'Created', 'Note'], 299 | ) 300 | else: 301 | print("No authenticators found") 302 | 303 | def cmd_authenticator_status(args): 304 | account = args.account.lower().strip() 305 | secrets_file = UserDataFile('authenticator/{}.json'.format(account)) 306 | sa = None 307 | 308 | wa = BetterMWA(account) 309 | 310 | if secrets_file.exists(): 311 | sa = SteamAuthenticator(secrets_file.read_json(), backend=wa) 312 | 313 | try: 314 | wa.bcli_login(sa_instance=sa) 315 | except (KeyboardInterrupt, EOFError): 316 | print("Login interrupted") 317 | return 1 # error 318 | 319 | if sa is None: 320 | sa = SteamAuthenticator(backend=wa) 321 | 322 | status = sa.status() 323 | 324 | print("----- Status ------------") 325 | mode = status['steamguard_scheme'] 326 | 327 | if mode == 0: 328 | print("Steam Guard mode: disabled/insecure") 329 | elif mode == 1: 330 | print("Steam Guard mode: enabled (email)") 331 | elif mode == 2: 332 | print("Steam Guard mode: enabled (authenticator)") 333 | else: 334 | print("Steam Guard mode: unknown ({})".format(mode)) 335 | 336 | print("Authenticator enabled:", "Yes" if status['state'] == 1 else "No") 337 | print("Authenticator allowed:", "Yes" if status['state'] else "No") 338 | print("Email verified:", "Yes" if status['email_validated'] else "No") 339 | print("External allowed:", "Yes" if status['allow_external_authenticator'] else "No") 340 | 341 | if status['state'] == 1: 342 | print("----- Token details -----") 343 | print("Token GID:", status['token_gid']) 344 | print("Created at:", fmt_datetime(status['time_created'])) 345 | print("Device identifier:", status['device_identifier']) 346 | print("Classified agent:", status['classified_agent']) 347 | print("Revocation attempts remaining:", status['revocation_attempts_remaining']) 348 | 349 | -------------------------------------------------------------------------------- /steamctl/clients.py: -------------------------------------------------------------------------------- 1 | import gevent.monkey 2 | gevent.monkey.patch_socket() 3 | gevent.monkey.patch_ssl() 4 | 5 | import os 6 | import logging 7 | from time import time 8 | from steam.enums import EResult, EPersonaState 9 | from steam.client import SteamClient, _cli_input, getpass 10 | from steam.client.cdn import CDNClient, CDNDepotManifest, CDNDepotFile, ContentServer 11 | from steam.exceptions import SteamError 12 | from steam.core.crypto import sha1_hash 13 | 14 | from steamctl.utils.format import fmt_size 15 | from steamctl.utils.storage import (UserCacheFile, UserDataFile, 16 | UserCacheDirectory, UserDataDirectory, 17 | ensure_dir, sanitizerelpath 18 | ) 19 | 20 | cred_dir = UserDataDirectory('client') 21 | 22 | class CachingSteamClient(SteamClient): 23 | credential_location = cred_dir.path 24 | persona_state = EPersonaState.Offline 25 | 26 | def __init__(self, *args, **kwargs): 27 | if not cred_dir.exists(): 28 | cred_dir.mkdir() 29 | SteamClient.__init__(self, *args, **kwargs) 30 | _LOG = logging.getLogger('CachingSteamClient') 31 | self._bootstrap_cm_list_from_file() 32 | 33 | def _handle_login_key(self, message): 34 | SteamClient._handle_login_key(self, message) 35 | 36 | with UserDataFile('client/%s.key' % self.username).open('w') as fp: 37 | fp.write(self.login_key) 38 | 39 | def get_cdnclient(self): 40 | return CachingCDNClient(self) 41 | 42 | def login_from_args(self, args, print_status=True): 43 | result = None 44 | 45 | # anonymous login 46 | if args.anonymous and not args.user: 47 | self._LOG.info("Attempting anonymous login") 48 | return self.anonymous_login() 49 | 50 | # user login 51 | user = args.user 52 | lastFile = UserDataFile('client/lastuser') 53 | 54 | # check for previously used user 55 | if not user and lastFile.exists(): 56 | user = lastFile.read_text() 57 | 58 | if user: 59 | self._LOG.info("Reusing previous username: %s", user) 60 | self._LOG.info("Hint: use 'steamctl --user ...' to change") 61 | else: 62 | self._LOG.debug("lastuser file is empty?") 63 | lastFile.remove() 64 | 65 | if user: 66 | # attempt login 67 | self.username = user 68 | 69 | if not lastFile.exists() or lastFile.read_text() != self.username: 70 | lastFile.write_text(self.username) 71 | 72 | # check for userkey and login without a prompt 73 | userkey = UserDataFile('client/%s.key' % self.username) 74 | if userkey.exists(): 75 | self._LOG.info("Attempting login with remembered credentials") 76 | self.login_key = userkey.read_text() 77 | result = self.relogin() 78 | 79 | self._LOG.debug("Re-login result is: %s", repr(EResult(result))) 80 | 81 | if result == EResult.InvalidPassword: 82 | self._LOG.info("Remembered credentials have expired") 83 | userkey.remove() 84 | else: 85 | return result 86 | 87 | self.sleep(0.1) 88 | 89 | # attempt manual cli login 90 | if not self.logged_on: 91 | result = EResult.InvalidPassword 92 | 93 | if not self.username: 94 | self._LOG.info("Enter Steam login") 95 | self.username = _cli_input("Username: ") 96 | else: 97 | self._LOG.info("Enter credentials for: %s", self.username) 98 | 99 | if args.password: 100 | password = args.password 101 | else: 102 | password = getpass() 103 | 104 | # check for existing authenticator 105 | secrets_file = UserDataFile('authenticator/{}.json'.format(self.username)) 106 | 107 | if secrets_file.exists(): 108 | from steam.guard import SteamAuthenticator 109 | sa = SteamAuthenticator(secrets_file.read_json()) 110 | 111 | while result == EResult.InvalidPassword: 112 | result = self.login(self.username, password, two_factor_code=sa.get_code()) 113 | 114 | if result == EResult.InvalidPassword: 115 | if args.password: 116 | return result 117 | password = getpass("Invalid password for %s. Enter password: " % repr(self.username)) 118 | self.sleep(0.1) 119 | 120 | if result != EResult.OK: 121 | result = self.cli_login(self.username, password) 122 | 123 | if not lastFile.exists() or lastFile.read_text() != self.username: 124 | lastFile.write_text(self.username) 125 | 126 | self._LOG.debug("Login result is: %s", repr(EResult(result))) 127 | 128 | if not self.relogin_available: 129 | self.wait_event(self.EVENT_NEW_LOGIN_KEY, timeout=10) 130 | 131 | return result 132 | 133 | def check_for_changes(self): 134 | """Check for changes since last check, and expire any cached appinfo""" 135 | changefile = UserCacheFile('last_change_number') 136 | change_number = 0 137 | 138 | if changefile.exists(): 139 | try: 140 | change_number = int(changefile.read_text()) 141 | except: 142 | changefile.remove() 143 | 144 | self._LOG.debug("Checking PICS for app changes") 145 | resp = self.get_changes_since(change_number, True, False) 146 | 147 | if resp.force_full_app_update: 148 | change_number = 0 149 | 150 | if resp.current_change_number != change_number: 151 | with changefile.open('w') as fp: 152 | fp.write(str(resp.current_change_number)) 153 | 154 | changed_apps = set((entry.appid for entry in resp.app_changes)) 155 | 156 | if change_number == 0 or changed_apps: 157 | self._LOG.debug("Checking for outdated cached appinfo files") 158 | 159 | for appinfo_file in UserCacheDirectory('appinfo').iter_files('*.json'): 160 | app_id = int(appinfo_file.filename[:-5]) 161 | 162 | if change_number == 0 or app_id in changed_apps: 163 | appinfo_file.remove() 164 | 165 | def has_cached_appinfo(self, app_id): 166 | return UserCacheFile("appinfo/{}.json".format(app_id)).exists() 167 | 168 | def get_cached_appinfo(self, app_id): 169 | cache_file = UserCacheFile("appinfo/{}.json".format(app_id)) 170 | 171 | if cache_file.exists(): 172 | return cache_file.read_json() 173 | 174 | def get_product_info(self, apps=[], packages=[], *args, **kwargs): 175 | resp = {'apps': {}, 'packages': {}} 176 | 177 | # if we have cached info for all apps, just serve from cache 178 | if apps and all(map(self.has_cached_appinfo, apps)): 179 | self._LOG.debug("Serving appinfo from cache") 180 | 181 | for app_id in apps: 182 | resp['apps'][app_id] = self.get_cached_appinfo(app_id) 183 | 184 | apps = [] 185 | 186 | if apps or packages: 187 | self._LOG.debug("Fetching product info") 188 | fresh_resp = SteamClient.get_product_info(self, apps, packages, *args, **kwargs) 189 | 190 | if apps: 191 | for app_id, appinfo in fresh_resp['apps'].items(): 192 | if not appinfo['_missing_token']: 193 | UserCacheFile("appinfo/{}.json".format(app_id)).write_json(appinfo) 194 | resp = fresh_resp 195 | else: 196 | resp['packages'] = fresh_resp['packages'] 197 | 198 | return resp 199 | 200 | 201 | class CTLDepotFile(CDNDepotFile): 202 | _LOG = logging.getLogger('CTLDepotFile') 203 | 204 | def download_to(self, target, no_make_dirs=False, pbar=None, verify=True): 205 | relpath = sanitizerelpath(self.filename) 206 | 207 | if no_make_dirs: 208 | relpath = os.path.basename(relpath) 209 | 210 | relpath = os.path.join(target, relpath) 211 | 212 | filepath = os.path.abspath(relpath) 213 | ensure_dir(filepath) 214 | 215 | checksum = self.file_mapping.sha_content.hex() 216 | 217 | # don't bother verifying if file doesn't already exist 218 | if not os.path.exists(filepath): 219 | verify = False 220 | 221 | with open(filepath, 'r+b' if verify else 'wb') as fp: 222 | fp.seek(0, 2) 223 | 224 | # pre-allocate space 225 | if fp.tell() != self.size: 226 | newsize = fp.truncate(self.size) 227 | 228 | if newsize != self.size: 229 | raise SteamError("Failed allocating space for {}".format(filepath)) 230 | 231 | # self._LOG.info('{} {} ({}, sha1:{})'.format( 232 | # 'Verifying' if verify else 'Downloading', 233 | # relpath, 234 | # fmt_size(self.size), 235 | # checksum 236 | # )) 237 | 238 | fp.seek(0) 239 | for chunk in self.chunks: 240 | # verify chunk sha hash 241 | if verify: 242 | cur_data = fp.read(chunk.cb_original) 243 | 244 | if sha1_hash(cur_data) == chunk.sha: 245 | if pbar: 246 | pbar.update(chunk.cb_original) 247 | continue 248 | 249 | fp.seek(chunk.offset) # rewind before write 250 | 251 | # download and write chunk 252 | data = self.manifest.cdn_client.get_chunk( 253 | self.manifest.app_id, 254 | self.manifest.depot_id, 255 | chunk.sha.hex(), 256 | ) 257 | 258 | fp.write(data) 259 | 260 | if pbar: 261 | pbar.update(chunk.cb_original) 262 | 263 | class CTLDepotManifest(CDNDepotManifest): 264 | DepotFileClass = CTLDepotFile 265 | 266 | 267 | class CachingCDNClient(CDNClient): 268 | DepotManifestClass = CTLDepotManifest 269 | _LOG = logging.getLogger('CachingCDNClient') 270 | _depot_keys = None 271 | skip_licenses = False 272 | 273 | def __init__(self, *args, **kwargs): 274 | CDNClient.__init__(self, *args, **kwargs) 275 | 276 | def fetch_content_servers(self, *args, **kwargs): 277 | cached_cs = UserDataFile('cs_servers.json') 278 | 279 | data = cached_cs.read_json() 280 | 281 | # load from cache, only keep for 5 minutes 282 | if data and (data['timestamp'] + 300) > time(): 283 | for server in data['servers']: 284 | entry = ContentServer() 285 | entry.__dict__.update(server) 286 | self.servers.append(entry) 287 | return 288 | 289 | # fetch cs servers 290 | CDNClient.fetch_content_servers(self, *args, **kwargs) 291 | 292 | # cache cs servers 293 | data = { 294 | "timestamp": int(time()), 295 | "cell_id": self.cell_id, 296 | "servers": list(map(lambda x: x.__dict__, self.servers)), 297 | } 298 | 299 | cached_cs.write_json(data) 300 | 301 | @property 302 | def depot_keys(self): 303 | if not self._depot_keys: 304 | self._depot_keys.update(self.get_cached_depot_keys()) 305 | return self._depot_keys 306 | 307 | @depot_keys.setter 308 | def depot_keys(self, value): 309 | self._depot_keys = value 310 | 311 | def get_cached_depot_keys(self): 312 | return {int(depot_id): bytes.fromhex(key) 313 | for depot_id, key in (UserDataFile('depot_keys.json').read_json() or {}).items() 314 | } 315 | 316 | def save_cache(self): 317 | cached_depot_keys = self.get_cached_depot_keys() 318 | 319 | if cached_depot_keys == self.depot_keys: 320 | return 321 | 322 | self.depot_keys.update(cached_depot_keys) 323 | out = {str(depot_id): key.hex() 324 | for depot_id, key in self.depot_keys.items() 325 | } 326 | 327 | UserDataFile('depot_keys.json').write_json(out) 328 | 329 | def has_cached_app_depot_info(self, app_id): 330 | if app_id in self.app_depots: 331 | return True 332 | cached_appinfo = UserCacheFile("appinfo/{}.json".format(app_id)) 333 | if cached_appinfo.exists(): 334 | return True 335 | return False 336 | 337 | 338 | def has_license_for_depot(self, depot_id): 339 | if self.skip_licenses: 340 | return True 341 | else: 342 | return CDNClient.has_license_for_depot(self, depot_id) 343 | 344 | def get_app_depot_info(self, app_id): 345 | if app_id not in self.app_depots: 346 | try: 347 | appinfo = self.steam.get_product_info([app_id])['apps'][app_id] 348 | except KeyError: 349 | raise SteamError("Invalid app id") 350 | 351 | if appinfo['_missing_token']: 352 | raise SteamError("No access token available") 353 | 354 | self.app_depots[app_id] = appinfo.get('depots', {}) 355 | 356 | return self.app_depots[app_id] 357 | 358 | def get_cached_manifest(self, app_id, depot_id, manifest_gid): 359 | key = (app_id, depot_id, manifest_gid) 360 | 361 | if key in self.manifests: 362 | return self.manifests[key] 363 | 364 | # if we don't have the manifest loaded, check cache 365 | cached_manifest = UserCacheFile("manifests/{}_{}_{}".format(app_id, depot_id, manifest_gid)) 366 | 367 | # we have a cached manifest file, load it 368 | if cached_manifest.exists(): 369 | with cached_manifest.open('r+b') as fp: 370 | try: 371 | manifest = self.DepotManifestClass(self, app_id, fp.read()) 372 | except Exception as exp: 373 | self._LOG.debug("Error parsing cached manifest: %s", exp) 374 | else: 375 | # if its not empty, load it 376 | if manifest.gid > 0: 377 | self.manifests[key] = manifest 378 | 379 | # update cached file if we have depot key for it 380 | if manifest.filenames_encrypted and manifest.depot_id in self.depot_keys: 381 | manifest.decrypt_filenames(self.depot_keys[manifest.depot_id]) 382 | fp.seek(0) 383 | fp.write(manifest.serialize(compress=False)) 384 | fp.truncate() 385 | 386 | return manifest 387 | 388 | # empty manifest files shouldn't exist, handle it gracefully by removing the file 389 | if key not in self.manifests: 390 | self._LOG.debug("Found cached manifest, but encountered error or file is empty") 391 | cached_manifest.remove() 392 | 393 | def get_manifest(self, app_id, depot_id, manifest_gid, decrypt=True, manifest_request_code=None): 394 | key = (app_id, depot_id, manifest_gid) 395 | cached_manifest = UserCacheFile("manifests/{}_{}_{}".format(*key)) 396 | 397 | if decrypt and depot_id not in self.depot_keys: 398 | self.get_depot_key(app_id, depot_id) 399 | 400 | manifest = self.get_cached_manifest(*key) 401 | 402 | # if manifest not cached, download from CDN 403 | if not manifest: 404 | manifest = CDNClient.get_manifest( 405 | self, app_id, depot_id, manifest_gid, decrypt=decrypt, manifest_request_code=manifest_request_code 406 | ) 407 | 408 | # cache the manifest 409 | with cached_manifest.open('wb') as fp: 410 | fp.write(manifest.serialize(compress=False)) 411 | 412 | return self.manifests[key] 413 | -------------------------------------------------------------------------------- /steamctl/commands/depot/gcmds.py: -------------------------------------------------------------------------------- 1 | import gevent 2 | import gevent.monkey 3 | gevent.monkey.patch_socket() 4 | gevent.monkey.patch_select() 5 | gevent.monkey.patch_ssl() 6 | 7 | from gevent.pool import Pool as GPool 8 | 9 | import re 10 | import os 11 | import sys 12 | import logging 13 | from io import open 14 | from contextlib import contextmanager 15 | from re import search as re_search 16 | from fnmatch import fnmatch 17 | from binascii import unhexlify 18 | import vpk 19 | from steam import webapi 20 | from steam.exceptions import SteamError, ManifestError 21 | from steam.enums import EResult, EDepotFileFlag 22 | from steam.client import EMsg, MsgProto 23 | from steam.client.cdn import decrypt_manifest_gid_2 24 | from steamctl.clients import CachingSteamClient, CTLDepotManifest, CTLDepotFile 25 | from steamctl.utils.web import make_requests_session 26 | from steamctl.utils.format import fmt_size, fmt_datetime 27 | from steamctl.utils.tqdm import tqdm, fake_tqdm 28 | from steamctl.commands.webapi import get_webapi_key 29 | 30 | from steamctl.utils.storage import ensure_dir, sanitizerelpath 31 | 32 | webapi._make_requests_session = make_requests_session 33 | 34 | LOG = logging.getLogger(__name__) 35 | 36 | # overload VPK with a missing method 37 | class c_VPK(vpk.VPK): 38 | def c_iter_index(self): 39 | if self.tree: 40 | index = self.tree.items() 41 | else: 42 | index = self.read_index_iter() 43 | 44 | for path, metadata in index: 45 | yield path, metadata 46 | 47 | # find and cache paths to vpk depot files, and set them up to be read directly from CDN 48 | class ManifestFileIndex(object): 49 | def __init__(self, manifests): 50 | self.manifests = manifests 51 | self._path_cache = {} 52 | 53 | def _locate_file_mapping(self, path): 54 | ref = self._path_cache.get(path, None) 55 | 56 | if ref: 57 | return ref 58 | else: 59 | self._path_cache[path] = None 60 | 61 | for manifest in self.manifests: 62 | try: 63 | foundfile = next(manifest.iter_files(path)) 64 | except StopIteration: 65 | continue 66 | else: 67 | self._path_cache[path] = ref = (manifest, foundfile.file_mapping) 68 | return ref 69 | 70 | def index(self, pattern=None, raw=True): 71 | for manifest in self.manifests: 72 | for filematch in manifest.iter_files(pattern): 73 | filepath = filematch.filename_raw if raw else filematch.filename 74 | self._path_cache[filepath] = (manifest, filematch.file_mapping) 75 | 76 | def file_exists(self, path): 77 | return self._locate_file_mapping(path) != None 78 | 79 | def get_file(self, path, *args, **kwargs): 80 | ref = self._locate_file_mapping(path) 81 | if ref: 82 | return CTLDepotFile(*ref) 83 | raise SteamError("File not found: {}".format(path)) 84 | 85 | def get_vpk(self, path): 86 | return c_VPK(path, fopen=self.get_file) 87 | 88 | 89 | # vpkfile download task 90 | def vpkfile_download_to(vpk_path, vpkfile, target, no_make_dirs, pbar): 91 | relpath = sanitizerelpath(vpkfile.filepath) 92 | 93 | if no_make_dirs: 94 | relpath = os.path.join(target, # output directory 95 | os.path.basename(relpath)) # filename from vpk 96 | else: 97 | relpath = os.path.join(target, # output directory 98 | vpk_path[:-4], # vpk path with extention (e.g. pak01_dir) 99 | relpath) # vpk relative path 100 | 101 | filepath = os.path.abspath(relpath) 102 | ensure_dir(filepath) 103 | 104 | LOG.info("Downloading VPK file to {} ({}, crc32:{})".format(relpath, 105 | fmt_size(vpkfile.file_length), 106 | vpkfile.crc32, 107 | )) 108 | 109 | with open(filepath, 'wb') as fp: 110 | for chunk in iter(lambda: vpkfile.read(16384), b''): 111 | fp.write(chunk) 112 | 113 | if pbar: 114 | pbar.update(len(chunk)) 115 | 116 | @contextmanager 117 | def init_clients(args): 118 | s = CachingSteamClient() 119 | 120 | if args.cell_id is not None: 121 | s.cell_id = args.cell_id 122 | 123 | cdn = s.get_cdnclient() 124 | 125 | # short-curcuit everything, if we pass manifest file(s) 126 | if getattr(args, 'file', None): 127 | manifests = [] 128 | for file_list in args.file: 129 | for fp in file_list: 130 | manifest = CTLDepotManifest(cdn, args.app or -1, fp.read()) 131 | manifest.name = os.path.basename(fp.name) 132 | manifests.append(manifest) 133 | yield None, None, manifests 134 | return 135 | 136 | # for everything else we need SteamClient and CDNClient 137 | 138 | if not args.app: 139 | raise SteamError("No app id specified") 140 | 141 | # only login when we may need it 142 | if (not args.skip_login # user requested no login 143 | and (not args.app 144 | or not args.depot 145 | or not args.manifest 146 | or not cdn.get_cached_manifest(args.app, args.depot, args.manifest) 147 | or args.depot not in cdn.depot_keys 148 | ) 149 | ): 150 | result = s.login_from_args(args) 151 | 152 | if result == EResult.OK: 153 | LOG.info("Login to Steam successful") 154 | else: 155 | raise SteamError("Failed to login: %r" % result) 156 | else: 157 | LOG.info("Skipping login") 158 | 159 | if getattr(args, 'no_manifests', None): 160 | manifests = [] 161 | 162 | # when app, depot, and manifest are specified, we can just go to CDN 163 | elif args.app and args.depot and args.manifest: 164 | cached_manifest = cdn.get_cached_manifest(args.app, args.depot, args.manifest) 165 | 166 | if args.skip_login and not cached_manifest: 167 | raise SteamError("No cached manifest found. Steam login required to fetch manifest.") 168 | 169 | # we can only decrypt if SteamClient is logged in, or we have depot key cached 170 | if args.skip_login and args.depot not in cdn.depot_keys: 171 | decrypt = False 172 | else: 173 | decrypt = True 174 | 175 | # load the manifest 176 | try: 177 | if not cached_manifest: 178 | manifest_code = cdn.get_manifest_request_code( 179 | args.app, args.depot, args.manifest 180 | ) 181 | else: 182 | manifest_code = None 183 | 184 | manifests = [ 185 | cdn.get_manifest( 186 | args.app, args.depot, args.manifest, 187 | decrypt=decrypt, manifest_request_code=manifest_code 188 | ) 189 | ] 190 | except SteamError as exp: 191 | if exp.eresult == EResult.AccessDenied: 192 | raise SteamError("This account doesn't have access to the app depot", exp.eresult) 193 | elif 'HTTP Error 404' in str(exp): 194 | raise SteamError("Manifest not found on CDN") 195 | else: 196 | raise 197 | 198 | # if only app is specified, or app and depot, we need product info to figure out manifests 199 | else: 200 | # no license, means no depot keys, and possibly not product info 201 | if not args.skip_licenses: 202 | LOG.info("Checking licenses") 203 | 204 | if s.logged_on and not s.licenses and s.steam_id.type != s.steam_id.EType.AnonUser: 205 | s.wait_event(EMsg.ClientLicenseList, raises=False, timeout=10) 206 | 207 | cdn.load_licenses() 208 | 209 | if args.app not in cdn.licensed_app_ids: 210 | raise SteamError("No license available for App ID: %s" % args.app, EResult.AccessDenied) 211 | 212 | # check if we need to invalidate the cache data 213 | if not args.skip_login: 214 | LOG.info("Checking change list") 215 | s.check_for_changes() 216 | 217 | # handle any filtering on depot list 218 | def depot_filter(depot_id, info): 219 | if args.depot is not None and args.depot != depot_id: 220 | return False 221 | 222 | if args.skip_depot and depot_id in args.skip_depot: 223 | return False 224 | 225 | if args.os != 'any': 226 | if args.os[-2:] == '64': 227 | os, arch = args.os[:-2], args.os[-2:] 228 | else: 229 | os, arch = args.os, None 230 | 231 | config = info.get('config', {}) 232 | 233 | if 'oslist' in config and (os not in config['oslist'].split(',')): 234 | return False 235 | if 'osarch' in config and config['osarch'] != arch: 236 | return False 237 | 238 | return True 239 | 240 | if args.skip_login: 241 | if cdn.has_cached_app_depot_info(args.app): 242 | LOG.info("Using cached app info") 243 | else: 244 | raise SteamError("No cached app info. Login to Steam") 245 | 246 | branch = args.branch 247 | password = args.password 248 | cdn.skip_licenses = args.skip_licenses 249 | 250 | LOG.info("Getting manifests for %s branch", repr(branch)) 251 | 252 | # enumerate manifests 253 | manifests = [] 254 | for manifest in cdn.get_manifests(args.app, branch=branch, password=password, filter_func=depot_filter, decrypt=False): 255 | if manifest.filenames_encrypted: 256 | if not args.skip_login: 257 | try: 258 | manifest.decrypt_filenames(cdn.get_depot_key(manifest.app_id, manifest.depot_id)) 259 | except Exception as exp: 260 | LOG.error("Failed to decrypt manifest %s (depot %s): %s", manifest.gid, manifest.depot_id, str(exp)) 261 | 262 | if not args.skip_licenses: 263 | continue 264 | 265 | manifests.append(manifest) 266 | 267 | LOG.debug("Got manifests: %r", manifests) 268 | 269 | yield s, cdn, manifests 270 | 271 | # clean and exit 272 | cdn.save_cache() 273 | s.disconnect() 274 | 275 | def cmd_depot_info(args): 276 | try: 277 | with init_clients(args) as (_, cdn, manifests): 278 | for i, manifest in enumerate(manifests, 1): 279 | print("App ID:", manifest.app_id) 280 | print("Depot ID:", manifest.metadata.depot_id) 281 | print("Depot Name:", manifest.name if manifest.name else 'Unnamed Depot') 282 | print("Manifest GID:", manifest.metadata.gid_manifest) 283 | print("Created On:", fmt_datetime(manifest.metadata.creation_time)) 284 | print("Size:", fmt_size(manifest.metadata.cb_disk_original)) 285 | print("Compressed Size:", fmt_size(manifest.metadata.cb_disk_compressed)) 286 | nchunks = sum((len(file.chunks) for file in manifest.payload.mappings)) 287 | unique_chunks = manifest.metadata.unique_chunks 288 | print("Unique/Total chunks:", unique_chunks, "/", nchunks, "({:.2f}%)".format(((1-(unique_chunks / nchunks))*100) if nchunks else 0)) 289 | print("Encrypted Filenames:", repr(manifest.metadata.filenames_encrypted)) 290 | print("Number of Files:", len(manifest.payload.mappings)) 291 | 292 | if cdn: 293 | depot_info = cdn.app_depots.get(manifest.app_id, {}).get(str(manifest.metadata.depot_id)) 294 | 295 | if depot_info: 296 | print("Config:", depot_info.get('config', '{}')) 297 | if 'dlcappid' in depot_info: 298 | print("DLC AppID:", depot_info['dlcappid']) 299 | 300 | print("Branch:", args.branch) 301 | print("Open branches:", ', '.join(depot_info.get('manifests', {}).keys())) 302 | print("Protected branches:", ', '.join(depot_info.get('encryptedmanifests', {}).keys())) 303 | 304 | if i != len(manifests): 305 | print("-"*40) 306 | 307 | except SteamError as exp: 308 | LOG.error(str(exp)) 309 | return 1 # error 310 | 311 | def cmd_depot_list(args): 312 | def print_file_info(filepath, info=None): 313 | # filepath filtering 314 | if args.name and not fnmatch(filepath, args.name): 315 | return 316 | if args.regex and not re_search(args.regex, filepath): 317 | return 318 | 319 | # output 320 | if info: 321 | print("{} - {}".format(filepath, info)) 322 | else: 323 | print(filepath) 324 | 325 | try: 326 | with init_clients(args) as (_, _, manifests): 327 | fileindex = ManifestFileIndex(manifests) 328 | 329 | # pre-index vpk file to speed up lookups 330 | if args.vpk: 331 | fileindex.index('*.vpk') 332 | 333 | for manifest in manifests: 334 | LOG.debug("Processing: %r", manifest) 335 | 336 | if manifest.filenames_encrypted: 337 | LOG.error("Manifest %s (depot %s) filenames are encrypted.", manifest.gid, manifest.depot_id) 338 | continue 339 | 340 | for mapping in manifest.payload.mappings: 341 | # ignore symlinks and directorys 342 | if mapping.linktarget or mapping.flags & EDepotFileFlag.Directory: 343 | continue 344 | 345 | filepath = mapping.filename.rstrip('\x00 \n\t') 346 | 347 | # filepath filtering 348 | if ( (not args.name and not args.regex) 349 | or (args.name and fnmatch(filepath, args.name)) 350 | or (args.regex and re_search(args.regex, filepath)) 351 | ): 352 | 353 | # print out for manifest file 354 | if not args.long: 355 | print(filepath) 356 | else: 357 | print("{} - size:{:,d} sha1:{}".format( 358 | filepath, 359 | mapping.size, 360 | mapping.sha_content.hex(), 361 | ) 362 | ) 363 | 364 | # list files inside vpk 365 | if args.vpk and filepath.endswith('.vpk'): 366 | # fast skip VPKs that can't possibly match 367 | if args.name and ':' in args.name: 368 | pre = args.name.split(':', 1)[0] 369 | if not fnmatch(filepath, pre): 370 | continue 371 | if args.regex and ':' in args.regex: 372 | pre = args.regex.split(':', 1)[0] 373 | if not re_search(pre + '$', filepath): 374 | continue 375 | 376 | # scan VPKs, but skip data only ones 377 | if filepath.endswith('_dir.vpk') or not re.search("_\d+\.vpk$", filepath): 378 | LOG.debug("Scanning VPK file: %s", filepath) 379 | 380 | try: 381 | fvpk = fileindex.get_vpk(filepath) 382 | except ValueError as exp: 383 | LOG.error("VPK read error: %s", str(exp)) 384 | else: 385 | for vpkfile_path, (_, crc32, _, _, _, size) in fvpk.c_iter_index(): 386 | complete_path = "{}:{}".format(filepath, vpkfile_path) 387 | 388 | if ( (not args.name and not args.regex) 389 | or (args.name and fnmatch(complete_path, args.name)) 390 | or (args.regex and re_search(args.regex, complete_path)) 391 | ): 392 | 393 | if args.long: 394 | print("{} - size:{:,d} crc32:{}".format( 395 | complete_path, 396 | size, 397 | crc32, 398 | ) 399 | ) 400 | else: 401 | print(complete_path) 402 | 403 | 404 | except SteamError as exp: 405 | LOG.error(str(exp)) 406 | return 1 # error 407 | 408 | def cmd_depot_download(args): 409 | pbar = fake_tqdm() 410 | pbar2 = fake_tqdm() 411 | 412 | try: 413 | with init_clients(args) as (_, _, manifests): 414 | fileindex = ManifestFileIndex(manifests) 415 | 416 | # pre-index vpk file to speed up lookups 417 | if args.vpk: 418 | fileindex.index('*.vpk') 419 | 420 | # calculate total size 421 | total_files = 0 422 | total_size = 0 423 | 424 | LOG.info("Locating and counting files...") 425 | 426 | for manifest in manifests: 427 | for depotfile in manifest: 428 | if not depotfile.is_file: 429 | continue 430 | 431 | filepath = depotfile.filename_raw 432 | 433 | # list files inside vpk 434 | if args.vpk and filepath.endswith('.vpk'): 435 | # fast skip VPKs that can't possibly match 436 | if args.name and ':' in args.name: 437 | pre = args.name.split(':', 1)[0] 438 | if not fnmatch(filepath, pre): 439 | continue 440 | if args.regex and ':' in args.regex: 441 | pre = args.regex.split(':', 1)[0] 442 | if not re_search(pre + '$', filepath): 443 | continue 444 | 445 | # scan VPKs, but skip data only ones 446 | if filepath.endswith('_dir.vpk') or not re.search("_\d+\.vpk$", filepath): 447 | LOG.debug("Scanning VPK file: %s", filepath) 448 | 449 | try: 450 | fvpk = fileindex.get_vpk(filepath) 451 | except ValueError as exp: 452 | LOG.error("VPK read error: %s", str(exp)) 453 | else: 454 | for vpkfile_path, (_, _, _, _, _, size) in fvpk.c_iter_index(): 455 | complete_path = "{}:{}".format(filepath, vpkfile_path) 456 | 457 | if args.name and not fnmatch(complete_path, args.name): 458 | continue 459 | if args.regex and not re_search(args.regex, complete_path): 460 | continue 461 | 462 | total_files += 1 463 | total_size += size 464 | 465 | # account for depot files 466 | if args.name and not fnmatch(filepath, args.name): 467 | continue 468 | if args.regex and not re_search(args.regex, filepath): 469 | continue 470 | 471 | total_files += 1 472 | total_size += depotfile.size 473 | 474 | if not total_files: 475 | raise SteamError("No files found to download") 476 | 477 | # enable progress bar 478 | if not args.no_progress and sys.stderr.isatty(): 479 | pbar = tqdm(desc='Data ', mininterval=0.5, maxinterval=1, miniters=1024**3*10, total=total_size, unit='B', unit_scale=True) 480 | pbar2 = tqdm(desc='Files', mininterval=0.5, maxinterval=1, miniters=10, total=total_files, position=1, unit=' file', unit_scale=False) 481 | gevent.spawn(pbar.gevent_refresh_loop) 482 | gevent.spawn(pbar2.gevent_refresh_loop) 483 | 484 | # download files 485 | tasks = GPool(6) 486 | 487 | for manifest in manifests: 488 | if pbar2.n == total_files: 489 | break 490 | 491 | LOG.info("Processing manifest (%s) '%s' ..." % (manifest.gid, manifest.name or "")) 492 | 493 | for depotfile in manifest: 494 | if pbar2.n == total_files: 495 | break 496 | 497 | if not depotfile.is_file: 498 | continue 499 | 500 | filepath = depotfile.filename_raw 501 | 502 | if args.vpk and filepath.endswith('.vpk'): 503 | # fast skip VPKs that can't possibly match 504 | if args.name and ':' in args.name: 505 | pre = args.name.split(':', 1)[0] 506 | if not fnmatch(filepath, pre): 507 | continue 508 | if args.regex and ':' in args.regex: 509 | pre = args.regex.split(':', 1)[0] 510 | if not re_search(pre + '$', filepath): 511 | continue 512 | 513 | # scan VPKs, but skip data only ones 514 | if filepath.endswith('_dir.vpk') or not re.search("_\d+\.vpk$", filepath): 515 | LOG.debug("Scanning VPK file: %s", filepath) 516 | 517 | try: 518 | fvpk = fileindex.get_vpk(filepath) 519 | except ValueError as exp: 520 | LOG.error("VPK read error: %s", str(exp)) 521 | else: 522 | for vpkfile_path, metadata in fvpk.c_iter_index(): 523 | complete_path = "{}:{}".format(filepath, vpkfile_path) 524 | 525 | if args.name and not fnmatch(complete_path, args.name): 526 | continue 527 | if args.regex and not re_search(args.regex, complete_path): 528 | continue 529 | 530 | tasks.spawn(vpkfile_download_to, 531 | depotfile.filename, 532 | fvpk.get_vpkfile_instance(vpkfile_path, 533 | fvpk._make_meta_dict(metadata)), 534 | args.output, 535 | no_make_dirs=args.no_directories, 536 | pbar=pbar, 537 | ) 538 | 539 | pbar2.update(1) 540 | 541 | # break out of vpk file loop 542 | if pbar2.n == total_files: 543 | break 544 | 545 | # break out of depotfile loop 546 | if pbar2.n == total_files: 547 | break 548 | 549 | # filepath filtering 550 | if args.name and not fnmatch(filepath, args.name): 551 | continue 552 | if args.regex and not re_search(args.regex, filepath): 553 | continue 554 | 555 | tasks.spawn(depotfile.download_to, args.output, 556 | no_make_dirs=args.no_directories, 557 | pbar=pbar, 558 | verify=(not args.skip_verify), 559 | ) 560 | 561 | pbar2.update(1) 562 | 563 | # wait on all downloads to finish 564 | tasks.join() 565 | gevent.sleep(0.5) 566 | except KeyboardInterrupt: 567 | pbar.close() 568 | LOG.info("Download canceled") 569 | return 1 # error 570 | except SteamError as exp: 571 | pbar.close() 572 | pbar.write(str(exp)) 573 | return 1 # error 574 | else: 575 | pbar.close() 576 | if not args.no_progress: 577 | pbar2.close() 578 | pbar2.write('\n') 579 | LOG.info('Download complete') 580 | 581 | from hashlib import sha1 582 | 583 | def calc_sha1_for_file(path): 584 | checksum = sha1() 585 | 586 | with open(path, 'rb') as fp: 587 | for chunk in iter(lambda: fp.read(16384), b''): 588 | checksum.update(chunk) 589 | 590 | return checksum.digest() 591 | 592 | def cmd_depot_diff(args): 593 | try: 594 | with init_clients(args) as (_, _, manifests): 595 | targetdir = args.TARGETDIR 596 | fileindex = {} 597 | 598 | for manifest in manifests: 599 | LOG.debug("Scanning manifest: %r", manifest) 600 | for mfile in manifest.iter_files(): 601 | if not mfile.is_file: 602 | continue 603 | 604 | if args.name and not fnmatch(mfile.filename_raw, args.name): 605 | continue 606 | if args.regex and not re_search(args.regex, mfile.filename_raw): 607 | continue 608 | 609 | if args.show_extra: 610 | fileindex[mfile.filename] = mfile.file_mapping 611 | 612 | if args.hide_missing and args.hide_mismatch: 613 | continue 614 | 615 | full_filepath = os.path.join(targetdir, mfile.filename) 616 | if os.path.isfile(full_filepath): 617 | # do mismatch, checksum checking 618 | size = os.path.getsize(full_filepath) 619 | 620 | if size != mfile.size: 621 | print("Mismatch (size):", full_filepath) 622 | continue 623 | 624 | # valve sets the checksum for empty files to all nulls 625 | if size == 0: 626 | chucksum = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' 627 | else: 628 | chucksum = calc_sha1_for_file(full_filepath) 629 | 630 | if chucksum != mfile.file_mapping.sha_content: 631 | print("Mismatch (checksum):", full_filepath) 632 | 633 | elif not args.hide_missing: 634 | print("Missing file:", full_filepath) 635 | 636 | 637 | # walk file system and show files not in manifest(s) 638 | if args.show_extra: 639 | for cdir, _, files in os.walk(targetdir): 640 | for filename in files: 641 | filepath = os.path.join(cdir, filename) 642 | rel_filepath = os.path.relpath(filepath, targetdir) 643 | 644 | if rel_filepath.lower() not in fileindex: 645 | print("Not in manifest:", filepath) 646 | 647 | except KeyboardInterrupt: 648 | return 1 # error 649 | except SteamError as exp: 650 | LOG.error(exp) 651 | return 1 # error 652 | 653 | 654 | def _decrypt_gid(egid, key): 655 | try: 656 | gid = decrypt_manifest_gid_2(unhexlify(egid), unhexlify(key)) 657 | except Exception as exp: 658 | if 'unpack requires a buffer' in str(exp): 659 | print(' ', egid, '- incorrect decryption key') 660 | else: 661 | print(' ', egid, '- Error: ', str(exp)) 662 | else: 663 | print(' ', egid, '=', gid) 664 | 665 | def cmd_depot_decrypt_gid(args): 666 | args.cell_id = 0 667 | args.no_manifests = True 668 | args.skip_login = False 669 | args.depot = None 670 | 671 | valid_gids = [] 672 | 673 | for egid in args.manifest_gid: 674 | if not re.match(r'[0-9A-Za-z]{32}$', egid): 675 | LOG.error("Skipping invalid gid: %s", egid) 676 | else: 677 | valid_gids.append(egid) 678 | 679 | if not valid_gids: 680 | LOG.error("No valid gids left to check") 681 | return 1 # error 682 | 683 | # offline: decrypt gid via decryption key 684 | if args.key: 685 | if not re.match(r'[0-9A-Za-z]{64}$', args.key): 686 | LOG.error("Invalid decryption key (format: hex encoded, a-z0-9, 64 bytes)") 687 | return 1 # error 688 | 689 | for egid in valid_gids: 690 | _decrypt_gid(egid, args.key) 691 | 692 | return 693 | 694 | # online: use branch password to fetch decryption key and attempt to decrypt gid 695 | with init_clients(args) as (s, _, _): 696 | resp = s.send_job_and_wait(MsgProto(EMsg.ClientCheckAppBetaPassword), 697 | {'app_id': args.app, 'betapassword': args.password}) 698 | 699 | if resp.eresult == EResult.OK: 700 | LOG.debug("Unlocked following beta branches: %s", 701 | ', '.join(map(lambda x: x.betaname.lower(), resp.betapasswords))) 702 | 703 | for entry in resp.betapasswords: 704 | print("Password is valid for branch:", entry.betaname) 705 | for egid in valid_gids: 706 | _decrypt_gid(egid, entry.betapassword) 707 | else: 708 | raise SteamError("App beta password check failed.", EResult(resp.eresult)) 709 | --------------------------------------------------------------------------------