Files
mc-bot-town-2/nodejs/controller/web-server.js
T
2026-07-12 17:26:59 -04:00

881 lines
32 KiB
JavaScript

'use strict';
const express = require('express');
const cors = require('cors');
const { CJbot } = require('../model/minecraft');
const Database = require('./storage/database');
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 settings = require('./settings/manager');
this.port = settings.get('storage.webPort') || 3000;
this.host = settings.get('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();
});
// Auth (OIDC/SSO) — the login routes and the session gate must be
// mounted before any plugin routers so every route is protected
const auth = require('./auth');
this.app.use('/auth', auth.createRouter());
this.app.use(auth.middleware);
// Flush any plugins that were queued before start()
for (const cls of this._pendingPlugins) {
this.registerPlugin(cls);
}
this._pendingPlugins = [];
this.setupRoutes();
// JSON error handler — keeps stack traces out of responses
this.app.use((err, req, res, next) => {
console.error(`WebServer: error on ${req.method} ${req.path}:`, err.message);
if (res.headersSent) return next(err);
res.status(err.status || 500).json({ error: err.message || 'Internal server error' });
});
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.put('/api/bots/:name/settings/:key', async (req, res) => {
try {
const bot = CJbot.bots[req.params.name];
if (!bot) return res.status(404).json({ error: 'Bot not found' });
const key = req.params.key;
const value = req.body?.value;
let type = 'string';
if (typeof value === 'boolean') type = 'boolean';
else if (typeof value === 'number') type = 'number';
else if (typeof value === 'object') type = 'json';
await Database.setBotSetting(req.params.name, key, value, type);
switch (key) {
case 'autoConnect': bot.autoConnect = value; break;
case 'autoReConnect': bot.autoReConnect = value; break;
case 'onDemand': bot.onDemand = value; break;
case 'idleTimeout': bot._idleTimeout = Number(value) || 30000; break;
case 'plugins': bot.pluginsWanted = value || {}; break;
}
res.json({ key, value, type });
} catch (error) {
console.error('API Error /api/bots/:name/settings/:key:', 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}
.bot-settings{margin-bottom:12px}
.bot-setting-toggle{display:flex;align-items:center;gap:6px;font-size:.8em;color:#9ca3af;cursor:pointer}
.bot-setting-toggle input[type="checkbox"]{accent-color:#60a5fa;cursor:pointer}
.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>
<span id="userBadge" style="display:none;font-size:.85em;color:#9ca3af"></span>
<button id="logoutBtn" style="display:none;background:#374151" onclick="location.href='/auth/logout'">Logout</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);
// === Auth badge ===
(async function() {
try {
const r = await fetch('/auth/me');
const d = await r.json();
if (d.enabled && d.username) {
const badge = document.getElementById('userBadge');
badge.textContent = d.username;
badge.style.display = '';
document.getElementById('logoutBtn').style.display = '';
}
} catch(e) {}
})();
// Session-expiry guard: if any API call starts returning 401, go to login
(function() {
const origFetch = window.fetch;
window.fetch = async function(...args) {
const res = await origFetch.apply(this, args);
if (res.status === 401 && !String(args[0]).startsWith('/auth/')) {
location.href = '/auth/login?redirect=' + encodeURIComponent(location.pathname);
}
return res;
};
})();
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
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">&times;</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 +
'<div class="bot-settings">' +
'<label class="bot-setting-toggle">' +
'<input type="checkbox" ' + (b.autoConnect ? 'checked' : '') + ' onchange="setBotSetting(\\'' + escHtml(name) + '\\',\\'autoConnect\\',this.checked)" />' +
' Auto-connect' +
'</label>' +
'</div>' +
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 setBotSetting(botName, key, value) {
try {
const r = await fetch('/api/bots/' + botName + '/settings/' + key, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ value: value })
});
const d = await r.json();
if (!r.ok) showToast(d.error || 'Failed', 'error');
else showToast(botName + ': ' + key + ' = ' + value, 'info');
} 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();