feat: implement AFK Bot Control UI and server functionality

- Updated index.html to reflect new title and layout for AFK Bot Control.
- Enhanced styling for better user experience with new color scheme and responsive design.
- Added WebSocket server in server.js to handle communication between browser clients and the parent process.
- Implemented bot management features including start, stop, and chat functionalities.
- Introduced logging mechanism to relay server and bot logs to the UI.
- Exported functions from hashed_profiles.js for better modularity.
- Added ws package to package.json for WebSocket support.
This commit is contained in:
ZareMate 2026-04-02 15:28:41 +02:00
parent 77af2a3912
commit 24c5debffc
6 changed files with 1736 additions and 232 deletions

181
afk.js
View File

@ -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...");

View File

@ -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,29 +116,118 @@ const botStates = USERNAMES.map((username) => ({
sawKick: false,
sawTimeout: false,
sawAlreadyOnline: false,
status: "stopped",
pid: null,
window: null,
lastError: null,
userStopped: false,
pendingRequests: new Map(),
}));
let startupOffset = 0;
for (const state of botStates) {
const delay = startupOffset;
state.startupTimer = setTimeout(() => {
state.startupTimer = null;
if (isShuttingDown) return;
startBot(state);
}, delay);
const botStateByUsername = new Map(botStates.map((s) => [s.username, s]));
if (delay > 0) {
const seconds = Math.round(delay / 1000);
console.log(`[parent] startup delay for ${state.username}: ${seconds}s`);
}
startupOffset += randomStartupGapMs();
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 shutdownAll() {
isShuttingDown = true;
console.log("[parent] shutdown requested");
for (const state of botStates) {
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;
@ -201,9 +236,354 @@ function shutdownAll() {
clearTimeout(state.restartTimer);
state.restartTimer = null;
}
const child = state.child;
if (child && !child.killed) child.kill("SIGINT");
}
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;
if (state.userStopped) return;
startBot(state, { source: "startup" });
}, delay);
if (delay > 0) {
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) {
stopBot(state, { source: "shutdown", userStopped: false });
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (serverWs) serverWs.close();
}
process.on("SIGINT", shutdownAll);

View File

@ -44,3 +44,8 @@ function checkAllHashedProfiles() {
}
console.log(checkAllHashedProfiles());
module.exports = {
checkIfHashedPresent,
checkAllHashedProfiles,
};

View File

@ -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"
}
}

444
server.js Normal file
View File

@ -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);

View File

