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>
265 lines
8.7 KiB
Python
265 lines
8.7 KiB
Python
"""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())
|