- 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.
591 lines
17 KiB
JavaScript
591 lines
17 KiB
JavaScript
"use strict";
|
|
|
|
const { spawn } = require("child_process");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { WebSocket } = require("ws");
|
|
|
|
// ─── config ───────────────────────────────────────────────────────────────────
|
|
|
|
const USERNAMES = ["ZareMate", "Tomek", "Cytrus"];
|
|
|
|
const AFK_SCRIPT = path.join(__dirname, "afk.js");
|
|
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, "_");
|
|
}
|
|
|
|
function writeSplitLog(logFile, line) {
|
|
if (!logFile) return;
|
|
const timestamp = new Date().toISOString();
|
|
fs.appendFileSync(logFile, `[${timestamp}] ${line}\n`);
|
|
}
|
|
|
|
function withUsernamePrefix(username, line) {
|
|
const prefix = `[${username}] `;
|
|
if (line.startsWith(prefix)) return line;
|
|
return `${prefix}${line}`;
|
|
}
|
|
|
|
function randomRestartDelayMs() {
|
|
return (
|
|
RESTART_MIN_MS +
|
|
Math.floor(Math.random() * (RESTART_MAX_MS - RESTART_MIN_MS + 1))
|
|
);
|
|
}
|
|
|
|
function randomStartupGapMs() {
|
|
return (
|
|
STARTUP_GAP_MIN_MS +
|
|
Math.floor(Math.random() * (STARTUP_GAP_MAX_MS - STARTUP_GAP_MIN_MS + 1))
|
|
);
|
|
}
|
|
|
|
function shouldRestartFromState(state) {
|
|
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;
|
|
}
|
|
|
|
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,
|
|
restartTimer: null,
|
|
startupTimer: null,
|
|
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;
|
|
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);
|
|
process.on("SIGTERM", shutdownAll);
|