The reports desk: a slip for surprises, replies in the hall
A Report button in a room's masthead files what happened and what was expected, with a screenshot if there is one, pinned to the room and the turn and rung through Sentry. Reports and the keeper's replies live in feedback.jsonl beside the ledgers; the hall shows each seat's reports with the thread under them and a line to answer. pull-reports.sh mirrors the desk locally, report-reply.sh answers, and a skill works the desk. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c49703ee4f
commit
8eca078043
+102
-2
@@ -10,6 +10,10 @@
|
||||
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room
|
||||
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
|
||||
// a seat in the Peanut Gallery, counted for the table
|
||||
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the turn
|
||||
// POST /api/reports/:id/image <image bytes> a screenshot for a report just filed
|
||||
// POST /api/reports/mine {seats: [{roomId, token}]} your reports and the keeper's replies
|
||||
// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply
|
||||
//
|
||||
// Caddy serves the static site and proxies /api and /ws here.
|
||||
|
||||
@@ -17,8 +21,10 @@ import * as Sentry from '@sentry/node';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { RateLimit } from './ratelimit';
|
||||
import { IMAGE_MAX_BYTES, Reports } from './reports';
|
||||
import { RoomError, Rooms, SPECTATOR } from './rooms';
|
||||
import { Store } from './store';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8788);
|
||||
const HOST = process.env.HOST ?? '127.0.0.1';
|
||||
@@ -35,16 +41,22 @@ process.on('unhandledRejection', (reason) => {
|
||||
});
|
||||
|
||||
const rooms = new Rooms(new Store(DATA_DIR));
|
||||
/** Reports live beside the room ledgers, not among them. */
|
||||
const reports = new Reports(dirname(DATA_DIR));
|
||||
const PUBLIC_URL = (process.env.WH_PUBLIC_URL ?? 'https://hands.kestrelsnest.social').replace(/\/$/, '');
|
||||
/** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */
|
||||
const doors = new RateLimit(40, 60 * 60 * 1000);
|
||||
/** Table talk: a lively table says a few lines a minute, not hundreds. */
|
||||
const voices = new RateLimit(240, 60 * 60 * 1000);
|
||||
/** Reports, answers and pictures: a few an hour from one address; each rings the keeper's phone. */
|
||||
const desk = new RateLimit(6, 60 * 60 * 1000);
|
||||
const MAX_AUDIENCE = 30;
|
||||
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
setInterval(() => {
|
||||
const n = rooms.evictIdle(IDLE_ROOM_MS);
|
||||
doors.prune();
|
||||
voices.prune();
|
||||
desk.prune();
|
||||
if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`);
|
||||
}, 60 * 60 * 1000).unref();
|
||||
|
||||
@@ -56,9 +68,84 @@ function clientOf(req: IncomingMessage): string {
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, status: number, body: unknown): void {
|
||||
const json = JSON.stringify(body);
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(json);
|
||||
res.end(status === 204 ? undefined : JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readBytes(req: IncomingMessage, limit: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const declared = Number(req.headers['content-length'] ?? 0);
|
||||
if (declared > limit) {
|
||||
reject(new RoomError('A picture of 2.5 MB at most.', 413));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > limit) {
|
||||
reject(new RoomError('A picture of 2.5 MB at most.', 413));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** A report or an answer is worth waking the keeper for; the desk's own replies are not. */
|
||||
function ringBell(title: string, detail: { fingerprint: string[]; tags: Record<string, string>; extra: Record<string, unknown> }): void {
|
||||
Sentry.captureMessage(title, { level: 'error', ...detail });
|
||||
}
|
||||
|
||||
/** The seats a browser claims, kept to the ones its tokens prove. */
|
||||
function provenSeats(raw: unknown): { roomId: string; seat: string }[] {
|
||||
const claims = Array.isArray(raw) ? raw.slice(0, 50) : [];
|
||||
const proven: { roomId: string; seat: string }[] = [];
|
||||
for (const claim of claims) {
|
||||
if (typeof claim !== 'object' || claim === null) continue;
|
||||
const c = claim as Record<string, unknown>;
|
||||
try {
|
||||
const room = rooms.get(String(c.roomId ?? ''));
|
||||
proven.push({ roomId: room.id, seat: rooms.seatOf(room, typeof c.token === 'string' ? c.token : undefined).id });
|
||||
} catch {
|
||||
// A seat this browser cannot prove is not its business.
|
||||
}
|
||||
}
|
||||
return proven;
|
||||
}
|
||||
|
||||
async function handleReports(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<void> {
|
||||
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
|
||||
if (parts[2] === 'mine' && parts.length === 3) {
|
||||
const body = await readBody(req);
|
||||
return send(res, 200, { reports: reports.mine(provenSeats(body.seats)) });
|
||||
}
|
||||
const report = reports.find(parts[2] ?? '');
|
||||
if (!report) throw new RoomError('No such report.', 404);
|
||||
if (parts[3] === 'image') {
|
||||
if (!desk.allow(clientOf(req))) throw new RoomError('The desk has plenty from here for now; more in an hour.', 429);
|
||||
reports.attachImage(report, await readBytes(req, IMAGE_MAX_BYTES));
|
||||
return send(res, 204, {});
|
||||
}
|
||||
if (parts[3] === 'answer') {
|
||||
if (!desk.allow(clientOf(req))) throw new RoomError('The desk has plenty from here for now; more in an hour.', 429);
|
||||
const body = await readBody(req);
|
||||
const room = rooms.get(String(body.roomId ?? report.roomId));
|
||||
const seat = rooms.seatOf(room, typeof body.token === 'string' ? body.token : undefined);
|
||||
if (room.id !== report.roomId) throw new RoomError('That report is not yours to answer.', 403);
|
||||
reports.answer(report, seat, body.text);
|
||||
ringBell(`${seat.name} answers on report ${report.id} (${room.id}): ${String(body.text ?? '').slice(0, 100)}`, {
|
||||
fingerprint: ['report-answer', report.id, new Date().toISOString()],
|
||||
tags: { room: room.id, report: report.id, player: seat.name },
|
||||
extra: { text: body.text, link: `${PUBLIC_URL}/join/${room.id}` }
|
||||
});
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
throw new RoomError('Not here.', 404);
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
@@ -89,6 +176,7 @@ function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
|
||||
if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404);
|
||||
|
||||
if (parts.length === 2 && req.method === 'POST') {
|
||||
@@ -129,6 +217,18 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
const seat = rooms.seatOf(room, token);
|
||||
return send(res, 200, rooms.view(room, seat.id));
|
||||
}
|
||||
if (action === 'report') {
|
||||
if (!desk.allow(clientOf(req))) throw new RoomError('The desk has plenty from here for now; more in an hour.', 429);
|
||||
const token = typeof body.token === 'string' && body.token ? body.token : undefined;
|
||||
const seat = token ? rooms.seatOf(room, token) : null;
|
||||
const report = reports.file(room, seat, body.happened, body.expected);
|
||||
ringBell(`Report from ${report.player} in ${room.id}: ${report.happened.slice(0, 100)}`, {
|
||||
fingerprint: ['report', report.id],
|
||||
tags: { room: room.id, report: report.id, player: report.player },
|
||||
extra: { happened: report.happened, expected: report.expected, turn: report.turn, seq: report.seq, link: `${PUBLIC_URL}/join/${room.id}` }
|
||||
});
|
||||
return send(res, 201, { id: report.id });
|
||||
}
|
||||
if (action === 'say') {
|
||||
if (!voices.allow(clientOf(req))) throw new RoomError('The table has heard enough from here for now.', 429);
|
||||
const token = typeof body.token === 'string' ? body.token : undefined;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// The reports desk: what a player says went wrong, pinned to the room and
|
||||
// the turn so the moment can be replayed. One JSONL file beside the room
|
||||
// ledgers; replies from the keeper and answers from the player fold onto
|
||||
// the report they name, and a screenshot sits in a folder next to it.
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { WizardId } from '../../src/lib/game/state';
|
||||
import { RoomError, type Room, type Seat } from './rooms';
|
||||
|
||||
export const TEXT_MAX = 2000;
|
||||
export const IMAGE_MAX_BYTES = 2_500_000;
|
||||
/** How long after filing a picture may still be attached. */
|
||||
const IMAGE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
export interface ReportLine {
|
||||
at: string;
|
||||
text: string;
|
||||
from: 'desk' | 'player';
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
at: string;
|
||||
roomId: string;
|
||||
/** The reporter's name, or "(gallery)" for a watcher. */
|
||||
player: string;
|
||||
seat: WizardId | null;
|
||||
/** The turn being written when the report was filed, and the ledger's length. */
|
||||
turn: number | null;
|
||||
seq: number;
|
||||
happened: string;
|
||||
expected: string;
|
||||
/** The whole exchange in order: the keeper's replies and the player's answers. */
|
||||
thread: ReportLine[];
|
||||
/** A screenshot's file name under images/, when one was sent. */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export function cleanText(raw: unknown): string {
|
||||
return String(raw ?? '')
|
||||
.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ')
|
||||
.trim()
|
||||
.slice(0, TEXT_MAX);
|
||||
}
|
||||
|
||||
export class Reports {
|
||||
private path: string;
|
||||
private imageDir: string;
|
||||
|
||||
constructor(dir: string) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
this.path = join(dir, 'feedback.jsonl');
|
||||
this.imageDir = join(dir, 'feedback-images');
|
||||
}
|
||||
|
||||
private append(entry: Record<string, unknown>): void {
|
||||
appendFileSync(this.path, JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
read(): Report[] {
|
||||
if (!existsSync(this.path)) return [];
|
||||
const reports = new Map<string, Report>();
|
||||
for (const raw of readFileSync(this.path, 'utf8').split('\n')) {
|
||||
if (!raw.trim()) continue;
|
||||
let line: Record<string, unknown>;
|
||||
try {
|
||||
line = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (typeof line.reportId === 'string') {
|
||||
const report = reports.get(line.reportId);
|
||||
if (!report) continue;
|
||||
if (typeof line.image === 'string') report.image = line.image;
|
||||
else if (line.from === 'player') report.thread.push({ at: String(line.at ?? ''), text: String(line.text ?? ''), from: 'player' });
|
||||
else report.thread.push({ at: String(line.at ?? ''), text: String(line.text ?? ''), from: 'desk', status: String(line.status ?? 'open') });
|
||||
continue;
|
||||
}
|
||||
if (typeof line.id !== 'string') continue;
|
||||
reports.set(line.id, {
|
||||
id: line.id,
|
||||
at: String(line.at ?? ''),
|
||||
roomId: String(line.roomId ?? ''),
|
||||
player: String(line.player ?? '?'),
|
||||
seat: typeof line.seat === 'string' ? line.seat : null,
|
||||
turn: typeof line.turn === 'number' ? line.turn : null,
|
||||
seq: Number(line.seq ?? 0),
|
||||
happened: String(line.happened ?? ''),
|
||||
expected: String(line.expected ?? ''),
|
||||
thread: []
|
||||
});
|
||||
}
|
||||
return [...reports.values()];
|
||||
}
|
||||
|
||||
find(id: string): Report | undefined {
|
||||
return this.read().find((r) => r.id === id);
|
||||
}
|
||||
|
||||
/** File a report from a seat, or from the gallery when there is none. */
|
||||
file(room: Room, seat: Seat | null, rawHappened: unknown, rawExpected: unknown): Report {
|
||||
const happened = cleanText(rawHappened);
|
||||
if (!happened) throw new RoomError('Say what happened.');
|
||||
const report: Report = {
|
||||
id: randomBytes(4).toString('hex'),
|
||||
at: new Date().toISOString(),
|
||||
roomId: room.id,
|
||||
player: seat?.name ?? '(gallery)',
|
||||
seat: seat?.id ?? null,
|
||||
turn: room.state ? room.state.turn + 1 : null,
|
||||
seq: room.seq,
|
||||
happened,
|
||||
expected: cleanText(rawExpected),
|
||||
thread: []
|
||||
};
|
||||
const { thread: _thread, ...line } = report;
|
||||
this.append(line);
|
||||
return report;
|
||||
}
|
||||
|
||||
/** The player's word under the keeper's reply; only the seat that filed it may answer. */
|
||||
answer(report: Report, seat: Seat, rawText: unknown): void {
|
||||
if (report.seat !== seat.id) throw new RoomError('That report is not yours to answer.', 403);
|
||||
const text = cleanText(rawText);
|
||||
if (!text) throw new RoomError('Say something.');
|
||||
this.append({ reportId: report.id, from: 'player', player: seat.name, text, at: new Date().toISOString() });
|
||||
}
|
||||
|
||||
/** One picture per report, soon after filing, a PNG, JPEG or WebP by its own first bytes. */
|
||||
attachImage(report: Report, body: Buffer): string {
|
||||
if (report.image) throw new RoomError('That report has its picture.', 409);
|
||||
if (Date.now() - Date.parse(report.at) > IMAGE_WINDOW_MS) throw new RoomError('Too late for a picture.', 410);
|
||||
if (body.length > IMAGE_MAX_BYTES) throw new RoomError('A picture of 2.5 MB at most.', 413);
|
||||
const ext = body.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
||||
? 'png'
|
||||
: body.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))
|
||||
? 'jpg'
|
||||
: body.subarray(0, 4).toString('ascii') === 'RIFF' && body.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
? 'webp'
|
||||
: null;
|
||||
if (!ext) throw new RoomError('A PNG, JPEG or WebP.', 415);
|
||||
mkdirSync(this.imageDir, { recursive: true });
|
||||
const name = `${report.id}.${ext}`;
|
||||
writeFileSync(join(this.imageDir, name), body);
|
||||
this.append({ reportId: report.id, image: name, at: new Date().toISOString() });
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Reports filed from the seats a browser can prove it holds. */
|
||||
mine(seats: { roomId: string; seat: WizardId }[]): Report[] {
|
||||
return this.read().filter((r) => seats.some((s) => s.roomId === r.roomId && s.seat === r.seat));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user