@ -3,182 +3,675 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spawner Control</title>
<title>AFK Bot Control</title>
<style>
:root {
color-scheme: dark;
--bg-a: #102532;
--bg-b: #07141f;
--panel: rgba(10, 22, 33, 0.84);
--line: rgba(132, 179, 201, 0.28);
--text: #e6f4fb;
--muted: #9bbbc9;
--good: #4fd7a0;
--warn: #f0b34a;
--bad: #ff6f6f;
--accent: #6bd5ff;
}
* {
box-sizing: border-box;
}
body {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
Arial, sans-serif;
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background:
radial-gradient(1000px 500px at 0% -20%, rgba(110, 212, 255, 0.2), transparent 65%),
radial-gradient(800px 420px at 100% 0%, rgba(79, 215, 160, 0.16), transparent 60%),
linear-gradient(160deg, var(--bg-a), var(--bg-b));
}
.layout {
width: min(1240px, calc(100vw - 1.5rem));
margin: 1rem auto;
display: grid;
place-items: center;
background: radial-gradient(1200px 600px at 20% -10%, #1f2a44 0%, #0d1018 45%, #090b12 100%);
color: #e6eaf2;
gap: 0.85rem;
grid-template-columns: 360px 1fr;
}
.app {
width: min(900px, calc(100vw - 2rem));
background: rgba(18, 22, 34, 0.9);
border: 1px solid rgba(154, 167, 199, 0.2);
border-radius: 16px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
padding: 1.25rem;
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
backdrop-filter: blur(6px);
}
h1 {
.panel h2 {
margin: 0;
font-size: 1.15rem;
letter-spacing: 0.02em;
font-size: 1rem;
letter-spacing: 0.04em;
}
.toolbar {
margin-top: 1rem;
.header {
padding: 0.8rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.65rem;
border-bottom: 1px solid var(--line);
}
.status-chip {
font-size: 0.84rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--muted);
}
.status-chip.ok {
color: var(--good);
border-color: rgba(79, 215, 160, 0.6);
}
.status-chip.bad {
color: var(--bad);
border-color: rgba(255, 111, 111, 0.6);
}
.bot-list {
padding: 0.7rem;
display: grid;
gap: 0.55rem;
}
.bot {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem;
cursor: pointer;
transition: border-color 120ms ease, transform 120ms ease;
}
.bot:hover {
border-color: rgba(107, 213, 255, 0.6);
transform: translateY(-1px);
}
.bot.active {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(107, 213, 255, 0.4) inset;
}
.bot-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.45rem;
}
.bot-name {
font-weight: 700;
}
.tiny {
font-size: 0.78rem;
color: var(--muted);
}
.controls {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
button {
border: 1px solid rgba(154, 167, 199, 0.35);
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.58rem 0.95rem;
font-size: 0.95rem;
font-weight: 600;
background: linear-gradient(180deg, rgba(19, 40, 58, 0.95), rgba(11, 27, 39, 0.95));
color: var(--text);
padding: 0.45rem 0.75rem;
cursor: pointer;
background: linear-gradient(180deg, #212a43 0%, #171f34 100%);
color: #edf2ff;
transition: transform 120ms ease, filter 120ms ease, border-color 120ms ease;
font-weight: 600;
}
button:hover {
filter: brightness(1.08);
border-color: rgba(154, 167, 199, 0.6);
border-color: rgba(107, 213, 255, 0.65);
}
button:active {
transform: translateY(1px);
button.warn {
border-color: rgba(240, 179, 74, 0.45);
}
#status {
color: #aeb8cf;
font-size: 0.92rem;
button.bad {
border-color: rgba(255, 111, 111, 0.45);
}
#logs {
margin-top: 1rem;
border: 1px solid rgba(154, 167, 199, 0.22);
border-radius: 12px;
padding: 0.75rem;
min-height: 280px;
max-height: 500px;
.right {
display: grid;
gap: 0.85rem;
grid-template-rows: auto auto 1fr;
}
.chat-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
padding: 0.8rem;
}
input {
width: 100%;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(7, 18, 28, 0.9);
color: var(--text);
padding: 0.56rem 0.7rem;
}
.window-title {
padding: 0.65rem 0.8rem;
border-bottom: 1px solid var(--line);
color: var(--muted);
}
.window-grid {
padding: 0.7rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: 0.45rem;
max-height: 320px;
overflow: auto;
background: rgba(7, 10, 17, 0.7);
}
#logs::-webkit-scrollbar {
width: 10px;
.slot {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.4rem;
min-height: 68px;
cursor: pointer;
background: rgba(10, 22, 34, 0.76);
}
#logs::-webkit-scrollbar-thumb {
background: rgba(154, 167, 199, 0.35);
border-radius: 999px;
.slot:hover {
border-color: rgba(107, 213, 255, 0.75);
}
.line {
margin: 0.4rem 0;
padding: 0.45rem 0.55rem;
.slot.empty {
color: #56758a;
}
.slot-name {
font-size: 0.78rem;
line-height: 1.2;
}
.slot-meta {
margin-top: 0.2rem;
font-size: 0.75rem;
color: var(--muted);
}
.logs {
padding: 0.65rem;
max-height: 320px;
overflow: auto;
display: grid;
gap: 0.35rem;
}
.log {
border: 1px solid rgba(132, 179, 201, 0.14);
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
white-space: pre-wrap;
word-break: break-word;
font-size: 0.93rem;
padding: 0.4rem 0.5rem;
font-size: 0.82rem;
line-height: 1.25;
color: #d3edf8;
}
.time {
color: #96a2c2;
.log .meta {
color: #8cb0c2;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main class="app">
<h1>Spawner Control</h1>
<div class="toolbar">
<button id="openSpawner">Open Spawner</button>
<button id="stopSpawner">Stop Loop</button>
<span id="status"></span>
<main class="layout">
<section class="panel">
<div class="header">
<h2>AFK Bots</h2>
<span id="parentConn" class="status-chip bad" title="afk_parent connection">Parent offline</span>
<span id="conn" class="status-chip bad">Disconnected</span>
</div>
<div id="bots" class="bot-list"></div>
</section>
<div id="logs" aria-live="polite"></div>
<section class="right">
<section class="panel">
<div class="header">
<h2 id="selectedName">No Bot Selected</h2>
<span id="selectedStatus" class="status-chip">-</span>
</div>
<div class="chat-row">
<input id="chatInput" placeholder="/afk, /order, or plain chat..." />
<button id="sendChat">Send Chat</button>
</div>
</section>
<section class="panel">
<div class="header">
<h2>Current Window</h2>
<button id="closeWindow" class="warn">Close Window</button>
</div>
<div id="windowTitle" class="window-title">No window open</div>
<div id="windowGrid" class="window-grid"></div>
</section>
<section class="panel">
<div class="header">
<h2>Live Logs</h2>
<button id="clearLogs">Clear</button>
</div>
<div id="logs" class="logs"></div>
</section>
</section>
</main>
<script>
const statusEl = document.getElementById("status");
const connEl = document.getElementById("conn");
const parentConnEl = document.getElementById("parentConn");
const botsEl = document.getElementById("bots");
const logsEl = document.getElementById("logs");
const openBtn = document.getElementById("openSpawner");
const stopBtn = document.getElementById("stopSpawner");
const selectedNameEl = document.getElementById("selectedName");
const selectedStatusEl = document.getElementById("selectedStatus");
const windowTitleEl = document.getElementById("windowTitle");
const windowGridEl = document.getElementById("windowGrid");
const chatInputEl = document.getElementById("chatInput");
const sendChatEl = document.getElementById("sendChat");
const closeWindowEl = document.getElementById("closeWindow");
const clearLogsEl = document.getElementById("clearLogs");
function setStatus(text) {
statusEl.textContent = text;
const bots = new Map();
const pending = new Map();
let requestCounter = 0;
let socket = null;
let selectedBotKey = null;
function setConnState(isConnected) {
connEl.textContent = isConnected ? "Connected" : "Disconnected";
connEl.classList.toggle("ok", isConnected);
connEl.classList.toggle("bad", !isConnected);
}
async function openSpawner() {
setStatus("opening...");
try {
const res = await fetch("/api/spawner", {
method: "POST",
function setParentConnState(isConnected) {
parentConnEl.textContent = isConnected ? "Parent online" : "Parent offline";
parentConnEl.classList.toggle("ok", isConnected);
parentConnEl.classList.toggle("bad", !isConnected);
}
function nextRequestId() {
requestCounter += 1;
return `ws-${Date.now()}-${requestCounter}`;
}
function makeBotKey(parentId, username) {
return `${parentId || "-"}:${username || "-"}`;
}
function selectedBotRecord() {
return selectedBotKey ? bots.get(selectedBotKey) || null : null;
}
function wsSend(type, payload = {}, timeoutMs = 10000) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("WebSocket is not connected"));
}
const requestId = nextRequestId();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(requestId);
reject(new Error("Request timed out"));
}, timeoutMs);
pending.set(requestId, { resolve, reject, timer });
socket.send(JSON.stringify({ type, requestId, ...payload }));
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Request failed");
}
setStatus("done");
} catch (err) {
setStatus(`error: ${err.message}`);
}
}
async function stopSpawner() {
setStatus("stopping...");
try {
const res = await fetch("/api/stop", {
method: "POST",
function statusChip(status) {
const s = String(status || "unknown");
if (s === "online") return "ok";
if (s === "stopping" || s === "restart-wait") return "warn";
if (s === "offline" || s === "stopped") return "bad";
return "";
}
function renderBots() {
botsEl.innerHTML = "";
const list = [...bots.values()].sort((a, b) =>
String(a.username).localeCompare(String(b.username)),
);
list.sort((a, b) => {
const byName = String(a.username).localeCompare(String(b.username));
if (byName !== 0) return byName;
return String(a.parentId || "").localeCompare(String(b.parentId || ""));
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Request failed");
if (!selectedBotKey && list.length > 0) {
selectedBotKey = makeBotKey(list[0].parentId, list[0].username);
}
setStatus("stopped");
} catch (err) {
setStatus(`error: ${err.message}`);
for (const bot of list) {
const key = makeBotKey(bot.parentId, bot.username);
const card = document.createElement("article");
card.className = `bot${selectedBotKey === key ? " active" : ""}`;
card.addEventListener("click", () => {
selectedBotKey = key;
renderBots();
renderSelected();
filterLogs();
});
const chipClass = statusChip(bot.status);
card.innerHTML = `
<div class="bot-head">
<div class="bot-name">${escapeHtml(bot.username)}</div>
<div class="status-chip ${chipClass}">${escapeHtml(bot.status || "unknown")}</div>
</div>
<div class="tiny">parent: ${escapeHtml(bot.parentId || "-")} | pid: ${bot.pid || "-"} | running: ${bot.running ? "yes" : "no"}</div>
<div class="controls" style="margin-top:.5rem">
<button data-act="start">Start</button>
<button data-act="stop" class="bad">Stop</button>
</div>
`;
const [startBtn, stopBtn] = card.querySelectorAll("button");
startBtn.addEventListener("click", async (ev) => {
ev.stopPropagation();
await sendBotAction("bot.start", bot);
});
stopBtn.addEventListener("click", async (ev) => {
ev.stopPropagation();
await sendBotAction("bot.stop", bot);
});
botsEl.appendChild(card);
}
}
async function refreshLogs() {
try {
const res = await fetch("/api/logs");
if (!res.ok) return;
const data = await res.json();
logsEl.innerHTML = "";
for (const item of data.logs) {
const line = document.createElement("div");
line.className = "line";
line.innerHTML = `<span class="time">[${new Date(item.time).toLocaleTimeString()}]</span> ${item.message}`;
logsEl.appendChild(line);
function renderSelected() {
const bot = selectedBotRecord();
if (!bot) {
selectedNameEl.textContent = "No Bot Selected";
selectedStatusEl.textContent = "-";
selectedStatusEl.className = "status-chip";
windowTitleEl.textContent = "No window open";
windowGridEl.innerHTML = "";
return;
}
logsEl.scrollTop = logsEl.scrollHeight;
selectedNameEl.textContent = bot.username;
selectedStatusEl.textContent = bot.status || "unknown";
selectedStatusEl.className = `status-chip ${statusChip(bot.status)}`;
const win = bot.window;
if (!win || !Array.isArray(win.slots)) {
windowTitleEl.textContent = "No window open";
windowGridEl.innerHTML = "";
return;
}
windowTitleEl.textContent = `${win.title || "Window"} | type: ${win.type} | slots: ${win.slots.length}`;
windowGridEl.innerHTML = "";
for (let slotIndex = 0; slotIndex < win.slots.length; slotIndex += 1) {
const item = win.slots[slotIndex];
const slotEl = document.createElement("div");
slotEl.className = `slot${item ? "" : " empty"}`;
if (!item) {
slotEl.innerHTML = `<div class="slot-name">slot ${slotIndex}</div><div class="slot-meta">empty</div>`;
} else {
slotEl.innerHTML = `
<div class="slot-name">${escapeHtml(item.displayName || item.name || "item")}</div>
<div class="slot-meta">slot ${slotIndex} | x${item.count || 0}</div>
`;
}
slotEl.addEventListener("click", async () => {
const current = selectedBotRecord();
if (!current) return;
try {
await wsSend("bot.window.click", {
username: current.username,
parentId: current.parentId,
slot: slotIndex,
mouseButton: 0,
mode: 0,
});
appendLog("ui", makeBotKey(current.parentId, current.username), `clicked slot ${slotIndex}`, current.username);
} catch (err) {
appendLog("error", makeBotKey(current.parentId, current.username), err.message, current.username);
}
});
windowGridEl.appendChild(slotEl);
}
}
function filterLogs() {
const current = selectedBotRecord();
const selectedParentKey = current ? `parent:${current.parentId || "-"}` : null;
for (const row of logsEl.children) {
const src = row.dataset.source;
const visible =
!selectedBotKey ||
src === selectedBotKey ||
src === selectedParentKey ||
src === "server";
row.style.display = visible ? "" : "none";
}
}
function appendLog(stream, sourceKey, line, label = sourceKey) {
const row = document.createElement("div");
row.className = "log";
row.dataset.source = sourceKey || "-";
const now = new Date().toLocaleTimeString();
row.innerHTML = `<span class="meta">[${now}] [${escapeHtml(stream)}] [${escapeHtml(label || "-")}]</span> ${escapeHtml(line || "")}`;
logsEl.prepend(row);
while (logsEl.children.length > 350) {
logsEl.removeChild(logsEl.lastChild);
}
const current = selectedBotRecord();
const selectedParentKey = current ? `parent:${current.parentId || "-"}` : null;
const src = sourceKey || "-";
const visible =
!selectedBotKey ||
src === selectedBotKey ||
src === selectedParentKey ||
src === "server";
row.style.display = visible ? "" : "none";
}
async function sendBotAction(type, bot) {
const sourceKey = makeBotKey(bot.parentId, bot.username);
try {
await wsSend(type, { username: bot.username, parentId: bot.parentId });
appendLog("ui", sourceKey, `${type} ok`, bot.username);
} catch (err) {
appendLog("error", sourceKey, `${type} failed: ${err.message}`, bot.username);
}
}
function applyBotUpdate(bot) {
if (!bot || !bot.username) return;
bots.set(makeBotKey(bot.parentId, bot.username), bot);
}
function processIncoming(msg) {
if (!msg || typeof msg !== "object") return;
if (msg.type === "ack") {
const entry = pending.get(msg.requestId);
if (!entry) return;
clearTimeout(entry.timer);
pending.delete(msg.requestId);
if (msg.ok) {
entry.resolve(msg);
} else {
entry.reject(new Error(msg.error || "Request failed"));
}
return;
}
if (msg.type === "hello" || msg.type === "state.full") {
if ("parentConnected" in msg) setParentConnState(Boolean(msg.parentConnected));
bots.clear();
for (const bot of msg.bots || []) {
applyBotUpdate(bot);
}
renderBots();
renderSelected();
filterLogs();
return;
}
if (msg.type === "parent.connected") {
setParentConnState(true);
return;
}
if (msg.type === "parent.disconnected") {
setParentConnState(Boolean(msg.parentCount));
if (typeof msg.parentId === "string") {
for (const [key, bot] of bots.entries()) {
if (bot.parentId === msg.parentId) bots.delete(key);
}
}
renderBots();
renderSelected();
filterLogs();
return;
}
if (msg.type === "bot.update") {
applyBotUpdate(msg.bot);
renderBots();
renderSelected();
return;
}
if (msg.type === "bot.log") {
const sourceKey = makeBotKey(msg.parentId, msg.username);
appendLog(msg.stream || "log", sourceKey, msg.line, msg.username);
return;
}
if (msg.type === "parent.log") {
const key = `parent:${msg.parentId || "-"}`;
const label = `parent ${msg.parentId || "-"}`;
appendLog(msg.stream || "stdout", key, msg.line, label);
return;
}
if (msg.type === "server.log") {
appendLog(msg.stream || "stdout", "server", msg.line, "server");
return;
}
if (msg.type === "bot.event") {
const sourceKey = makeBotKey(msg.parentId, msg.username);
appendLog("event", sourceKey, `${msg.event}: ${msg.reason || ""}`, msg.username);
}
}
function connect() {
const protocol = location.protocol === "https:" ? "wss" : "ws";
socket = new WebSocket(`${protocol}://${location.host}/ws`);
socket.addEventListener("open", () => {
setConnState(true);
});
socket.addEventListener("close", () => {
setConnState(false);
for (const entry of pending.values()) {
clearTimeout(entry.timer);
entry.reject(new Error("Socket closed"));
}
pending.clear();
setTimeout(connect, 1200);
});
socket.addEventListener("message", (event) => {
try {
const msg = JSON.parse(String(event.data));
processIncoming(msg);
} catch {
setStatus("could not fetch logs");
appendLog("error", "ui", "invalid server payload");
}
});
}
openBtn.addEventListener("click", openSpawner);
stopBtn.addEventListener("click", stopSpawner);
refreshLogs();
setInterval(refreshLogs, 1000);
sendChatEl.addEventListener("click", async () => {
const current = selectedBotRecord();
const text = chatInputEl.value.trim();
if (!current || !text) return;
try {
await wsSend("bot.chat", { username: current.username, parentId: current.parentId, message: text });
appendLog("ui", makeBotKey(current.parentId, current.username), `chat sent: ${text}`, current.username);
chatInputEl.value = "";
} catch (err) {
appendLog("error", makeBotKey(current.parentId, current.username), err.message, current.username);
}
});
chatInputEl.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
sendChatEl.click();
}
});
closeWindowEl.addEventListener("click", async () => {
const current = selectedBotRecord();
if (!current) return;
try {
await wsSend("bot.window.close", { username: current.username, parentId: current.parentId });
appendLog("ui", makeBotKey(current.parentId, current.username), "window close sent", current.username);
} catch (err) {
appendLog("error", makeBotKey(current.parentId, current.username), err.message, current.username);
}
});
clearLogsEl.addEventListener("click", () => {
logsEl.innerHTML = "";
});
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
connect();
</script>
</body>
</html>