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:
parent
77af2a3912
commit
24c5debffc
181
afk.js
181
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...");
|
||||
|
||||
614
afk_parent.js
614
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);
|
||||
|
||||
@ -44,3 +44,8 @@ function checkAllHashedProfiles() {
|
||||
}
|
||||
|
||||
console.log(checkAllHashedProfiles());
|
||||
|
||||
module.exports = {
|
||||
checkIfHashedPresent,
|
||||
checkAllHashedProfiles,
|
||||
};
|
||||
|
||||
@ -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
444
server.js
Normal 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);
|
||||
719
web/index.html
719
web/index.html
@ -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>
|
||||
</div>
|
||||
<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 }));
|
||||
});
|
||||
}
|
||||
|
||||
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 (!selectedBotKey && list.length > 0) {
|
||||
selectedBotKey = makeBotKey(list[0].parentId, list[0].username);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
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",
|
||||
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);
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Request failed");
|
||||
}
|
||||
setStatus("stopped");
|
||||
} catch (err) {
|
||||
setStatus(`error: ${err.message}`);
|
||||
stopBtn.addEventListener("click", async (ev) => {
|
||||
ev.stopPropagation();
|
||||
await sendBotAction("bot.stop", bot);
|
||||
});
|
||||
|
||||
botsEl.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
logsEl.scrollTop = logsEl.scrollHeight;
|
||||
} catch {
|
||||
setStatus("could not fetch logs");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
openBtn.addEventListener("click", openSpawner);
|
||||
stopBtn.addEventListener("click", stopSpawner);
|
||||
refreshLogs();
|
||||
setInterval(refreshLogs, 1000);
|
||||
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 {
|
||||
appendLog("error", "ui", "invalid server payload");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user