DonutBot/web/index.html
ZareMate 24c5debffc 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.
2026-04-02 15:28:41 +02:00

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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
connect();
</script>
</body>
</html>