diff --git a/RELEASE.rst b/RELEASE.rst new file mode 100644 index 0000000..be7273f --- /dev/null +++ b/RELEASE.rst @@ -0,0 +1,3 @@ +type: minor +--- +Add support for uploading to the steam workshop. diff --git a/mypy.ini b/mypy.ini index d4f9ca9..0ef4f55 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2,3 +2,6 @@ mypy_path = stubs files = src strict = True + +[mypy-steam.*] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index 758995a..6078b93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ build-backend = "flit_core.buildapi" module = "tts" dist-name = "tabletop-tools" requires = [ + "appdirs", "attrs", "bson", "requests", @@ -16,6 +17,11 @@ author-email = "tom.prince@hocat.ca" description-file = "README.rst" classifiers = ["License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)"] +[tool.flit.metadata.requires-extra] +upload = [ + "steam", +] + [tool.flit.metadata.urls] Repository = "https://github.com/tomprince/tabletop-tools" @@ -26,3 +32,6 @@ tts = "tts.cli:main" [tool.tts_tooling] release_file = "RELEASE.rst" changelog = "CHANGELOG.rst" + +[tool.black] +include = '\.pyi?$' diff --git a/src/tts/cli.py b/src/tts/cli.py index 2b15d7b..c6d7569 100644 --- a/src/tts/cli.py +++ b/src/tts/cli.py @@ -36,18 +36,47 @@ def unpack_cmd(*, savegame_file: Optional[Path], fileid: Optional[int]) -> None: dest="savegame_file", type=Path, nargs="?", - default="build/savegame.json", ) -def repack_cmd(*, savegame_file: Path) -> None: +@app.argument("--fileid", type=int, help="Workshop file id to unpack.") +@app.argument("--binary", action="store_true") +def repack_cmd( + *, savegame_file: Optional[Path], fileid: Optional[int], binary: bool +) -> None: from .config import config from .repack import repack - if not savegame_file.parent.exists(): + if fileid and savegame_file: + raise Exception("Can't specify both a savegame file and a workshop file id.") + elif fileid and binary: + raise Exception("Can't specify both a workshop file id and '--binary'.") + elif not savegame_file: + if binary: + savegame_file = Path("build/savegame.bson") + else: + savegame_file = Path("build/savegame.json") + + if not fileid and not savegame_file.parent.exists(): savegame_file.parent.mkdir(parents=True) savegame = repack(config=config) - savegame_file.write_text(format_json(savegame)) + if fileid: + import bson + + from tts.steam import cli_login, update_file, upload_file + + client = cli_login() + # It appears that tabletop simulator depends on the file being named + # `WorkshopUpload`. + upload_file(client, "WorkshopUpload", bson.dumps(savegame)) + update_file(client, fileid, "WorkshopUpload") + + elif binary: + import bson + + savegame_file.write_bytes(bson.dumps(savegame)) + else: + savegame_file.write_text(format_json(savegame)) @app.command("workshop-download", help="Download a mod from the steam workshop.") @@ -65,4 +94,28 @@ def download_cmd(*, fileid: int, output: Optional[Path]) -> None: output.write_text(format_json(mod)) +@app.command( + "workshop-upload", + help="Upload a mod to the steam workshop.", + description="This will currently only update a existing mod.", +) +@app.argument("fileid", type=int) +@app.argument( + "savegame_file", + metavar="savegame", + type=Path, +) +def upload_cmd(*, fileid: int, savegame_file: Path) -> None: + import bson + + from tts.steam import cli_login, update_file, upload_file + + savegame = json.loads(savegame_file.read_text()) + client = cli_login() + # It appears that tabletop simulator depends on the file being named + # `WorkshopUpload`. + upload_file(client, "WorkshopUpload", bson.dumps(savegame)) + update_file(client, fileid, "WorkshopUpload") + + main = app.main diff --git a/src/tts/config.py b/src/tts/config.py index c7a40e2..17a19bf 100644 --- a/src/tts/config.py +++ b/src/tts/config.py @@ -1,6 +1,11 @@ from pathlib import Path import attr +from appdirs import AppDirs + +APPID = 286160 + +appdirs = AppDirs("tabletop-tools", appauthor=False) @attr.s(auto_attribs=True) diff --git a/src/tts/steam/__init__.py b/src/tts/steam/__init__.py new file mode 100644 index 0000000..0666162 --- /dev/null +++ b/src/tts/steam/__init__.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from steam.client import SteamClient +from steam.enums.common import EResult + +from ..config import APPID, appdirs +from ._upload import upload_file + +__all__ = ["cli_login", "update_file", "upload_file"] + + +def cli_login() -> SteamClient: + cache_dir = Path(appdirs.user_cache_dir) + client = SteamClient() + client.credential_location = cache_dir.joinpath("steam") + res = client.cli_login() + if res != EResult.OK: + raise Exception(f"Could not login: {res.name}") + + return client + + +def update_file(client: SteamClient, file_id: int, file_name: str) -> None: + resp = client.send_um_and_wait( + "PublishedFile.Update#1", + { + "appid": APPID, + "publishedfileid": file_id, + "filename": file_name, + }, + ) + if resp.header.eresult != EResult.OK: + raise Exception(f"Couldn't publish file: {EResult(resp.header.eresult).name}") diff --git a/src/tts/steam/_upload.py b/src/tts/steam/_upload.py new file mode 100644 index 0000000..297a615 --- /dev/null +++ b/src/tts/steam/_upload.py @@ -0,0 +1,85 @@ +import hashlib +import time + +import requests +from steam.client import SteamClient +from steam.enums.common import EResult +from steam.protobufs.steammessages_cloud_pb2 import ClientCloudFileUploadBlockDetails + +from ..config import APPID + + +def _hash_sha1(data: bytes) -> bytes: + m = hashlib.sha1() + m.update(data) + return m.digest() + + +def upload_file(client: SteamClient, file_name: str, file_data: bytes) -> None: + file_sha = _hash_sha1(file_data) + resp = client.send_um_and_wait( + "Cloud.ClientBeginFileUpload#1", + { + "appid": APPID, + "file_size": len(file_data), + "raw_file_size": len(file_data), + "file_sha": file_sha, + "filename": file_name, + "can_encrypt": False, + "time_stamp": int(time.time()), + "is_shared_file": False, + }, + ) + if resp.header.eresult == EResult.DuplicateRequest: + # Already uploaded. + return + if resp.header.eresult != EResult.OK: + raise Exception(f"Couldn't start upload: {EResult(resp.header.eresult).name}") + try: + for block_request in resp.body.block_requests: + _upload_block(block_request, file_data) + except Exception: + client.send_um_and_wait( + "Cloud.ClientCommitFileUpload#1", + { + "appid": APPID, + "filename": file_name, + "file_sha": file_sha, + "transfer_succeeded": False, + }, + ) + raise + else: + resp = client.send_um_and_wait( + "Cloud.ClientCommitFileUpload#1", + { + "appid": APPID, + "filename": file_name, + "file_sha": file_sha, + "transfer_succeeded": True, + }, + ) + if resp.header.eresult != EResult.OK: + raise Exception( + f"Couldn't commit upload: {EResult(resp.header.eresult).name}" + ) + + +def _upload_block( + block_request: ClientCloudFileUploadBlockDetails, file_data: bytes +) -> None: + url = f"https://{block_request.url_host}{block_request.url_path}" + headers = {} + for header in block_request.request_headers: + if header.name == "Content-Disposition": + headers[header.name] = header.value.rstrip(";") + else: + headers[header.name] = header.value + block_end = block_request.block_offset + block_request.block_length + block_data = file_data[block_request.block_offset : block_end] + headers["Content-Length"] = f"{block_request.block_length}" + headers[ + "Content-Range" + ] = f"bytes {block_request.block_offset}-{block_end}/{len(file_data)}" + response = requests.put(url, headers=headers, data=block_data) + response.raise_for_status() diff --git a/stubs/appdirs.pyi b/stubs/appdirs.pyi new file mode 100644 index 0000000..2a8fb13 --- /dev/null +++ b/stubs/appdirs.pyi @@ -0,0 +1,26 @@ +from typing import Literal, Optional, Union + + +class AppDirs: + def __init__( + self, + appname: Optional[str] = ..., + appauthor: Union[str, Literal[False], None] = ..., + version: Optional[str] = ..., + roaming: bool = ..., + multipath: bool = ..., + ) -> None: ... + @property + def user_data_dir(self) -> str: ... + @property + def site_data_dir(self) -> str: ... + @property + def user_config_dir(self) -> str: ... + @property + def site_config_dir(self) -> str: ... + @property + def user_cache_dir(self) -> str: ... + @property + def user_state_dir(self) -> str: ... + @property + def user_log_dir(self) -> str: ... diff --git a/stubs/bson.pyi b/stubs/bson.pyi index df81dec..8cc10b0 100644 --- a/stubs/bson.pyi +++ b/stubs/bson.pyi @@ -1,4 +1,4 @@ from typing import Any, Dict -def loads(data: bytes) -> Dict[Any, Any]: - ... +def loads(data: bytes) -> Dict[Any, Any]: ... +def dumps(data: Dict[Any, Any]) -> bytes: ...