DonutBot/afk_parent.js

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", "Qawe"];
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://server.suchodupin.com:${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);