diff --git a/afk.js b/afk.js index afed4e7..7fd396d 100644 --- a/afk.js +++ b/afk.js @@ -18,6 +18,7 @@ function toSafeFilePart(value) { } const runtimeUsername = getRuntimeUsername(); +const IPC_ENABLED = typeof process.send === "function"; // Configuration const BOT_CONFIG = { @@ -46,6 +47,16 @@ function log(message) { fs.appendFileSync(LOG_FILE, logMessage + "\n"); } +function sendToParent(type, payload = {}) { + if (!IPC_ENABLED) return; + + try { + process.send({ type, ...payload }); + } catch { + // Parent IPC may close during shutdown. + } +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -84,6 +95,63 @@ function closeCurrentWindow() { } } +function getInventoryStart(window = bot.currentWindow) { + if (!window) return 9; + if (Number.isFinite(window.inventoryStart)) return window.inventoryStart; + + // Fallback for custom windows that don't expose inventoryStart. + if (Array.isArray(window.slots) && window.slots.length >= 36) { + return Math.max(0, window.slots.length - 36); + } + + return 9; +} + +function serializeWindow(window = bot.currentWindow) { + if (!window || !Array.isArray(window.slots)) return null; + + return { + id: window.id, + title: toPlainText(window.title || window.name || "Window"), + type: window.type || "unknown", + inventoryStart: getInventoryStart(window), + slots: window.slots.map((item, slot) => { + if (!item) return null; + return { + slot, + name: item.name || "", + displayName: item.displayName || item.name || "", + count: Number(item.count || 0), + }; + }), + }; +} + +let attachedWindow = null; +let attachedWindowUpdateHandler = null; + +function detachWindowUpdateHook() { + if (!attachedWindow || !attachedWindowUpdateHandler) return; + attachedWindow.removeListener("updateSlot", attachedWindowUpdateHandler); + attachedWindow = null; + attachedWindowUpdateHandler = null; +} + +function attachWindowUpdateHook(window) { + detachWindowUpdateHook(); + if (!window || typeof window.on !== "function") return; + + attachedWindow = window; + attachedWindowUpdateHandler = () => { + sendToParent("windowSnapshot", { + username: runtimeUsername, + window: serializeWindow(window), + }); + }; + + window.on("updateSlot", attachedWindowUpdateHandler); +} + function findWindowSlotByCandidates(candidates) { if (!bot.currentWindow || !Array.isArray(bot.currentWindow.slots)) return null; @@ -224,6 +292,11 @@ bot.on("login", () => { hasDetectedTeleport = false; clearAfkRetryTimer(); bot.setControlState("forward", false); + sendToParent("botStatus", { + username: runtimeUsername, + status: "online", + pid: process.pid, + }); }); bot.on("spawn", () => { @@ -238,17 +311,48 @@ bot.on("spawn", () => { scheduleAfkRetryCheck(AFK_INITIAL_CHECK_DELAY_MS); }); +bot.on("windowOpen", (window) => { + attachWindowUpdateHook(window); + sendToParent("windowSnapshot", { + username: runtimeUsername, + window: serializeWindow(window), + }); +}); + +bot.on("windowClose", () => { + detachWindowUpdateHook(); + sendToParent("windowSnapshot", { + username: runtimeUsername, + window: null, + }); +}); + bot.on("error", (err) => { log(`Error: ${err.message}`); + sendToParent("botError", { + username: runtimeUsername, + error: err.message, + }); }); bot.on("kicked", (reason) => { log(`Kicked: ${reason}`); + sendToParent("botEvent", { + username: runtimeUsername, + event: "kicked", + reason: toPlainText(reason), + }); }); bot.on("end", () => { + detachWindowUpdateHook(); clearAfkRetryTimer(); log("Bot disconnected"); + sendToParent("botStatus", { + username: runtimeUsername, + status: "offline", + pid: process.pid, + }); process.exit(0); }); @@ -305,8 +409,85 @@ bot._client.on("set_subtitle_text", (packet) => { // Graceful shutdown process.on("SIGINT", () => { log("Shutdown signal received"); + detachWindowUpdateHook(); clearAfkRetryTimer(); bot.quit(); }); +process.on("message", async (message) => { + if (!message || typeof message !== "object") return; + + const { type, requestId } = message; + const respond = (ok, error) => { + sendToParent("actionResult", { + username: runtimeUsername, + requestId, + ok, + error: error || null, + }); + }; + + try { + if (type === "chat") { + const text = String(message.message || "").trim(); + if (!text) throw new Error("Chat message is empty"); + bot.chat(text); + respond(true); + return; + } + + if (type === "window.click") { + if (!bot.currentWindow) throw new Error("No window open"); + const slot = Number(message.slot); + if (!Number.isInteger(slot) || slot < 0) { + throw new Error("Invalid slot index"); + } + + const mouseButton = Number.isInteger(Number(message.mouseButton)) + ? Number(message.mouseButton) + : 0; + const mode = Number.isInteger(Number(message.mode)) + ? Number(message.mode) + : 0; + + await bot.clickWindow(slot, mouseButton, mode); + await sleep(75); + sendToParent("windowSnapshot", { + username: runtimeUsername, + window: serializeWindow(), + }); + respond(true); + return; + } + + if (type === "window.close") { + closeCurrentWindow(); + respond(true); + return; + } + + if (type === "quit") { + respond(true); + bot.quit(); + return; + } + + if (type === "state.request") { + sendToParent("botStatus", { + username: runtimeUsername, + status: bot.player ? "online" : "connecting", + pid: process.pid, + }); + sendToParent("windowSnapshot", { + username: runtimeUsername, + window: serializeWindow(), + }); + respond(true); + return; + } + } catch (err) { + respond(false, err.message || "Command failed"); + } +}); + log("Starting AFK bot..."); diff --git a/afk_parent.js b/afk_parent.js index 252e658..50eb362 100644 --- a/afk_parent.js +++ b/afk_parent.js @@ -1,8 +1,12 @@ +"use strict"; + const { spawn } = require("child_process"); const fs = require("fs"); const path = require("path"); +const { WebSocket } = require("ws"); + +// ─── config ─────────────────────────────────────────────────────────────────── -// Add all bot usernames here. const USERNAMES = ["ZareMate", "Tomek", "Cytrus"]; const AFK_SCRIPT = path.join(__dirname, "afk.js"); @@ -10,15 +14,35 @@ const OUTPUT_MODE = ( process.env.PARENT_OUTPUT_MODE || "prefixed" ).toLowerCase(); const SPLIT_LOGS_ENABLED = process.env.PARENT_SPLIT_LOGS === "1"; + +// URL of the control server's parent endpoint. +const SERVER_WS_URL = + process.env.AFK_SERVER_WS_URL || + `ws://localhost:${Number(process.env.AFK_PARENT_PORT || 3008)}/parent`; + +// Optional shared secret matching PARENT_SECRET on server.js +const PARENT_SECRET = process.env.PARENT_SECRET || ""; +const PARENT_ID = + process.env.AFK_PARENT_ID || + `${process.env.HOSTNAME || "parent"}-${process.pid}`; + const RESTART_MIN_MS = 2 * 60 * 1000; const RESTART_MAX_MS = 5 * 60 * 1000; const STARTUP_GAP_MIN_MS = 15 * 1000; const STARTUP_GAP_MAX_MS = 30 * 1000; +const RECONNECT_DELAY_MS = 3_000; const ALREADY_ONLINE_TEXT = "You are already online, try restarting your game."; const TIMEOUT_REGEX = /timeout|timed out|ETIMEDOUT|ECONNRESET|socket hang up/i; +// ─── globals ────────────────────────────────────────────────────────────────── + let isShuttingDown = false; +let requestCounter = 0; +let serverWs = null; +let reconnectTimer = null; + +// ─── helpers ────────────────────────────────────────────────────────────────── function toSafeFilePart(value) { return String(value).replace(/[^a-zA-Z0-9_-]/g, "_"); @@ -51,117 +75,39 @@ function randomStartupGapMs() { } function shouldRestartFromState(state) { - if (state.sawKick) return true; - if (state.sawTimeout) return true; - if (state.sawAlreadyOnline) return true; - return false; + return state.sawKick || state.sawTimeout || state.sawAlreadyOnline; } function updateRestartSignalsFromLine(state, line) { if (!line) return; - - if (line.includes("Kicked:")) { - state.sawKick = true; - } - if (TIMEOUT_REGEX.test(line)) { - state.sawTimeout = true; - } - if (line.includes(ALREADY_ONLINE_TEXT)) { - state.sawAlreadyOnline = true; - } + if (line.includes("Kicked:")) state.sawKick = true; + if (TIMEOUT_REGEX.test(line)) state.sawTimeout = true; + if (line.includes(ALREADY_ONLINE_TEXT)) state.sawAlreadyOnline = true; } -function startBot(state) { - const { username } = state; - const shouldPrefix = OUTPUT_MODE !== "raw"; - const logFile = SPLIT_LOGS_ENABLED - ? path.join(__dirname, `afk-parent-${toSafeFilePart(username)}.log`) - : null; - - state.sawKick = false; - state.sawTimeout = false; - state.sawAlreadyOnline = false; - state.child = null; - - const child = spawn(process.execPath, [AFK_SCRIPT, username], { - cwd: __dirname, - stdio: shouldPrefix ? ["inherit", "pipe", "pipe"] : "inherit", - }); - state.child = child; - - console.log(`[parent] started ${username} (pid=${child.pid})`); - - child.on("exit", (code, signal) => { - const reason = signal ? `signal ${signal}` : `code ${code}`; - console.log(`[parent] ${username} exited (${reason})`); - - if (isShuttingDown) return; - if (!shouldRestartFromState(state)) return; - - const delay = randomRestartDelayMs(); - const seconds = Math.round(delay / 1000); - console.log(`[parent] scheduling restart for ${username} in ${seconds}s`); - - if (state.restartTimer) clearTimeout(state.restartTimer); - state.restartTimer = setTimeout(() => { - if (isShuttingDown) return; - startBot(state); - }, delay); - }); - - child.on("error", (err) => { - console.error(`[parent] failed to start ${username}: ${err.message}`); - }); - - if (shouldPrefix) { - let stdoutBuffer = ""; - let stderrBuffer = ""; - - child.stdout.on("data", (chunk) => { - const text = stdoutBuffer + chunk.toString("utf8"); - const lines = text.split(/\r?\n/); - stdoutBuffer = lines.pop() || ""; - for (const line of lines) { - if (!line) continue; - updateRestartSignalsFromLine(state, line); - const rendered = withUsernamePrefix(username, line); - process.stdout.write(`${rendered}\n`); - writeSplitLog(logFile, `stdout: ${line}`); - } - }); - - child.stderr.on("data", (chunk) => { - const text = stderrBuffer + chunk.toString("utf8"); - const lines = text.split(/\r?\n/); - stderrBuffer = lines.pop() || ""; - for (const line of lines) { - if (!line) continue; - updateRestartSignalsFromLine(state, line); - const rendered = withUsernamePrefix(username, line); - process.stderr.write(`${rendered}\n`); - writeSplitLog(logFile, `stderr: ${line}`); - } - }); - - child.on("close", () => { - if (stdoutBuffer) { - updateRestartSignalsFromLine(state, stdoutBuffer); - const rendered = withUsernamePrefix(username, stdoutBuffer); - process.stdout.write(`${rendered}\n`); - writeSplitLog(logFile, `stdout: ${stdoutBuffer}`); - } - if (stderrBuffer) { - updateRestartSignalsFromLine(state, stderrBuffer); - const rendered = withUsernamePrefix(username, stderrBuffer); - process.stderr.write(`${rendered}\n`); - writeSplitLog(logFile, `stderr: ${stderrBuffer}`); - } - }); - } - - return child; +function nowIso() { + return new Date().toISOString(); } +function parseLineChunks(pushLine) { + let buffer = ""; + return (chunk, flush = false) => { + const text = buffer + chunk.toString("utf8"); + const lines = text.split(/\r?\n/); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line) continue; + pushLine(line); + } + if (flush && buffer) { + pushLine(buffer); + buffer = ""; + } + }; +} + +// ─── bot state ──────────────────────────────────────────────────────────────── + const botStates = USERNAMES.map((username) => ({ username, child: null, @@ -170,40 +116,474 @@ const botStates = USERNAMES.map((username) => ({ sawKick: false, sawTimeout: false, sawAlreadyOnline: false, + status: "stopped", + pid: null, + window: null, + lastError: null, + userStopped: false, + pendingRequests: new Map(), })); +const botStateByUsername = new Map(botStates.map((s) => [s.username, s])); + +function serializeState(state) { + return { + username: state.username, + status: state.status, + pid: state.pid, + running: Boolean(state.child), + userStopped: state.userStopped, + sawKick: state.sawKick, + sawTimeout: state.sawTimeout, + sawAlreadyOnline: state.sawAlreadyOnline, + lastError: state.lastError, + window: state.window, + }; +} + +function resolveState(username) { + const key = String(username || "").trim(); + if (!key) return null; + return botStateByUsername.get(key) || null; +} + +// ─── server WS communication ────────────────────────────────────────────────── + +function sendToServer(payload) { + if (!serverWs || serverWs.readyState !== WebSocket.OPEN) return; + try { + serverWs.send( + JSON.stringify({ ...payload, parentId: payload.parentId || PARENT_ID }), + ); + } catch (_) { + /* ignore */ + } +} + +function broadcast(payload) { + sendToServer(payload); +} + +function broadcastState(state) { + broadcast({ + type: "bot.update", + serverTime: nowIso(), + bot: serializeState(state), + }); +} + +// ─── forward parent console logs to server UI ──────────────────────────────── +// Patch after sendToServer is defined. Lines emitted before the WS connects +// are silently dropped (sendToServer is a no-op when not connected). +{ + const _log = console.log.bind(console); + const _err = console.error.bind(console); + const strip = (s) => s.replace(/^\[parent\] /, ""); + console.log = (...args) => { + const line = args.map(String).join(" "); + _log(line); + sendToServer({ + type: "parent.log", + serverTime: nowIso(), + stream: "stdout", + line: strip(line), + }); + }; + console.error = (...args) => { + const line = args.map(String).join(" "); + _err(line); + sendToServer({ + type: "parent.log", + serverTime: nowIso(), + stream: "stderr", + line: strip(line), + }); + }; +} + +// ─── child log handling ─────────────────────────────────────────────────────── + +function onChildLogLine(state, streamName, line) { + updateRestartSignalsFromLine(state, line); + + const rendered = + OUTPUT_MODE === "raw" ? line : withUsernamePrefix(state.username, line); + if (streamName === "stderr") process.stderr.write(`${rendered}\n`); + else process.stdout.write(`${rendered}\n`); + + const logFile = SPLIT_LOGS_ENABLED + ? path.join(__dirname, `afk-parent-${toSafeFilePart(state.username)}.log`) + : null; + writeSplitLog(logFile, `${streamName}: ${line}`); + + broadcast({ + type: "bot.log", + serverTime: nowIso(), + username: state.username, + stream: streamName, + line, + }); +} + +// ─── timer helpers ──────────────────────────────────────────────────────────── + +function clearTimers(state) { + if (state.startupTimer) { + clearTimeout(state.startupTimer); + state.startupTimer = null; + } + if (state.restartTimer) { + clearTimeout(state.restartTimer); + state.restartTimer = null; + } +} + +function rejectPendingRequests(state, errorMessage) { + for (const { reject, timeoutId } of state.pendingRequests.values()) { + clearTimeout(timeoutId); + reject(new Error(errorMessage || "Bot process ended")); + } + state.pendingRequests.clear(); +} + +// ─── child IPC ──────────────────────────────────────────────────────────────── + +function handleChildIpcMessage(state, msg) { + if (!msg || typeof msg !== "object") return; + + if (msg.type === "windowSnapshot") { + state.window = msg.window || null; + broadcastState(state); + return; + } + + if (msg.type === "botStatus") { + if (typeof msg.status === "string" && msg.status.trim()) + state.status = msg.status; + if (Number.isInteger(msg.pid)) state.pid = msg.pid; + broadcastState(state); + return; + } + + if (msg.type === "botError") { + state.lastError = String(msg.error || "Unknown bot error"); + broadcastState(state); + return; + } + + if (msg.type === "botEvent") { + broadcast({ + type: "bot.event", + serverTime: nowIso(), + username: state.username, + event: msg.event || "unknown", + reason: msg.reason || null, + }); + return; + } + + if (msg.type === "actionResult") { + const req = state.pendingRequests.get(msg.requestId); + if (!req) return; + clearTimeout(req.timeoutId); + state.pendingRequests.delete(msg.requestId); + if (msg.ok) req.resolve(msg); + else req.reject(new Error(msg.error || "Bot action failed")); + } +} + +// ─── bot lifecycle ──────────────────────────────────────────────────────────── + +function startBot(state, options = {}) { + if (state.child) return state.child; + + state.userStopped = false; + state.sawKick = false; + state.sawTimeout = false; + state.sawAlreadyOnline = false; + state.lastError = null; + state.window = null; + state.status = "starting"; + clearTimers(state); + broadcastState(state); + + const child = spawn(process.execPath, [AFK_SCRIPT, state.username], { + cwd: __dirname, + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + + state.child = child; + state.pid = child.pid; + + const src = options.source ? ` source=${options.source}` : ""; + console.log(`[parent] started ${state.username} (pid=${child.pid})${src}`); + + const consumeStdout = parseLineChunks((line) => + onChildLogLine(state, "stdout", line), + ); + const consumeStderr = parseLineChunks((line) => + onChildLogLine(state, "stderr", line), + ); + + child.stdout.on("data", (chunk) => consumeStdout(chunk)); + child.stderr.on("data", (chunk) => consumeStderr(chunk)); + child.stdout.on("close", () => consumeStdout(Buffer.from(""), true)); + child.stderr.on("close", () => consumeStderr(Buffer.from(""), true)); + + child.on("message", (msg) => handleChildIpcMessage(state, msg)); + + child.on("exit", (code, signal) => { + const reason = signal ? `signal ${signal}` : `code ${code}`; + console.log(`[parent] ${state.username} exited (${reason})`); + + rejectPendingRequests(state, `Bot ${state.username} exited`); + + state.child = null; + state.pid = null; + state.window = null; + state.status = "stopped"; + broadcastState(state); + + if (isShuttingDown) return; + if (state.userStopped) return; + if (!shouldRestartFromState(state)) return; + + const delay = randomRestartDelayMs(); + console.log( + `[parent] scheduling restart for ${state.username} in ${Math.round(delay / 1000)}s`, + ); + + state.restartTimer = setTimeout(() => { + state.restartTimer = null; + if (isShuttingDown) return; + if (state.userStopped) return; + startBot(state, { source: "restart" }); + }, delay); + + state.status = "restart-wait"; + broadcastState(state); + }); + + child.on("error", (err) => { + state.lastError = err.message; + console.error(`[parent] failed to start ${state.username}: ${err.message}`); + broadcastState(state); + }); + + broadcastState(state); + + try { + child.send({ type: "state.request", requestId: `bootstrap-${Date.now()}` }); + } catch { + /* child may have already exited */ + } + + return child; +} + +function stopBot(state, options = {}) { + const source = options.source || "manual"; + const setUserStopped = options.userStopped !== false; + + clearTimers(state); + if (setUserStopped) state.userStopped = true; + + if (!state.child) { + state.status = "stopped"; + broadcastState(state); + return; + } + + console.log(`[parent] stopping ${state.username} source=${source}`); + state.status = "stopping"; + broadcastState(state); + + if (!state.child.killed) state.child.kill("SIGINT"); +} + +function sendCommandToBot(state, payload, timeoutMs = 10_000) { + if (!state.child) { + return Promise.reject(new Error(`Bot ${state.username} is not running`)); + } + + requestCounter += 1; + const requestId = `${state.username}-${Date.now()}-${requestCounter}`; + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + state.pendingRequests.delete(requestId); + reject(new Error("Bot action timed out")); + }, timeoutMs); + + state.pendingRequests.set(requestId, { resolve, reject, timeoutId }); + + try { + state.child.send({ ...payload, requestId }); + } catch (err) { + clearTimeout(timeoutId); + state.pendingRequests.delete(requestId); + reject(err); + } + }); +} + +// ─── handle messages from the control server ────────────────────────────────── + +async function handleServerMessage(msg) { + if (!msg || typeof msg !== "object") return; + + const relayId = msg._relayId || null; + const reply = (ok, extra = {}) => { + sendToServer({ type: "ack", _relayId: relayId, ok, ...extra }); + }; + + if (msg.type === "state.get") { + sendToServer({ + type: "state.full", + _relayId: relayId, + serverTime: nowIso(), + bots: botStates.map(serializeState), + }); + return; + } + + const state = resolveState(msg.username); + if (!state) { + reply(false, { error: "Unknown bot username" }); + return; + } + + try { + if (msg.type === "bot.start") { + startBot(state, { source: "web" }); + reply(true); + return; + } + if (msg.type === "bot.stop") { + stopBot(state, { source: "web", userStopped: true }); + reply(true); + return; + } + if (msg.type === "bot.chat") { + await sendCommandToBot(state, { type: "chat", message: msg.message }); + reply(true); + return; + } + if (msg.type === "bot.window.click") { + await sendCommandToBot(state, { + type: "window.click", + slot: msg.slot, + mouseButton: msg.mouseButton, + mode: msg.mode, + }); + reply(true); + return; + } + if (msg.type === "bot.window.close") { + await sendCommandToBot(state, { type: "window.close" }); + reply(true); + return; + } + reply(false, { error: "Unknown message type" }); + } catch (err) { + reply(false, { error: err.message || "Action failed" }); + } +} + +// ─── server WebSocket client ────────────────────────────────────────────────── + +function connectToServer() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (isShuttingDown) return; + + const wsUrl = new URL(SERVER_WS_URL); + wsUrl.searchParams.set("parentId", PARENT_ID); + if (PARENT_SECRET) wsUrl.searchParams.set("secret", PARENT_SECRET); + const url = wsUrl.toString(); + + const ws = new WebSocket(url); + + ws.on("open", () => { + serverWs = ws; + console.log(`[parent] connected to server as ${PARENT_ID}`); + // Push current bot state so server cache is fresh + sendToServer({ + type: "state.full", + serverTime: nowIso(), + bots: botStates.map(serializeState), + }); + }); + + ws.on("message", (raw) => { + let msg; + try { + msg = JSON.parse(String(raw)); + } catch { + return; + } + handleServerMessage(msg); + }); + + ws.on("close", () => { + if (serverWs === ws) serverWs = null; + if (!isShuttingDown) { + console.log( + `[parent] server connection lost, reconnecting in ${RECONNECT_DELAY_MS / 1000}s`, + ); + reconnectTimer = setTimeout(connectToServer, RECONNECT_DELAY_MS); + } + }); + + ws.on("error", (err) => { + // 'close' fires after 'error'; reconnect is handled there + console.error(`[parent] server ws error: ${err.message}`); + ws.terminate(); + }); +} + +// ─── startup ────────────────────────────────────────────────────────────────── + +connectToServer(); + let startupOffset = 0; for (const state of botStates) { const delay = startupOffset; + state.status = delay > 0 ? "startup-wait" : "starting"; state.startupTimer = setTimeout(() => { state.startupTimer = null; if (isShuttingDown) return; - startBot(state); + if (state.userStopped) return; + startBot(state, { source: "startup" }); }, delay); if (delay > 0) { - const seconds = Math.round(delay / 1000); - console.log(`[parent] startup delay for ${state.username}: ${seconds}s`); + console.log( + `[parent] startup delay for ${state.username}: ${Math.round(delay / 1000)}s`, + ); } startupOffset += randomStartupGapMs(); } +// ─── shutdown ───────────────────────────────────────────────────────────────── + function shutdownAll() { + if (isShuttingDown) return; isShuttingDown = true; console.log("[parent] shutdown requested"); + for (const state of botStates) { - if (state.startupTimer) { - clearTimeout(state.startupTimer); - state.startupTimer = null; - } - if (state.restartTimer) { - clearTimeout(state.restartTimer); - state.restartTimer = null; - } - const child = state.child; - if (child && !child.killed) child.kill("SIGINT"); + stopBot(state, { source: "shutdown", userStopped: false }); } + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (serverWs) serverWs.close(); } process.on("SIGINT", shutdownAll); diff --git a/hashed_profiles.js b/hashed_profiles.js index db62b04..a8a6b9b 100644 --- a/hashed_profiles.js +++ b/hashed_profiles.js @@ -44,3 +44,8 @@ function checkAllHashedProfiles() { } console.log(checkAllHashedProfiles()); + +module.exports = { + checkIfHashedPresent, + checkAllHashedProfiles, +}; diff --git a/package.json b/package.json index 46a7bf2..0630b6e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dotenv": "^17.3.1", "mineflayer": "^4.35.0", "mineflayer-gui": "^4.0.2", - "vec3": "^0.1.10" + "vec3": "^0.1.10", + "ws": "^8.20.0" } -} \ No newline at end of file +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..0269416 --- /dev/null +++ b/server.js @@ -0,0 +1,444 @@ +"use strict"; + +/** + * Standalone control server. + * + * - Serves web/index.html at GET / + * - Browser WebSocket clients connect at /ws + * - afk_parent connects as a single privileged client at /parent + * (optionally authenticated via ?secret=... query param when + * PARENT_SECRET env var is set) + * + * The server relays messages: + * browser → parent (bot.start / bot.stop / bot.chat / window.* / state.get) + * parent → browsers (bot.update / bot.log / bot.event / state.full / ack) + * + * Each browser request gets a _relayId tag so the corresponding ack/response + * from the parent can be routed back to the exact browser that made the call. + */ + +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const { WebSocketServer, WebSocket } = require("ws"); + +const WEB_PORT = Number(process.env.AFK_PARENT_PORT || 3008); +const PARENT_SECRET = process.env.PARENT_SECRET || ""; +const WEB_UI_PATH = path.join(__dirname, "web", "index.html"); + +// ─── state ─────────────────────────────────────────────────────────────────── + +const parentsById = new Map(); // parentId -> ws +const wsToParentId = new Map(); // ws -> parentId +const browsers = new Map(); // sessionId → ws +let sessionCounter = 0; +let relayCounter = 0; +const pendingRelays = new Map(); // relayId → { sessionId, originalRequestId, parentId } +const cachedBots = new Map(); // `${parentId}:${username}` -> bot + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function nowIso() { + return new Date().toISOString(); +} + +function sendTo(ws, payload) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + ws.send(JSON.stringify(payload)); + } catch (_) { + /* ignore */ + } +} + +function broadcastToBrowsers(payload) { + for (const ws of browsers.values()) sendTo(ws, payload); +} + +function botKey(parentId, username) { + return `${parentId}:${username}`; +} + +function listCachedBots() { + return [...cachedBots.values()]; +} + +// ─── forward server console logs to browsers ───────────────────────────────── +{ + const _log = console.log.bind(console); + const _err = console.error.bind(console); + const strip = (s) => s.replace(/^\[server\] /, ""); + console.log = (...args) => { + const line = args.map(String).join(" "); + _log(line); + broadcastToBrowsers({ + type: "server.log", + serverTime: nowIso(), + stream: "stdout", + line: strip(line), + }); + }; + console.error = (...args) => { + const line = args.map(String).join(" "); + _err(line); + broadcastToBrowsers({ + type: "server.log", + serverTime: nowIso(), + stream: "stderr", + line: strip(line), + }); + }; +} + +function nextRelayId(sessionId, originalRequestId, parentId) { + relayCounter++; + const id = `relay-${relayCounter}`; + pendingRelays.set(id, { sessionId, originalRequestId, parentId }); + return id; +} + +// ─── HTTP server ────────────────────────────────────────────────────────────── + +const httpServer = http.createServer((req, res) => { + if (req.method !== "GET") { + res.writeHead(405, { "Content-Type": "text/plain" }); + res.end("Method not allowed"); + return; + } + + if (req.url === "/" || req.url === "/index.html") { + try { + const html = fs.readFileSync(WEB_UI_PATH, "utf8"); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(html); + } catch { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("UI file missing"); + } + return; + } + + if (req.url === "/health") { + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + ok: true, + parentConnected: parentsById.size > 0, + parentCount: parentsById.size, + bots: listCachedBots(), + }), + ); + return; + } + + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not found"); +}); + +// ─── WebSocket upgrade routing ──────────────────────────────────────────────── + +const browserWss = new WebSocketServer({ noServer: true }); +const parentWssI = new WebSocketServer({ noServer: true }); + +httpServer.on("upgrade", (req, socket, head) => { + let pathname; + try { + pathname = new URL(req.url, "http://x").pathname; + } catch { + socket.destroy(); + return; + } + + if (pathname === "/parent") { + if (PARENT_SECRET) { + let secret; + try { + secret = new URL(req.url, "http://x").searchParams.get("secret"); + } catch { + /* ignore */ + } + if (secret !== PARENT_SECRET) { + socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + } + parentWssI.handleUpgrade(req, socket, head, (ws) => + parentWssI.emit("connection", ws, req), + ); + } else if (pathname === "/ws") { + browserWss.handleUpgrade(req, socket, head, (ws) => + browserWss.emit("connection", ws), + ); + } else { + socket.destroy(); + } +}); + +// ─── parent connection ──────────────────────────────────────────────────────── + +parentWssI.on("connection", (ws, req) => { + const parentId = + new URL(req.url, "http://x").searchParams.get("parentId") || + `parent-${Date.now()}-${Math.floor(Math.random() * 10_000)}`; + + const existing = parentsById.get(parentId); + if (existing && existing !== ws) { + console.log( + `[server] replacing existing parent connection for ${parentId}`, + ); + existing.terminate(); + } + + parentsById.set(parentId, ws); + wsToParentId.set(ws, parentId); + + console.log(`[server] parent connected (${parentId})`); + broadcastToBrowsers({ + type: "parent.connected", + serverTime: nowIso(), + parentId, + parentCount: parentsById.size, + }); + + ws.on("message", (raw) => { + let msg; + try { + msg = JSON.parse(String(raw)); + } catch { + return; + } + handleParentMessage(parentId, msg); + }); + + ws.on("close", () => { + const closedParentId = wsToParentId.get(ws); + wsToParentId.delete(ws); + if (!closedParentId) return; + + const activeWs = parentsById.get(closedParentId); + if (activeWs === ws) parentsById.delete(closedParentId); + + for (const key of [...cachedBots.keys()]) { + if (key.startsWith(`${closedParentId}:`)) cachedBots.delete(key); + } + + console.log(`[server] parent disconnected (${closedParentId})`); + broadcastToBrowsers({ + type: "parent.disconnected", + serverTime: nowIso(), + parentId: closedParentId, + parentCount: parentsById.size, + }); + broadcastToBrowsers({ + type: "state.full", + serverTime: nowIso(), + bots: listCachedBots(), + parentConnected: parentsById.size > 0, + parentCount: parentsById.size, + }); + }); + + ws.on("error", (err) => { + console.error("[server] parent ws error:", err.message); + }); +}); + +function handleParentMessage(parentId, msg) { + if (!msg || typeof msg !== "object") return; + + // ── broadcasts ────────────────────────────────────────────────────────────── + + if (msg.type === "bot.update") { + if (msg.bot) { + const bot = { ...msg.bot, parentId }; + cachedBots.set(botKey(parentId, bot.username), bot); + } + broadcastToBrowsers({ + ...msg, + bot: msg.bot ? { ...msg.bot, parentId } : msg.bot, + parentId, + serverTime: msg.serverTime || nowIso(), + }); + return; + } + + if ( + msg.type === "bot.log" || + msg.type === "bot.event" || + msg.type === "parent.log" + ) { + broadcastToBrowsers({ + ...msg, + parentId, + serverTime: msg.serverTime || nowIso(), + }); + return; + } + + // ── state snapshot (response to state.get, or initial push on connect) ────── + + if (msg.type === "state.full") { + if (Array.isArray(msg.bots)) { + for (const bot of msg.bots) { + if (!bot || !bot.username) continue; + cachedBots.set(botKey(parentId, bot.username), { ...bot, parentId }); + } + } + + const relay = msg._relayId ? pendingRelays.get(msg._relayId) : null; + if (relay) { + if (relay.parentId && relay.parentId !== parentId) return; + pendingRelays.delete(msg._relayId); + const ws = browsers.get(relay.sessionId); + if (ws) + sendTo(ws, { + ...msg, + parentId, + bots: (msg.bots || []).map((bot) => ({ ...bot, parentId })), + requestId: relay.originalRequestId, + _relayId: undefined, + }); + } else { + broadcastToBrowsers({ + ...msg, + parentId, + bots: listCachedBots(), + serverTime: msg.serverTime || nowIso(), + _relayId: undefined, + parentConnected: parentsById.size > 0, + parentCount: parentsById.size, + }); + } + return; + } + + // ── ack for a specific browser action ─────────────────────────────────────── + + if (msg.type === "ack" && msg._relayId) { + const relay = pendingRelays.get(msg._relayId); + if (!relay) return; + if (relay.parentId && relay.parentId !== parentId) return; + pendingRelays.delete(msg._relayId); + const ws = browsers.get(relay.sessionId); + if (ws) + sendTo(ws, { + ...msg, + parentId, + requestId: relay.originalRequestId, + _relayId: undefined, + }); + } +} + +// ─── browser connections ────────────────────────────────────────────────────── + +browserWss.on("connection", (ws) => { + sessionCounter++; + const sessionId = `s${sessionCounter}`; + browsers.set(sessionId, ws); + + // Send initial state immediately from cache; browser shows "parent offline" + // badge when parentConnected is false. + sendTo(ws, { + type: "hello", + serverTime: nowIso(), + bots: listCachedBots(), + parentConnected: parentsById.size > 0, + parentCount: parentsById.size, + }); + + ws.on("message", (raw) => { + let msg; + try { + msg = JSON.parse(String(raw)); + } catch { + sendTo(ws, { + type: "ack", + requestId: null, + ok: false, + error: "Invalid JSON", + }); + return; + } + handleBrowserMessage(sessionId, ws, msg); + }); + + ws.on("close", () => { + browsers.delete(sessionId); + // Remove any pending relays so we don't leak memory + for (const [id, relay] of pendingRelays) { + if (relay.sessionId === sessionId) pendingRelays.delete(id); + } + }); +}); + +function handleBrowserMessage(sessionId, ws, msg) { + if (!msg || typeof msg !== "object") return; + + if (parentsById.size === 0) { + sendTo(ws, { + type: "ack", + requestId: msg.requestId || null, + ok: false, + error: "Parent not connected", + }); + return; + } + + let targetParentId = msg.parentId || null; + + if (!targetParentId && msg.username) { + const parentIds = new Set(); + for (const bot of cachedBots.values()) { + if (bot.username === msg.username && bot.parentId) + parentIds.add(bot.parentId); + } + if (parentIds.size === 1) { + targetParentId = [...parentIds][0]; + } else if (parentIds.size > 1) { + sendTo(ws, { + type: "ack", + requestId: msg.requestId || null, + ok: false, + error: "Ambiguous username across multiple parents; specify parentId", + }); + return; + } + } + + if (!targetParentId && parentsById.size === 1) { + targetParentId = parentsById.keys().next().value; + } + + const targetWs = targetParentId ? parentsById.get(targetParentId) : null; + if (!targetWs || targetWs.readyState !== WebSocket.OPEN) { + sendTo(ws, { + type: "ack", + requestId: msg.requestId || null, + ok: false, + error: "Target parent not connected", + }); + return; + } + + const relayId = nextRelayId(sessionId, msg.requestId || null, targetParentId); + sendTo(targetWs, { ...msg, parentId: targetParentId, _relayId: relayId }); +} + +// ─── startup ────────────────────────────────────────────────────────────────── + +httpServer.listen(WEB_PORT, () => { + console.log(`[server] web ui http://localhost:${WEB_PORT}`); + console.log(`[server] browser ws ws://localhost:${WEB_PORT}/ws`); + console.log(`[server] parent ws ws://localhost:${WEB_PORT}/parent`); +}); + +function shutdown() { + httpServer.close(); + browserWss.close(); + parentWssI.close(); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/web/index.html b/web/index.html index 0b43cd1..30865e2 100644 --- a/web/index.html +++ b/web/index.html @@ -3,182 +3,675 @@
-