Ship Steam login, D1 player sync, and cached「我」dashboard.
Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
"""Probe D1 for an account, enqueue login_refresh, wait, probe again.
|
||||
|
||||
Uses Cloudflare Global API Key env (no secret echo).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
WORKER = HERE / "player-sync"
|
||||
ACCOUNT_ID = "510534f7f6284344aadaf2f5a0794d48"
|
||||
QUEUE_ID = "371f11c7b4114f9b99ab10062a38ecd7"
|
||||
TARGET = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
|
||||
|
||||
|
||||
def _cf_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
email = env.get("CLOUDFLARE_EMAIL") or env.get("KEYZOO_ASSET_META_USERNAME")
|
||||
key = env.get("CLOUDFLARE_API_KEY") or env.get(
|
||||
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||
)
|
||||
if not email or not key:
|
||||
raise SystemExit("missing Cloudflare credentials")
|
||||
env["CLOUDFLARE_EMAIL"] = email
|
||||
env["CLOUDFLARE_API_KEY"] = key
|
||||
env["CLOUDFLARE_ACCOUNT_ID"] = ACCOUNT_ID
|
||||
return env
|
||||
|
||||
|
||||
def d1_query(sql: str, env: dict[str, str]) -> list:
|
||||
cmd = (
|
||||
f'npx --yes wrangler@3 d1 execute climperor-users --remote --json '
|
||||
f'--command "{sql}"'
|
||||
)
|
||||
r = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(WORKER),
|
||||
env=env,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
print(r.stderr or r.stdout, file=sys.stderr)
|
||||
raise SystemExit(r.returncode)
|
||||
try:
|
||||
data = json.loads(r.stdout)
|
||||
except json.JSONDecodeError:
|
||||
print(r.stdout)
|
||||
return []
|
||||
if isinstance(data, list) and data:
|
||||
return data[0].get("results") or []
|
||||
return []
|
||||
|
||||
|
||||
class CfApiError(RuntimeError):
|
||||
def __init__(self, code: int, body: str):
|
||||
super().__init__(f"CF API {code}")
|
||||
self.code = code
|
||||
self.body = body
|
||||
|
||||
|
||||
def cf_api(method: str, path: str, env: dict[str, str], body: dict | None = None) -> dict:
|
||||
url = f"https://api.cloudflare.com/client/v4{path}"
|
||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"X-Auth-Email": env["CLOUDFLARE_EMAIL"],
|
||||
"X-Auth-Key": env["CLOUDFLARE_API_KEY"],
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "climperor-trigger-sync",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8", errors="replace")
|
||||
raise CfApiError(e.code, raw[:1200]) from e
|
||||
|
||||
|
||||
def probe(env: dict[str, str], label: str) -> None:
|
||||
print(f"\n=== {label} account_id={TARGET} ===", flush=True)
|
||||
users = d1_query(
|
||||
f"SELECT account_id, personaname, last_login_at FROM users "
|
||||
f"WHERE account_id={TARGET}",
|
||||
env,
|
||||
)
|
||||
print("users:", json.dumps(users, ensure_ascii=False), flush=True)
|
||||
jobs = d1_query(
|
||||
f"SELECT kind, status, substr(COALESCE(error,''),1,160) AS error, "
|
||||
f"updated_at FROM sync_jobs WHERE account_id={TARGET} "
|
||||
f"ORDER BY updated_at DESC LIMIT 5",
|
||||
env,
|
||||
)
|
||||
print("sync_jobs:", json.dumps(jobs, ensure_ascii=False), flush=True)
|
||||
matches = d1_query(
|
||||
f"SELECT COUNT(*) AS n, "
|
||||
f"SUM(CASE WHEN r2_key IS NOT NULL AND r2_key != '' THEN 1 ELSE 0 END) AS r2 "
|
||||
f"FROM player_matches WHERE account_id={TARGET}",
|
||||
env,
|
||||
)
|
||||
print("matches:", json.dumps(matches, ensure_ascii=False), flush=True)
|
||||
stats = d1_query(
|
||||
f"SELECT scope, sample, winrate FROM player_stats "
|
||||
f"WHERE account_id={TARGET}",
|
||||
env,
|
||||
)
|
||||
print("stats:", json.dumps(stats, ensure_ascii=False), flush=True)
|
||||
heroes = d1_query(
|
||||
f"SELECT COUNT(*) AS n FROM player_heroes WHERE account_id={TARGET}",
|
||||
env,
|
||||
)
|
||||
print("heroes:", json.dumps(heroes, ensure_ascii=False), flush=True)
|
||||
|
||||
|
||||
def worker_http_sync(env: dict[str, str], msg: dict) -> str:
|
||||
"""POST Worker fetch handler. Returns 'http'."""
|
||||
print("Worker HTTP sync …", flush=True)
|
||||
try:
|
||||
cf_api(
|
||||
"POST",
|
||||
f"/accounts/{ACCOUNT_ID}/workers/scripts/climperor-player-sync/subdomain",
|
||||
env,
|
||||
{"enabled": True},
|
||||
)
|
||||
except CfApiError as e:
|
||||
print(f"enable subdomain: {e.code} {e.body[:400]}", flush=True)
|
||||
sub_name = ""
|
||||
try:
|
||||
sub = cf_api("GET", f"/accounts/{ACCOUNT_ID}/workers/subdomain", env)
|
||||
sub_name = ((sub.get("result") or {}).get("subdomain") or "").strip()
|
||||
except CfApiError as e:
|
||||
print(f"get subdomain: {e.code} {e.body[:400]}", flush=True)
|
||||
if not sub_name:
|
||||
raise SystemExit("cannot resolve workers.dev subdomain")
|
||||
url = f"https://climperor-player-sync.{sub_name}.workers.dev/"
|
||||
print(f"POST {url}", flush=True)
|
||||
data = json.dumps(msg).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "climperor-trigger-sync",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
print(f"worker HTTP {resp.status} len={len(raw)}", flush=True)
|
||||
print(raw[:800], flush=True)
|
||||
return "http"
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8", errors="replace")
|
||||
print(f"worker HTTP {e.code}: {raw[:800]}", file=sys.stderr)
|
||||
raise SystemExit(1) from e
|
||||
except urllib.error.URLError as e:
|
||||
print(f"worker URL error: {e}", file=sys.stderr)
|
||||
raise SystemExit(1) from e
|
||||
|
||||
|
||||
def enqueue(env: dict[str, str]) -> str:
|
||||
"""Enqueue or HTTP-sync. Returns 'queue' | 'http'."""
|
||||
steamid = str(TARGET + 76561197960265728)
|
||||
msg = {
|
||||
"kind": "login_refresh",
|
||||
"account_id": TARGET,
|
||||
"steamid": steamid,
|
||||
"personaname": "refining",
|
||||
}
|
||||
if os.environ.get("CLIMPEROR_FORCE_HTTP_SYNC", "").strip() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
):
|
||||
return worker_http_sync(env, msg)
|
||||
|
||||
print("\nenqueue login_refresh …", flush=True)
|
||||
# https://developers.cloudflare.com/queues/configuration/javascript-apis/#producer
|
||||
# HTTP: POST /accounts/:account_id/queues/:queue_id/messages
|
||||
attempts = [
|
||||
(
|
||||
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages",
|
||||
{"body": msg},
|
||||
),
|
||||
(
|
||||
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages",
|
||||
{"messages": [{"body": json.dumps(msg)}]},
|
||||
),
|
||||
(
|
||||
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages/batch",
|
||||
{"messages": [{"body": msg}]},
|
||||
),
|
||||
(
|
||||
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages/batch",
|
||||
{"messages": [{"body": json.dumps(msg)}]},
|
||||
),
|
||||
]
|
||||
for path, body in attempts:
|
||||
try:
|
||||
out = cf_api("POST", path, env, body)
|
||||
except CfApiError as e:
|
||||
print(f"try {path.split('/')[-1]} HTTP {e.code}: {e.body[:400]}", flush=True)
|
||||
continue
|
||||
ok = bool(out.get("success"))
|
||||
print(f"try {path.split('/')[-1]} success={ok}", flush=True)
|
||||
if ok:
|
||||
return "queue"
|
||||
print(json.dumps(out, ensure_ascii=False)[:600], flush=True)
|
||||
# Last resort: enable workers.dev and POST the Worker fetch handler.
|
||||
return worker_http_sync(env, msg)
|
||||
|
||||
|
||||
def check_worker(env: dict[str, str]) -> None:
|
||||
out = cf_api("GET", f"/accounts/{ACCOUNT_ID}/workers/scripts", env)
|
||||
names = [r.get("id") or r.get("name") for r in (out.get("result") or [])]
|
||||
print("workers:", ", ".join(n for n in names if n)[:500], flush=True)
|
||||
has = "climperor-player-sync" in names
|
||||
print(f"climperor-player-sync deployed={has}", flush=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
env = _cf_env()
|
||||
check_worker(env)
|
||||
probe(env, "before")
|
||||
mode = enqueue(env)
|
||||
waits = 2 if mode == "http" else 8
|
||||
for i in range(1, waits + 1):
|
||||
delay = 3 if mode == "http" else 8
|
||||
time.sleep(delay)
|
||||
probe(env, f"after wait #{i} ({i * delay}s)")
|
||||
users = d1_query(
|
||||
f"SELECT account_id FROM users WHERE account_id={TARGET}", env
|
||||
)
|
||||
stats = d1_query(
|
||||
f"SELECT scope FROM player_stats WHERE account_id={TARGET}", env
|
||||
)
|
||||
filled = d1_query(
|
||||
f"SELECT scope, sample FROM player_stats WHERE account_id={TARGET} "
|
||||
f"AND sample > 0",
|
||||
env,
|
||||
)
|
||||
if users and filled:
|
||||
print("\nSYNC OK — user + non-empty stats", flush=True)
|
||||
return 0
|
||||
print("\nSYNC PENDING/FAILED — no non-empty stats after waits", flush=True)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user