diff --git a/.gitignore b/.gitignore index 81a23d0..2a53e59 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ frontend/public/manifest.json # Logs *.log npm-debug.log* +logs/ # OS files .DS_Store diff --git a/tools/jackbox-logger.js b/tools/jackbox-logger.js new file mode 100644 index 0000000..5447e6b --- /dev/null +++ b/tools/jackbox-logger.js @@ -0,0 +1,492 @@ +#!/usr/bin/env node + +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +let WebSocket; +try { + WebSocket = require('ws'); +} catch (_) { + try { + WebSocket = require('../backend/node_modules/ws'); + } catch (_2) { + console.error('Error: WebSocket library (ws) not found.'); + console.error('Run: cd backend && npm install'); + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// ANSI helpers +// --------------------------------------------------------------------------- +const C = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', +}; + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- +function parseArgs() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h') || args.length === 0) { + console.log(` +${C.bold}Jackbox API Logger${C.reset} +Connects to a Jackbox game room and logs all WebSocket events. + +${C.bold}Usage:${C.reset} + node tools/jackbox-logger.js [options] + +${C.bold}Options:${C.reset} + --role Connection role (default: shard) + --name Display name (default: JBLogger) + --no-file Skip writing to log file + --verbose Print full JSON to console + --help, -h Show this help + +${C.bold}Examples:${C.reset} + node tools/jackbox-logger.js ABCD + node tools/jackbox-logger.js ABCD --role audience + node tools/jackbox-logger.js ABCD --verbose --no-file +`); + process.exit(0); + } + + const opts = { + roomCode: null, + role: 'shard', + name: 'JBLogger', + noFile: false, + verbose: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--role') { + opts.role = args[++i]; + } else if (arg === '--name') { + opts.name = args[++i]; + } else if (arg === '--no-file') { + opts.noFile = true; + } else if (arg === '--verbose') { + opts.verbose = true; + } else if (!arg.startsWith('-') && !opts.roomCode) { + opts.roomCode = arg.toUpperCase(); + } + } + + if (!opts.roomCode) { + console.error(`${C.red}Error: room code is required${C.reset}`); + process.exit(1); + } + + if (!['shard', 'audience', 'player'].includes(opts.role)) { + console.error(`${C.red}Error: --role must be shard, audience, or player${C.reset}`); + process.exit(1); + } + + return opts; +} + +// --------------------------------------------------------------------------- +// REST: fetch room info +// --------------------------------------------------------------------------- +function getRoomInfo(roomCode) { + return new Promise((resolve, reject) => { + https.get(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + const json = JSON.parse(data); + if (json.ok) resolve(json.body); + else reject(new Error(json.error || 'Room not found')); + } catch (e) { + reject(e); + } + }); + }).on('error', reject); + }); +} + +// --------------------------------------------------------------------------- +// Timestamp +// --------------------------------------------------------------------------- +function ts() { + return new Date().toISOString().slice(11, 23); +} + +// --------------------------------------------------------------------------- +// Console summarizer — merges patterns from ws-probe.js and ws-lifecycle-test.js +// --------------------------------------------------------------------------- +function summarize(msg) { + const r = msg.result || {}; + + switch (msg.opcode) { + case 'client/welcome': { + const hereEntries = r.here ? Object.entries(r.here) : []; + const players = hereEntries + .filter(([, v]) => v.roles?.player) + .map(([id, v]) => `${v.roles.player.name}(${id})`); + const entityKeys = r.entities ? Object.keys(r.entities) : []; + return ( + `id=${r.id} reconnect=${r.reconnect} secret=${r.secret}\n` + + ` here: ${hereEntries.length} connections [${players.join(', ') || 'no players'}]\n` + + ` entities: [${entityKeys.join(', ')}]` + ); + } + + case 'object': { + if (r.key === 'room' || r.key === 'bc:room') { + const v = r.val || {}; + return `ROOM state=${v.state} lobby=${v.lobbyState} canStart=${v.gameCanStart} starting=${v.gameIsStarting} finished=${v.gameFinished} v${r.version}`; + } + if (r.key === 'textDescriptions') { + const latest = r.val?.latestDescriptions; + if (Array.isArray(latest) && latest.length > 0) { + const last = latest[latest.length - 1]; + return `TEXT "${last.text}" (${last.category}) v${r.version}`; + } + return `textDescriptions v${r.version}`; + } + if (r.key?.startsWith('player:')) { + const v = r.val || {}; + return `PLAYER ${r.key} state=${v.state || 'init'} name=${v.playerName || '?'} vip=${v.playerIsVIP} v${r.version}`; + } + const valKeys = r.val ? Object.keys(r.val).slice(0, 5).join(',') : 'null'; + return `ENTITY ${r.key} v${r.version} from=${r.from} val=[${valKeys}...]`; + } + + case 'client/connected': { + const roleName = r.roles ? Object.keys(r.roles)[0] : r.role; + const playerName = r.roles?.player?.name || r.name || ''; + return `id=${r.id} userId=${r.userId} role=${roleName} name=${playerName}`; + } + + case 'client/disconnected': { + const roleName = r.roles ? Object.keys(r.roles)[0] : r.role; + return `id=${r.id} role=${roleName}`; + } + + case 'client/kicked': + return JSON.stringify(r).slice(0, 200); + + case 'room/lock': + return 'room locked (game starting)'; + + case 'room/exit': + return 'room closed'; + + case 'room/get-audience': + return `audience connections=${r.connections}`; + + case 'error': + return `code=${r.code}: ${r.msg}`; + + case 'ok': + return `seq response`; + + default: + return JSON.stringify(r).slice(0, 200); + } +} + +function opcodeColor(opcode) { + if (opcode === 'client/welcome') return C.green; + if (opcode === 'error') return C.red; + if (opcode === 'client/connected') return C.cyan; + if (opcode === 'client/disconnected') return C.yellow; + if (opcode === 'room/lock' || opcode === 'room/exit') return C.magenta; + if (opcode === 'object') return C.white; + return C.dim; +} + +// --------------------------------------------------------------------------- +// File logger +// --------------------------------------------------------------------------- +class FileLogger { + constructor(roomCode) { + const logsDir = path.join(__dirname, '..', 'logs'); + fs.mkdirSync(logsDir, { recursive: true }); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + this.filePath = path.join(logsDir, `jackbox-${roomCode}-${timestamp}.jsonl`); + this.stream = fs.createWriteStream(this.filePath, { flags: 'a' }); + } + + write(entry) { + this.stream.write(JSON.stringify(entry) + '\n'); + } + + close() { + this.stream.end(); + } +} + +// --------------------------------------------------------------------------- +// Main logger +// --------------------------------------------------------------------------- +async function main() { + const opts = parseArgs(); + const { roomCode, role, name, noFile, verbose } = opts; + + console.log(`${C.bold}Jackbox API Logger${C.reset}`); + console.log(`${C.cyan}Room:${C.reset} ${roomCode} ${C.cyan}Role:${C.reset} ${role} ${C.cyan}Name:${C.reset} ${name}`); + console.log(); + + // Fetch room info + console.log(`${C.dim}[${ts()}]${C.reset} Fetching room info...`); + let roomInfo; + try { + roomInfo = await getRoomInfo(roomCode); + } catch (e) { + console.error(`${C.red}Failed to fetch room info: ${e.message}${C.reset}`); + process.exit(1); + } + + console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}Room found${C.reset}`); + console.log(` ${C.cyan}Game:${C.reset} ${roomInfo.appTag}`); + console.log(` ${C.cyan}Host:${C.reset} ${roomInfo.host}`); + console.log(` ${C.cyan}Players:${C.reset} max ${roomInfo.maxPlayers}`); + console.log(` ${C.cyan}Locked:${C.reset} ${roomInfo.locked}`); + console.log(` ${C.cyan}Full:${C.reset} ${roomInfo.full}`); + console.log(` ${C.cyan}Audience:${C.reset} ${roomInfo.audienceEnabled ? 'enabled' : 'disabled'}`); + console.log(); + + // File logger + let fileLogger = null; + if (!noFile) { + fileLogger = new FileLogger(roomCode); + console.log(`${C.dim}[${ts()}]${C.reset} Logging to ${C.bold}${fileLogger.filePath}${C.reset}`); + fileLogger.write({ + ts: new Date().toISOString(), + direction: 'meta', + type: 'session_start', + roomCode, + role, + name, + host: roomInfo.host, + appTag: roomInfo.appTag, + maxPlayers: roomInfo.maxPlayers, + locked: roomInfo.locked, + full: roomInfo.full, + audienceEnabled: roomInfo.audienceEnabled, + }); + } + + // State for reconnection + let shardId = null; + let secret = null; + let msgCount = 0; + let reconnecting = false; + let manuallyStopped = false; + const startTime = Date.now(); + + function buildWsUrl(reconnect) { + if (role === 'audience') { + return `wss://${roomInfo.audienceHost || roomInfo.host}/api/v2/audience/${roomCode}/play`; + } + const base = `wss://${roomInfo.host}/api/v2/rooms/${roomCode}/play`; + if (reconnect && secret && shardId) { + return `${base}?role=${role}&name=${encodeURIComponent(name)}&format=json&secret=${secret}&id=${shardId}`; + } + return `${base}?role=${role}&name=${encodeURIComponent(name)}&userId=${name}-${Date.now()}&format=json`; + } + + function connect(isReconnect) { + const url = buildWsUrl(isReconnect); + console.log(`${C.dim}[${ts()}]${C.reset} ${isReconnect ? 'Reconnecting' : 'Connecting'}: ${C.dim}${url}${C.reset}`); + + const ws = new WebSocket(url, ['ecast-v0'], { + headers: { + Origin: 'https://jackbox.tv', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + }, + handshakeTimeout: 10000, + }); + + ws.on('open', () => { + console.log(`${C.dim}[${ts()}]${C.reset} ${C.green}${C.bold}CONNECTED${C.reset}`); + console.log(); + }); + + ws.on('message', (raw) => { + msgCount++; + try { + const msg = JSON.parse(raw.toString()); + + // Capture credentials from welcome for reconnection + if (msg.opcode === 'client/welcome' && msg.result) { + shardId = msg.result.id; + secret = msg.result.secret; + } + + // Console output + const color = opcodeColor(msg.opcode); + const summary = summarize(msg); + console.log( + `${C.dim}[${ts()}]${C.reset} ${C.dim}#${msgCount} pc:${msg.pc ?? '-'}${C.reset} ${color}${C.bold}${msg.opcode}${C.reset} ${summary}` + ); + if (verbose) { + console.log(JSON.stringify(msg, null, 2)); + } + + // File output + if (fileLogger) { + fileLogger.write({ + ts: new Date().toISOString(), + direction: 'recv', + msgNum: msgCount, + pc: msg.pc, + opcode: msg.opcode, + raw: msg, + }); + } + } catch (e) { + console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}UNPARSEABLE${C.reset} ${raw.toString().slice(0, 200)}`); + if (fileLogger) { + fileLogger.write({ + ts: new Date().toISOString(), + direction: 'recv', + msgNum: msgCount, + parseError: true, + raw: raw.toString().slice(0, 2000), + }); + } + } + }); + + ws.on('close', (code, reason) => { + console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}DISCONNECTED${C.reset} code=${code} reason=${reason}`); + if (fileLogger) { + fileLogger.write({ + ts: new Date().toISOString(), + direction: 'meta', + type: 'disconnected', + code, + reason: reason?.toString(), + }); + } + if (!manuallyStopped && secret != null) { + reconnectWithBackoff(ws); + } + }); + + ws.on('error', (err) => { + console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}WS ERROR: ${err.message}${C.reset}`); + if (fileLogger) { + fileLogger.write({ + ts: new Date().toISOString(), + direction: 'meta', + type: 'error', + message: err.message, + }); + } + }); + + // SIGINT handler + const onSigint = () => { + manuallyStopped = true; + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(); + console.log(`${C.bold}--- Session Summary ---${C.reset}`); + console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`); + console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`); + if (fileLogger) { + console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`); + fileLogger.write({ + ts: new Date().toISOString(), + direction: 'meta', + type: 'session_end', + msgCount, + durationMs: Date.now() - startTime, + }); + fileLogger.close(); + } + console.log(); + try { + ws.close(1000, 'Logger stopped'); + } catch (_) {} + setTimeout(() => process.exit(0), 500); + }; + + process.removeAllListeners('SIGINT'); + process.on('SIGINT', onSigint); + + return ws; + } + + async function reconnectWithBackoff() { + if (reconnecting || manuallyStopped) return; + reconnecting = true; + const delays = [2000, 4000, 8000]; + + for (let i = 0; i < delays.length; i++) { + console.log(`${C.dim}[${ts()}]${C.reset} ${C.yellow}Reconnect attempt ${i + 1}/${delays.length} in ${delays[i] / 1000}s...${C.reset}`); + await new Promise((r) => setTimeout(r, delays[i])); + + if (manuallyStopped) { + reconnecting = false; + return; + } + + try { + const freshRoom = await getRoomInfo(roomCode); + if (!freshRoom) { + console.log(`${C.dim}[${ts()}]${C.reset} ${C.red}Room no longer exists${C.reset}`); + reconnecting = false; + shutdown(); + return; + } + roomInfo.host = freshRoom.host; + connect(true); + reconnecting = false; + return; + } catch (e) { + console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}Reconnect attempt ${i + 1} failed: ${e.message}${C.reset}`); + } + } + + console.error(`${C.dim}[${ts()}]${C.reset} ${C.red}${C.bold}All reconnect attempts failed. Exiting.${C.reset}`); + reconnecting = false; + shutdown(); + } + + function shutdown() { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(); + console.log(`${C.bold}--- Session Summary ---${C.reset}`); + console.log(` ${C.cyan}Messages:${C.reset} ${msgCount}`); + console.log(` ${C.cyan}Duration:${C.reset} ${elapsed}s`); + if (fileLogger) { + console.log(` ${C.cyan}Log file:${C.reset} ${fileLogger.filePath}`); + fileLogger.write({ + ts: new Date().toISOString(), + direction: 'meta', + type: 'session_end', + msgCount, + durationMs: Date.now() - startTime, + }); + fileLogger.close(); + } + console.log(); + process.exit(1); + } + + connect(false); +} + +main().catch((e) => { + console.error(`${C.red}Fatal: ${e.message}${C.reset}`); + process.exit(1); +});