From 61cdf70ebc17b73e2857a3a41201c839576640e3 Mon Sep 17 00:00:00 2001 From: Seth Date: Sun, 22 Mar 2026 04:10:14 -0400 Subject: [PATCH] feat(oracle): express + websocket server with trace/command endpoints Co-Authored-By: Claude Opus 4.6 (1M context) --- oracle-bot/server.js | 261 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 oracle-bot/server.js diff --git a/oracle-bot/server.js b/oracle-bot/server.js new file mode 100644 index 0000000..4100902 --- /dev/null +++ b/oracle-bot/server.js @@ -0,0 +1,261 @@ +'use strict'; + +/** + * server.js — OracleBot entrypoint. + * + * Express HTTP server + WebSocket server providing: + * POST /trace — receive tool traces from the gateway + * POST /command — accept follow/scan commands + * GET /health — liveness / status check + * Static files from public/ + * WS /ws — live push to dashboard clients + */ + +const http = require('http'); +const path = require('path'); + +const express = require('express'); +const { WebSocketServer } = require('ws'); + +const OracleBot = require('./bot.js'); +const WorldState = require('./world-state.js'); + +// ────────────────────────────────────────────── +// Config +// ────────────────────────────────────────────── + +const PORT = parseInt(process.env.PORT || '3333', 10); +const HEARTBEAT_INTERVAL_MS = 5000; // idle pulse +const ACTIVE_INTERVAL_MS = 1000; // active world-snapshot push + +const MAX_WS_TOTAL = 100; // global WebSocket cap +const MAX_WS_PER_IP = 5; // per-IP WebSocket cap + +// ────────────────────────────────────────────── +// Instances +// ────────────────────────────────────────────── + +const bot = new OracleBot(); +const state = new WorldState(bot); + +// ────────────────────────────────────────────── +// Express app +// ────────────────────────────────────────────── + +const app = express(); + +app.use(express.json({ limit: '64kb' })); +app.use(express.static(path.join(__dirname, 'public'))); + +// ── POST /trace ────────────────────────────── + +app.post('/trace', (req, res) => { + const body = req.body; + + if (!body || typeof body.tool !== 'string') { + return res.status(400).json({ ok: false, error: 'Missing required field: tool' }); + } + + const prevPlayer = state.activePlayer; + + state.onTrace(body); + + // Broadcast: trace event, mode change, then world snapshot + broadcast(state.buildTraceEvent(body)); + broadcast(state.buildModeChange()); + + const snapshot = state.buildWorldSnapshot(); + if (snapshot) broadcast(snapshot); + + // If the active player changed, follow them + if (state.activePlayer && state.activePlayer !== prevPlayer) { + try { + bot.followPlayer(state.activePlayer); + } catch (err) { + // Bot may not be spawned yet — non-fatal + console.warn(`[server] followPlayer failed: ${err.message}`); + } + } + + return res.json({ ok: true }); +}); + +// ── POST /command ───────────────────────────── + +app.post('/command', (req, res) => { + const { action, target, center, radius } = req.body || {}; + + if (!action) { + return res.status(400).json({ ok: false, error: 'Missing required field: action' }); + } + + if (action === 'follow') { + if (!target || typeof target !== 'string') { + return res.status(400).json({ ok: false, error: 'follow requires target (player name)' }); + } + try { + bot.followPlayer(target); + return res.json({ ok: true, action: 'follow', target }); + } catch (err) { + return res.status(503).json({ ok: false, error: err.message }); + } + } + + if (action === 'scan') { + if (!center || typeof center.x !== 'number' || typeof center.y !== 'number' || typeof center.z !== 'number') { + return res.status(400).json({ ok: false, error: 'scan requires center {x, y, z}' }); + } + const r = typeof radius === 'number' ? radius : 16; + try { + const blocks = bot.scanArea(center.x, center.y, center.z, r); + const entities = bot.getNearbyEntities(center.x, center.y, center.z, r); + return res.json({ ok: true, action: 'scan', center, radius: r, blocks, entities }); + } catch (err) { + return res.status(503).json({ ok: false, error: err.message }); + } + } + + return res.status(400).json({ ok: false, error: `Unknown action: ${action}` }); +}); + +// ── GET /health ─────────────────────────────── + +app.get('/health', (_req, res) => { + res.json({ + ok: true, + botConnected: bot._spawned === true, + wsClients: wss.clients.size, + mode: state.mode, + activePlayer: state.activePlayer, + }); +}); + +// ────────────────────────────────────────────── +// HTTP server +// ────────────────────────────────────────────── + +const server = http.createServer(app); + +// ────────────────────────────────────────────── +// WebSocket server +// ────────────────────────────────────────────── + +const wss = new WebSocketServer({ server, path: '/ws' }); + +// Track connections per IP: Map +const ipCounts = new Map(); + +wss.on('connection', (ws, req) => { + // Resolve client IP (trust x-forwarded-for from Caddy) + const forwarded = req.headers['x-forwarded-for']; + const ip = forwarded ? forwarded.split(',')[0].trim() : (req.socket.remoteAddress || 'unknown'); + + // Enforce total cap + if (wss.clients.size > MAX_WS_TOTAL) { + ws.close(1008, 'Server at capacity'); + return; + } + + // Enforce per-IP cap + const currentCount = ipCounts.get(ip) || 0; + if (currentCount >= MAX_WS_PER_IP) { + ws.close(1008, 'Too many connections from your IP'); + return; + } + ipCounts.set(ip, currentCount + 1); + + // Store IP on the socket for cleanup + ws._remoteIp = ip; + + // Send current state immediately on connect + ws.send(JSON.stringify(state.buildHeartbeat())); + + // If we're not idle, also send current mode + if (state.mode !== 'idle') { + ws.send(JSON.stringify(state.buildModeChange())); + } + + ws.on('close', () => { + const count = ipCounts.get(ws._remoteIp) || 1; + if (count <= 1) { + ipCounts.delete(ws._remoteIp); + } else { + ipCounts.set(ws._remoteIp, count - 1); + } + }); +}); + +/** + * Broadcast a message object to all connected WebSocket clients. + * @param {Object} msg + */ +function broadcast(msg) { + if (wss.clients.size === 0) return; + const payload = JSON.stringify(msg); + for (const client of wss.clients) { + if (client.readyState === client.OPEN) { + client.send(payload); + } + } +} + +// ────────────────────────────────────────────── +// Periodic updates +// ────────────────────────────────────────────── + +// Heartbeat every 5s (idle mode pulse + idle timeout check) +setInterval(() => { + if (wss.clients.size === 0) return; + + // Check if session has timed out + const wentIdle = state.checkIdle(); + if (wentIdle) { + broadcast(state.buildModeChange()); + } + + if (state.mode === 'idle') { + broadcast(state.buildHeartbeat()); + } +}, HEARTBEAT_INTERVAL_MS); + +// World snapshot every 1s (active mode only) +setInterval(() => { + if (wss.clients.size === 0) return; + if (state.mode === 'idle') return; + + const snapshot = state.buildWorldSnapshot(); + if (snapshot) broadcast(snapshot); +}, ACTIVE_INTERVAL_MS); + +// ────────────────────────────────────────────── +// Bot event handlers +// ────────────────────────────────────────────── + +bot.on('connected', () => { + broadcast(state.buildStatus(true)); +}); + +bot.on('disconnected', () => { + broadcast(state.buildStatus(false)); +}); + +bot.on('playerJoined', () => { + broadcast(state.buildHeartbeat()); +}); + +bot.on('playerLeft', () => { + broadcast(state.buildHeartbeat()); +}); + +// ────────────────────────────────────────────── +// Startup +// ────────────────────────────────────────────── + +bot.connect(); + +server.listen(PORT, () => { + console.log(`[oracle-bot] HTTP + WS server listening on port ${PORT}`); + console.log(`[oracle-bot] WebSocket path: ws://localhost:${PORT}/ws`); + console.log(`[oracle-bot] Health check: http://localhost:${PORT}/health`); + console.log(`[oracle-bot] Connecting bot to Minecraft at ${bot.host}:${bot.port} ...`); +});