- 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.
678 lines
20 KiB
HTML
678 lines
20 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>AFK Bot Control</title>
|
|
<style>
|
|
:root {
|
|
--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 {
|
|
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;
|
|
gap: 0.85rem;
|
|
grid-template-columns: 360px 1fr;
|
|
}
|
|
|
|
.panel {
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
backdrop-filter: blur(6px);
|
|
}
|
|
|
|
.panel h2 {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.header {
|
|
padding: 0.8rem 1rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
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 var(--line);
|
|
border-radius: 10px;
|
|
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;
|
|
font-weight: 600;
|
|
}
|
|
|
|
button:hover {
|
|
border-color: rgba(107, 213, 255, 0.65);
|
|
}
|
|
|
|
button.warn {
|
|
border-color: rgba(240, 179, 74, 0.45);
|
|
}
|
|
|
|
button.bad {
|
|
border-color: rgba(255, 111, 111, 0.45);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.slot {
|
|
border: 1px solid var(--line);
|
|
border-radius: 10px;
|
|
padding: 0.4rem;
|
|
min-height: 68px;
|
|
cursor: pointer;
|
|
background: rgba(10, 22, 34, 0.76);
|
|
}
|
|
|
|
.slot:hover {
|
|
border-color: rgba(107, 213, 255, 0.75);
|
|
}
|
|
|
|
.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;
|
|
padding: 0.4rem 0.5rem;
|
|
font-size: 0.82rem;
|
|
line-height: 1.25;
|
|
color: #d3edf8;
|
|
}
|
|
|
|
.log .meta {
|
|
color: #8cb0c2;
|
|
}
|
|
|
|
@media (max-width: 980px) {
|
|
.layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<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>
|
|
|
|
<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 connEl = document.getElementById("conn");
|
|
const parentConnEl = document.getElementById("parentConn");
|
|
const botsEl = document.getElementById("bots");
|
|
const logsEl = document.getElementById("logs");
|
|
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");
|
|
|
|
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);
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
const chipClass = statusChip(bot.status);
|
|
card.innerHTML = `
|
|
<div class="bot-head">
|
|
<div class="bot-name">${escapeHtml(bot.username)}</div>
|
|
<div class="status-chip ${chipClass}">${escapeHtml(bot.status || "unknown")}</div>
|
|
</div>
|
|
<div class="tiny">parent: ${escapeHtml(bot.parentId || "-")} | pid: ${bot.pid || "-"} | running: ${bot.running ? "yes" : "no"}</div>
|
|
<div class="controls" style="margin-top:.5rem">
|
|
<button data-act="start">Start</button>
|
|
<button data-act="stop" class="bad">Stop</button>
|
|
</div>
|
|
`;
|
|
|
|
const [startBtn, stopBtn] = card.querySelectorAll("button");
|
|
startBtn.addEventListener("click", async (ev) => {
|
|
ev.stopPropagation();
|
|
await sendBotAction("bot.start", bot);
|
|
});
|
|
stopBtn.addEventListener("click", async (ev) => {
|
|
ev.stopPropagation();
|
|
await sendBotAction("bot.stop", bot);
|
|
});
|
|
|
|
botsEl.appendChild(card);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
await wsSend(type, { username: bot.username, parentId: bot.parentId });
|
|
appendLog("ui", sourceKey, `${type} ok`, bot.username);
|
|
} catch (err) {
|
|
appendLog("error", sourceKey, `${type} failed: ${err.message}`, bot.username);
|
|
}
|
|
}
|
|
|
|
function applyBotUpdate(bot) {
|
|
if (!bot || !bot.username) return;
|
|
bots.set(makeBotKey(bot.parentId, bot.username), bot);
|
|
}
|
|
|
|
function processIncoming(msg) {
|
|
if (!msg || typeof msg !== "object") return;
|
|
|
|
if (msg.type === "ack") {
|
|
const entry = pending.get(msg.requestId);
|
|
if (!entry) return;
|
|
clearTimeout(entry.timer);
|
|
pending.delete(msg.requestId);
|
|
if (msg.ok) {
|
|
entry.resolve(msg);
|
|
} else {
|
|
entry.reject(new Error(msg.error || "Request failed"));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "hello" || msg.type === "state.full") {
|
|
if ("parentConnected" in msg) setParentConnState(Boolean(msg.parentConnected));
|
|
bots.clear();
|
|
for (const bot of msg.bots || []) {
|
|
applyBotUpdate(bot);
|
|
}
|
|
renderBots();
|
|
renderSelected();
|
|
filterLogs();
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "parent.connected") {
|
|
setParentConnState(true);
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "parent.disconnected") {
|
|
setParentConnState(Boolean(msg.parentCount));
|
|
if (typeof msg.parentId === "string") {
|
|
for (const [key, bot] of bots.entries()) {
|
|
if (bot.parentId === msg.parentId) bots.delete(key);
|
|
}
|
|
}
|
|
renderBots();
|
|
renderSelected();
|
|
filterLogs();
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "bot.update") {
|
|
applyBotUpdate(msg.bot);
|
|
renderBots();
|
|
renderSelected();
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "bot.log") {
|
|
const sourceKey = makeBotKey(msg.parentId, msg.username);
|
|
appendLog(msg.stream || "log", sourceKey, msg.line, msg.username);
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "parent.log") {
|
|
const key = `parent:${msg.parentId || "-"}`;
|
|
const label = `parent ${msg.parentId || "-"}`;
|
|
appendLog(msg.stream || "stdout", key, msg.line, label);
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "server.log") {
|
|
appendLog(msg.stream || "stdout", "server", msg.line, "server");
|
|
return;
|
|
}
|
|
|
|
if (msg.type === "bot.event") {
|
|
const sourceKey = makeBotKey(msg.parentId, msg.username);
|
|
appendLog("event", sourceKey, `${msg.event}: ${msg.reason || ""}`, msg.username);
|
|
}
|
|
}
|
|
|
|
function connect() {
|
|
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
|
socket = new WebSocket(`${protocol}://${location.host}/ws`);
|
|
|
|
socket.addEventListener("open", () => {
|
|
setConnState(true);
|
|
});
|
|
|
|
socket.addEventListener("close", () => {
|
|
setConnState(false);
|
|
for (const entry of pending.values()) {
|
|
clearTimeout(entry.timer);
|
|
entry.reject(new Error("Socket closed"));
|
|
}
|
|
pending.clear();
|
|
setTimeout(connect, 1200);
|
|
});
|
|
|
|
socket.addEventListener("message", (event) => {
|
|
try {
|
|
const msg = JSON.parse(String(event.data));
|
|
processIncoming(msg);
|
|
} catch {
|
|
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>
|