'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 => `
${p.tabLabel}
` ).join('\n\t\t\t'); const tabPanels = plugins.map(p => `
${p.html || ''}
` ).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 ` MC Bot Town

MC Bot Town

${tabButtons}
Bots
${tabPanels}
Loading bots...
${sidebarJs ? '' : ''} `; } } module.exports = new WebServer();