Reports are threads, and every turn of one rings the desk's bell

A player answers the desk under its reply from their lobby, proven by
the seat the report was filed from; the lobby shows the exchange in
order and says whose word is pending. A new report, a player's answer,
and a desk reply each raise a Sentry issue on the keeper's phone — the
first two from the server, the last from the reply script itself,
straight to Sentry from the droplet. The Desktop digest lists the
answers, and the desk's rule reads a report as reopened when the last
word is the player's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-21 12:08:55 -04:00
co-authored by Claude Fable 5.1
parent ffbca5bfb9
commit 11e9f950c6
7 changed files with 114 additions and 16 deletions
+17
View File
@@ -17,3 +17,20 @@ console.log(JSON.stringify({
text: process.env.TEXT,
}));')
printf '%s\n' "$LINE" | ssh "root@$HOST" 'cat >> /var/lib/wizwar/feedback.jsonl && tail -1 /var/lib/wizwar/feedback.jsonl'
# The desk's bell rings for the reply too, so Eric sees the exchange move:
# an event straight to Sentry from the droplet, with the DSN the service holds.
ssh "root@$HOST" "REPORT_ID='$REPORT_ID' STATUS='$STATUS' TEXT=\"\$(cat)\" python3 - <<'PY'
import json, os, re, uuid, urllib.request, datetime
svc = open('/etc/systemd/system/wizwar.service').read()
m = re.search(r'SENTRY_DSN=https://([0-9a-f]+)@([^/]+)/(\\d+)', svc)
if not m: raise SystemExit(0)
key, host, project = m.groups()
text = os.environ['TEXT']; rid = os.environ['REPORT_ID']; status = os.environ['STATUS']
event = {'event_id': uuid.uuid4().hex, 'timestamp': datetime.datetime.now(datetime.UTC).isoformat(), 'level': 'error', 'platform': 'other',
'message': {'formatted': f'Desk replies on report {rid} ({status}): {text[:100]}'}, 'fingerprint': ['report-reply', rid, datetime.datetime.now(datetime.UTC).isoformat()],
'tags': {'report': rid, 'status': status}, 'extra': {'text': text}}
body = json.dumps({'event_id': event['event_id'], 'sent_at': event['timestamp']}) + '\\n' + json.dumps({'type': 'event'}) + '\\n' + json.dumps(event)
req = urllib.request.Request(f'https://{host}/api/{project}/envelope/', data=body.encode(), headers={'Content-Type': 'application/x-sentry-envelope', 'X-Sentry-Auth': f'Sentry sentry_version=7, sentry_client=wizwar-desk/1, sentry_key={key}'})
try: urllib.request.urlopen(req, timeout=10); print('bell rung')
except Exception as e: print('bell failed:', e)
PY" <<< "$TEXT"
+4 -3
View File
@@ -20,7 +20,7 @@ for raw in open(os.path.join(out, "feedback.jsonl"), encoding="utf8"):
r = reports.get(line["reportId"])
if not r: continue
if "image" in line: r["image"] = line["image"]
else: r.setdefault("replies", []).append(line)
else: r.setdefault("replies", []).append(line) # desk replies and player answers alike, in order
continue
rid = line.get("id") or line.get("at")
reports[rid] = dict(line, replies=[]); order.append(rid)
@@ -33,8 +33,9 @@ for rid in reversed(order):
if r.get("expected","").strip(): lines.append(f"**What they expected:** {r['expected'].strip()}")
if r.get("image"): lines.append(f"**Picture:** ![{r['image']}](images/{r['image']})")
for rep in r["replies"]:
lines.append(f"> **{rep.get('status','')}** ({rep.get('at','')[:16].replace('T',' ')}): {rep.get('text','').strip()}")
if not r["replies"]: lines.append("> _unanswered_")
who = f"{r.get('player','the player')} answers" if rep.get("from") == "player" else f"**{rep.get('status','')}**"
lines.append(f"> {who} ({rep.get('at','')[:16].replace('T',' ')}): {rep.get('text','').strip()}")
if not r["replies"] or r["replies"][-1].get("from") == "player": lines.append("> _awaiting the desk_")
lines.append("")
open(os.path.join(out, "reports.md"), "w", encoding="utf8").write("\n".join(lines))
print(f"{len(order)} reports, {sum(1 for r in reports.values() if r.get('image'))} with pictures -> {out}/reports.md")