Skip to content

Commit 692aee5

Browse files
committed
WIP: steam upload
1 parent 92ff834 commit 692aee5

File tree

7 files changed

+184
-4
lines changed

7 files changed

+184
-4
lines changed

RELEASE.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
type: minor
2+
---
3+
Add support for uploading to the steam workshop.

mypy.ini

+3
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@
22
mypy_path = stubs
33
files = src
44
strict = True
5+
6+
[mypy-steam.*]
7+
ignore_missing_imports = True

pyproject.toml

+9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ build-backend = "flit_core.buildapi"
66
module = "tts"
77
dist-name = "tabletop-tools"
88
requires = [
9+
"appdirs",
910
"attrs",
1011
"bson",
1112
"requests",
@@ -16,6 +17,11 @@ author-email = "[email protected]"
1617
description-file = "README.rst"
1718
classifiers = ["License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)"]
1819

20+
[tool.flit.metadata.requires-extra]
21+
upload = [
22+
"steam",
23+
]
24+
1925
[tool.flit.metadata.urls]
2026
Repository = "https://github.com/tomprince/tabletop-tools"
2127

@@ -26,3 +32,6 @@ tts = "tts.cli:main"
2632
[tool.tts_tooling]
2733
release_file = "RELEASE.rst"
2834
changelog = "CHANGELOG.rst"
35+
36+
[tool.black]
37+
include = '\.pyi?$'

src/tts/cli.py

+46-4
Original file line numberDiff line numberDiff line change
@@ -37,23 +37,41 @@ def unpack_cmd(*, savegame_file: Optional[Path], fileid: Optional[int]) -> None:
3737
type=Path,
3838
nargs="?",
3939
)
40+
@app.argument("--fileid", type=int, help="Workshop file id to unpack.")
4041
@app.argument("--binary", action="store_true")
41-
def repack_cmd(*, savegame_file: Optional[Path], binary: bool) -> None:
42+
def repack_cmd(
43+
*, savegame_file: Optional[Path], fileid: Optional[int], binary: bool
44+
) -> None:
4245
from .config import config
4346
from .repack import repack
4447

45-
if not savegame_file:
48+
if fileid and savegame_file:
49+
raise Exception("Can't specify both a savegame file and a workshop file id.")
50+
elif fileid and binary:
51+
raise Exception("Can't specify both a workshop file id and '--binary'.")
52+
elif not savegame_file:
4653
if binary:
4754
savegame_file = Path("build/savegame.bson")
4855
else:
4956
savegame_file = Path("build/savegame.json")
5057

51-
if not savegame_file.parent.exists():
58+
if not fileid and not savegame_file.parent.exists():
5259
savegame_file.parent.mkdir(parents=True)
5360

5461
savegame = repack(config=config)
5562

56-
if binary:
63+
if fileid:
64+
import bson
65+
66+
from tts.steam import cli_login, update_file, upload_file
67+
68+
client = cli_login()
69+
# It appears that tabletop simulator depends on the file being named
70+
# `WorkshopUpload`.
71+
upload_file(client, "WorkshopUpload", bson.dumps(savegame))
72+
update_file(client, fileid, "WorkshopUpload")
73+
74+
elif binary:
5775
import bson
5876

5977
savegame_file.write_bytes(bson.dumps(savegame))
@@ -76,4 +94,28 @@ def download_cmd(*, fileid: int, output: Optional[Path]) -> None:
7694
output.write_text(format_json(mod))
7795

7896

97+
@app.command(
98+
"workshop-upload",
99+
help="Upload a mod to the steam workshop.",
100+
description="This will currently only update a existing mod.",
101+
)
102+
@app.argument("fileid", type=int)
103+
@app.argument(
104+
"savegame_file",
105+
metavar="savegame",
106+
type=Path,
107+
)
108+
def upload_cmd(*, fileid: int, savegame_file: Optional[Path]) -> None:
109+
import bson
110+
111+
from tts.steam import cli_login, update_file, upload_file
112+
113+
savegame = json.loads(savegame_file.read_text())
114+
client = cli_login()
115+
# It appears that tabletop simulator depends on the file being named
116+
# `WorkshopUpload`.
117+
upload_file(client, "WorkshopUpload", bson.dumps(savegame))
118+
update_file(client, fileid, "WorkshopUpload")
119+
120+
79121
main = app.main

src/tts/config.py

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
from pathlib import Path
22

