#!/bin/bash # The visitors digest: who is at the table right now and who came today, # the rooms opened and how far each got, with today's traffic so far. # Runs ON the droplet: visitors.sh [YYYY-MM-DD] (default: today, UTC) # Single-player duels live in players' browsers, so they show only as # visits to /play; duels between people show in full from their ledgers. set -u DAY="${1:-$(date -u +%F)}" python3 - "$DAY" <<'PY' import datetime, glob, json, re, subprocess, sys from collections import Counter day = sys.argv[1] now = datetime.datetime.now(datetime.timezone.utc) print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day)) def sh(cmd): try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20).stdout.strip() except Exception: return "?" print("live sockets: %s | rooms touched in the last hour: %s" % ( sh("ss -tn state established '( sport = :8788 )' | tail -n +2 | wc -l"), sh("find /var/lib/waving-hands/rooms -name '*.jsonl' -mmin -60 2>/dev/null | wc -l"))) # --- traffic: Caddy's access log, people and bots apart --- BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|preview|fetch|curl|python|go-http|headless|scan|monitor|uptime", re.I) PAGES = {"/": "hall", "/play": "play", "/rules": "rules"} addresses, bots = set(), set() pages, joins, referrers, campaigns, agents = Counter(), Counter(), Counter(), Counter(), Counter() requests = 0 for path in sorted(glob.glob("/var/lib/caddy/access.log*")): try: with open(path) as f: for line in f: try: x = json.loads(line) except Exception: continue ts = datetime.datetime.fromtimestamp(x["ts"], datetime.timezone.utc) if ts.strftime("%F") != day: continue req = x["request"] ua = " ".join(req.get("headers", {}).get("User-Agent", [""])) ip = req.get("remote_ip") or req.get("client_ip", "?") uri = req.get("uri", "") path_only = uri.split("?")[0] if BOT.search(ua) or not ua: bots.add(ip) continue if path_only.startswith("/_app/") or path_only.startswith("/api/") or path_only == "/ws" or path_only.endswith((".png", ".ico", ".txt")): continue requests += 1 addresses.add(ip) if path_only in PAGES: pages[PAGES[path_only]] += 1 elif path_only.startswith("/join/"): joins[path_only.split("/")[2].upper()] += 1 ref = " ".join(req.get("headers", {}).get("Referer", [""])) if ref and "hands.kestrelsnest.social" not in ref: referrers[re.sub(r"^https?://", "", ref).split("/")[0]] += 1 m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri) if m: campaigns[m.group(1) + "=" + m.group(2)[:24]] += 1 agents[re.sub(r"\(.*?\)", "", ua)[:40].strip()] += 1 except OSError: continue print("traffic: %d addresses of people, %d page requests (%d bot addresses); hall %d, play %d, rules %d, room links %s; referrers %s; campaigns %s" % ( len(addresses), requests, len(bots), pages["hall"], pages["play"], pages["rules"], dict(joins) or "none", dict(referrers) or "none", dict(campaigns) or "none")) # --- rooms: every ledger, today's in detail --- first_seen = {} rooms = [] for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"): try: lines = [json.loads(l) for l in open(f) if l.strip()] meta = lines[0] assert meta["t"] == "room" except Exception: continue created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc) humans, bots_seated, started, turns, last = [], [], False, 0, created for x in lines[1:]: if x["t"] == "seat": (bots_seated if x["bot"] else humans).append(x["name"]) elif x["t"] == "start": started = True elif x["t"] == "turn": turns += 1 last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc) for h in humans: if h not in first_seen or created < first_seen[h]: first_seen[h] = created rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last)) today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"]) names = sorted({h for r in today for h in r["humans"]}) new = [n for n in names if first_seen[n].strftime("%F") == day] print("players today: %s | NEW today: %s" % (", ".join(names) or "none", ", ".join(new) or "none")) print("rooms today: %d (of %d ever)" % (len(today), len(rooms))) for r in today: state = "lobby only" if not r["started"] else ("started, no turns" if not r["turns"] else "in play") span = ("%d min" % round((r["last"] - r["created"]).total_seconds() / 60)) if r["turns"] else "" seated = "+".join(r["humans"]) + ((" vs %d bot%s" % (len(r["bots"]), "" if len(r["bots"]) == 1 else "s")) if r["bots"] else "") empty = r["size"] - len(r["humans"]) - len(r["bots"]) print(" %s %s %s%s %s %d turns %s last turn %s" % ( r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "", state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never")) PY