forked from wmantly/mc-bot-town
here
This commit is contained in:
@@ -0,0 +1,787 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { CJbot } = require('../model/minecraft');
|
||||
|
||||
class WebServer {
|
||||
constructor() {
|
||||
this.app = null;
|
||||
this.port = null;
|
||||
this.host = null;
|
||||
this.server = null;
|
||||
this.pluginRegistry = new Map(); // pluginName → { slot, webUI }
|
||||
this._pendingPlugins = []; // plugins queued before start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a plugin class for web registration.
|
||||
* Called from CJbot.pluginAdd() — may happen before start().
|
||||
*/
|
||||
queuePlugin(cls) {
|
||||
if (this.app) {
|
||||
// Server already running, register immediately
|
||||
this.registerPlugin(cls);
|
||||
} else {
|
||||
this._pendingPlugins.push(cls);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a plugin's web support (router + UI descriptor).
|
||||
* Creates a permanent middleware proxy slot so routes survive plugin reload.
|
||||
*/
|
||||
registerPlugin(cls) {
|
||||
if (this.pluginRegistry.has(cls.name)) return; // already registered
|
||||
|
||||
const slot = { router: null };
|
||||
|
||||
if (typeof cls.createRouter === 'function') {
|
||||
const resolver = this._makeInstanceResolver(cls.name);
|
||||
slot.router = cls.createRouter(resolver);
|
||||
}
|
||||
|
||||
const webUI = cls.webUI || null;
|
||||
this.pluginRegistry.set(cls.name, { slot, webUI });
|
||||
|
||||
// Permanent middleware proxy — delegates to slot.router at request time
|
||||
this.app.use((req, res, next) => {
|
||||
if (slot.router) return slot.router(req, res, next);
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a resolver function: (botName?) => { plugin, bot }
|
||||
* Searches CJbot.bots for any bot with the named plugin loaded.
|
||||
* For on-demand bots, returns { plugin: null, bot } so callers can use ensureConnected.
|
||||
*/
|
||||
_makeInstanceResolver(pluginName) {
|
||||
return (botName) => {
|
||||
// Try specific bot first
|
||||
if (botName) {
|
||||
const bot = CJbot.bots[botName];
|
||||
if (bot && bot.plunginsLoaded[pluginName]) {
|
||||
return { plugin: bot.plunginsLoaded[pluginName], bot };
|
||||
}
|
||||
if (bot && bot.onDemand && bot.pluginsWanted[pluginName] !== undefined) {
|
||||
return { plugin: null, bot };
|
||||
}
|
||||
return { plugin: null, bot: null };
|
||||
}
|
||||
// Search all bots — prefer already-loaded
|
||||
for (const bot of Object.values(CJbot.bots)) {
|
||||
if (bot.plunginsLoaded[pluginName]) {
|
||||
return { plugin: bot.plunginsLoaded[pluginName], bot };
|
||||
}
|
||||
}
|
||||
// Fall back to on-demand bots that want this plugin
|
||||
for (const bot of Object.values(CJbot.bots)) {
|
||||
if (bot.onDemand && bot.pluginsWanted[pluginName] !== undefined) {
|
||||
return { plugin: null, bot };
|
||||
}
|
||||
}
|
||||
return { plugin: null, bot: null };
|
||||
};
|
||||
}
|
||||
|
||||
async start() {
|
||||
const conf = require('../conf');
|
||||
|
||||
this.port = conf.storage?.webPort || 3000;
|
||||
this.host = conf.storage?.webHost || '0.0.0.0';
|
||||
|
||||
this.app = express();
|
||||
|
||||
// Middleware
|
||||
this.app.use(express.json());
|
||||
this.app.use(cors());
|
||||
|
||||
this.app.use((req, res, next) => {
|
||||
console.log(`WebServer: ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Flush any plugins that were queued before start()
|
||||
for (const cls of this._pendingPlugins) {
|
||||
this.registerPlugin(cls);
|
||||
}
|
||||
this._pendingPlugins = [];
|
||||
|
||||
this.setupRoutes();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = this.app.listen(this.port, this.host, () => {
|
||||
console.log(`WebServer: Running at http://${this.host}:${this.port}`);
|
||||
resolve();
|
||||
});
|
||||
this.server.on('error', (err) => {
|
||||
console.error('WebServer: Failed to start:', err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setupRoutes() {
|
||||
// Index page — assembled dynamically from plugin descriptors
|
||||
this.app.get('/', (req, res) => {
|
||||
res.send(this.getIndexHTML());
|
||||
});
|
||||
|
||||
// Health check
|
||||
this.app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', server: `${this.host}:${this.port}` });
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Bot management API routes (core)
|
||||
// ========================================
|
||||
|
||||
this.app.get('/api/bots', (req, res) => {
|
||||
try {
|
||||
const bots = {};
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
const info = {
|
||||
name,
|
||||
connected: bot.isReady,
|
||||
autoReConnect: bot.autoReConnect,
|
||||
autoConnect: bot.autoConnect,
|
||||
onDemand: bot.onDemand || false,
|
||||
pluginsWanted: Object.keys(bot.pluginsWanted || {}),
|
||||
pluginsLoaded: Object.keys(bot.plunginsLoaded || {}),
|
||||
};
|
||||
if (bot.isReady && bot.bot && bot.bot.entity) {
|
||||
info.health = bot.bot.health;
|
||||
info.food = bot.bot.food;
|
||||
info.position = {
|
||||
x: Math.round(bot.bot.entity.position.x),
|
||||
y: Math.round(bot.bot.entity.position.y),
|
||||
z: Math.round(bot.bot.entity.position.z),
|
||||
};
|
||||
}
|
||||
bots[name] = info;
|
||||
}
|
||||
res.json({ bots });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/bots:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.get('/api/plugins', (req, res) => {
|
||||
try {
|
||||
res.json({ plugins: Object.keys(CJbot.plungins) });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/plugins:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/bots/:name/connect', async (req, res) => {
|
||||
try {
|
||||
const bot = CJbot.bots[req.params.name];
|
||||
if (!bot) return res.status(404).json({ error: 'Bot not found' });
|
||||
if (bot.isReady) return res.status(400).json({ error: 'Bot already connected' });
|
||||
|
||||
bot.autoReConnect = req.body?.autoReConnect ?? true;
|
||||
bot.connect().catch(err => console.error(`Web connect error for ${req.params.name}:`, err));
|
||||
|
||||
res.json({ status: 'connecting' });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/bots/:name/connect:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/bots/:name/disconnect', async (req, res) => {
|
||||
try {
|
||||
const bot = CJbot.bots[req.params.name];
|
||||
if (!bot) return res.status(404).json({ error: 'Bot not found' });
|
||||
if (!bot.isReady) return res.status(400).json({ error: 'Bot not connected' });
|
||||
|
||||
bot.autoReConnect = false;
|
||||
bot.quit(true);
|
||||
|
||||
res.json({ status: 'disconnecting' });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/bots/:name/disconnect:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/bots/:name/plugins/:plugin/load', async (req, res) => {
|
||||
try {
|
||||
const bot = CJbot.bots[req.params.name];
|
||||
if (!bot) return res.status(404).json({ error: 'Bot not found' });
|
||||
if (!bot.isReady) return res.status(400).json({ error: 'Bot not connected' });
|
||||
|
||||
const pluginName = req.params.plugin;
|
||||
if (!CJbot.plungins[pluginName]) return res.status(404).json({ error: 'Plugin not registered' });
|
||||
if (bot.plunginsLoaded[pluginName]) return res.status(400).json({ error: 'Plugin already loaded' });
|
||||
|
||||
bot.pluginLoad(pluginName, req.body || {}).catch(err => console.error(`Web plugin load error:`, err));
|
||||
|
||||
res.json({ status: 'loading', plugin: pluginName });
|
||||
} catch (error) {
|
||||
console.error('API Error plugin load:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/bots/:name/plugins/:plugin/unload', async (req, res) => {
|
||||
try {
|
||||
const bot = CJbot.bots[req.params.name];
|
||||
if (!bot) return res.status(404).json({ error: 'Bot not found' });
|
||||
|
||||
const pluginName = req.params.plugin;
|
||||
if (!bot.plunginsLoaded[pluginName]) return res.status(400).json({ error: 'Plugin not loaded' });
|
||||
|
||||
bot.pluginUnload(pluginName).catch(err => console.error(`Web plugin unload error:`, err));
|
||||
|
||||
res.json({ status: 'unloading', plugin: pluginName });
|
||||
} catch (error) {
|
||||
console.error('API Error plugin unload:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/bots/:name/command', async (req, res) => {
|
||||
try {
|
||||
const bot = CJbot.bots[req.params.name];
|
||||
if (!bot) return res.status(404).json({ error: 'Bot not found' });
|
||||
if (!bot.isReady) return res.status(400).json({ error: 'Bot not connected' });
|
||||
|
||||
const { command, args, plugin } = req.body || {};
|
||||
if (!command) return res.status(400).json({ error: 'Missing command' });
|
||||
|
||||
// Find plugin with handleCommand — check specified plugin first, then search
|
||||
let targetPlugin = null;
|
||||
if (plugin && bot.plunginsLoaded[plugin]) {
|
||||
targetPlugin = bot.plunginsLoaded[plugin];
|
||||
} else {
|
||||
for (const p of Object.values(bot.plunginsLoaded)) {
|
||||
if (typeof p.handleCommand === 'function') {
|
||||
targetPlugin = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetPlugin || typeof targetPlugin.handleCommand !== 'function') {
|
||||
return res.status(503).json({ error: 'No plugin with handleCommand loaded on this bot' });
|
||||
}
|
||||
|
||||
targetPlugin.handleCommand('web-ui', command, ...(args || []))
|
||||
.catch(err => console.error('Web command error:', err));
|
||||
|
||||
res.json({ status: 'queued', command });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/bots/:name/command:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getIndexHTML() {
|
||||
// Collect plugin UI descriptors sorted by tabOrder
|
||||
const plugins = [];
|
||||
for (const [name, reg] of this.pluginRegistry) {
|
||||
if (reg.webUI) plugins.push(reg.webUI);
|
||||
}
|
||||
plugins.sort((a, b) => (a.tabOrder || 100) - (b.tabOrder || 100));
|
||||
|
||||
// Build tab buttons, content panels, CSS, JS, sidebar
|
||||
const tabButtons = plugins.map(p =>
|
||||
`<div class="tab" onclick="switchTab('${p.tabId}')">${p.tabLabel}</div>`
|
||||
).join('\n\t\t\t');
|
||||
|
||||
const tabPanels = plugins.map(p =>
|
||||
`<div id="tab-${p.tabId}" class="tab-content">${p.html || ''}</div>`
|
||||
).join('\n\t\t');
|
||||
|
||||
const pluginCSS = plugins.map(p => p.css || '').join('\n');
|
||||
const pluginJS = plugins.map(p => p.js || '').join('\n');
|
||||
|
||||
const sidebarHtml = plugins.map(p => p.sidebarHtml || '').join('\n');
|
||||
const sidebarJs = plugins.map(p => p.sidebarJs || '').join('\n');
|
||||
|
||||
// Build ALL_TABS array for switchTab
|
||||
const allTabIds = plugins.map(p => `'${p.tabId}'`).concat("'bots'");
|
||||
const onTabActiveMap = plugins
|
||||
.filter(p => p.onTabActive)
|
||||
.map(p => `'${p.tabId}': ${p.onTabActive}`)
|
||||
.join(', ');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MC Bot Town</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Segoe UI',Tahoma,sans-serif;background:#111827;color:#e5e7eb;min-height:100vh}
|
||||
.header{background:#1f2937;padding:16px 24px;border-bottom:1px solid #374151;display:flex;align-items:center;justify-content:space-between}
|
||||
.header h1{font-size:1.4em;color:#60a5fa}
|
||||
.header button{background:#2563eb;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:.85em}
|
||||
.header button:hover{background:#1d4ed8}
|
||||
.layout{display:flex;height:calc(100vh - 57px)}
|
||||
.sidebar{width:320px;background:#1f2937;border-right:1px solid #374151;display:flex;flex-direction:column;flex-shrink:0}
|
||||
.main{flex:1;overflow:auto;padding:20px}
|
||||
.tabs{display:flex;border-bottom:1px solid #374151}
|
||||
.tab{padding:10px 16px;cursor:pointer;color:#9ca3af;font-size:.9em;border-bottom:2px solid transparent}
|
||||
.tab.active{color:#60a5fa;border-bottom-color:#60a5fa}
|
||||
.tab:hover{color:#e5e7eb}
|
||||
.tab-content{display:none}
|
||||
.tab-content.active{display:block}
|
||||
/* Bot management styles */
|
||||
.bot-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
|
||||
.bot-card{background:#111827;border:1px solid #374151;border-radius:8px;padding:16px;transition:border-color .2s}
|
||||
.bot-card:hover{border-color:#60a5fa}
|
||||
.bot-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
|
||||
.bot-card-header h3{font-size:1em;display:flex;align-items:center;gap:8px}
|
||||
.bot-status{width:10px;height:10px;border-radius:50%;display:inline-block}
|
||||
.bot-status.online{background:#10b981}
|
||||
.bot-status.offline{background:#ef4444}
|
||||
.bot-info{font-size:.8em;color:#9ca3af;margin-bottom:12px}
|
||||
.bot-info span{display:block;margin:2px 0}
|
||||
.plugin-tags{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:12px}
|
||||
.plugin-tag{background:#1e3a5f;color:#60a5fa;padding:3px 8px;border-radius:4px;font-size:.75em;display:flex;align-items:center;gap:4px}
|
||||
.plugin-tag .unload-btn{background:none;border:none;color:#ef4444;cursor:pointer;font-size:.9em;padding:0 2px;line-height:1}
|
||||
.plugin-tag .unload-btn:hover{color:#f87171}
|
||||
.bot-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
||||
.bot-actions select{padding:6px;border:1px solid #374151;border-radius:4px;background:#1f2937;color:#e5e7eb;font-size:.8em}
|
||||
.btn-connect{background:#059669;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:.8em}
|
||||
.btn-connect:hover{background:#047857}
|
||||
.btn-disconnect{background:#dc2626;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:.8em}
|
||||
.btn-disconnect:hover{background:#b91c1c}
|
||||
.btn-action{background:#2563eb;color:#fff;border:none;padding:6px 10px;border-radius:4px;cursor:pointer;font-size:.75em}
|
||||
.btn-action:hover{background:#1d4ed8}
|
||||
.btn-load{background:#7c3aed;color:#fff;border:none;padding:6px 10px;border-radius:4px;cursor:pointer;font-size:.8em}
|
||||
.btn-load:hover{background:#6d28d9}
|
||||
.bot-commands{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}
|
||||
${pluginCSS}
|
||||
/* Toast notifications */
|
||||
#toastContainer{position:fixed;top:16px;right:16px;z-index:200;display:flex;flex-direction:column;gap:8px;pointer-events:none}
|
||||
.toast{pointer-events:auto;padding:12px 20px;border-radius:8px;font-size:.85em;color:#fff;box-shadow:0 4px 12px rgba(0,0,0,.4);animation:toastIn .3s ease;max-width:380px;word-wrap:break-word}
|
||||
.toast.removing{animation:toastOut .3s ease forwards}
|
||||
.toast.info{background:#2563eb}
|
||||
.toast.success{background:#059669}
|
||||
.toast.error{background:#dc2626}
|
||||
.toast.warning{background:#d97706}
|
||||
@keyframes toastIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
@keyframes toastOut{from{transform:translateX(0);opacity:1}to{transform:translateX(100%);opacity:0}}
|
||||
/* Timestamps */
|
||||
.last-updated{font-size:.75em;color:#6b7280;margin-left:8px}
|
||||
/* Online players dropdown */
|
||||
.players-dropdown{position:relative;display:inline-block}
|
||||
.players-btn{background:#374151;color:#e5e7eb;border:none;padding:8px 14px;border-radius:6px;cursor:pointer;font-size:.85em}
|
||||
.players-btn:hover{background:#4b5563}
|
||||
.players-list{display:none;position:absolute;top:100%;right:0;margin-top:4px;background:#1f2937;border:1px solid #374151;border-radius:6px;min-width:180px;max-height:300px;overflow:auto;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,.4)}
|
||||
.players-list.open{display:block}
|
||||
.players-list .pl-item{padding:8px 14px;font-size:.85em;color:#e5e7eb;border-bottom:1px solid #374151}
|
||||
.players-list .pl-item:last-child{border-bottom:none}
|
||||
.players-list .pl-empty{padding:12px 14px;color:#6b7280;font-size:.85em;text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="toastContainer"></div>
|
||||
<div class="header">
|
||||
<h1>MC Bot Town</h1>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<div class="players-dropdown" id="playersDropdown">
|
||||
<button class="players-btn" onclick="togglePlayersDropdown()">Players: <span id="playersCount">?</span></button>
|
||||
<div class="players-list" id="playersList"></div>
|
||||
</div>
|
||||
<button onclick="loadAll()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebarArea">
|
||||
${sidebarHtml}
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="tabs" id="mainTabs">
|
||||
${tabButtons}
|
||||
<div class="tab" onclick="switchTab('bots')">Bots</div>
|
||||
</div>
|
||||
${tabPanels}
|
||||
<div id="tab-bots" class="tab-content" style="margin-top:16px">
|
||||
<div style="margin-bottom:8px"><span class="last-updated" id="ts-bots"></span></div>
|
||||
<div id="botsArea"><div style="padding:20px;color:#6b7280;text-align:center">Loading bots...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// === Toast notifications ===
|
||||
function showToast(message, type='info', duration=4000) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
el.textContent = message;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => {
|
||||
el.classList.add('removing');
|
||||
el.addEventListener('animationend', () => el.remove());
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// === Timestamps ===
|
||||
const tsMap = {};
|
||||
function updateTimestamp(elId) {
|
||||
tsMap[elId] = Date.now();
|
||||
tickTimestamp(elId);
|
||||
}
|
||||
function tickTimestamp(elId) {
|
||||
const el = document.getElementById(elId);
|
||||
if (!el || !tsMap[elId]) return;
|
||||
const sec = Math.round((Date.now() - tsMap[elId]) / 1000);
|
||||
el.textContent = sec < 5 ? 'just now' : sec + 's ago';
|
||||
}
|
||||
setInterval(() => { for (const id of Object.keys(tsMap)) tickTimestamp(id); }, 5000);
|
||||
|
||||
// === Online players ===
|
||||
let onlinePlayers = [];
|
||||
async function pollOnlinePlayers() {
|
||||
try {
|
||||
const r = await fetch('/api/invite/online-players');
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
onlinePlayers = d.players || [];
|
||||
document.getElementById('playersCount').textContent = onlinePlayers.length;
|
||||
} catch(e) {}
|
||||
}
|
||||
function togglePlayersDropdown() {
|
||||
const list = document.getElementById('playersList');
|
||||
const isOpen = list.classList.contains('open');
|
||||
list.classList.toggle('open');
|
||||
if (!isOpen) {
|
||||
if (onlinePlayers.length === 0) {
|
||||
list.innerHTML = '<div class="pl-empty">No players online</div>';
|
||||
} else {
|
||||
list.innerHTML = onlinePlayers.map(p => '<div class="pl-item">' + escHtml(p) + '</div>').join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', function(e) {
|
||||
const dd = document.getElementById('playersDropdown');
|
||||
if (dd && !dd.contains(e.target)) document.getElementById('playersList').classList.remove('open');
|
||||
});
|
||||
pollOnlinePlayers();
|
||||
setInterval(pollOnlinePlayers, 15000);
|
||||
|
||||
const ALL_TABS = [${allTabIds.join(',')}];
|
||||
const TAB_ACTIVE_HANDLERS = {${onTabActiveMap}};
|
||||
let currentTab = ALL_TABS[0] || 'bots';
|
||||
let allPlugins=[], botsLoaded=false, botsInterval=null;
|
||||
|
||||
function fmtName(n){return n?n.replace(/_/g,' ').replace(/\\b\\w/g,c=>c.toUpperCase()):''}
|
||||
function fmt(n){return n>=1e6?(n/1e6).toFixed(1)+'M':n>=1e3?(n/1e3).toFixed(1)+'K':n}
|
||||
function escHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
|
||||
function switchTab(name) {
|
||||
currentTab = name;
|
||||
document.querySelectorAll('#mainTabs .tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.main > .tab-content').forEach(t => t.classList.remove('active'));
|
||||
const panel = document.getElementById('tab-'+name);
|
||||
if (panel) panel.classList.add('active');
|
||||
// Activate the correct tab button
|
||||
const allTabEls = document.querySelectorAll('#mainTabs .tab');
|
||||
const tabOrder = [...ALL_TABS.map(id=>id), 'bots'];
|
||||
// Remove the quotes from ALL_TABS ids for comparison
|
||||
const idx = tabOrder.indexOf(name);
|
||||
if (idx >= 0 && allTabEls[idx]) allTabEls[idx].classList.add('active');
|
||||
// Show/hide sidebar based on whether active plugin has sidebar content
|
||||
const sidebar = document.getElementById('sidebarArea');
|
||||
const hasSidebar = ${JSON.stringify(plugins.filter(p => p.sidebarHtml).map(p => p.tabId))}.includes(name);
|
||||
sidebar.style.display = hasSidebar ? '' : 'none';
|
||||
// Call plugin's onTabActive handler
|
||||
if (TAB_ACTIVE_HANDLERS[name]) TAB_ACTIVE_HANDLERS[name]();
|
||||
// Bots tab auto-refresh
|
||||
if (name === 'bots') {
|
||||
loadBots();
|
||||
if (!botsInterval) botsInterval = setInterval(() => { if (currentTab === 'bots') loadBots(); }, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function setupAC(inputId, listId, getOptions, onSelect) {
|
||||
const input=document.getElementById(inputId);
|
||||
const list=document.getElementById(listId);
|
||||
if (!input || !list) return;
|
||||
let activeIdx=-1;
|
||||
|
||||
function show(opts) {
|
||||
if (!opts.length){list.classList.remove('open');return}
|
||||
const q=input.value.toLowerCase();
|
||||
list.innerHTML=opts.slice(0,15).map((o,i)=>{
|
||||
const label=typeof o==='string'?o:o.label;
|
||||
const extra=typeof o==='string'?'':o.extra||'';
|
||||
const hl=highlightMatch(label,q);
|
||||
return '<div class="ac-opt'+(i===activeIdx?' active':'')+'" data-idx="'+i+'" data-val="'+(typeof o==='string'?o:o.value)+'">'+
|
||||
'<span>'+hl+'</span>'+(extra?'<span class="ac-count">'+extra+'</span>':'')+'</div>';
|
||||
}).join('');
|
||||
list.classList.add('open');
|
||||
|
||||
list.querySelectorAll('.ac-opt').forEach(el=>{
|
||||
el.addEventListener('mousedown',e=>{
|
||||
e.preventDefault();
|
||||
input.value=el.dataset.val;
|
||||
list.classList.remove('open');
|
||||
if(onSelect)onSelect(el.dataset.val);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function highlightMatch(text, q) {
|
||||
if(!q)return fmtName(text);
|
||||
const name=fmtName(text);
|
||||
const idx=name.toLowerCase().indexOf(q);
|
||||
if(idx===-1)return name;
|
||||
return name.substring(0,idx)+'<span class="ac-match">'+name.substring(idx,idx+q.length)+'</span>'+name.substring(idx+q.length);
|
||||
}
|
||||
|
||||
input.addEventListener('input',()=>{
|
||||
activeIdx=-1;
|
||||
const opts=getOptions(input.value);
|
||||
show(opts);
|
||||
if(onSelect)onSelect(null);
|
||||
});
|
||||
|
||||
input.addEventListener('focus',()=>{
|
||||
if(input.value||getOptions('').length<=20){
|
||||
const opts=getOptions(input.value);
|
||||
show(opts);
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur',()=>{
|
||||
setTimeout(()=>list.classList.remove('open'),150);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown',e=>{
|
||||
const opts=list.querySelectorAll('.ac-opt');
|
||||
if(!opts.length)return;
|
||||
if(e.key==='ArrowDown'){
|
||||
e.preventDefault();
|
||||
activeIdx=Math.min(activeIdx+1,opts.length-1);
|
||||
opts.forEach((o,i)=>o.classList.toggle('active',i===activeIdx));
|
||||
opts[activeIdx]?.scrollIntoView({block:'nearest'});
|
||||
}else if(e.key==='ArrowUp'){
|
||||
e.preventDefault();
|
||||
activeIdx=Math.max(activeIdx-1,0);
|
||||
opts.forEach((o,i)=>o.classList.toggle('active',i===activeIdx));
|
||||
opts[activeIdx]?.scrollIntoView({block:'nearest'});
|
||||
}else if(e.key==='Enter'&&activeIdx>=0){
|
||||
e.preventDefault();
|
||||
input.value=opts[activeIdx].dataset.val;
|
||||
list.classList.remove('open');
|
||||
if(onSelect)onSelect(opts[activeIdx].dataset.val);
|
||||
}else if(e.key==='Escape'){
|
||||
list.classList.remove('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === Plugin JS ===
|
||||
${pluginJS}
|
||||
|
||||
// === BOTS ===
|
||||
async function loadBots() {
|
||||
try {
|
||||
const [botsRes, pluginsRes] = await Promise.all([
|
||||
fetch('/api/bots'),
|
||||
fetch('/api/plugins')
|
||||
]);
|
||||
const botsData = await botsRes.json();
|
||||
const pluginsData = await pluginsRes.json();
|
||||
allPlugins = pluginsData.plugins || [];
|
||||
renderBots(botsData.bots || {});
|
||||
botsLoaded = true;
|
||||
updateTimestamp('ts-bots');
|
||||
} catch(e) {
|
||||
document.getElementById('botsArea').innerHTML='<div style="padding:20px;color:#ef4444">Failed to load bots</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderBots(bots) {
|
||||
const area = document.getElementById('botsArea');
|
||||
const names = Object.keys(bots);
|
||||
if (names.length === 0) {
|
||||
area.innerHTML='<div style="padding:20px;color:#6b7280">No bots configured</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
area.innerHTML = '<div class="bot-grid">' + names.map(name => {
|
||||
const b = bots[name];
|
||||
const online = b.connected;
|
||||
const statusCls = online ? 'online' : 'offline';
|
||||
const statusText = online ? 'Online' : 'Offline';
|
||||
|
||||
let infoHtml = '';
|
||||
if (online && b.position) {
|
||||
infoHtml = '<div class="bot-info">' +
|
||||
'<span>Health: ' + (b.health != null ? b.health + '/20' : '?') + ' | Food: ' + (b.food != null ? b.food + '/20' : '?') + '</span>' +
|
||||
'<span>Position: ' + b.position.x + ', ' + b.position.y + ', ' + b.position.z + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Plugin tags
|
||||
let pluginHtml = '';
|
||||
if (b.pluginsLoaded && b.pluginsLoaded.length > 0) {
|
||||
pluginHtml = '<div class="plugin-tags">' +
|
||||
b.pluginsLoaded.map(p =>
|
||||
'<span class="plugin-tag">' + escHtml(p) +
|
||||
' <button class="unload-btn" onclick="unloadPlugin(\\'' + escHtml(name) + '\\',\\'' + escHtml(p) + '\\')" title="Unload">×</button>' +
|
||||
'</span>'
|
||||
).join('') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Available plugins to load (not already loaded)
|
||||
const loadable = allPlugins.filter(p => !(b.pluginsLoaded || []).includes(p));
|
||||
let loadSelect = '';
|
||||
if (online && loadable.length > 0) {
|
||||
loadSelect = '<select id="plugin-select-' + name + '">' +
|
||||
loadable.map(p => '<option value="' + escHtml(p) + '">' + escHtml(p) + '</option>').join('') +
|
||||
'</select>' +
|
||||
'<button class="btn-load" onclick="loadPlugin(\\'' + escHtml(name) + '\\')">Load</button>';
|
||||
}
|
||||
|
||||
// Connect/disconnect button
|
||||
const connBtn = online
|
||||
? '<button class="btn-disconnect" onclick="disconnectBot(\\'' + escHtml(name) + '\\')">Disconnect</button>'
|
||||
: '<button class="btn-connect" onclick="connectBot(\\'' + escHtml(name) + '\\')">Connect</button>';
|
||||
|
||||
// Command buttons for plugins with handleCommand
|
||||
let cmdHtml = '';
|
||||
if (online && (b.pluginsLoaded || []).includes('Storage')) {
|
||||
cmdHtml = '<div class="bot-commands">' +
|
||||
'<button class="btn-action" onclick="runCommand(\\'' + escHtml(name) + '\\',\\'scan\\')">Scan</button>' +
|
||||
'<button class="btn-action" onclick="runCommand(\\'' + escHtml(name) + '\\',\\'organize\\')">Organize</button>' +
|
||||
'<button class="btn-action" onclick="runCommand(\\'' + escHtml(name) + '\\',\\'consolidate\\')">Consolidate</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
return '<div class="bot-card">' +
|
||||
'<div class="bot-card-header">' +
|
||||
'<h3><span class="bot-status ' + statusCls + '"></span> ' + escHtml(name) + '</h3>' +
|
||||
'<span style="font-size:.8em;color:#9ca3af">' + statusText + '</span>' +
|
||||
'</div>' +
|
||||
infoHtml +
|
||||
pluginHtml +
|
||||
'<div class="bot-actions">' + connBtn + ' ' + loadSelect + '</div>' +
|
||||
cmdHtml +
|
||||
'</div>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
async function connectBot(name) {
|
||||
try {
|
||||
const r = await fetch('/api/bots/' + name + '/connect', { method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}' });
|
||||
const d = await r.json();
|
||||
if (!r.ok) showToast(d.error || 'Failed', 'error');
|
||||
else showToast(name + ' connecting...', 'info');
|
||||
setTimeout(loadBots, 2000);
|
||||
} catch(e) { showToast('Network error', 'error'); }
|
||||
}
|
||||
|
||||
async function disconnectBot(name) {
|
||||
try {
|
||||
const r = await fetch('/api/bots/' + name + '/disconnect', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (!r.ok) showToast(d.error || 'Failed', 'error');
|
||||
else showToast(name + ' disconnecting...', 'info');
|
||||
setTimeout(loadBots, 1000);
|
||||
} catch(e) { showToast('Network error', 'error'); }
|
||||
}
|
||||
|
||||
async function loadPlugin(botName) {
|
||||
const sel = document.getElementById('plugin-select-' + botName);
|
||||
if (!sel) return;
|
||||
const pluginName = sel.value;
|
||||
try {
|
||||
const r = await fetch('/api/bots/' + botName + '/plugins/' + pluginName + '/load', { method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}' });
|
||||
const d = await r.json();
|
||||
if (!r.ok) showToast(d.error || 'Failed', 'error');
|
||||
else showToast('Loading ' + pluginName + '...', 'success');
|
||||
setTimeout(loadBots, 2000);
|
||||
} catch(e) { showToast('Network error', 'error'); }
|
||||
}
|
||||
|
||||
async function unloadPlugin(botName, pluginName) {
|
||||
try {
|
||||
const r = await fetch('/api/bots/' + botName + '/plugins/' + pluginName + '/unload', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (!r.ok) showToast(d.error || 'Failed', 'error');
|
||||
else showToast('Unloading ' + pluginName + '...', 'success');
|
||||
setTimeout(loadBots, 1000);
|
||||
} catch(e) { showToast('Network error', 'error'); }
|
||||
}
|
||||
|
||||
async function runCommand(botName, command) {
|
||||
// Find and disable the button that was clicked
|
||||
const btns = document.querySelectorAll('.bot-commands .btn-action');
|
||||
let clickedBtn = null;
|
||||
btns.forEach(b => { if (b.textContent.toLowerCase() === command) clickedBtn = b; });
|
||||
const origText = clickedBtn ? clickedBtn.textContent : '';
|
||||
if (clickedBtn) { clickedBtn.disabled = true; clickedBtn.textContent = command.charAt(0).toUpperCase() + command.slice(1) + '...'; }
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/bots/' + botName + '/command', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ command, args: [] })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) {
|
||||
showToast(d.error || 'Failed', 'error');
|
||||
if (clickedBtn) { clickedBtn.disabled = false; clickedBtn.textContent = origText; }
|
||||
return;
|
||||
}
|
||||
showToast(command.charAt(0).toUpperCase() + command.slice(1) + ' started...', 'info');
|
||||
|
||||
// Poll storage status until not busy
|
||||
const poll = async () => {
|
||||
try {
|
||||
const sr = await fetch('/api/storage/status?bot=' + encodeURIComponent(botName));
|
||||
if (!sr.ok) return finish();
|
||||
const sd = await sr.json();
|
||||
if (sd.busy) {
|
||||
if (clickedBtn) clickedBtn.textContent = (sd.command || command).charAt(0).toUpperCase() + (sd.command || command).slice(1) + '...';
|
||||
setTimeout(poll, 1500);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
} catch(e) { finish(); }
|
||||
};
|
||||
const finish = () => {
|
||||
if (clickedBtn) { clickedBtn.disabled = false; clickedBtn.textContent = origText; }
|
||||
showToast(command.charAt(0).toUpperCase() + command.slice(1) + ' complete!', 'success');
|
||||
if (typeof storageLoadAll === 'function') storageLoadAll();
|
||||
loadBots();
|
||||
};
|
||||
setTimeout(poll, 1500);
|
||||
} catch(e) {
|
||||
showToast('Network error', 'error');
|
||||
if (clickedBtn) { clickedBtn.disabled = false; clickedBtn.textContent = origText; }
|
||||
}
|
||||
}
|
||||
|
||||
function loadAll(){
|
||||
if (typeof storageLoadAll === 'function') storageLoadAll();
|
||||
if(currentTab==='bots') loadBots();
|
||||
if(currentTab==='activity' && typeof loadActivity === 'function') loadActivity();
|
||||
if(currentTab==='ai' && typeof loadAiStatus === 'function') loadAiStatus();
|
||||
}
|
||||
|
||||
// Activate first tab on load
|
||||
switchTab(ALL_TABS[0] || 'bots');
|
||||
setInterval(loadAll,30000);
|
||||
</script>
|
||||
${sidebarJs ? '<script>' + sidebarJs + '</script>' : ''}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WebServer();
|
||||
Reference in New Issue
Block a user