From ffd5b3b8831a1224f534939b51946d551b119966 Mon Sep 17 00:00:00 2001 From: filoxenace Date: Sun, 21 Dec 2025 21:05:20 -0500 Subject: [PATCH] Add onsaling + better documentation. --- .env.example | 1 + README.md | 3 ++ src/main.py | 16 +++++-- src/utils/roblox_service.py | 87 +++++++++++++++++++++++++++++++++++-- 4 files changed, 100 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index a6b1ccf..0b27e8f 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ TARGET_ID= VALID_API_KEY= ROBLOSECURITY_TOKEN= +PUBLISHER_USER_ID= diff --git a/README.md b/README.md index b347c4f..9afc51d 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,11 @@ Create a `.env` file in the project root with the following variables: TARGET_ID= # The Roblox group ID to upload clothing to VALID_API_KEY= # API key for authorizing requests to this service ROBLOSECURITY_TOKEN= # Your Roblox roblosecurity cookie (used only for Roblox API calls) +PUBLISHER_USER_ID= # The Roblox user ID associated with the ROBLOSECURITY_TOKEN ``` +**Important:** `PUBLISHER_USER_ID` must match the user ID of the account that owns the `ROBLOSECURITY_TOKEN`. + ## ⚠️ Disclaimer This tool uses Roblox's APIs in a way that violates their Terms of Service. Roblox may moderate or ban accounts that use this. Use at your own risk. diff --git a/src/main.py b/src/main.py index 0895e28..72d6333 100644 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,7 @@ import os from dotenv import load_dotenv -from fastapi import FastAPI, Depends, HTTPException, status +from fastapi import Depends, FastAPI, HTTPException, status from fastapi.security import APIKeyHeader import models @@ -12,7 +12,7 @@ load_dotenv() TARGET = os.getenv("TARGET_ID") VALID_API_KEY = os.getenv("VALID_API_KEY") -if not TARGET: +if TARGET is None: raise EnvironmentError("TARGET_ID is missing from environment.") if not VALID_API_KEY: raise EnvironmentError("VALID_API_KEY is missing from environment.") @@ -25,8 +25,7 @@ api_key_header = APIKeyHeader(name="x-api-key") async def verify_api_key(api_key: str = Depends(api_key_header)): if api_key != VALID_API_KEY: raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid API key" + status_code=status.HTTP_403_FORBIDDEN, detail="Invalid API key" ) return api_key @@ -56,4 +55,13 @@ async def reupload_asset(asset_id: int, _: str = Depends(verify_api_key)): asset.asset_type, models.RbxCreator(int(TARGET), "Upload_Group", "Group"), ) + new_asset_id = uploaded.get("asset_id") + if new_asset_id: + onsale = await roblox_service.onsale_asset( + new_asset_id, + asset.name, + asset.description, + int(TARGET), + ) + return {"uploaded": uploaded} return uploaded diff --git a/src/utils/roblox_service.py b/src/utils/roblox_service.py index 8ac25a5..9bba700 100644 --- a/src/utils/roblox_service.py +++ b/src/utils/roblox_service.py @@ -1,5 +1,7 @@ +import asyncio import json import os +import uuid import xml.etree.ElementTree import httpx @@ -16,6 +18,12 @@ ROBLOSECURITY = os.getenv("ROBLOSECURITY_TOKEN") if not ROBLOSECURITY: raise EnvironmentError("ROBLOSECURITY_TOKEN is missing from environment.") +# Must match the user ID of the account that owns the ROBLOSECURITY_TOKEN +PUBLISHER_USER_ID = os.getenv("PUBLISHER_USER_ID") + +if not PUBLISHER_USER_ID: + raise EnvironmentError("PUBLISHER_USER_ID is missing from environment.") + FETCH_HEADERS = { "Cookie": f".ROBLOSECURITY={ROBLOSECURITY}", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", @@ -43,6 +51,7 @@ _client = None def _get_client() -> httpx.AsyncClient: + """Get or create the shared HTTP client for connection pooling.""" global _client if _client is None: _client = httpx.AsyncClient() @@ -53,6 +62,7 @@ def _get_client() -> httpx.AsyncClient: async def _economy_request(asset_id: int) -> httpx.Response: + """Fetch asset details from the Roblox economy API.""" client = _get_client() return await client.get( ECONOMY_BASE_URL.format(asset_id=asset_id), headers=FETCH_HEADERS @@ -60,13 +70,17 @@ async def _economy_request(asset_id: int) -> httpx.Response: async def _asset_delivery_request(asset_id: int) -> httpx.Response: + """Fetch an asset from the Roblox asset delivery API.""" client = _get_client() return await client.get( - ASSET_DELIVERY_BASE_URL.format(asset_id=asset_id), headers=FETCH_HEADERS, follow_redirects=True + ASSET_DELIVERY_BASE_URL.format(asset_id=asset_id), + headers=FETCH_HEADERS, + follow_redirects=True, ) async def _get_asset_xml(asset: models.RbxAsset) -> xml.etree.ElementTree.Element: + """Fetch and parse asset XML content.""" response = await _asset_delivery_request(asset.asset_id) response.raise_for_status() content = response.content.decode("utf-8") @@ -75,6 +89,7 @@ async def _get_asset_xml(asset: models.RbxAsset) -> xml.etree.ElementTree.Elemen def _get_shirt_template_id_from_xml(root: xml.etree.ElementTree.Element) -> int: + """Extract shirt template ID from asset XML.""" url_element = root.find(".//url") if url_element is None: raise ValueError("XML did not contain a tag.") @@ -85,9 +100,10 @@ def _get_shirt_template_id_from_xml(root: xml.etree.ElementTree.Element) -> int: return int(template_id) -async def _get_csrf_token() -> str: +async def _get_csrf_token(url: str = CSRF_URL) -> str: + """Retrieve CSRF token from the Roblox API.""" client = _get_client() - response = await client.post(CSRF_URL, cookies=CSRF_COOKIES, headers=CSRF_HEADERS) + response = await client.post(url, cookies=CSRF_COOKIES, headers=CSRF_HEADERS) csrf = response.headers.get("X-CSRF-TOKEN") if not csrf: raise httpx.HTTPStatusError( @@ -102,6 +118,7 @@ async def _get_csrf_token() -> str: async def asset_from_id(id: int) -> models.RbxAsset: + """Fetch asset information from Roblox by asset ID.""" response = await _economy_request(id) response.raise_for_status() asset_info = json.loads(response.content) @@ -133,6 +150,7 @@ async def asset_from_id(id: int) -> models.RbxAsset: async def fetch_clothing_image(asset: models.ClothingAsset) -> bytes: + """Fetch the image data for a clothing asset.""" try: xml = await _get_asset_xml(asset) template_id = _get_shirt_template_id_from_xml(xml) @@ -150,6 +168,7 @@ async def upload_clothing_image( asset_type: models.RbxAssetType, target: models.RbxCreator, ) -> dict: + """Upload a clothing image to Roblox and return the asset ID.""" csrf = await _get_csrf_token() meta = { "displayName": name, @@ -183,4 +202,66 @@ async def upload_clothing_image( ) response.raise_for_status() data = response.json() + + operation_id = data.get("operationId") + if operation_id: + max_tries = 10 + wait_time = 1 + for attempt in range(max_tries): + await asyncio.sleep(wait_time) + op_response = await client.get( + f"https://apis.roblox.com/assets/user-auth/v1/operations/{operation_id}", + headers={"X-CSRF-TOKEN": csrf}, + cookies=CSRF_COOKIES, + ) + op_response.raise_for_status() + op_data = op_response.json() + + if op_data.get("done"): + if op_data.get("response") and op_data["response"].get("assetId"): + return {"asset_id": op_data["response"]["assetId"]} + return op_data + return data + + +async def onsale_asset( + asset_id: int, + name: str, + description: str, + group_id: int, + price: int = 5, +): + """Put an asset on sale.""" + csrf = await _get_csrf_token() + data = { + "saleLocationConfiguration": {"saleLocationType": 1, "places": []}, + "targetId": asset_id, + "priceInRobux": price, + "publishingType": 2, + "idempotencyToken": str(uuid.uuid4()), + "publisherUserId": PUBLISHER_USER_ID, + "creatorGroupId": group_id, + "name": name, + "description": description, + "isFree": False, + "agreedPublishingFee": 0, + "priceOffset": 0, + "quantity": 0, + "quantityLimitPerUser": 0, + "resaleRestriction": 2, + "targetType": 0, + } + client = _get_client() + response = await client.post( + "https://itemconfiguration.roblox.com/v1/collectibles", + json=data, + headers={ + "X-CSRF-TOKEN": csrf, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0", + "Referer": "https://create.roblox.com/", + "Origin": "https://create.roblox.com", + }, + cookies=CSRF_COOKIES, + ) + response.raise_for_status()