Join the RoClicks Discord
RoClicks

devproducts.py

Python, 129 lines from Engineering overview

Download devproducts.py
#!/usr/bin/env python3
"""List and create Roblox developer products with an Open Cloud API key, without ever printing the key.

Usage:
  python3 devproducts.py list   --universe <universeId>
  python3 devproducts.py create --universe <universeId> --spec products.json [--dry-run]

The key is read from a file (default ~/.roblox_api_key, override with --key-file or $ROBLOX_KEY_FILE).
The file must be chmod 600; the script refuses otherwise. The key is never taken from argv (it would land in
shell history and `ps`), never printed, and never written into logs.

products.json:
  [ {"name": "Coins Pack S", "description": "1,000 coins", "price": 49, "image": "icons/coins_s.png"}, ... ]
Products whose name already exists in the universe are skipped, so re-running is safe.
The output is a name -> productId map: paste it into your Products config (an unconfigured id stays 0 = inert).

Endpoints (Open Cloud, header x-api-key):
  GET  https://apis.roblox.com/developer-products/v2/universes/{universeId}/developer-products/creator?pageSize=50
       (follow nextPageToken via &pageToken=...)
  POST https://apis.roblox.com/developer-products/v2/universes/{universeId}/developer-products
       multipart/form-data: name, description, price, isForSale, imageFile (optional)
The key needs developer-product read + write for that universe (for a group game, create the key under the group).
"""
import argparse
import json
import mimetypes
import os
import stat
import sys
import urllib.error
import urllib.request
import uuid
from pathlib import Path

BASE = "https://apis.roblox.com/developer-products/v2/universes/{u}/developer-products"


def read_key(path: str) -> str:
    p = Path(os.path.expanduser(path))
    if not p.exists():
        sys.exit(f"no key file at {p} (create it, then chmod 600)")
    mode = stat.S_IMODE(p.stat().st_mode)
    if mode & 0o077:
        sys.exit(f"{p} is readable by others (mode {oct(mode)}); run: chmod 600 {p}")
    key = p.read_text().strip()
    if not key:
        sys.exit(f"{p} is empty")
    return key


def request(method, url, key, body=None, ctype=None):
    req = urllib.request.Request(url, data=body, method=method)
    req.add_header("x-api-key", key)
    if ctype:
        req.add_header("Content-Type", ctype)
    try:
        with urllib.request.urlopen(req, timeout=60) as r:
            return json.loads(r.read() or b"{}")
    except urllib.error.HTTPError as e:
        # print the status and Roblox's message only; the request (and key) is never echoed
        sys.exit(f"{method} failed: HTTP {e.code} {e.read()[:400].decode('utf-8', 'replace')}")


def list_products(universe, key):
    out, token = [], ""
    while True:
        url = BASE.format(u=universe) + "/creator?pageSize=50" + (f"&pageToken={token}" if token else "")
        page = request("GET", url, key)
        out += page.get("developerProducts", [])
        token = page.get("nextPageToken")
        if not token:
            return out


def multipart(fields: dict, file_path=None):
    boundary = uuid.uuid4().hex
    parts = []
    for k, v in fields.items():
        parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode())
    if file_path:
        data = Path(file_path).read_bytes()
        mt = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
        parts.append((f'--{boundary}\r\nContent-Disposition: form-data; name="imageFile"; '
                      f'filename="{Path(file_path).name}"\r\nContent-Type: {mt}\r\n\r\n').encode() + data + b"\r\n")
    parts.append(f"--{boundary}--\r\n".encode())
    return b"".join(parts), f"multipart/form-data; boundary={boundary}"


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("cmd", choices=["list", "create"])
    ap.add_argument("--universe", required=True)
    ap.add_argument("--spec")
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--key-file", default=os.environ.get("ROBLOX_KEY_FILE", "~/.roblox_api_key"))
    a = ap.parse_args()
    key = read_key(a.key_file)

    existing = list_products(a.universe, key)
    by_name = {p.get("name"): p.get("productId") for p in existing}
    if a.cmd == "list":
        for p in existing:
            print(f'{p.get("productId")}\t{p.get("price") or p.get("priceInformation")}\t{p.get("name")}')
        return 0

    if not a.spec:
        sys.exit("create needs --spec products.json")
    result = {}
    for spec in json.loads(Path(a.spec).read_text()):
        name = spec["name"]
        if name in by_name:
            result[name] = by_name[name]
            print(f"exists  {name} -> {by_name[name]}")
            continue
        if a.dry_run:
            print(f"would create {name} ({spec['price']} Robux)")
            continue
        body, ctype = multipart({"name": name, "description": spec.get("description", ""),
                                 "price": str(int(spec["price"])), "isForSale": "true"}, spec.get("image"))
        made = request("POST", BASE.format(u=a.universe), key, body, ctype)
        result[name] = made.get("productId")
        print(f"created {name} -> {result[name]}")
    print(json.dumps(result, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())