rbx-upload 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "mcp__acp__Bash",
5
+ "mcp__acp__Write"
6
+ ]
7
+ }
8
+ }
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .env
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: rbx-upload
3
+ Version: 0.1.0
4
+ Summary: Roblox asset upload client
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: httpx>=0.25.0
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "rbx-upload"
3
+ version = "0.1.0"
4
+ description = "Roblox asset upload client"
5
+ requires-python = ">=3.13"
6
+ dependencies = [
7
+ "httpx>=0.25.0",
8
+ ]
9
+ license = "MIT"
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [tool.hatch.build.targets.wheel]
16
+ packages = ["src/rbx_upload"]
@@ -0,0 +1,11 @@
1
+ from .client import RobloxClient, RateLimitError
2
+ from .models import RbxAsset, ClothingAsset, RbxCreator, RbxAssetType
3
+
4
+ __all__ = [
5
+ "RobloxClient",
6
+ "RateLimitError",
7
+ "RbxAsset",
8
+ "ClothingAsset",
9
+ "RbxCreator",
10
+ "RbxAssetType",
11
+ ]
@@ -0,0 +1,248 @@
1
+ import asyncio
2
+ import json
3
+ import uuid
4
+ import xml.etree.ElementTree
5
+
6
+ import httpx
7
+
8
+ from .models import ClothingAsset, RbxAsset, RbxAssetType, RbxCreator
9
+
10
+
11
+ class RateLimitError(Exception):
12
+ """Raised when hitting Roblox rate limits (HTTP 429)."""
13
+
14
+ pass
15
+
16
+
17
+ class RobloxClient:
18
+ def __init__(
19
+ self,
20
+ roblosecurity: str,
21
+ publisher_user_id: int,
22
+ proxy: str | None = None,
23
+ ):
24
+ self._roblosecurity = roblosecurity
25
+ self._publisher_user_id = publisher_user_id
26
+ self._proxy = proxy
27
+ self._http = httpx.AsyncClient()
28
+
29
+ self._fetch_headers = {
30
+ "Cookie": f".ROBLOSECURITY={roblosecurity}",
31
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
32
+ }
33
+ self._csrf_headers = {
34
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
35
+ "Referer": "https://create.roblox.com/",
36
+ "Origin": "https://create.roblox.com",
37
+ }
38
+ self._csrf_cookies = {".ROBLOSECURITY": roblosecurity}
39
+
40
+ def _proxy_url(self, url: str) -> str:
41
+ if not self._proxy:
42
+ return url
43
+ return url.replace("roblox.com", self._proxy)
44
+
45
+ async def _get_csrf_token(self) -> str:
46
+ url = self._proxy_url("https://apis.roblox.com/assets/user-auth/v1/assets")
47
+ response = await self._http.post(
48
+ url, cookies=self._csrf_cookies, headers=self._csrf_headers
49
+ )
50
+ csrf = response.headers.get("X-CSRF-TOKEN")
51
+ if not csrf:
52
+ raise httpx.HTTPStatusError(
53
+ "Failed to retrieve X-CSRF-TOKEN.",
54
+ request=response.request,
55
+ response=response,
56
+ )
57
+ return csrf
58
+
59
+ async def _economy_request(self, asset_id: int) -> httpx.Response:
60
+ url = self._proxy_url(
61
+ f"https://economy.roblox.com/v2/assets/{asset_id}/details"
62
+ )
63
+ return await self._http.get(url, headers=self._fetch_headers)
64
+
65
+ async def _asset_delivery_request(self, asset_id: int) -> httpx.Response:
66
+ url = self._proxy_url(
67
+ f"https://assetdelivery.roblox.com/v1/asset/?id={asset_id}"
68
+ )
69
+ return await self._http.get(
70
+ url, headers=self._fetch_headers, follow_redirects=True
71
+ )
72
+
73
+ async def _get_asset_xml(self, asset: RbxAsset) -> xml.etree.ElementTree.Element:
74
+ response = await self._asset_delivery_request(asset.asset_id)
75
+ response.raise_for_status()
76
+ content = response.content.decode("utf-8")
77
+ return xml.etree.ElementTree.fromstring(content)
78
+
79
+ @staticmethod
80
+ def _get_shirt_template_id_from_xml(root: xml.etree.ElementTree.Element) -> int:
81
+ url_element = root.find(".//url")
82
+ if url_element is None:
83
+ raise ValueError("XML did not contain a <url> tag.")
84
+ url = url_element.text
85
+ if not url:
86
+ raise ValueError("<url> tag did not contain any text.")
87
+ return int(url.split("id=")[1])
88
+
89
+ async def asset_from_id(self, asset_id: int) -> RbxAsset:
90
+ """Fetch asset information from Roblox by asset ID."""
91
+ response = await self._economy_request(asset_id)
92
+ response.raise_for_status()
93
+ asset_info = response.json()
94
+ creator_info = asset_info["Creator"]
95
+ creator = RbxCreator(
96
+ creator_id=creator_info["Id"],
97
+ username=creator_info["Name"],
98
+ creator_type=creator_info["CreatorType"],
99
+ )
100
+ asset_type_id = asset_info["AssetTypeId"]
101
+ if asset_type_id in (RbxAssetType.SHIRT, RbxAssetType.PANTS):
102
+ return ClothingAsset(
103
+ asset_id=asset_info["AssetId"],
104
+ creator=creator,
105
+ name=asset_info["Name"],
106
+ description=asset_info["Description"],
107
+ asset_type=asset_type_id,
108
+ )
109
+ return RbxAsset(
110
+ asset_id=asset_info["AssetId"],
111
+ creator=creator,
112
+ name=asset_info["Name"],
113
+ description=asset_info["Description"],
114
+ asset_type=asset_type_id,
115
+ )
116
+
117
+ async def fetch_clothing_image(self, asset: ClothingAsset) -> bytes:
118
+ """Fetch the image data for a clothing asset."""
119
+ xml_root = await self._get_asset_xml(asset)
120
+ template_id = self._get_shirt_template_id_from_xml(xml_root)
121
+ image = await self._asset_delivery_request(template_id)
122
+ image.raise_for_status()
123
+ return image.content
124
+
125
+ async def upload_clothing_image(
126
+ self,
127
+ image: bytes,
128
+ name: str,
129
+ description: str,
130
+ asset_type: RbxAssetType,
131
+ group_id: int,
132
+ ) -> dict:
133
+ """Upload a clothing image to Roblox and return the operation result."""
134
+ csrf = await self._get_csrf_token()
135
+ upload_url = self._proxy_url(
136
+ "https://apis.roblox.com/assets/user-auth/v1/assets"
137
+ )
138
+ meta = {
139
+ "displayName": name,
140
+ "description": description,
141
+ "assetType": asset_type,
142
+ "creationContext": {
143
+ "creator": {"groupId": group_id},
144
+ "expectedPrice": 10,
145
+ },
146
+ }
147
+ upload_headers = {
148
+ "X-CSRF-TOKEN": csrf,
149
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
150
+ "Accept": "*/*",
151
+ "Accept-Language": "en-US,en;q=0.5",
152
+ "Referer": "https://create.roblox.com/",
153
+ "Origin": "https://create.roblox.com",
154
+ "Sec-Fetch-Dest": "empty",
155
+ "Sec-Fetch-Mode": "cors",
156
+ "Sec-Fetch-Site": "same-site",
157
+ }
158
+ response = await self._http.post(
159
+ upload_url,
160
+ files={
161
+ "request": (None, json.dumps(meta), "application/json"),
162
+ "fileContent": ("clothing_upload", image, "image/png"),
163
+ },
164
+ headers=upload_headers,
165
+ cookies=self._csrf_cookies,
166
+ )
167
+
168
+ if response.status_code == 429:
169
+ raise RateLimitError("Rate limit hit during upload")
170
+
171
+ response.raise_for_status()
172
+ data = response.json()
173
+
174
+ operation_id = data.get("operationId")
175
+ if operation_id:
176
+ for _ in range(10):
177
+ await asyncio.sleep(1)
178
+ op_response = await self._http.get(
179
+ self._proxy_url(
180
+ f"https://apis.roblox.com/assets/user-auth/v1/operations/{operation_id}"
181
+ ),
182
+ headers={"X-CSRF-TOKEN": csrf},
183
+ cookies=self._csrf_cookies,
184
+ )
185
+ op_response.raise_for_status()
186
+ op_data = op_response.json()
187
+ if op_data.get("done"):
188
+ if op_data.get("response", {}).get("assetId"):
189
+ return {"asset_id": op_data["response"]["assetId"]}
190
+ return op_data
191
+
192
+ return data
193
+
194
+ async def onsale_asset(
195
+ self,
196
+ asset_id: int,
197
+ name: str,
198
+ description: str,
199
+ group_id: int,
200
+ price: int = 5,
201
+ ) -> dict:
202
+ """Put an asset on sale as a collectible."""
203
+ csrf = await self._get_csrf_token()
204
+ data = {
205
+ "saleLocationConfiguration": {"saleLocationType": 1, "places": []},
206
+ "targetId": asset_id,
207
+ "priceInRobux": price,
208
+ "publishingType": 2,
209
+ "idempotencyToken": str(uuid.uuid4()),
210
+ "publisherUserId": self._publisher_user_id,
211
+ "creatorGroupId": group_id,
212
+ "name": name,
213
+ "description": description,
214
+ "isFree": False,
215
+ "agreedPublishingFee": 0,
216
+ "priceOffset": 0,
217
+ "quantity": 0,
218
+ "quantityLimitPerUser": 0,
219
+ "resaleRestriction": 2,
220
+ "targetType": 0,
221
+ }
222
+ response = await self._http.post(
223
+ self._proxy_url("https://itemconfiguration.roblox.com/v1/collectibles"),
224
+ json=data,
225
+ headers={
226
+ "X-CSRF-TOKEN": csrf,
227
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
228
+ "Referer": "https://create.roblox.com/",
229
+ "Origin": "https://create.roblox.com",
230
+ },
231
+ cookies=self._csrf_cookies,
232
+ )
233
+
234
+ if response.status_code == 429:
235
+ raise RateLimitError("Rate limit hit during onsale")
236
+
237
+ response.raise_for_status()
238
+ return response.json()
239
+
240
+ async def close(self):
241
+ """Close the underlying HTTP client."""
242
+ await self._http.aclose()
243
+
244
+ async def __aenter__(self):
245
+ return self
246
+
247
+ async def __aexit__(self, *args):
248
+ await self.close()
@@ -0,0 +1,56 @@
1
+ from enum import IntEnum
2
+ from typing import Literal
3
+
4
+
5
+ class RbxAssetType(IntEnum):
6
+ IMAGE = 1
7
+ SHIRT = 11
8
+ PANTS = 12
9
+
10
+
11
+ ClothingAssetType = Literal[
12
+ RbxAssetType.SHIRT,
13
+ RbxAssetType.PANTS,
14
+ ]
15
+ CreatorType = Literal["User", "Group"]
16
+
17
+
18
+ class RbxCreator:
19
+ def __init__(self, creator_id: int, username: str, creator_type: CreatorType):
20
+ self.creator_id = creator_id
21
+ self.username = username
22
+ self.creator_type = creator_type
23
+
24
+
25
+ class RbxAsset:
26
+ def __init__(
27
+ self,
28
+ asset_id: int,
29
+ creator: RbxCreator,
30
+ name: str,
31
+ description: str,
32
+ asset_type: RbxAssetType,
33
+ ) -> None:
34
+ self.asset_id = asset_id
35
+ self.name = name
36
+ self.description = description
37
+ self.creator = creator
38
+ self.asset_type = asset_type
39
+
40
+
41
+ class ClothingAsset(RbxAsset):
42
+ def __init__(
43
+ self,
44
+ asset_id: int,
45
+ creator: RbxCreator,
46
+ name: str,
47
+ description: str,
48
+ asset_type: ClothingAssetType,
49
+ ) -> None:
50
+ super().__init__(
51
+ asset_id=asset_id,
52
+ creator=creator,
53
+ name=name,
54
+ description=description,
55
+ asset_type=asset_type,
56
+ )