- A room opened with Play now never loads /join: the API's answer to POST /api/rooms is counted as rooms opened here. - Link previews (Slack, Discord, Facebook, iMessage, WhatsApp and the rest) are reported as where the link was shared, since the people who click them arrive with no referrer. WhatsApp's fetcher is a bot now. - An address that asks for a scanner's paths, or keeps coming back without opening a game, is a bot's all day; so is a browser that loads pages at a steady interval from a new address each time, never playing. A declared bot is still judged request by request. - The site's own addresses, sslip.io included, are not referrers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
84 lines
4.4 KiB
Bash
Executable File
84 lines
4.4 KiB
Bash
Executable File
#!/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 ---
|
|
sys.path.insert(0, "/usr/local/lib/waving-hands")
|
|
import traffic
|
|
t = traffic.scan(day)
|
|
print("traffic: %d addresses of people, %d page requests (%d bot addresses, of them %d scanners, %d monitors); hall %d, play %d, rules %d; rooms opened here %d, room links %s; shared on %s; referrers %s; campaigns %s" % (
|
|
len(t["addresses"]), t["human"], len(t["botAddresses"]), t["scanners"], t["monitors"], t["pages"]["hall"], t["pages"]["play"], t["pages"]["rules"],
|
|
t["opened"], dict(t["joins"]) or "none", dict(t["shared"]) or "none", dict(t["referrers"]) or "none", dict(t["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, over, talk = [], [], False, 0, created, None, 0
|
|
names = {}
|
|
for x in lines[1:]:
|
|
if x["t"] == "seat":
|
|
(bots_seated if x["bot"] else humans).append(x["name"])
|
|
names[x["id"]] = x["name"]
|
|
elif x["t"] == "unseat":
|
|
bots_seated.remove(names.pop(x["id"], None)) if x["id"] in names else None
|
|
elif x["t"] == "start":
|
|
started = True
|
|
elif x["t"] == "turn":
|
|
turns += 1
|
|
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
|
|
elif x["t"] == "chat":
|
|
talk += 1
|
|
elif x["t"] == "over":
|
|
over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"])
|
|
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, over=over, talk=talk))
|
|
|
|
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:
|
|
idle_h = (now - r["last"]).total_seconds() / 3600
|
|
state = "lobby only" if not r["started"] else ("started, no turns" if not r["turns"] else (r["over"] or ("abandoned" if idle_h > 12 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%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",
|
|
(" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else ""))
|
|
PY
|