33
import attr
4+
from appdirs import AppDirs
5+
6+
APPID = 286160
7+
8+
appdirs = AppDirs("tabletop-tools", appauthor=False)
49

510

611
@attr.s(auto_attribs=True)

src/tts/steam/__init__.py

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from pathlib import Path
2+
3+
from steam.client import SteamClient
4+
from steam.enums.common import EResult
5+
6+
from ..config import APPID, appdirs
7+
from ._upload import upload_file
8+
9+
__all__ = ["cli_login", "update_file", "upload_file"]
10+
11+
12+
def cli_login() -> SteamClient:
13+
cache_dir = Path(appdirs.user_cache_dir)
14+
client = SteamClient()
15+
client.credential_location = cache_dir.joinpath("steam")
16+
res = client.cli_login()
17+
if res != EResult.OK:
18+
raise Exception(f"Could not login: {res.name}")
19+
20+
return client
21+
22+
23+
def update_file(client: SteamClient, file_id: int, file_name: str) -> None:
24+
resp = client.send_um_and_wait(
25+
"PublishedFile.Update#1",
26+
{
27+
"appid": APPID,
28+
"publishedfileid": file_id,
29+
"filename": file_name,
30+
},
31+
)
32+
if resp.header.eresult != EResult.OK:
33+
raise Exception(f"Couldn't publish file: {EResult(resp.header.eresult).name}")

src/tts/steam/_upload.py

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import hashlib
2+
import time
3+
4+
import requests
5+
from steam.client import SteamClient
6+
from steam.enums.common import EResult
7+
from steam.protobufs.steammessages_cloud_pb2 import ClientCloudFileUploadBlockDetails
8+
9+
from ..config import APPID
10+
11+
12+
def _hash_sha1(data: bytes) -> bytes:
13+
m = hashlib.sha1()
14+
m.update(data)
15+
return m.digest()
16+
17+
18+
def upload_file(client: SteamClient, file_name: str, file_data: bytes) -> None:
19+
file_sha = _hash_sha1(file_data)
20+
resp = client.send_um_and_wait(
21+
"Cloud.ClientBeginFileUpload#1",
22+
{
23+
"appid": APPID,
24+
"file_size": len(file_data),
25+
"raw_file_size": len(file_data),
26+
"file_sha": file_sha,
27+
"filename": file_name,
28+
"can_encrypt": False,
29+
"time_stamp": int(time.time()),
30+
"is_shared_file": False,
31+
},
32+
)
33+
if resp.header.eresult == EResult.DuplicateRequest:
34+
# Already uploaded.
35+
return
36+
if resp.header.eresult != EResult.OK:
37+
raise Exception(f"Couldn't start upload: {EResult(resp.header.eresult).name}")
38+
try:
39+
for block_request in resp.body.block_requests:
40+
_upload_block(block_request, file_data)
41+
except Exception:
42+
client.send_um_and_wait(
43+
"Cloud.ClientCommitFileUpload#1",
44+
{
45+
"appid": APPID,
46+
"filename": file_name,
47+
"file_sha": file_sha,
48+
"transfer_succeeded": False,
49+
},
50+
)
51+
raise
52+
else:
53+
resp = client.send_um_and_wait(
54+
"Cloud.ClientCommitFileUpload#1",
55+
{
56+
"appid": APPID,
57+
"filename": file_name,
58+
"file_sha": file_sha,
59+
"transfer_succeeded": True,
60+
},
61+
)
62+
if resp.header.eresult != EResult.OK:
63+
raise Exception(
64+
f"Couldn't commit upload: {EResult(resp.header.eresult).name}"
65+
)
66+
67+
68+
def _upload_block(
69+
block_request: ClientCloudFileUploadBlockDetails, file_data: bytes
70+
) -> None:
71+
url = f"https://{block_request.url_host}{block_request.url_path}"
72+
headers = {}
73+
for header in block_request.request_headers:
74+
if header.name == "Content-Disposition":
75+
headers[header.name] = header.value.rstrip(";")
76+
else:
77+
headers[header.name] = header.value
78+
block_end = block_request.block_offset + block_request.block_length
79+
block_data = file_data[block_request.block_offset : block_end]
80+
headers["Content-Length"] = f"{block_request.block_length}"
81+
headers[
82+
"Content-Range"
83+
] = f"bytes {block_request.block_offset}-{block_end}/{len(file_data)}"
84+
response = requests.put(url, headers=headers, data=block_data)
85+
response.raise_for_status()

0 commit comments

Comments
 (0)