Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Add support for directly uploading to steam. #13

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
type: minor
---
Add support for uploading to the steam workshop.
3 changes: 3 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
mypy_path = stubs
files = src
strict = True

[mypy-steam.*]
ignore_missing_imports = True
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ build-backend = "flit_core.buildapi"
module = "tts"
dist-name = "tabletop-tools"
requires = [
"appdirs",
"attrs",
"bson",
"requests",
Expand All @@ -16,6 +17,11 @@ author-email = "[email protected]"
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"

Expand All @@ -26,3 +32,6 @@ tts = "tts.cli:main"
[tool.tts_tooling]
release_file = "RELEASE.rst"
changelog = "CHANGELOG.rst"

[tool.black]
include = '\.pyi?$'
61 changes: 57 additions & 4 deletions src/tts/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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
5 changes: 5 additions & 0 deletions src/tts/config.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
33 changes: 33 additions & 0 deletions src/tts/steam/__init__.py
Original file line number Diff line number Diff line change
@@ -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}")
85 changes: 85 additions & 0 deletions src/tts/steam/_upload.py
Original file line number Diff line number Diff line change
@@ -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()
26 changes: 26 additions & 0 deletions stubs/appdirs.pyi
Original file line number Diff line number Diff line change
@@ -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: ...
4 changes: 2 additions & 2 deletions stubs/bson.pyi
Original file line number Diff line number Diff line change
@@ -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: ...