This commit is contained in:
2026-02-22 20:27:09 -05:00
parent 7b326a112e
commit 92024c8a64
37 changed files with 6785 additions and 3344 deletions
+147
View File
@@ -0,0 +1,147 @@
'use strict';
const express = require('express');
const { CJbot } = require('../model/minecraft');
const ACTIVITY_PLUGINS = ['Swing', 'Craft', 'GuardianFarm', 'GoldFarm', 'AutoEat'];
function createRouter() {
const router = express.Router();
router.get('/api/activity', (req, res) => {
try {
const result = {};
for (const [name, bot] of Object.entries(CJbot.bots)) {
const plugins = {};
for (const pluginName of ACTIVITY_PLUGINS) {
const instance = bot.plunginsLoaded[pluginName];
if (!instance) continue;
const cls = CJbot.plungins[pluginName];
if (cls && typeof cls.getStatus === 'function') {
plugins[pluginName] = cls.getStatus(instance);
} else {
plugins[pluginName] = { active: true };
}
}
if (Object.keys(plugins).length > 0) {
result[name] = { connected: bot.isReady, plugins };
}
}
res.json({ bots: result });
} catch (error) {
console.error('API Error /api/activity:', error);
res.status(500).json({ error: error.message });
}
});
return router;
}
const webUI = {
tabId: 'activity',
tabLabel: 'Activity',
tabOrder: 20,
html: `
<div style="margin-bottom:8px"><span class="last-updated" id="ts-activity"></span></div>
<div id="activityArea">
<div style="padding:20px;color:#6b7280;text-align:center">Loading activity...</div>
</div>
`,
css: `
.activity-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
.activity-card{background:#111827;border:1px solid #374151;border-radius:8px;padding:16px;transition:border-color .2s}
.activity-card:hover{border-color:#60a5fa}
.activity-card h3{font-size:1em;color:#60a5fa;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.activity-plugin{background:#1f2937;border:1px solid #374151;border-radius:6px;padding:10px 14px;margin-bottom:8px}
.activity-plugin:last-child{margin-bottom:0}
.activity-plugin-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}
.activity-plugin-name{font-size:.9em;font-weight:600;color:#e5e7eb}
.activity-badge{padding:2px 8px;border-radius:10px;font-size:.75em;font-weight:600}
.activity-badge.active{background:#059669;color:#fff}
.activity-detail{font-size:.8em;color:#9ca3af;margin-top:4px}
.hunger-bar{display:flex;gap:2px;margin-top:4px}
.hunger-pip{width:12px;height:12px;border-radius:2px;background:#374151}
.hunger-pip.filled{background:#f59e0b}
.hunger-pip.low{background:#ef4444}
.activity-empty{padding:40px 20px;color:#6b7280;text-align:center;font-size:.9em}
`,
onTabActive: 'onActivityTabActive',
js: `
let activityInterval=null;
function onActivityTabActive() {
loadActivity();
if (!activityInterval) activityInterval = setInterval(() => { if (currentTab === 'activity') loadActivity(); }, 5000);
}
async function loadActivity() {
try {
const r = await fetch('/api/activity');
if (!r.ok) { document.getElementById('activityArea').innerHTML='<div class="activity-empty">Failed to load activity</div>'; return; }
const d = await r.json();
renderActivity(d.bots || {});
updateTimestamp('ts-activity');
} catch(e) {
document.getElementById('activityArea').innerHTML='<div class="activity-empty">Failed to load activity</div>';
}
}
function renderActivity(bots) {
const area = document.getElementById('activityArea');
const names = Object.keys(bots);
if (names.length === 0) {
area.innerHTML='<div class="activity-empty">No active automation plugins</div>';
return;
}
area.innerHTML = '<div class="activity-grid">' + names.map(name => {
const bot = bots[name];
const pluginHtml = Object.entries(bot.plugins).map(([pName, status]) => {
let detail = '';
// Special case: AutoEat gets a hunger bar
if (pName === 'AutoEat' && status.hunger != null) {
let hungerBar = '<div class="hunger-bar">';
for (let i = 0; i < 20; i++) {
const filled = i < status.hunger;
const low = status.hunger < (status.threshold || 0);
hungerBar += '<div class="hunger-pip' + (filled ? (low ? ' low' : ' filled') : '') + '"></div>';
}
hungerBar += '</div>';
detail += hungerBar;
}
// Render all status keys generically
const skipKeys = new Set(['active']);
for (const [key, val] of Object.entries(status)) {
if (skipKeys.has(key)) continue;
if (val === null || val === undefined) continue;
const label = fmtName(key.replace(/([A-Z])/g, '_$1').toLowerCase());
let display;
if (typeof val === 'boolean') display = val ? 'Yes' : 'No';
else if (Array.isArray(val)) display = val.map(fmtName).join(', ') || 'None';
else display = escHtml(String(val));
detail += '<div class="activity-detail">' + escHtml(label) + ': ' + display + '</div>';
}
if (!detail) detail = '<div class="activity-detail">Running</div>';
return '<div class="activity-plugin">' +
'<div class="activity-plugin-header">' +
'<span class="activity-plugin-name">' + escHtml(pName) + '</span>' +
'<span class="activity-badge active">Active</span>' +
'</div>' +
detail +
'</div>';
}).join('');
return '<div class="activity-card">' +
'<h3><span class="bot-status ' + (bot.connected ? 'online' : 'offline') + '"></span> ' + escHtml(name) + '</h3>' +
pluginHtml +
'</div>';
}).join('') + '</div>';
}
`,
};
module.exports = { name: 'Activity', createRouter, webUI };
+36 -9
View File
@@ -10,13 +10,17 @@ class Ai{
this.bot = args.bot;
this.promptName = args.promptName;
this.prompCustom = args.prompCustom || '';
this.intervalLength = args.intervalLength || 30;
// interval takes precedence over intervalLength (both are valid config names)
this.intervalLength = args.interval || args.intervalLength || 30;
this.intervalStop;
this.messageListener;
this.provider = null;
// Bot-specific AI config (overrides global config)
this.botConfig = args.botConfig || {};
// When loaded via config, args contains provider, model, baseUrl, etc. directly
// When loaded via /ai command, only promptName/prompCustom are passed
const { bot, promptName, prompCustom, intervalLength, interval, ...configProps } = args;
this.botConfig = args.botConfig || configProps || {};
}
// Get merged config: bot-specific settings override global settings
@@ -58,14 +62,31 @@ class Ai{
try{
messages = [''];
if(!this.provider.getResponse(result)) return;
const responseText = this.provider.getResponse(result);
if(!responseText) return;
for(let message of JSON.parse(this.provider.getResponse(result))){
console.log('toSay', message.delay, message.text);
if(message.text === '___') return;
setTimeout(async (message)=>{
await this.bot.sayAiSafe(message.text);
}, message.delay*1000, message);
// Try to parse JSON response
try {
const parsed = JSON.parse(responseText);
if(Array.isArray(parsed)){
for(let message of parsed){
console.log('toSay', message.delay, message.text);
if(message.text.trim().startsWith('_')) return;
setTimeout(async (message)=>{
await this.bot.sayAiSafe(message.text);
}, 0*1000, message);
}
} else {
throw new Error('Response is not an array');
}
} catch(jsonError){
// JSON parsing failed, treat as plain text
console.log('JSON parse failed, treating as plain text:', responseText.substring(0, 100));
// Skip empty responses, underscore signals, and single dash signals
const text = responseText.trim();
if(text && text !== '___' && !text.match(/^[-_]+$/)){
await this.bot.sayAiSafe(text);
}
}
}catch(error){
console.log('Error in AI message loop', error, result);
@@ -108,6 +129,8 @@ class Ai{
model: config.model,
promptName: this.promptName,
baseUrl: config.baseUrl,
maxOutputTokens: config.maxOutputTokens,
interval: config.interval,
});
const prompt = conf.ai.prompts[this.promptName](
@@ -142,4 +165,8 @@ class Ai{
const AiWeb = require('./ai/web');
Ai.createRouter = AiWeb.createRouter;
Ai.webUI = AiWeb.webUI;
module.exports = Ai;
+43 -10
View File
@@ -24,7 +24,21 @@ class OllamaProvider {
temperature: this.config.temperature || 1,
top_p: this.config.topP || 0.95,
top_k: this.config.topK || 64,
num_predict: this.config.maxOutputTokens || 8192,
num_predict: this.config.maxOutputTokens || 2048,
};
}
__jsonFormat() {
return {
type: 'array',
items: {
type: 'object',
properties: {
text: { type: 'string' },
delay: { type: 'number' }
},
required: ['text', 'delay']
}
};
}
@@ -46,15 +60,19 @@ class OllamaProvider {
}
];
// console.log('Ollama messages', messages)
const requestBody = {
model: this.model,
messages: messages,
stream: false,
format: this.__jsonFormat(),
options: this.__settings()
};
// console.log('Ollama request:', JSON.stringify(requestBody, null, 2));
const response = await axios.post(
`${this.baseUrl}/api/chat`,
{
model: this.model,
messages: messages,
stream: false,
format: 'json', // Request JSON response
options: this.__settings()
},
requestBody,
{
timeout: this.config.timeout || 30000,
headers: {
@@ -63,6 +81,11 @@ class OllamaProvider {
}
);
// Log raw response for debugging
const rawContent = response.data.message.content;
// console.log('Ollama raw response:', JSON.stringify(rawContent));
// console.log('Ollama raw response length:', rawContent?.length);
// Update history
this.messages.push({
role: 'user',
@@ -72,8 +95,8 @@ class OllamaProvider {
this.messages.push({
role: 'model',
parts: [{ text: response.data.message.content }],
content: response.data.message.content
parts: [{ text: rawContent }],
content: rawContent
});
// Return in a format compatible with the Ai class
@@ -83,6 +106,16 @@ class OllamaProvider {
}
};
} catch (error) {
// Log detailed error information
const errorDetails = {
message: error.message,
status: error.response?.status,
data: error.response?.data,
url: error.config?.url,
retryCount: retryCount
};
console.log('Ollama API error details:', errorDetails);
if (retryCount > 3) {
throw new Error(`Ollama API error after ${retryCount} retries: ${error.message}`);
}
+103
View File
@@ -0,0 +1,103 @@
'use strict';
const express = require('express');
const { CJbot } = require('../../model/minecraft');
function createRouter() {
const router = express.Router();
router.get('/api/ai/status', (req, res) => {
try {
const result = {};
for (const [name, bot] of Object.entries(CJbot.bots)) {
const ai = bot.plunginsLoaded['Ai'];
if (!ai) continue;
const config = ai.__getConfig();
result[name] = {
connected: bot.isReady,
provider: config.provider || 'unknown',
model: config.model || 'unknown',
interval: ai.intervalLength,
promptName: ai.promptName || 'unknown',
active: !!ai.intervalStop,
};
}
res.json({ bots: result });
} catch (error) {
console.error('API Error /api/ai/status:', error);
res.status(500).json({ error: error.message });
}
});
return router;
}
const webUI = {
tabId: 'ai',
tabLabel: 'AI',
tabOrder: 30,
html: `
<div id="aiArea">
<div style="padding:20px;color:#6b7280;text-align:center">Loading AI status...</div>
</div>
`,
css: `
.ai-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
.ai-card{background:#111827;border:1px solid #374151;border-radius:8px;padding:16px;transition:border-color .2s}
.ai-card:hover{border-color:#a78bfa}
.ai-card h3{font-size:1em;color:#a78bfa;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.ai-info{font-size:.85em;color:#9ca3af;margin:4px 0}
.ai-info .ai-label{color:#6b7280;display:inline-block;min-width:80px}
.ai-info .ai-value{color:#e5e7eb}
.ai-status-badge{padding:2px 8px;border-radius:10px;font-size:.75em;font-weight:600}
.ai-status-badge.active{background:#059669;color:#fff}
.ai-status-badge.inactive{background:#6b7280;color:#fff}
.ai-empty{padding:40px 20px;color:#6b7280;text-align:center;font-size:.9em}
`,
onTabActive: 'onAiTabActive',
js: `
let aiInterval=null;
function onAiTabActive() {
loadAiStatus();
if (!aiInterval) aiInterval = setInterval(() => { if (currentTab === 'ai') loadAiStatus(); }, 10000);
}
async function loadAiStatus() {
try {
const r = await fetch('/api/ai/status');
if (!r.ok) { document.getElementById('aiArea').innerHTML='<div class="ai-empty">Failed to load AI status</div>'; return; }
const d = await r.json();
renderAiStatus(d.bots || {});
} catch(e) {
document.getElementById('aiArea').innerHTML='<div class="ai-empty">Failed to load AI status</div>';
}
}
function renderAiStatus(bots) {
const area = document.getElementById('aiArea');
const names = Object.keys(bots);
if (names.length === 0) {
area.innerHTML='<div class="ai-empty">No bots with AI loaded</div>';
return;
}
area.innerHTML = '<div class="ai-grid">' + names.map(name => {
const ai = bots[name];
const badge = ai.active
? '<span class="ai-status-badge active">Active</span>'
: '<span class="ai-status-badge inactive">Inactive</span>';
return '<div class="ai-card">' +
'<h3><span class="bot-status ' + (ai.connected ? 'online' : 'offline') + '"></span> ' + escHtml(name) + ' ' + badge + '</h3>' +
'<div class="ai-info"><span class="ai-label">Provider:</span> <span class="ai-value">' + escHtml(ai.provider) + '</span></div>' +
'<div class="ai-info"><span class="ai-label">Model:</span> <span class="ai-value">' + escHtml(ai.model) + '</span></div>' +
'<div class="ai-info"><span class="ai-label">Interval:</span> <span class="ai-value">' + ai.interval + 's</span></div>' +
'<div class="ai-info"><span class="ai-label">Prompt:</span> <span class="ai-value">' + escHtml(ai.promptName) + '</span></div>' +
'</div>';
}).join('') + '</div>';
}
`,
};
module.exports = { createRouter, webUI };
+92
View File
@@ -0,0 +1,92 @@
'use strict';
const { sleep } = require('../utils');
const FOOD_ITEMS = [
'golden_carrot', 'cooked_beef', 'steak', 'cooked_porkchop',
'cooked_mutton', 'cooked_chicken', 'cooked_salmon', 'cooked_cod',
'baked_potato', 'bread', 'cooked_rabbit', 'golden_apple',
'carrot', 'apple', 'sweet_berries', 'melon_slice',
'dried_kelp', 'potato', 'beetroot', 'cookie',
];
class AutoEat {
constructor(args) {
this.bot = args.bot;
this.threshold = args.threshold || 14;
this.isEating = false;
this._checkInterval = null;
this._onHealthListener = null;
}
async init() {
this.onReadyListen = this.bot.on('onReady', () => {
this._onHealthListener = () => this._onHealth();
this.bot.bot.on('health', this._onHealthListener);
this._checkInterval = setInterval(() => this._onHealth(), 30000);
console.log(`AutoEat: Active (threshold: ${this.threshold}/20)`);
});
}
unload() {
if (this._checkInterval) {
clearInterval(this._checkInterval);
this._checkInterval = null;
}
if (this._onHealthListener && this.bot.isReady) {
this.bot.bot.removeListener('health', this._onHealthListener);
}
this._onHealthListener = null;
if (this.onReadyListen) this.onReadyListen();
console.log('AutoEat: Unloaded');
}
async _onHealth() {
if (this.isEating) return;
if (this.bot.bot.food >= this.threshold) return;
await this._eat();
}
async _eat() {
this.isEating = true;
try {
const food = this._findFood();
if (!food) {
console.log('AutoEat: No food in inventory');
return;
}
console.log(`AutoEat: Eating ${food.name} (hunger: ${this.bot.bot.food}/20)`);
await this.bot.bot.equip(food, 'hand');
await this.bot.bot.consume();
console.log(`AutoEat: Done (hunger: ${this.bot.bot.food}/20)`);
} catch (error) {
console.error('AutoEat: Error eating:', error.message);
} finally {
this.isEating = false;
}
}
_findFood() {
for (const name of FOOD_ITEMS) {
const item = this.bot.bot.inventory.items().find(i => i.name === name);
if (item) return item;
}
return null;
}
}
AutoEat.getStatus = function(instance) {
return {
threshold: instance.threshold,
isEating: instance.isEating,
hunger: instance.bot.isReady ? instance.bot.bot.food : null,
foodCount: instance.bot.isReady ?
instance.bot.bot.inventory.items().filter(i => FOOD_ITEMS.includes(i.name)).reduce((s, i) => s + i.count, 0) : 0,
};
};
module.exports = AutoEat;
-64
View File
@@ -1,64 +0,0 @@
'use strict';
const conf = require('../conf');
const {sleep} = require('../utils');
class Craft{
constructor(args){
this.bot = args.bot;
this.interval = args.interval;
this.target = args.target;
this.intervalStop;
this.isAction = true;
}
async init(){
this.bot.on('onReady', async ()=>{
this.bot.bot.setControlState('jump', true);
setTimeout(()=> this.bot.bot.setControlState('jump', false), 2000);
await sleep(2000);
let chest = this.bot.findChestBySign('FILLED BOXES');
await this.bot.goTo({
where: chest,
range: 3,
});
await this.bot.getFullShulkersFromChest(chest, {id:3});
// goto 'FILLED BOXES' box
// get 4 boxes of 'prismarine_shard'
// get 5 boxes of 'prismarine_crystals'
// place boxes
});
}
unload(){
if(this.intervalStop){
clearInterval(this.intervalStop);
this.intervalStop = undefined;
}
return true;
}
async goToSpot(){
await this.bot.goTo({
where: this.bot.findBlockBySign('guardian\nattack spot'),
range: 0,
});
}
async swing(){
this.intervalStop = setInterval(()=>{
try{
this.bot.bot.attack(
this.bot.bot.nearestEntity(
entity => entity.name.toLowerCase() === 'guardian'
)
);
}catch(error){}
}, 4000);
}
}
module.exports = Craft;
+298
View File
@@ -0,0 +1,298 @@
'use strict';
const express = require('express');
const { CJbot } = require('../model/minecraft');
// In-memory ring buffer for chat messages
const MAX_MESSAGES = 500;
const messages = [];
let messageId = 0;
function addMessage(type, from, text, botName) {
messages.push({
id: ++messageId,
type, // 'chat', 'whisper', 'system', 'bot'
from,
text,
botName,
timestamp: Date.now(),
});
if (messages.length > MAX_MESSAGES) messages.splice(0, messages.length - MAX_MESSAGES);
}
// Hook into all bots' chat events (called once per bot connection)
const hookedBots = new Set();
function hookBot(bot) {
const name = bot.name;
if (hookedBots.has(name)) return;
hookedBots.add(name);
// Re-hook on each spawn (reconnection creates a new mineflayer bot)
const attach = () => {
if (!bot.bot) return;
bot.bot.on('chat', (from, message) => {
addMessage('chat', from, message, name);
});
bot.bot.on('whisper', (from, message) => {
addMessage('whisper', from, message, name);
});
bot.bot.on('message', (jsonMsg, position) => {
if (position === 'game_info') return; // skip action bar
const text = jsonMsg.toString();
// Skip empty or already-captured chat/whisper
if (!text || text.startsWith('<')) return;
addMessage('system', null, text, name);
});
};
// If the bot is already connected, attach now
if (bot.bot) attach();
// Also attach on every future spawn
const origConnect = bot.connect.bind(bot);
bot.connect = async function (...args) {
const result = await origConnect(...args);
attach();
return result;
};
}
// Periodically check for new bots to hook
setInterval(() => {
for (const [name, bot] of Object.entries(CJbot.bots)) {
hookBot(bot);
}
}, 2000);
// Also hook any bots that exist right now
for (const [name, bot] of Object.entries(CJbot.bots)) {
hookBot(bot);
}
function createRouter() {
const router = express.Router();
// Get messages, optionally filtering by ?since=<id> for polling
router.get('/api/chat/messages', (req, res) => {
try {
const since = parseInt(req.query.since) || 0;
const filtered = since ? messages.filter(m => m.id > since) : messages.slice(-100);
res.json({ messages: filtered, lastId: messageId });
} catch (error) {
console.error('API Error /api/chat/messages:', error);
res.status(500).json({ error: error.message });
}
});
// Send a chat message as a bot
router.post('/api/chat/send', async (req, res) => {
try {
const { botName, message, whisperTo } = req.body;
if (!message) return res.status(400).json({ error: 'Missing message' });
// Find a bot to send from
let bot = null;
if (botName && CJbot.bots[botName]) {
bot = CJbot.bots[botName];
} else {
// Use first connected bot
for (const b of Object.values(CJbot.bots)) {
if (b.isReady) { bot = b; break; }
}
}
if (!bot || !bot.isReady) {
return res.status(503).json({ error: 'No connected bot available' });
}
if (whisperTo) {
await bot.whisper(whisperTo, message);
addMessage('bot', bot.bot.entity.username, `/msg ${whisperTo} ${message}`, bot.name);
} else {
await bot.say(message);
addMessage('bot', bot.bot.entity.username, message, bot.name);
}
res.json({ status: 'sent' });
} catch (error) {
console.error('API Error /api/chat/send:', error);
res.status(500).json({ error: error.message });
}
});
return router;
}
const webUI = {
tabId: 'chat',
tabLabel: 'Chat',
tabOrder: 5,
html: `
<div id="chatArea">
<div class="chat-messages" id="chatMessages">
<div style="padding:20px;color:#6b7280;text-align:center">Loading chat...</div>
</div>
<div class="chat-input-area">
<select id="chatBot" style="padding:8px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.85em">
<option value="">Any bot</option>
</select>
<input type="text" id="chatWhisper" placeholder="Whisper to (optional)" style="width:120px;padding:8px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.85em">
<input type="text" id="chatInput" placeholder="Type a message..." autocomplete="off" style="flex:1;padding:8px 12px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.9em">
<button id="chatSendBtn" style="background:#2563eb;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:.85em">Send</button>
</div>
</div>
`,
css: `
#chatArea{display:flex;flex-direction:column;height:calc(100vh - 140px)}
.chat-messages{flex:1;overflow-y:auto;padding:12px;background:#0f172a;border:1px solid #374151;border-radius:8px;margin-bottom:12px;font-family:'Consolas','Monaco',monospace;font-size:.85em;line-height:1.6}
.chat-msg{padding:2px 0;word-wrap:break-word}
.chat-msg .chat-time{color:#4b5563;font-size:.8em;margin-right:6px}
.chat-msg .chat-from{font-weight:600}
.chat-msg.type-chat .chat-from{color:#60a5fa}
.chat-msg.type-whisper .chat-from{color:#a78bfa}
.chat-msg.type-whisper{background:rgba(167,139,250,.08);padding:2px 4px;border-radius:3px}
.chat-msg.type-system{color:#6b7280;font-style:italic}
.chat-msg.type-bot .chat-from{color:#f59e0b}
.chat-input-area{display:flex;gap:8px;align-items:center}
.chat-filter-bar{display:flex;gap:8px;align-items:center;margin-bottom:8px}
.chat-filter-bar input{padding:8px 12px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.85em;flex:1}
.chat-filter-bar label{font-size:.8em;color:#9ca3af;display:flex;align-items:center;gap:4px;cursor:pointer}
.chat-filter-bar label input[type=checkbox]{accent-color:#2563eb}
`,
onTabActive: 'onChatTabActive',
js: `
let chatLastId=0, chatInterval=null, chatAutoScroll=true, chatFilter='';
let chatShowTypes={chat:true,whisper:true,system:true,bot:true};
function onChatTabActive() {
loadChatMessages();
populateBotSelect();
if (!chatInterval) chatInterval = setInterval(() => { if (currentTab === 'chat') pollChat(); }, 1500);
}
async function loadChatMessages() {
try {
const r = await fetch('/api/chat/messages');
if (!r.ok) return;
const d = await r.json();
chatLastId = d.lastId || 0;
renderChatMessages(d.messages || []);
} catch(e) {}
}
async function pollChat() {
try {
const r = await fetch('/api/chat/messages?since=' + chatLastId);
if (!r.ok) return;
const d = await r.json();
if (d.messages && d.messages.length > 0) {
chatLastId = d.lastId || chatLastId;
appendChatMessages(d.messages);
}
} catch(e) {}
}
function formatChatTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
}
function renderChatLine(msg) {
const time = '<span class="chat-time">' + formatChatTime(msg.timestamp) + '</span>';
if (msg.type === 'system') {
return '<div class="chat-msg type-system">' + time + escHtml(msg.text) + '</div>';
}
const label = msg.type === 'whisper' ? ' whispers: ' : ': ';
return '<div class="chat-msg type-' + msg.type + '">' +
time +
'<span class="chat-from">' + escHtml(msg.from || '???') + '</span>' +
label + escHtml(msg.text) +
'</div>';
}
function matchesFilter(msg) {
if (!chatShowTypes[msg.type]) return false;
if (!chatFilter) return true;
const q = chatFilter.toLowerCase();
return (msg.from && msg.from.toLowerCase().includes(q)) ||
(msg.text && msg.text.toLowerCase().includes(q));
}
function renderChatMessages(msgs) {
const container = document.getElementById('chatMessages');
const filtered = msgs.filter(matchesFilter);
if (filtered.length === 0) {
container.innerHTML = '<div style="padding:20px;color:#6b7280;text-align:center">No messages yet</div>';
return;
}
container.innerHTML = filtered.map(renderChatLine).join('');
if (chatAutoScroll) container.scrollTop = container.scrollHeight;
}
function appendChatMessages(msgs) {
const container = document.getElementById('chatMessages');
// Remove placeholder if present
const placeholder = container.querySelector('div[style]');
if (placeholder && container.children.length === 1 && placeholder.textContent.includes('No messages')) {
container.innerHTML = '';
}
const filtered = msgs.filter(matchesFilter);
for (const msg of filtered) {
container.insertAdjacentHTML('beforeend', renderChatLine(msg));
}
// Trim old messages from DOM
while (container.children.length > 500) container.removeChild(container.firstChild);
if (chatAutoScroll) container.scrollTop = container.scrollHeight;
}
async function populateBotSelect() {
try {
const r = await fetch('/api/bots');
if (!r.ok) return;
const d = await r.json();
const sel = document.getElementById('chatBot');
const current = sel.value;
sel.innerHTML = '<option value="">Any bot</option>' +
Object.entries(d.bots || {}).filter(([,b]) => b.connected).map(([name]) =>
'<option value="' + escHtml(name) + '">' + escHtml(name) + '</option>'
).join('');
sel.value = current;
} catch(e) {}
}
async function sendChatMessage() {
const input = document.getElementById('chatInput');
const message = input.value.trim();
if (!message) return;
const botName = document.getElementById('chatBot').value || undefined;
const whisperTo = document.getElementById('chatWhisper').value.trim() || undefined;
try {
const r = await fetch('/api/chat/send', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ botName, message, whisperTo })
});
if (r.ok) {
input.value = '';
setTimeout(pollChat, 300);
} else {
const d = await r.json();
showToast(d.error || 'Failed to send', 'error');
}
} catch(e) { showToast('Network error', 'error'); }
}
document.getElementById('chatInput').addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); sendChatMessage(); }
});
document.getElementById('chatSendBtn').addEventListener('click', sendChatMessage);
// Auto-scroll toggle: disable if user scrolls up, re-enable at bottom
document.getElementById('chatMessages').addEventListener('scroll', function() {
chatAutoScroll = this.scrollTop + this.clientHeight >= this.scrollHeight - 30;
});
`,
};
module.exports = { name: 'Chat', createRouter, webUI };
+2 -3
View File
@@ -2,7 +2,6 @@ module.exports = {
'help': {
desc: `Print the allowed commands.`,
async function(from){
console.log('called help', from)
let intro = [
'I am a bot owned and operated by',
'wmantly <wmantly@gmail.com>',
@@ -70,10 +69,10 @@ module.exports = {
allowed: ['wmantly', 'useless666', 'tux4242',],
ignoreLock: true,
async function(from, botName, action) {
this.whisper(from, `Loading ${plugin}`);
this.whisper(from, `Loading ${action}`);
if(botName in this.constructor.bots){
let bot = this.constructor.bots[botName];
let status = await bot.pluginLoad(plugin);
let status = await bot.pluginLoad(action);
return this.whisper(from, `plugin status ${status}`);
}
+28 -107
View File
@@ -1,128 +1,49 @@
'use strict';
const {sleep} = require('../../utils');
let myAccounts = ['wmantly', 'useless666', 'tux4242']
let germans = ['YTMatze', 'mytzor']
let townMemebers = [
'wmantly', 'useless666', 'tux4242',
'VinceNL',
'Ethan63020', 'Ethan63021',
'pi_chef',
'EXLAlphaWolf', 'Sillychubbs',
'BearSkates420', 'hloop',
'ogeiDNight', 'BobinaBlu', 'Roby_G_27',
'kawiimeowz', 'RaindropCake24', 'KimiKava',
'Keebyys',
'YTMatze', 'mytzor',
'jj_disaster', 'Cuttaway',
'sonic_joe',
]
let sites = {
fo: {
bot: 'jimin',
desc: `Get an invite to the Farming outpost.`,
allowed: ['wmantly', 'useless666', 'tux4242', 'pi_chef', '1_cut', 'nootbot', 'VinceNL', 'Ethan63020', 'Ethan63021', 'KimiKava', 'kawiimeowz', 'RaindropCake24', 'AndyNyg', 'AndyNyg_II'],
},
mega:{
bot: 'ayay',
desc: `Get an invite to the Farming outpost 2.`,
allowed: ['wmantly', 'useless666', 'tux4242', 'pi_chef', '1_cut', '__Ethan63020', '__Ethan63021', 'VinceNL', 'nootbot'],
},
guardian: {
bot: 'art',
desc: 'blah',
allowed: ['wmantly', 'useless666', 'tux4242', 'pi_chef', '1_cut', 'Ethan63020', 'Ethan63021', 'VinceNL', 'nootbot'],
},
fo2: {
bot: 'henry',
desc: `Get an invite to the Farming outpost 2.`,
allowed: ['wmantly', 'useless666', 'tux4242', 'pi_chef', '1_cut', 'Ethan63020', 'Ethan63021', 'VinceNL', 'nootbot'],
},
foend: {
bot: 'ez',
desc: `Get an invite to the Farming outpost in the end.`,
allowed: ['wmantly', 'useless666', 'tux4242', 'pi_chef', '1_cut',],
},
sb: {
bot: 'owen',
desc: `Get an invite to the Sky Base.`,
allowed: ['wmantly', 'useless666', 'tux4242', 'pi_chef', '1_cut', 'Ethan63020', 'Ethan63021', 'VinceNL', 'nootbot'],
},
core: {
bot: 'nova',
desc: `Get an invite to the Core.`,
allowed: ['wmantly', 'useless666', 'tux4242', 'pi_chef', '1_cut', 'Ethan63020', 'Ethan63021', 'VinceNL', 'nootbot', 'AndyNyg', 'AndyNyg_II','Lost_Imback', 'KimiKava', 'kawiimeowz', 'RaindropCake24',],
},
art: {
bot: 'art',
desc: 'Invite to art',
allowed: ['wmantly', 'useless666', 'tux4242']
},
german: {
bot: 'linda',
desc: `Get an invite you Germans area.`,
allowed: ['wmantly', 'useless666', 'tux4242', 'owenshorts', 'VinceNL', 'Ethan63020', 'Ethan63021', 'pi_chef', 'YTMatze', 'mytzor', 'pi_chef', '1_cut', 'nootbot', 'Lost_Imback',],
},
}
function getSiteFromBot(name){
for(let site in sites){
if(sites[site].bot === name){
return sites[site];
}
}
}
const Database = require('../storage/database');
const Invite = require('../invite');
module.exports = {
'.invite': {
desc: `The bot will /accept an /invite from you.`,
allowed: ['wmantly', 'useless666', 'tux4242', 'owenshorts', 'pi_chef', '1_cut',],
ignoreLock: true,
async function(from){
async function(from) {
const allowed = await Database.isPlayerAllowedAtSite('*', from);
// Allow if player has permission at any site
const sites = await Database.getInviteSites();
const hasAnySite = sites.some(s => {
const players = s.players ? s.players.split(',') : [];
return players.includes(from);
});
if (!hasAnySite) return;
await this.whisper('Coming');
await this.say(`/invite accept`);
}
},
'inv': {
desc: `Have bot.\n Site -- one'`,
desc: `Have a bot invite you to a site.\n Usage: inv <site>`,
ignoreLock: true,
async function(from, site){
async function(from, site) {
this.__unLockCommand();
if(sites[site] && sites[site].allowed.includes(from)){
let bot = this.constructor.bots[sites[site].bot];
if (!site) return;
if(!bot.isReady){
try{
await bot.connect();
}catch(error){
console.log('inv error connecting to bot');
this.whisper('Bot is not available right now, try again in 30 seconds.');
}
var clear = setTimeout(()=>{
bot.pluginUnload('Tp');
bot.quit()
}, 10000);
}
await bot.pluginLoad('Tp');
await bot.bot.chat(`/invite ${from}`);
await bot.whisper(from, `accept invite from ${bot.bot.entity.username} within 10 seconds...`);
bot.on('message', async (message) =>{
if(message.toString() === `${from} teleported to you.`){
await bot.pluginUnload('Tp');
const siteData = await Database.getInviteSiteByName(site);
if (!siteData) return;
if(clear){
clearTimeout(clear);
bot.quit();
}
}
});
const allowed = await Database.isPlayerAllowedAtSite(site, from);
if (!allowed) return;
try {
const bot = this.constructor.bots[siteData.bot_name];
if (!bot) return;
await Invite.executeInvite(siteData.bot_name, from);
} catch (error) {
console.log('inv error:', error);
this.whisper('Bot is not available right now, try again in 30 seconds.');
}
}
},
};
+130 -3
View File
@@ -1,10 +1,15 @@
'use strict';
const { sleep } = require('../../utils');
// Owner players who can run admin commands
const owners = ['wmantly', 'useless666', 'tux4242'];
// Team players who can use basic storage features
const team = [...owners, 'pi_chef', 'Ethan', 'Vince_NL'];
const botSlots = [0, 1, 2, 3, 9, 10, 11, 12, 18, 19, 20, 21];
const customerSlots = [5, 6, 7, 8, 14, 15, 16, 17, 23, 24, 25, 26];
module.exports = {
'scan': {
desc: 'Force chest area scan',
@@ -28,14 +33,22 @@ module.exports = {
}
},
'withdraw': {
desc: 'Withdraw items from storage',
desc: 'Withdraw items from storage (use "3s" for 3 shulkers)',
allowed: team,
async function(from, itemName, countStr) {
console.log(`Storage command 'withdraw' from ${from}: ${itemName} x${countStr}`);
const storage = this.plunginsLoaded['Storage'];
if (!storage) return this.whisper(from, 'Storage plugin not loaded');
const count = parseInt(countStr) || 1;
await storage.handleCommand(from, 'withdraw', itemName, count);
// Parse count — "3s" means 3 shulkers, "10" means 10 items
const str = (countStr || '1').toString().trim();
if (str.endsWith('s') || str.endsWith('S')) {
const shulkerCount = parseInt(str) || 1;
await storage.handleCommand(from, 'withdraw-shulkers', itemName, shulkerCount);
} else {
const count = parseInt(str) || 1;
await storage.handleCommand(from, 'withdraw', itemName, count);
}
}
},
'find': {
@@ -70,6 +83,17 @@ module.exports = {
await storage.handleCommand(from, 'organize');
}
},
'consolidate': {
desc: 'Merge partially filled shulkers',
allowed: owners,
ignoreLock: true,
async function(from) {
console.log(`Storage command 'consolidate' from ${from}`);
const storage = this.plunginsLoaded['Storage'];
if (!storage) return this.whisper(from, 'Storage plugin not loaded');
await storage.handleCommand(from, 'consolidate');
}
},
'addplayer': {
desc: 'Add player to storage',
allowed: owners,
@@ -103,4 +127,107 @@ module.exports = {
await storage.handleCommand(from, 'players');
}
},
'.trade': {
desc: 'Handle trade deposits/withdrawals for storage',
allowed: team,
ignoreLock: true,
async function(from) {
const storage = this.plunginsLoaded['Storage'];
if (!storage) return;
storage._busy = true;
try {
const pending = storage.pendingWithdrawals.get(from);
await this.say('/trade accept');
let window = await this.once('windowOpen');
// If there's a pending withdrawal, place items in bot's trade slots
if (pending) {
console.log(`Storage trade: Withdrawal pickup for ${from}${pending.count}x ${pending.itemName} (mode: ${pending.mode})`);
let placed = 0;
for (const slotNum of botSlots) {
if (placed >= 12) break;
// Find matching item in bot inventory portion of trade window
for (let i = window.inventoryStart; i < window.inventoryEnd; i++) {
const item = window.slots[i];
if (!item) continue;
if (pending.mode === 'shulkers') {
if (!item.name.includes('shulker_box')) continue;
} else {
if (item.name !== pending.itemName) continue;
}
try {
await this.bot.moveSlotItem(i, slotNum);
await sleep(200);
placed++;
break;
} catch (error) {
console.log(`Storage trade: Could not move item to slot ${slotNum}: ${error.message}`);
}
}
}
console.log(`Storage trade: Placed ${placed} stack(s) in trade window`);
}
// Poll for customer confirmation (lime_dye at slot 53)
let timeoutCheck = setTimeout(() => {
this.bot.closeWindow(window);
this.whisper(from, 'Trade timed out.');
}, 120000);
let confirmationCheck = setInterval(async () => {
try {
const indicator = window.slots[53];
if (indicator && indicator.name === 'lime_dye') {
this.bot.moveSlotItem(37, 37);
}
} catch (e) {
// window may have closed
}
}, 500);
// Wait for trade to complete
await this.once('windowClose');
clearInterval(confirmationCheck);
if (timeoutCheck._destroyed) {
storage._busy = false;
return;
}
clearTimeout(timeoutCheck);
if (pending) {
// Withdrawal complete — clear pending
if (pending.timeoutId) clearTimeout(pending.timeoutId);
storage.pendingWithdrawals.delete(from);
this.whisper(from, `Withdrawal complete! Enjoy your ${pending.itemName}.`);
} else {
// Deposit — collect items from bot inventory and sort into storage
await sleep(500);
const hotbarNames = new Set((storage.config.hotbarItems || []).map(h => h.name));
const itemsReceived = [];
for (const item of this.bot.inventory.items()) {
if (hotbarNames.has(item.name)) continue;
itemsReceived.push({ name: item.name, id: item.type, count: item.count, nbt: item.nbt });
}
if (itemsReceived.length > 0) {
this.whisper(from, `Received ${itemsReceived.length} item type(s). Sorting into storage.`);
await storage.handleTrade(from, itemsReceived);
} else {
this.whisper(from, 'No items received.');
}
}
} finally {
storage._busy = false;
}
}
},
};
+4
View File
@@ -192,4 +192,8 @@ class Craft{
}
}
Craft.getStatus = function(instance) {
return { active: true, target: 'sea_lantern' };
};
module.exports = Craft;
-209
View File
@@ -1,209 +0,0 @@
'use strict';
const conf = require('../conf');
const {sleep, nextTick} = require('../utils');
class CraftChests{
constructor(args){
this.bot = args.bot;
this.interval = args.interval;
this.target = args.target;
this.intervalStop;
this.isAction = true;
}
init(){
return new Promise(async (resolve, reject)=>{
this.bot.on('onReady', async ()=>{
try{
await sleep(500);
await this.bot.goTo({
where: this.bot.findBlockBySign('bot walk 2').position,
range: 0,
});
await this.bot.goTo({
where: this.bot.findBlockBySign('bot walk 1').position,
range: 0,
});
await this.bot.goTo({
where: this.bot.findBlockBySign('bot walk 2').position,
range: 0,
});
let hasItems = await this.getItems();
// while(hasItems){
// await this.craft();
// hasItems = await this.getItems();
// }
return resolve();
}catch(error){
reject(error);
}
});
});
}
unload(){
if(this.intervalStop){
clearInterval(this.intervalStop);
this.intervalStop = undefined;
}
return true;
}
async getItems(){
/*clear inventory*/
await this.bot.goTo({
where: this.bot.findChestBySign('bot dump'),
range: 2,
})
await this.bot.dumpToChest(this.bot.findChestBySign('bot dump'));
/*
Bamboo
*/
let packed_bambooChest = this.bot.findChestBySign('packed bamboo');
await this.bot.goTo({
where: packed_bambooChest.position,
range: 2,
});
await this.bot.getFullShulkersFromChest(packed_bambooChest, 'bamboo');
return;
let hasShard = await this.bot.checkItemsFromContainer(
prismarine_shardChest, 'prismarine_shard', 64*4
);
/*
crystals
*/
let prismarine_crystalsChest = this.bot.findChestBySign('crystals');
await this.bot.goTo({
where: prismarine_crystalsChest.position,
range: 2,
});
let hasCrystals = await this.bot.checkItemsFromContainer(
prismarine_crystalsChest, 'prismarine_crystals', 64*5
);
if(!hasShard || !hasCrystals) return false;
/*
get
*/
await sleep(3000);
await this.bot.getItemsFromChest(
prismarine_shardChest, 'prismarine_shard', 64*4
);
await sleep(1000);
await this.bot.getItemsFromChest(
prismarine_crystalsChest, 'prismarine_crystals', 64*5
);
return true;
}
async craft(){
// Ensure the bot has enough items (4 shards and 5 crystals for 1 lantern)
let prismarineShardsCount = this.bot.bot.inventory.count(this.bot.mcData.itemsByName.prismarine_shard.id);
let prismarineCrystalsCount = this.bot.bot.inventory.count(this.bot.mcData.itemsByName.prismarine_crystals.id);
if(prismarineShardsCount < 4 || prismarineCrystalsCount < 5){
console.log("Not enough materials to craft 64 Sea Lanterns.");
return;
}else{
console.log('good to make sea_lantern!');
}
// Hold onto the closest crafting table
let craftingTable = this.bot.bot.findBlock({
matching: this.bot.mcData.blocksByName.crafting_table.id,
maxDistance: 64
});
await this.bot.goTo({
where: craftingTable.position,
range: 1,
});
// Hold onto the recipe
let recipe = this.bot.bot.recipesAll(
this.bot.mcData.itemsByName.sea_lantern.id,
null,
craftingTable
)[0];
let window = await this.bot.openCraftingTable(craftingTable);
// Move these into openCrating function
let windowOnce = (event)=> new Promise((resolve, reject)=> window.once(event, resolve));
let inventory = window.slots.slice(window.inventoryStart, window.inventoryEnd);
// Move the items into the crafting grid
// Keep track of used inventory slots to avoid reusing the same slot
let usedInventorySlots = new Set();
let slotCount = 1;
for(let shapeRow of recipe.inShape){
for(let shape of shapeRow){
let inventorySlot = inventory.findIndex((element, index) =>
element && element.type === shape.id && !usedInventorySlots.has(index)
);
if (inventorySlot === -1) {
throw new Error(`Not enough items of type ${shape.id} in inventory`);
}
let actualSlot = window.inventoryStart + inventorySlot;
usedInventorySlots.add(inventorySlot);
this.bot.bot.moveSlotItem(actualSlot, slotCount);
await windowOnce(`updateSlot:${slotCount}`);
slotCount++;
}
}
// Wait for the server to catch up.
await sleep(500);
// Craft each item until all are gone.
let craftedCount = 0;
while(window.slots[0]){
await this.bot.bot.moveSlotItem(
window.craftingResultSlot,
38 // dont hard code this!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
);
craftedCount++;
await windowOnce(`updateSlot:0`);
await sleep(50); // wait for the client to catchup
}
await window.close();
/*
Dump items to chest
*/
let seaLanternChest = this.bot.findChestBySign('sea_lantern');
await this.bot.goTo({
where: seaLanternChest.position,
range: 4,
});
await this.bot.dumpToChest(seaLanternChest, 'sea_lantern')
}
}
module.exports = CraftChests;
+4
View File
@@ -162,4 +162,8 @@ class GoldFarm{
}
}
GoldFarm.getStatus = function(instance) {
return { active: true };
};
module.exports = GoldFarm;
+4
View File
@@ -92,4 +92,8 @@ class GuardianFarm extends Plugin{
}
}
GuardianFarm.getStatus = function(instance) {
return { active: true, subPlugins: Object.keys(instance.plunginsLoaded || {}) };
};
module.exports = GuardianFarm;
+439
View File
@@ -0,0 +1,439 @@
'use strict';
const express = require('express');
const { CJbot } = require('../model/minecraft');
const database = require('./storage/database');
function createRouter() {
const router = express.Router();
function dbAvailable() {
return database && database.db;
}
router.get('/api/invite/sites', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const sites = await database.getInviteSites();
// Add bot online status
const result = sites.map(s => ({
...s,
players: s.players ? s.players.split(',') : [],
bot_online: !!(CJbot.bots[s.bot_name] && CJbot.bots[s.bot_name].isReady),
}));
res.json({ sites: result });
} catch (error) {
console.error('API Error /api/invite/sites:', error);
res.status(500).json({ error: error.message });
}
});
router.get('/api/invite/bots', (req, res) => {
try {
const bots = Object.keys(CJbot.bots);
res.json({ bots });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.get('/api/invite/online-players', (req, res) => {
try {
const playerSet = new Set();
for (const bot of Object.values(CJbot.bots)) {
if (bot.isReady && bot.bot && bot.bot.players) {
for (const name of Object.keys(bot.bot.players)) {
playerSet.add(name);
}
}
}
res.json({ players: [...playerSet].sort() });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
router.post('/api/invite/sites', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const { name, label, bot_name, description } = req.body;
if (!name || !label || !bot_name) {
return res.status(400).json({ error: 'Missing name, label, or bot_name' });
}
await database.addInviteSite(name, label, bot_name, description);
res.json({ status: 'created' });
} catch (error) {
if (error.message && error.message.includes('UNIQUE')) {
return res.status(409).json({ error: 'Site name already exists' });
}
console.error('API Error POST /api/invite/sites:', error);
res.status(500).json({ error: error.message });
}
});
router.put('/api/invite/sites/:id', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const id = parseInt(req.params.id);
await database.updateInviteSite(id, req.body);
res.json({ status: 'updated' });
} catch (error) {
console.error('API Error PUT /api/invite/sites/:id:', error);
res.status(500).json({ error: error.message });
}
});
router.delete('/api/invite/sites/:id', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const id = parseInt(req.params.id);
await database.deleteInviteSite(id);
res.json({ status: 'deleted' });
} catch (error) {
console.error('API Error DELETE /api/invite/sites/:id:', error);
res.status(500).json({ error: error.message });
}
});
router.post('/api/invite/sites/:id/players', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const siteId = parseInt(req.params.id);
const { player_name } = req.body;
if (!player_name) return res.status(400).json({ error: 'Missing player_name' });
await database.addInvitePermission(siteId, player_name);
res.json({ status: 'added' });
} catch (error) {
console.error('API Error POST /api/invite/sites/:id/players:', error);
res.status(500).json({ error: error.message });
}
});
router.delete('/api/invite/sites/:id/players/:player', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const siteId = parseInt(req.params.id);
const playerName = req.params.player;
await database.removeInvitePermission(siteId, playerName);
res.json({ status: 'removed' });
} catch (error) {
console.error('API Error DELETE /api/invite/sites/:id/players/:player:', error);
res.status(500).json({ error: error.message });
}
});
router.post('/api/invite/trigger', async (req, res) => {
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
try {
const { siteName, playerName } = req.body;
if (!siteName || !playerName) {
return res.status(400).json({ error: 'Missing siteName or playerName' });
}
const site = await database.getInviteSiteByName(siteName);
if (!site) return res.status(404).json({ error: 'Site not found' });
const allowed = await database.isPlayerAllowedAtSite(siteName, playerName);
if (!allowed) return res.status(403).json({ error: `${playerName} is not allowed at ${siteName}` });
const Invite = require('./invite');
Invite.executeInvite(site.bot_name, playerName)
.catch(err => console.error('Web invite trigger error:', err));
res.json({ status: 'triggered', message: `Invite sent for ${playerName} via ${site.bot_name}` });
} catch (error) {
console.error('API Error POST /api/invite/trigger:', error);
res.status(500).json({ error: error.message });
}
});
return router;
}
const webUI = {
tabId: 'invites',
tabLabel: 'Invites',
tabOrder: 30,
html: `
<div id="inviteArea">
<div style="padding:20px;color:#6b7280;text-align:center">Loading invite sites...</div>
</div>
`,
css: `
.invite-toolbar{display:flex;gap:8px;margin-bottom:16px;align-items:center;flex-wrap:wrap}
.invite-toolbar button{background:#2563eb;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:.85em}
.invite-toolbar button:hover{background:#1d4ed8}
.invite-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:16px}
.invite-card{background:#1f2937;border:1px solid #374151;border-radius:8px;padding:16px;transition:border-color .2s}
.invite-card:hover{border-color:#60a5fa}
.invite-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.invite-card-header h3{font-size:1em;color:#60a5fa;display:flex;align-items:center;gap:8px}
.invite-card-header .actions{display:flex;gap:4px}
.invite-card-header .actions button{background:none;border:1px solid #374151;color:#9ca3af;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:.75em}
.invite-card-header .actions button:hover{border-color:#60a5fa;color:#60a5fa}
.invite-card-header .actions button.del:hover{border-color:#ef4444;color:#ef4444}
.invite-meta{font-size:.8em;color:#9ca3af;margin-bottom:10px}
.invite-meta span{margin-right:12px}
.invite-players{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:10px;min-height:28px}
.player-chip{background:#1e3a5f;color:#60a5fa;padding:3px 8px;border-radius:12px;font-size:.8em;display:flex;align-items:center;gap:4px}
.player-chip .remove{background:none;border:none;color:#ef4444;cursor:pointer;font-size:.9em;padding:0 2px;line-height:1}
.player-chip .remove:hover{color:#f87171}
.invite-add-player{display:flex;gap:4px;margin-bottom:10px}
.invite-add-player input{flex:1;padding:6px 8px;border:1px solid #374151;border-radius:4px;background:#111827;color:#e5e7eb;font-size:.8em}
.invite-add-player button{background:#059669;color:#fff;border:none;padding:6px 10px;border-radius:4px;cursor:pointer;font-size:.8em}
.invite-add-player button:hover{background:#047857}
.invite-trigger{display:flex;gap:4px;align-items:center}
.invite-trigger input{flex:1;padding:6px 8px;border:1px solid #374151;border-radius:4px;background:#111827;color:#e5e7eb;font-size:.8em}
.invite-trigger button{background:#7c3aed;color:#fff;border:none;padding:6px 10px;border-radius:4px;cursor:pointer;font-size:.8em}
.invite-trigger button:hover{background:#6d28d9}
.invite-trigger-status{font-size:.8em;min-height:16px;margin-top:4px}
.invite-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:100;display:flex;align-items:center;justify-content:center}
.invite-modal{background:#1f2937;border:1px solid #374151;border-radius:8px;padding:24px;width:400px;max-width:90vw}
.invite-modal h3{color:#60a5fa;margin-bottom:16px}
.invite-modal label{display:block;font-size:.85em;color:#9ca3af;margin-bottom:4px;margin-top:12px}
.invite-modal input,.invite-modal select,.invite-modal textarea{width:100%;padding:8px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.85em;box-sizing:border-box}
.invite-modal textarea{resize:vertical;min-height:60px}
.invite-modal .modal-actions{display:flex;gap:8px;margin-top:20px;justify-content:flex-end}
.invite-modal .modal-actions button{padding:8px 16px;border-radius:6px;cursor:pointer;font-size:.85em;border:none}
.invite-modal .modal-actions .btn-save{background:#2563eb;color:#fff}
.invite-modal .modal-actions .btn-save:hover{background:#1d4ed8}
.invite-modal .modal-actions .btn-cancel{background:#374151;color:#e5e7eb}
.invite-modal .modal-actions .btn-cancel:hover{background:#4b5563}
.invite-empty{padding:40px 20px;color:#6b7280;text-align:center;font-size:.9em}
`,
onTabActive: 'onInviteTabActive',
js: `
let inviteSites=[], inviteBots=[], inviteLoaded=false, inviteOnlinePlayers=[];
function onInviteTabActive() {
if (!inviteLoaded) loadInviteSites();
}
async function loadInviteSites() {
try {
const [sitesRes, botsRes, playersRes] = await Promise.all([
fetch('/api/invite/sites'),
fetch('/api/invite/bots'),
fetch('/api/invite/online-players')
]);
if (!sitesRes.ok || !botsRes.ok) {
document.getElementById('inviteArea').innerHTML='<div class="invite-empty">Failed to load invite data</div>';
return;
}
const sitesData = await sitesRes.json();
const botsData = await botsRes.json();
inviteSites = sitesData.sites || [];
inviteBots = botsData.bots || [];
if (playersRes.ok) {
const playersData = await playersRes.json();
inviteOnlinePlayers = playersData.players || [];
}
inviteLoaded = true;
renderInviteSites();
} catch(e) {
document.getElementById('inviteArea').innerHTML='<div class="invite-empty">Failed to load invite data</div>';
}
}
function renderInviteSites() {
const area = document.getElementById('inviteArea');
let html = '<div class="invite-toolbar">' +
'<button onclick="showInviteModal()">+ Add Site</button>' +
'<button onclick="loadInviteSites()" style="background:#374151">Refresh</button>' +
'</div>';
if (inviteSites.length === 0) {
html += '<div class="invite-empty">No invite sites configured</div>';
area.innerHTML = html;
return;
}
html += '<div class="invite-grid">';
for (const site of inviteSites) {
const statusCls = site.bot_online ? 'online' : 'offline';
const playerChips = (site.players || []).map(p =>
'<span class="player-chip">' + escHtml(p) +
' <button class="remove" onclick="removeInvitePlayer(' + site.id + ',\\'' + escHtml(p) + '\\')">&times;</button>' +
'</span>'
).join('');
html += '<div class="invite-card">' +
'<div class="invite-card-header">' +
'<h3><span class="bot-status ' + statusCls + '"></span> ' + escHtml(site.label) + ' (' + escHtml(site.name) + ')</h3>' +
'<div class="actions">' +
'<button onclick="showInviteModal(' + site.id + ')">Edit</button>' +
'<button class="del" onclick="deleteInviteSite(' + site.id + ',\\'' + escHtml(site.name) + '\\')">Delete</button>' +
'</div>' +
'</div>' +
'<div class="invite-meta">' +
'<span>Bot: <strong>' + escHtml(site.bot_name) + '</strong></span>' +
(site.description ? '<span>' + escHtml(site.description) + '</span>' : '') +
'</div>' +
'<div class="invite-players">' + (playerChips || '<span style="color:#6b7280;font-size:.8em">No players</span>') + '</div>' +
'<div class="invite-add-player">' +
'<div class="ac-wrap" style="flex:1">' +
'<input type="text" placeholder="Player name" id="inv-add-' + site.id + '" autocomplete="off" onkeydown="if(event.key===\\'Enter\\')addInvitePlayer(' + site.id + ')">' +
'<div class="ac-list" id="ac-inv-add-' + site.id + '"></div>' +
'</div>' +
'<button onclick="addInvitePlayer(' + site.id + ')">Add</button>' +
'</div>' +
'<div class="invite-trigger">' +
'<div class="ac-wrap" style="flex:1">' +
'<input type="text" placeholder="Player to invite" id="inv-trig-' + site.id + '" autocomplete="off">' +
'<div class="ac-list" id="ac-inv-trig-' + site.id + '"></div>' +
'</div>' +
'<button onclick="triggerInvite(\\'' + escHtml(site.name) + '\\',' + site.id + ')">Invite</button>' +
'</div>' +
'<div class="invite-trigger-status" id="inv-status-' + site.id + '"></div>' +
'</div>';
}
html += '</div>';
area.innerHTML = html;
// Wire up autocomplete on player inputs
for (const site of inviteSites) {
setupAC('inv-add-' + site.id, 'ac-inv-add-' + site.id,
q => {
const lower = q.toLowerCase();
return inviteOnlinePlayers
.filter(p => !lower || p.toLowerCase().includes(lower))
.map(p => ({label: p, value: p}));
}
);
setupAC('inv-trig-' + site.id, 'ac-inv-trig-' + site.id,
q => {
const lower = q.toLowerCase();
return inviteOnlinePlayers
.filter(p => !lower || p.toLowerCase().includes(lower))
.map(p => ({label: p, value: p}));
}
);
}
}
function showInviteModal(editId) {
const site = editId ? inviteSites.find(s => s.id === editId) : null;
const title = site ? 'Edit Site' : 'Add Site';
const botOptions = inviteBots.map(b =>
'<option value="' + escHtml(b) + '"' + (site && site.bot_name === b ? ' selected' : '') + '>' + escHtml(b) + '</option>'
).join('');
const overlay = document.createElement('div');
overlay.className = 'invite-modal-overlay';
overlay.id = 'inviteModalOverlay';
overlay.innerHTML = '<div class="invite-modal">' +
'<h3>' + title + '</h3>' +
'<label>Short Name (key)</label>' +
'<input type="text" id="invModalName" value="' + (site ? escHtml(site.name) : '') + '">' +
'<label>Display Label</label>' +
'<input type="text" id="invModalLabel" value="' + (site ? escHtml(site.label) : '') + '">' +
'<label>Bot</label>' +
'<select id="invModalBot">' + botOptions + '</select>' +
'<label>Description</label>' +
'<textarea id="invModalDesc">' + (site ? escHtml(site.description || '') : '') + '</textarea>' +
'<div class="modal-actions">' +
'<button class="btn-cancel" onclick="closeInviteModal()">Cancel</button>' +
'<button class="btn-save" onclick="saveInviteSite(' + (editId || 'null') + ')">Save</button>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
overlay.addEventListener('click', e => { if (e.target === overlay) closeInviteModal(); });
}
function closeInviteModal() {
const el = document.getElementById('inviteModalOverlay');
if (el) el.remove();
}
async function saveInviteSite(editId) {
const name = document.getElementById('invModalName').value.trim();
const label = document.getElementById('invModalLabel').value.trim();
const bot_name = document.getElementById('invModalBot').value;
const description = document.getElementById('invModalDesc').value.trim();
if (!name || !label || !bot_name) { showToast('Fill in name, label, and bot', 'warning'); return; }
try {
let r;
if (editId) {
r = await fetch('/api/invite/sites/' + editId, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name, label, bot_name, description })
});
} else {
r = await fetch('/api/invite/sites', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name, label, bot_name, description })
});
}
const d = await r.json();
if (!r.ok) { showToast(d.error || 'Failed', 'error'); return; }
closeInviteModal();
showToast('Site saved', 'success');
loadInviteSites();
} catch(e) { showToast('Network error', 'error'); }
}
async function deleteInviteSite(id, name) {
if (!confirm('Delete site "' + name + '"? This removes all permissions too.')) return;
try {
const r = await fetch('/api/invite/sites/' + id, { method: 'DELETE' });
if (!r.ok) { const d = await r.json(); showToast(d.error || 'Failed', 'error'); return; }
showToast('Site deleted', 'success');
loadInviteSites();
} catch(e) { showToast('Network error', 'error'); }
}
async function addInvitePlayer(siteId) {
const input = document.getElementById('inv-add-' + siteId);
const name = input.value.trim();
if (!name) return;
try {
const r = await fetch('/api/invite/sites/' + siteId + '/players', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ player_name: name })
});
if (!r.ok) { const d = await r.json(); showToast(d.error || 'Failed', 'error'); return; }
input.value = '';
showToast('Player added', 'success');
loadInviteSites();
} catch(e) { showToast('Network error', 'error'); }
}
async function removeInvitePlayer(siteId, playerName) {
try {
const r = await fetch('/api/invite/sites/' + siteId + '/players/' + encodeURIComponent(playerName), { method: 'DELETE' });
if (!r.ok) { const d = await r.json(); showToast(d.error || 'Failed', 'error'); return; }
showToast('Player removed', 'success');
loadInviteSites();
} catch(e) { showToast('Network error', 'error'); }
}
async function triggerInvite(siteName, siteId) {
const input = document.getElementById('inv-trig-' + siteId);
const statusEl = document.getElementById('inv-status-' + siteId);
const playerName = input.value.trim();
if (!playerName) { statusEl.textContent = 'Enter player name'; statusEl.style.color = '#ef4444'; return; }
try {
statusEl.textContent = 'Sending invite...';
statusEl.style.color = '#9ca3af';
const r = await fetch('/api/invite/trigger', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ siteName, playerName })
});
const d = await r.json();
statusEl.textContent = r.ok ? (d.message || 'Triggered') : (d.error || 'Failed');
statusEl.style.color = r.ok ? '#10b981' : '#ef4444';
} catch(e) {
statusEl.textContent = 'Network error';
statusEl.style.color = '#ef4444';
}
}
`,
};
module.exports = { name: 'Invite', createRouter, webUI };
+48
View File
@@ -0,0 +1,48 @@
'use strict';
const { CJbot } = require('../model/minecraft');
const { sleep } = require('../utils');
const InviteWeb = require('./invite-web');
class Invite {
static createRouter = InviteWeb.createRouter;
static webUI = InviteWeb.webUI;
constructor(args) { this.bot = args.bot; }
async init() {}
async unload() {}
static async executeInvite(targetBotName, playerName) {
const bot = CJbot.bots[targetBotName];
if (!bot) throw new Error(`Bot "${targetBotName}" not found`);
let wasOffline = !bot.isReady;
if (!bot.isReady) {
try {
await bot.connect();
} catch (error) {
console.log('Invite: error connecting to bot', targetBotName);
throw error;
}
}
await bot.pluginLoad('Tp');
await bot.bot.chat(`/invite ${playerName}`);
const disconnectTimeout = setTimeout(() => {
bot.pluginUnload('Tp');
if (wasOffline) bot.quit();
}, 10000);
bot.on('message', async (message) => {
if (message.toString() === `${playerName} teleported to you.`) {
clearTimeout(disconnectTimeout);
await bot.pluginUnload('Tp');
if (wasOffline) bot.quit();
}
});
}
}
module.exports = Invite;
+196
View File
@@ -0,0 +1,196 @@
'use strict';
const express = require('express');
// In-memory ring buffer for log entries
const MAX_ENTRIES = 1000;
const logEntries = [];
let logId = 0;
// Monkey-patch console to capture output
const origLog = console.log;
const origError = console.error;
const origWarn = console.warn;
function captureLog(level, args) {
const text = args.map(a => {
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch(e) { return String(a); }
}).join(' ');
logEntries.push({
id: ++logId,
level,
text,
timestamp: Date.now(),
});
if (logEntries.length > MAX_ENTRIES) logEntries.splice(0, logEntries.length - MAX_ENTRIES);
}
console.log = function (...args) {
captureLog('log', args);
origLog.apply(console, args);
};
console.error = function (...args) {
captureLog('error', args);
origError.apply(console, args);
};
console.warn = function (...args) {
captureLog('warn', args);
origWarn.apply(console, args);
};
function createRouter() {
const router = express.Router();
router.get('/api/logs', (req, res) => {
try {
const since = parseInt(req.query.since) || 0;
const level = req.query.level; // comma-separated: "log,error,warn"
const allowedLevels = level ? new Set(level.split(',')) : null;
let filtered = since ? logEntries.filter(e => e.id > since) : logEntries.slice(-200);
if (allowedLevels) {
filtered = filtered.filter(e => allowedLevels.has(e.level));
}
res.json({ entries: filtered, lastId: logId });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
return router;
}
const webUI = {
tabId: 'logs',
tabLabel: 'Logs',
tabOrder: 25,
html: `
<div id="logArea">
<div class="log-controls">
<label class="log-filter"><input type="checkbox" checked onchange="updateLogFilter()" id="logShowLog"> Log</label>
<label class="log-filter"><input type="checkbox" checked onchange="updateLogFilter()" id="logShowError"> Error</label>
<label class="log-filter"><input type="checkbox" checked onchange="updateLogFilter()" id="logShowWarn"> Warn</label>
<input type="text" id="logSearch" placeholder="Search logs..." oninput="rerenderLogs()" style="flex:1;padding:8px 12px;border:1px solid #374151;border-radius:6px;background:#111827;color:#e5e7eb;font-size:.85em">
</div>
<div class="log-feed" id="logFeed">
<div style="padding:20px;color:#6b7280;text-align:center">Loading logs...</div>
</div>
</div>
`,
css: `
#logArea{display:flex;flex-direction:column;height:calc(100vh - 140px)}
.log-controls{display:flex;gap:10px;align-items:center;margin-bottom:8px;flex-wrap:wrap}
.log-filter{font-size:.85em;color:#9ca3af;display:flex;align-items:center;gap:4px;cursor:pointer}
.log-filter input[type=checkbox]{accent-color:#2563eb}
.log-feed{flex:1;overflow-y:auto;padding:8px;background:#0f172a;border:1px solid #374151;border-radius:8px;font-family:'Consolas','Monaco',monospace;font-size:.8em;line-height:1.5}
.log-line{padding:2px 4px;word-wrap:break-word;border-bottom:1px solid #1e293b}
.log-line .log-time{color:#4b5563;margin-right:6px;font-size:.85em}
.log-line .log-level{font-weight:700;margin-right:6px;font-size:.8em;padding:1px 5px;border-radius:3px}
.log-line .log-level.log{color:#60a5fa;background:rgba(96,165,250,.1)}
.log-line .log-level.error{color:#ef4444;background:rgba(239,68,68,.1)}
.log-line .log-level.warn{color:#f59e0b;background:rgba(245,158,11,.1)}
.log-line.level-error{background:rgba(239,68,68,.05)}
.log-line.level-warn{background:rgba(245,158,11,.05)}
`,
onTabActive: 'onLogTabActive',
js: `
let logLastId=0, logInterval=null, logAutoScroll=true, allLogEntries=[];
function onLogTabActive() {
loadLogs();
if (!logInterval) logInterval = setInterval(() => { if (currentTab === 'logs') pollLogs(); }, 2000);
}
function getLogLevels() {
const levels = [];
if (document.getElementById('logShowLog').checked) levels.push('log');
if (document.getElementById('logShowError').checked) levels.push('error');
if (document.getElementById('logShowWarn').checked) levels.push('warn');
return levels;
}
function updateLogFilter() { rerenderLogs(); }
async function loadLogs() {
try {
const r = await fetch('/api/logs');
if (!r.ok) return;
const d = await r.json();
logLastId = d.lastId || 0;
allLogEntries = d.entries || [];
rerenderLogs();
} catch(e) {}
}
async function pollLogs() {
try {
const r = await fetch('/api/logs?since=' + logLastId);
if (!r.ok) return;
const d = await r.json();
if (d.entries && d.entries.length > 0) {
logLastId = d.lastId || logLastId;
allLogEntries = allLogEntries.concat(d.entries);
if (allLogEntries.length > 2000) allLogEntries = allLogEntries.slice(-1500);
appendLogEntries(d.entries);
}
} catch(e) {}
}
function formatLogTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
}
function renderLogLine(entry) {
return '<div class="log-line level-' + entry.level + '">' +
'<span class="log-time">' + formatLogTime(entry.timestamp) + '</span>' +
'<span class="log-level ' + entry.level + '">' + entry.level.toUpperCase() + '</span>' +
escHtml(entry.text) +
'</div>';
}
function matchesLogFilter(entry) {
const levels = getLogLevels();
if (!levels.includes(entry.level)) return false;
const q = (document.getElementById('logSearch').value || '').toLowerCase();
if (q && !entry.text.toLowerCase().includes(q)) return false;
return true;
}
function rerenderLogs() {
const feed = document.getElementById('logFeed');
const filtered = allLogEntries.filter(matchesLogFilter);
if (filtered.length === 0) {
feed.innerHTML = '<div style="padding:20px;color:#6b7280;text-align:center">No log entries</div>';
return;
}
feed.innerHTML = filtered.map(renderLogLine).join('');
if (logAutoScroll) feed.scrollTop = feed.scrollHeight;
}
function appendLogEntries(entries) {
const feed = document.getElementById('logFeed');
const placeholder = feed.querySelector('div[style]');
if (placeholder && feed.children.length === 1 && placeholder.textContent.includes('No log')) {
feed.innerHTML = '';
}
const filtered = entries.filter(matchesLogFilter);
for (const entry of filtered) {
feed.insertAdjacentHTML('beforeend', renderLogLine(entry));
}
while (feed.children.length > 1500) feed.removeChild(feed.firstChild);
if (logAutoScroll) feed.scrollTop = feed.scrollHeight;
}
document.getElementById('logFeed').addEventListener('scroll', function() {
logAutoScroll = this.scrollTop + this.clientHeight >= this.scrollHeight - 30;
});
`,
};
module.exports = { name: 'Logs', createRouter, webUI };
+33 -3
View File
@@ -1,12 +1,13 @@
'use strict';
// Require log-web early to capture all console output from other modules
const LogWeb = require('./log-web');
const {sleep} = require('../utils');
const conf = require('../conf');
const {CJbot} = require('../model/minecraft');
const inventoryViewer = require('mineflayer-web-inventory');
const commands = require('./commands');
const {onJoin} = require('./player_list');
CJbot.pluginAdd(require('./swing'));
CJbot.pluginAdd(require('./craft'));
@@ -14,8 +15,8 @@ CJbot.pluginAdd(require('./tp'));
CJbot.pluginAdd(require('./ai'));
CJbot.pluginAdd(require('./guardianFarm'));
CJbot.pluginAdd(require('./goldFarm'));
CJbot.pluginAdd(require('./craft_chests'));
CJbot.pluginAdd(require('./storage'));
CJbot.pluginAdd(require('./auto-eat'));
for(let name in conf.mc.bots){
if(CJbot.bots[name]) continue;
@@ -29,6 +30,32 @@ for(let name in conf.mc.bots){
}
}
// Initialize storage database early so web read-only routes work even with bots offline
const Database = require('./storage/database');
if (!Database.db) {
Database.initialize(conf.storage.dbPath || './storage/storage.db')
.then(async () => {
console.log('Early DB initialization complete');
// Seed invite sites from config after DB is ready
if (conf.invite && conf.invite.seedSites) {
await Database.seedInviteSites(conf.invite.seedSites);
console.log('Invite sites seeded');
}
})
.catch(err => console.error('Failed to initialize storage DB:', err));
}
// Start app-level web server (always available, even before bots connect)
const webServer = require('./web-server');
const ActivityWeb = require('./activity-web');
const ChatWeb = require('./chat-web');
const InvitePlugin = require('./invite');
webServer.queuePlugin(ChatWeb);
webServer.queuePlugin(ActivityWeb);
webServer.queuePlugin(LogWeb);
webServer.queuePlugin(InvitePlugin);
webServer.start().catch(err => console.error('Failed to start web server:', err));
(async ()=>{try{
for(let name in CJbot.bots){
let bot = CJbot.bots[name];
@@ -36,6 +63,9 @@ for(let name in conf.mc.bots){
console.log('Trying to connect', name)
console.log('Status for', name, await bot.connect());
// bot.bot.setControlState('jump', true);
// await sleep(5000);
// bot.bot.setControlState('jump', false);
await sleep(30000);
}
}
+484 -100
View File
@@ -22,6 +22,9 @@ class Database {
driver: sqlite3.Database
});
// Enable foreign key enforcement (required for ON DELETE CASCADE)
await this.db.run('PRAGMA foreign_keys = ON');
await this.createTables();
await this.insertDefaultPermissions();
@@ -65,10 +68,11 @@ class Database {
shulker_type TEXT DEFAULT 'shulker_box',
category TEXT,
item_focus TEXT,
slot_count INTEGER DEFAULT 27,
slot_count INTEGER DEFAULT 0,
total_items INTEGER DEFAULT 0,
last_scan TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (chest_id) REFERENCES chests(id) ON DELETE CASCADE
FOREIGN KEY (chest_id) REFERENCES chests(id) ON DELETE CASCADE,
UNIQUE(chest_id, slot)
)
`);
@@ -83,7 +87,7 @@ class Database {
count INTEGER NOT NULL,
nbt_data TEXT,
FOREIGN KEY (shulker_id) REFERENCES shulkers(id) ON DELETE CASCADE,
UNIQUE(shulker_id, item_id),
UNIQUE(shulker_id, slot),
CHECK(slot >= 0 AND slot <= 26),
CHECK(count > 0 AND count <= 64)
)
@@ -100,16 +104,17 @@ class Database {
)
`);
// Pending withdrawals table
// Chest loose items table (non-shulker items sitting directly in chests)
await this.db.exec(`
CREATE TABLE IF NOT EXISTS pending_withdrawals (
CREATE TABLE IF NOT EXISTS chest_loose_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT NOT NULL,
item_id INTEGER NOT NULL,
chest_id INTEGER NOT NULL,
slot INTEGER NOT NULL,
item_name TEXT NOT NULL,
requested_count INTEGER NOT NULL,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'ready', 'completed', 'cancelled')),
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
item_id INTEGER NOT NULL,
count INTEGER NOT NULL,
FOREIGN KEY (chest_id) REFERENCES chests(id) ON DELETE CASCADE,
UNIQUE(chest_id, slot)
)
`);
@@ -124,6 +129,29 @@ class Database {
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Invite sites table
await this.db.exec(`
CREATE TABLE IF NOT EXISTS invite_sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
bot_name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Invite permissions table
await this.db.exec(`
CREATE TABLE IF NOT EXISTS invite_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL,
player_name TEXT NOT NULL,
FOREIGN KEY (site_id) REFERENCES invite_sites(id) ON DELETE CASCADE,
UNIQUE(site_id, player_name)
)
`);
}
async insertDefaultPermissions() {
@@ -227,45 +255,44 @@ class Database {
const result = await this.db.run(`
INSERT INTO shulkers (chest_id, slot, shulker_type, category, item_focus)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
slot_count = excluded.slot_count,
total_items = excluded.total_items,
ON CONFLICT(chest_id, slot) DO UPDATE SET
shulker_type = excluded.shulker_type,
category = COALESCE(excluded.category, shulkers.category),
item_focus = COALESCE(excluded.item_focus, shulkers.item_focus),
last_scan = CURRENT_TIMESTAMP
`, [chestId, slot, shulkerType, category, itemFocus]);
return result.lastID;
}
async getShulkersByChest(chestId) {
return await this.db.all('SELECT * FROM shulkers WHERE chest_id = ? ORDER BY slot', [chestId]);
async upsertAndGetShulker(chestId, slot, shulkerType, category = null) {
await this.db.run(`
INSERT INTO shulkers (chest_id, slot, shulker_type, category)
VALUES (?, ?, ?, ?)
ON CONFLICT(chest_id, slot) DO UPDATE SET
shulker_type = excluded.shulker_type,
category = COALESCE(excluded.category, shulkers.category),
last_scan = CURRENT_TIMESTAMP
`, [chestId, slot, shulkerType, category]);
return await this.db.get(
'SELECT * FROM shulkers WHERE chest_id = ? AND slot = ?',
[chestId, slot]
);
}
async getAllShulkers() {
return await this.db.all('SELECT * FROM shulkers ORDER BY id');
async getShulkersByChest(chestId) {
return await this.db.all('SELECT * FROM shulkers WHERE chest_id = ? ORDER BY slot', [chestId]);
}
async getShulkerById(id) {
return await this.db.get('SELECT * FROM shulkers WHERE id = ?', [id]);
}
async findShulkerForItem(itemId, categoryName) {
// Find shulker with matching item and space
return await this.db.get(`
SELECT s.*, si.count as slot_item_count
FROM shulkers s
INNER JOIN shulker_items si ON s.id = si.shulker_id
WHERE s.item_focus = (SELECT item_name FROM shulker_items WHERE item_id = ? LIMIT 1)
AND s.category = ?
AND s.slot_count < 27
LIMIT 1
`, [itemId, categoryName]);
}
async createEmptyShulker(chestId, slot, categoryName, shulkerType = 'shulker_box') {
return await this.db.run(`
INSERT INTO shulkers (chest_id, slot, shulker_type, category, item_focus, slot_count, total_items)
VALUES (?, ?, ?, ?, NULL, 0, 0)
`, [chestId, slot, shulkerType, categoryName]);
async getShulkerByChestSlot(chestId, slot) {
return await this.db.get(
'SELECT * FROM shulkers WHERE chest_id = ? AND slot = ?',
[chestId, slot]
);
}
async updateShulkerCounts(shulkerId, slotCount, totalItems) {
@@ -276,10 +303,127 @@ class Database {
`, [slotCount, totalItems, shulkerId]);
}
async updateShulkerItemFocus(shulkerId, itemFocus) {
return await this.db.run(
'UPDATE shulkers SET item_focus = ? WHERE id = ?',
[itemFocus, shulkerId]
);
}
async deleteShulker(id) {
return await this.db.run('DELETE FROM shulkers WHERE id = ?', [id]);
}
// Find a shulker that already stores this item type and has space (<27 slots used, not in-transit)
async findShulkerWithSpace(itemName, excludeId = null) {
return await this.db.get(`
SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type
FROM shulkers s
INNER JOIN chests c ON c.id = s.chest_id
WHERE s.item_focus = ? AND s.slot_count >= 0 AND s.slot_count < 27
AND (? IS NULL OR s.id != ?)
ORDER BY s.slot_count DESC
LIMIT 1
`, [itemName, excludeId, excludeId]);
}
// Find any empty shulker (no item_focus, no items, not in-transit)
async findEmptyShulker(excludeId = null) {
return await this.db.get(`
SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type
FROM shulkers s
INNER JOIN chests c ON c.id = s.chest_id
WHERE s.item_focus IS NULL AND s.total_items = 0 AND s.slot_count >= 0
AND (? IS NULL OR s.id != ?)
ORDER BY s.id ASC
LIMIT 1
`, [excludeId, excludeId]);
}
// Find shulkers containing a specific item (for withdrawal, excludes in-transit)
async findShulkersWithItem(itemName) {
return await this.db.all(`
SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type,
SUM(si.count) as available_count
FROM shulkers s
INNER JOIN chests c ON c.id = s.chest_id
INNER JOIN shulker_items si ON si.shulker_id = s.id
WHERE si.item_name = ? AND s.slot_count >= 0
GROUP BY s.id
ORDER BY available_count ASC
`, [itemName]);
}
// Find item types that have more than one non-full shulker (candidates for consolidation)
async findConsolidatableItems() {
return await this.db.all(`
SELECT s.item_focus, COUNT(*) as shulker_count,
SUM(s.slot_count) as total_slots_used, SUM(s.total_items) as total_items
FROM shulkers s
WHERE s.item_focus IS NOT NULL
AND s.slot_count >= 0
AND s.slot_count < 27
GROUP BY s.item_focus
HAVING COUNT(*) > 1
ORDER BY total_slots_used ASC
`);
}
// Get all non-full shulkers for a given item, sorted least-full first
async getShulkersByItemFocus(itemName) {
return await this.db.all(`
SELECT s.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type
FROM shulkers s
INNER JOIN chests c ON c.id = s.chest_id
WHERE s.item_focus = ? AND s.slot_count >= 0
ORDER BY s.slot_count ASC
`, [itemName]);
}
// Find a chest slot that doesn't have a shulker (for placing newly crafted ones)
async findEmptyChestSlot() {
const chests = await this.db.all(`
SELECT c.*, COUNT(s.id) as shulker_count
FROM chests c
LEFT JOIN shulkers s ON s.chest_id = c.id
GROUP BY c.id
HAVING shulker_count < CASE WHEN c.chest_type = 'double' THEN 54 ELSE 27 END
ORDER BY c.id ASC
LIMIT 1
`);
if (!chests || chests.length === 0) return null;
const chest = chests[0];
const shulkers = await this.getShulkersByChest(chest.id);
const usedSlots = new Set(shulkers.map(s => s.slot));
const maxSlots = chest.chest_type === 'double' ? 54 : 27;
for (let i = 0; i < maxSlots; i++) {
if (!usedSlots.has(i)) {
return {
chest_id: chest.id,
pos_x: chest.pos_x,
pos_y: chest.pos_y,
pos_z: chest.pos_z,
slot: i,
};
}
}
return null;
}
// Get total count of a specific item across all shulkers
async getItemTotalCount(itemName) {
const result = await this.db.get(`
SELECT SUM(si.count) as total
FROM shulker_items si
WHERE si.item_name = ?
`, [itemName]);
return result?.total || 0;
}
// ========================================
// Shulker Items
// ========================================
@@ -288,25 +432,72 @@ class Database {
return await this.db.run(`
INSERT INTO shulker_items (shulker_id, item_id, item_name, slot, count, nbt_data)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(shulker_id, item_id) DO UPDATE SET
slot = excluded.slot,
ON CONFLICT(shulker_id, slot) DO UPDATE SET
item_id = excluded.item_id,
item_name = excluded.item_name,
count = excluded.count,
nbt_data = excluded.nbt_data
`, [shulkerId, itemId, itemName, slot, count, nbt ? JSON.stringify(nbt) : null]);
}
async batchUpsertShulkerItems(shulkerId, items) {
if (!items.length) return;
await this.db.run('BEGIN TRANSACTION');
try {
const stmt = await this.db.prepare(`
INSERT INTO shulker_items (shulker_id, item_id, item_name, slot, count, nbt_data)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(shulker_id, slot) DO UPDATE SET
item_id = excluded.item_id,
item_name = excluded.item_name,
count = excluded.count,
nbt_data = excluded.nbt_data
`);
for (const item of items) {
await stmt.run(shulkerId, item.id, item.name, item.slot, item.count, item.nbt ? JSON.stringify(item.nbt) : null);
}
await stmt.finalize();
await this.db.run('COMMIT');
} catch (error) {
await this.db.run('ROLLBACK');
throw error;
}
}
async getShulkerItems(shulkerId) {
return await this.db.all('SELECT * FROM shulker_items WHERE shulker_id = ? ORDER BY slot', [shulkerId]);
}
async deleteShulkerItem(shulkerId, itemId) {
return await this.db.run('DELETE FROM shulker_items WHERE shulker_id = ? AND item_id = ?', [shulkerId, itemId]);
}
async clearShulkerItems(shulkerId) {
return await this.db.run('DELETE FROM shulker_items WHERE shulker_id = ?', [shulkerId]);
}
async getShulkerItemById(id) {
return await this.db.get('SELECT * FROM shulker_items WHERE id = ?', [id]);
}
/**
* Get all "special" items — those with NBT containing displayName, lore, or customModelData.
* Returns items with location info (chest position, shulker slot).
*/
async getSpecialItems() {
return await this.db.all(`
SELECT si.*, s.slot as shulker_slot, s.chest_id, s.item_focus,
c.pos_x, c.pos_y, c.pos_z
FROM shulker_items si
INNER JOIN shulkers s ON s.id = si.shulker_id
INNER JOIN chests c ON c.id = s.chest_id
WHERE si.nbt_data IS NOT NULL
AND si.nbt_data != 'null'
AND (
si.nbt_data LIKE '%"displayName"%'
OR si.nbt_data LIKE '%"lore"%'
OR si.nbt_data LIKE '%"customModelData"%'
)
ORDER BY si.item_name, si.id
`);
}
// ========================================
// Trades
// ========================================
@@ -332,64 +523,15 @@ class Database {
);
}
// ========================================
// Pending Withdrawals
// ========================================
async queueWithdrawal(playerName, itemId, itemName, count) {
return await this.db.run(`
INSERT INTO pending_withdrawals (player_name, item_id, item_name, requested_count)
VALUES (?, ?, ?, ?)
`, [playerName, itemId, itemName, count]);
}
async getPendingWithdrawals(playerName) {
return await this.db.all(`
SELECT * FROM pending_withdrawals
WHERE player_name = ? AND status IN ('pending', 'ready')
ORDER BY timestamp ASC
`, [playerName]);
}
async getWithdrawalById(id) {
return await this.db.get('SELECT * FROM pending_withdrawals WHERE id = ?', [id]);
}
async updateWithdrawStatus(id, status) {
return await this.db.run(
'UPDATE pending_withdrawals SET status = ? WHERE id = ?',
[status, id]
);
}
async markCompletedWithdrawals(playerName) {
return await this.db.run(`
UPDATE pending_withdrawals
SET status = 'completed'
WHERE player_name = ? AND status = 'ready'
`, [playerName]);
}
// ========================================
// Item Index
// ========================================
async updateItemIndex(itemId, itemName, shulkerId, count) {
// This is a simplified version - in production, you'd want to handle
// the shulker_ids JSON aggregation more carefully
return await this.db.run(`
INSERT INTO item_index (item_id, item_name, total_count)
VALUES (?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET
total_count = total_count + ?,
last_updated = CURRENT_TIMESTAMP
`, [itemId, itemName, count, count]);
}
async rebuildItemIndex() {
// Rebuild entire index from shulker_items
return await this.db.exec(`
INSERT OR REPLACE INTO item_index (item_id, item_name, total_count, shulker_ids, last_updated)
// Clear and rebuild from shulker_items
await this.db.run('DELETE FROM item_index');
await this.db.run(`
INSERT INTO item_index (item_id, item_name, total_count, shulker_ids, last_updated)
SELECT
si.item_id,
si.item_name,
@@ -397,18 +539,36 @@ class Database {
GROUP_CONCAT('{"id":' || si.shulker_id || ',"count":' || si.count || '}') as shulker_ids,
CURRENT_TIMESTAMP
FROM shulker_items si
GROUP BY si.item_id, si.item_name
GROUP BY si.item_name
`);
const count = await this.db.get('SELECT COUNT(*) as c FROM item_index');
console.log(`Database: Rebuilt item index with ${count?.c || 0} entries`);
}
async searchItems(query) {
// Query shulker_items plus empty shulkers as a virtual item
if (!query) {
return await this.db.all('SELECT * FROM item_index ORDER BY item_name ASC');
return await this.db.all(`
SELECT item_name, SUM(count) as total_count FROM (
SELECT item_name, count FROM shulker_items
UNION ALL
SELECT shulker_type AS item_name, 1 AS count
FROM shulkers WHERE total_items = 0 AND item_focus IS NULL
)
GROUP BY item_name
ORDER BY total_count DESC
`);
}
return await this.db.all(
"SELECT * FROM item_index WHERE item_name LIKE ? ORDER BY item_name ASC",
[`%${query}%`]
);
return await this.db.all(`
SELECT item_name, SUM(count) as total_count FROM (
SELECT item_name, count FROM shulker_items WHERE item_name LIKE ?
UNION ALL
SELECT shulker_type AS item_name, 1 AS count
FROM shulkers WHERE total_items = 0 AND item_focus IS NULL AND shulker_type LIKE ?
)
GROUP BY item_name
ORDER BY total_count DESC
`, [`%${query}%`, `%${query}%`]);
}
async getItemDetails(itemId) {
@@ -432,6 +592,125 @@ class Database {
return { ...item, locations };
}
// ========================================
// Map / Aggregation
// ========================================
// Get all chests with a summary of their shulker contents (for map view)
async getChestsWithSummary() {
return await this.db.all(`
SELECT
c.id, c.pos_x, c.pos_y, c.pos_z, c.chest_type, c.row, c.column, c.category,
COUNT(s.id) as shulker_count,
COALESCE(SUM(s.total_items), 0) as total_items,
GROUP_CONCAT(DISTINCT s.item_focus) as item_focuses,
COALESCE((SELECT COUNT(*) FROM chest_loose_items cli WHERE cli.chest_id = c.id), 0) as loose_item_count
FROM chests c
LEFT JOIN shulkers s ON s.chest_id = c.id
GROUP BY c.id
ORDER BY c.pos_x, c.pos_z, c.pos_y
`);
}
// Get detailed shulker info with all items
async getShulkerWithItems(shulkerId) {
const shulker = await this.db.get(`
SELECT s.*, c.pos_x, c.pos_y, c.pos_z
FROM shulkers s
INNER JOIN chests c ON c.id = s.chest_id
WHERE s.id = ?
`, [shulkerId]);
if (!shulker) return null;
const items = await this.getShulkerItems(shulkerId);
return { ...shulker, items };
}
// Get all shulkers for a chest with their items
async getChestContents(chestId) {
const chest = await this.getChestById(chestId);
if (!chest) return null;
const shulkers = await this.db.all(`
SELECT s.*,
GROUP_CONCAT(si.item_name || ':' || si.count) as item_summary
FROM shulkers s
LEFT JOIN shulker_items si ON si.shulker_id = s.id
WHERE s.chest_id = ?
GROUP BY s.id
ORDER BY s.slot
`, [chestId]);
return { chest, shulkers };
}
// ========================================
// Chest Loose Items
// ========================================
async clearLooseItems(chestId) {
return await this.db.run('DELETE FROM chest_loose_items WHERE chest_id = ?', [chestId]);
}
async upsertLooseItem(chestId, slot, itemName, itemId, count) {
return await this.db.run(`
INSERT INTO chest_loose_items (chest_id, slot, item_name, item_id, count)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(chest_id, slot) DO UPDATE SET
item_name = excluded.item_name,
item_id = excluded.item_id,
count = excluded.count
`, [chestId, slot, itemName, itemId, count]);
}
async batchUpsertLooseItems(chestId, items) {
if (!items.length) return;
await this.db.run('BEGIN TRANSACTION');
try {
const stmt = await this.db.prepare(`
INSERT INTO chest_loose_items (chest_id, slot, item_name, item_id, count)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(chest_id, slot) DO UPDATE SET
item_name = excluded.item_name,
item_id = excluded.item_id,
count = excluded.count
`);
for (const item of items) {
await stmt.run(chestId, item.slot, item.name, item.id, item.count);
}
await stmt.finalize();
await this.db.run('COMMIT');
} catch (error) {
await this.db.run('ROLLBACK');
throw error;
}
}
async getChestsWithLooseItems() {
return await this.db.all(`
SELECT DISTINCT c.*
FROM chests c
INNER JOIN chest_loose_items cli ON cli.chest_id = c.id
ORDER BY c.id
`);
}
async getAllLooseItems() {
return await this.db.all(`
SELECT cli.*, c.pos_x, c.pos_y, c.pos_z, c.chest_type
FROM chest_loose_items cli
INNER JOIN chests c ON c.id = cli.chest_id
ORDER BY c.id, cli.slot
`);
}
async deleteLooseItem(chestId, slot) {
return await this.db.run(
'DELETE FROM chest_loose_items WHERE chest_id = ? AND slot = ?',
[chestId, slot]
);
}
// ========================================
// Stats
// ========================================
@@ -442,6 +721,11 @@ class Database {
const totalChests = await this.db.get('SELECT COUNT(*) as total FROM chests');
const emptyShulkers = await this.db.get("SELECT COUNT(*) as total FROM shulkers WHERE slot_count = 0");
const recentTrades = await this.db.get('SELECT COUNT(*) as total FROM trades WHERE timestamp > datetime("now", "-1 day")');
const looseItems = await this.db.get('SELECT COALESCE(SUM(count), 0) as total FROM chest_loose_items');
const chestCapacity = await this.db.get(`
SELECT COALESCE(SUM(CASE WHEN chest_type = 'double' THEN 54 ELSE 27 END), 0) as total_slots
FROM chests
`);
// Category breakdown
const categories = await this.db.all(`
@@ -462,10 +746,110 @@ class Database {
totalChests: totalChests?.total || 0,
emptyShulkers: emptyShulkers?.total || 0,
recentTrades: recentTrades?.total || 0,
looseItemCount: looseItems?.total || 0,
totalChestSlots: chestCapacity?.total_slots || 0,
categories: categoryMap
};
}
// ========================================
// Invite Sites
// ========================================
async getInviteSites() {
return await this.db.all(`
SELECT s.*,
GROUP_CONCAT(p.player_name) as players
FROM invite_sites s
LEFT JOIN invite_permissions p ON p.site_id = s.id
GROUP BY s.id
ORDER BY s.name
`);
}
async getInviteSiteByName(name) {
return await this.db.get('SELECT * FROM invite_sites WHERE name = ?', [name]);
}
async getInviteSitePlayers(siteId) {
const rows = await this.db.all(
'SELECT player_name FROM invite_permissions WHERE site_id = ? ORDER BY player_name',
[siteId]
);
return rows.map(r => r.player_name);
}
async isPlayerAllowedAtSite(siteName, playerName) {
const row = await this.db.get(`
SELECT 1 FROM invite_sites s
INNER JOIN invite_permissions p ON p.site_id = s.id
WHERE s.name = ? AND p.player_name = ?
`, [siteName, playerName]);
return !!row;
}
async addInviteSite(name, label, botName, description) {
return await this.db.run(
'INSERT INTO invite_sites (name, label, bot_name, description) VALUES (?, ?, ?, ?)',
[name, label, botName, description || null]
);
}
async updateInviteSite(id, fields) {
const allowed = ['name', 'label', 'bot_name', 'description'];
const sets = [];
const values = [];
for (const key of allowed) {
if (fields[key] !== undefined) {
sets.push(`${key} = ?`);
values.push(fields[key]);
}
}
if (sets.length === 0) return;
values.push(id);
return await this.db.run(
`UPDATE invite_sites SET ${sets.join(', ')} WHERE id = ?`,
values
);
}
async deleteInviteSite(id) {
return await this.db.run('DELETE FROM invite_sites WHERE id = ?', [id]);
}
async addInvitePermission(siteId, playerName) {
return await this.db.run(
'INSERT OR IGNORE INTO invite_permissions (site_id, player_name) VALUES (?, ?)',
[siteId, playerName]
);
}
async removeInvitePermission(siteId, playerName) {
return await this.db.run(
'DELETE FROM invite_permissions WHERE site_id = ? AND player_name = ?',
[siteId, playerName]
);
}
async seedInviteSites(sites) {
for (const site of sites) {
try {
await this.db.run(
'INSERT OR IGNORE INTO invite_sites (name, label, bot_name, description) VALUES (?, ?, ?, ?)',
[site.name, site.label, site.bot, site.description || null]
);
const row = await this.getInviteSiteByName(site.name);
if (row && site.allowed) {
for (const player of site.allowed) {
await this.addInvitePermission(row.id, player);
}
}
} catch (error) {
console.error('Error seeding invite site:', site.name, error);
}
}
}
async close() {
if (this.db) {
await this.db.close();
File diff suppressed because it is too large Load Diff
-103
View File
@@ -1,103 +0,0 @@
'use strict';
const Vec3 = require('vec3');
const conf = require('../../conf');
class Organizer {
constructor() {
this.categories = conf.storage?.categories || {
minerals: ['diamond', 'netherite_ingot', 'gold_ingot', 'iron_ingot'],
food: ['bread', 'cooked_porkchop', 'steak'],
tools: ['diamond_sword', 'diamond_pickaxe', 'netherite_pickaxe'],
armor: ['diamond_chestplate', 'netherite_helmet'],
blocks: ['stone', 'dirt', 'cobblestone'],
redstone: ['redstone', 'repeater', 'piston'],
misc: []
};
}
categorizeItem(itemName) {
// Fast path: check each category
for (const [category, items] of Object.entries(this.categories)) {
if (items.includes(itemName)) {
return category;
}
}
return 'misc';
}
async findShulkerForItem(database, itemId, categoryName) {
// Find shulker with matching item that has space
const shulker = await database.findShulkerForItem(itemId, categoryName);
return shulker;
}
async findEmptyShulkerSlot(database, categoryName) {
// Find an empty shulker in the appropriate category and row (prefer row 4 for empty storage)
const chests = await database.getChests();
// Filter chests by category and row 4 (top row for empty/new shulkers)
const categoryChests = chests.filter(c =>
c.category === categoryName && c.row === 4
).sort((a, b) => a.column - b.column); // Left to right
for (const chest of categoryChests) {
const shulkers = await database.getShulkersByChest(chest.id);
// Find first shulker that's empty (slotCount = 0) or has space
for (const shulker of shulkers) {
if (!shulker.item_focus) {
// Empty shulker available
return {
chest_id: chest.id,
chestPosition: new Vec3(chest.pos_x, chest.pos_y, chest.pos_z),
chestSlot: shulker.slot,
shulker_id: shulker.id
};
}
}
}
// If no empty shulker, look for first available slot in row 4
// ... this would need to scan actual chest for empty slots
return null;
}
async sortItemIntoStorage(bot, database, item, categoryName) {
// Find existing shulker with same item and space
const existingShulker = await this.findShulkerForItem(database, item.id, categoryName);
if (existingShulker) {
// Space available, add to existing shulker
console.log(`Organizer: Found shulker ${existingShulker.id} for ${item.name}`);
return existingShulker;
} else {
// Need new shulker
console.log(`Organizer: Creating new shulker for ${item.name} (${categoryName})`);
const shulkerSlot = await this.findEmptyShulkerSlot(database, categoryName);
if (!shulkerSlot) {
console.log(`Organizer: No available shulker slot for ${item.name}`);
return null;
}
// Create/prepare new shulker
await database.upsertShulker(
shulkerSlot.chest_id,
shulkerSlot.chestSlot,
'shulker_box',
categoryName,
item.name // item_focus
);
console.log(`Organizer: Created shulker at chest ${shulkerSlot.chest_id}, slot ${shulkerSlot.chestSlot}`);
return {
chest_id: shulkerSlot.chest_id,
slot: shulkerSlot.chestSlot,
new: true
};
}
}
}
module.exports = Organizer;
+192 -52
View File
@@ -1,6 +1,7 @@
'use strict';
const Vec3 = require('vec3');
const { sleep } = require('../../utils');
class Scanner {
constructor() {
@@ -15,11 +16,12 @@ class Scanner {
}
}
this._scanRadius = radius;
console.log(`Scanner: Discovering chests within ${radius} blocks...`);
const chestPositions = bot.bot.findBlocks({
matching: this.chestBlockType,
maxDistance: radius,
count: 1000, // Find up to 1000 chests
count: Infinity,
});
console.log(`Scanner: Found ${chestPositions.length} chest block(s)`);
@@ -58,13 +60,29 @@ class Scanner {
});
}
// Don't delete orphans for now - just add new ones
// await database.deleteOrphanChests(discoveredChests);
// Remove DB records for chest positions no longer discovered
// (e.g., the old canonical half of a double chest that switched sides)
if (discoveredChests.length > 0) {
await database.deleteOrphanChests(discoveredChests);
}
console.log(`Scanner: Discovered/updated ${discoveredChests.length} chest(s)`);
return discoveredChests;
}
detectChestType(bot, position) {
const block = bot.bot.blockAt(position);
if (!block) return { type: 'single' };
// Use block state properties (Minecraft 1.13+ has type: single/left/right)
const props = typeof block.getProperties === 'function' ? block.getProperties() : null;
if (props && props.type) {
if (props.type === 'single') return { type: 'single' };
// Register the 'left' half as canonical, skip 'right'
if (props.type === 'left') return { type: 'double' };
return { type: 'skip' }; // 'right' half
}
// Fallback: adjacency check for older versions
const directions = [
new Vec3(1, 0, 0),
new Vec3(-1, 0, 0),
@@ -73,10 +91,10 @@ class Scanner {
];
for (const dir of directions) {
const adjacentPos = position.offset(dir);
const adjacentPos = position.offset(dir.x, dir.y, dir.z);
const adjacentBlock = bot.bot.blockAt(adjacentPos);
if (adjacentBlock && adjacentBlock.name.includes('chest')) {
if (adjacentBlock && adjacentBlock.name === 'chest') {
if (dir.x === -1 || dir.z === -1) {
return { type: 'double' };
}
@@ -107,20 +125,26 @@ class Scanner {
console.log(`Scanner: Scanning chest at ${chestPosition.x},${chestPosition.y},${chestPosition.z}`);
try {
// Ensure bot is close enough to interact
const distance = bot.bot.entity.position.distanceTo(chestPosition);
if (distance > 4) {
await bot.goTo({ where: chestPosition, range: 3 });
}
const chestBlock = bot.bot.blockAt(chestPosition);
if (!chestBlock || !chestBlock.name.includes('chest')) {
console.log(`Scanner: Not a chest at ${chestPosition.x},${chestPosition.y},${chestPosition.z}`);
return [];
return 0;
}
// Get chest from database
const chest = await database.getChestByPosition(chestPosition.x, chestPosition.y, chestPosition.z);
if (!chest) {
console.log(`Scanner: Chest not in database`);
return [];
return 0;
}
const window = await bot.bot.openChest(chestBlock);
const window = await bot.openContainer(chestBlock);
const slots = window.slots;
let shulkerCount = 0;
@@ -128,17 +152,37 @@ class Scanner {
const chestSlotCount = window.inventoryStart || 27;
console.log(`Scanner: Chest has ${chestSlotCount} slots`);
// Correct DB chest_type if it doesn't match the actual window size
const actualType = chestSlotCount > 27 ? 'double' : 'single';
if (chest.chest_type !== actualType) {
console.log(`Scanner: Correcting chest type: DB says '${chest.chest_type}', actual is '${actualType}'`);
await database.upsertChest(
chestPosition.x, chestPosition.y, chestPosition.z,
actualType, chest.row, chest.column, chest.category
);
}
// Clear previous loose item records before re-scanning
await database.clearLooseItems(chest.id);
const looseItems = [];
for (let i = 0; i < chestSlotCount; i++) {
const slot = slots[i];
if (!slot) continue;
if (slot.name.includes('shulker_box')) {
console.log(`Scanner: Found shulker at slot ${i}: ${slot.name}`);
await this.scanShulkerFromNBT(database, chest.id, i, slot);
await this.scanShulkerFromNBT(bot, database, chest.id, i, slot);
shulkerCount++;
} else {
looseItems.push({ slot: i, name: slot.name, id: slot.type, count: slot.count });
}
}
if (looseItems.length > 0) {
await database.batchUpsertLooseItems(chest.id, looseItems);
}
await bot.bot.closeWindow(window);
console.log(`Scanner: Found ${shulkerCount} shulkers in chest`);
return shulkerCount;
@@ -157,39 +201,74 @@ class Scanner {
let scannedCount = 0;
let skippedCount = 0;
for (const chest of chests) {
const position = new Vec3(chest.pos_x, chest.pos_y, chest.pos_z);
// Track scanned positions so we don't re-scan or re-queue
const scannedPositions = new Set();
// Check distance to chest
// Visit chests in nearest-neighbor order to minimize travel
const remaining = chests.map(c => ({ ...c, pos: new Vec3(c.pos_x, c.pos_y, c.pos_z) }));
for (const c of remaining) {
scannedPositions.add(`${c.pos.x},${c.pos.y},${c.pos.z}`);
}
while (remaining.length > 0) {
const botPos = bot.bot.entity.position;
const distance = botPos.distanceTo(position);
if (distance > 4.5) {
// Try to walk to the chest
console.log(`Scanner: Walking to chest at ${position} (distance: ${distance.toFixed(1)})`);
// Find the closest unscanned chest
let closestIdx = 0;
let closestDist = botPos.distanceTo(remaining[0].pos);
for (let i = 1; i < remaining.length; i++) {
const dist = botPos.distanceTo(remaining[i].pos);
if (dist < closestDist) {
closestDist = dist;
closestIdx = i;
}
}
const chest = remaining.splice(closestIdx, 1)[0];
if (closestDist > 4.5) {
console.log(`Scanner: Walking to chest at ${chest.pos} (distance: ${closestDist.toFixed(1)})`);
try {
await bot.goTo({
where: position,
const reached = await bot.goTo({
where: chest.pos,
range: 3,
});
if (reached === false) {
console.log(`Scanner: Could not reach chest at ${chest.pos}: no path`);
skippedCount++;
continue;
}
} catch (error) {
console.log(`Scanner: Could not reach chest at ${position}: ${error.message}`);
console.log(`Scanner: Could not reach chest at ${chest.pos}: ${error.message}`);
skippedCount++;
continue;
}
}
const shulkerCount = await this.scanChest(bot, database, position);
// Wait for anti-ESP to reveal nearby blocks after arriving
await sleep(250);
// Discover any new chests now visible from this position (every 5th stop or first)
if (scannedCount % 5 === 0) {
const newChests = await this.discoverChests(bot, this._scanRadius || 30, database);
for (const nc of newChests) {
const key = `${nc.x},${nc.y},${nc.z}`;
if (!scannedPositions.has(key)) {
scannedPositions.add(key);
remaining.push({ ...nc, pos: new Vec3(nc.x, nc.y, nc.z) });
console.log(`Scanner: Discovered new chest at ${nc.x},${nc.y},${nc.z} while walking`);
}
}
}
const shulkerCount = await this.scanChest(bot, database, chest.pos);
totalShulkers += shulkerCount;
scannedCount++;
// Progress update every 10 chests
if (scannedCount % 10 === 0) {
console.log(`Scanner: Progress - ${scannedCount}/${chests.length} chests scanned, ${totalShulkers} shulkers found`);
console.log(`Scanner: Progress - ${scannedCount}/${scannedCount + remaining.length} chests scanned, ${totalShulkers} shulkers found`);
}
// Small delay between chests to avoid overwhelming the server
await new Promise(resolve => setTimeout(resolve, 250));
}
await database.rebuildItemIndex();
@@ -198,51 +277,53 @@ class Scanner {
}
// Read shulker contents from NBT data (no physical interaction needed)
async scanShulkerFromNBT(database, chestId, chestSlot, shulkerItem) {
async scanShulkerFromNBT(bot, database, chestId, chestSlot, shulkerItem) {
console.log(`Scanner: Reading shulker NBT at slot ${chestSlot}`);
try {
// Create/update shulker record
const shulkerId = await database.upsertShulker(
// Create/update shulker record and get its ID in one call
const shulkerRecord = await database.upsertAndGetShulker(
chestId,
chestSlot,
shulkerItem.name,
null // category will be set based on contents
);
if (!shulkerRecord) {
console.error(`Scanner: No shulker record found for chest ${chestId} slot ${chestSlot}`);
return [];
}
const shulkerId = shulkerRecord.id;
await database.clearShulkerItems(shulkerId);
// Extract items from shulker NBT
const items = this.extractShulkerContents(shulkerItem);
const items = this.extractShulkerContents(bot, shulkerItem);
let totalItems = 0;
const itemTypes = new Set();
for (const item of items) {
await database.upsertShulkerItem(
shulkerId,
item.id,
item.name,
item.slot,
item.count,
item.nbt
);
await database.batchUpsertShulkerItems(shulkerId, items);
for (const item of items) {
totalItems += item.count;
itemTypes.add(item.name);
}
// Update shulker stats
const itemFocus = itemTypes.size === 1 ? Array.from(itemTypes)[0] : null;
let itemFocus = itemTypes.size === 1 ? Array.from(itemTypes)[0] : null;
const usedSlots = items.length;
// If any item in the shulker is special, append #special to the focus
if (itemFocus) {
const hasSpecial = items.some(item => Scanner.isSpecialItem(item.nbt));
if (hasSpecial) {
itemFocus = itemFocus + '#special';
}
}
await database.updateShulkerCounts(shulkerId, usedSlots, totalItems);
if (itemFocus && database.db) {
await database.db.run(
'UPDATE shulkers SET item_focus = ? WHERE id = ?',
[itemFocus, shulkerId]
);
}
await database.updateShulkerItemFocus(shulkerId, itemFocus);
console.log(`Scanner: Shulker has ${items.length} slot(s) used, ${totalItems} total items`);
return items;
@@ -254,7 +335,7 @@ class Scanner {
}
// Extract items from shulker box NBT data
extractShulkerContents(shulkerItem) {
extractShulkerContents(bot, shulkerItem) {
const items = [];
if (!shulkerItem.nbt) {
@@ -304,12 +385,16 @@ class Scanner {
// Clean up the id (remove minecraft: prefix)
const cleanId = String(id).replace('minecraft:', '');
if (count <= 0 || cleanId === 'air') continue;
// tag may be a prismarine-nbt compound or a plain object
const tag = nbtItem.tag?.value ?? nbtItem.tag ?? null;
items.push({
slot: slot,
name: cleanId,
id: typeof nbtItem.id === 'object' ? 0 : nbtItem.id,
id: bot.mcData.itemsByName[cleanId]?.id || 0,
count: count,
nbt: nbtItem.tag ? this.parseNBT(nbtItem.tag) : null
nbt: tag ? this.parseNBT(tag) : null
});
}
} catch (error) {
@@ -320,6 +405,27 @@ class Scanner {
return items;
}
// Recursively unwrap prismarine-nbt {type, value} structures into plain objects
simplifyNBT(nbt) {
if (nbt === null || nbt === undefined) return nbt;
if (typeof nbt !== 'object') return nbt;
// prismarine-nbt compound/value wrapper
if (nbt.type !== undefined && nbt.value !== undefined) {
return this.simplifyNBT(nbt.value);
}
if (Array.isArray(nbt)) {
return nbt.map(v => this.simplifyNBT(v));
}
const out = {};
for (const key of Object.keys(nbt)) {
out[key] = this.simplifyNBT(nbt[key]);
}
return out;
}
parseNBT(nbt) {
if (!nbt) return null;
if (typeof nbt === 'string') {
@@ -330,13 +436,19 @@ class Scanner {
}
}
// Unwrap prismarine-nbt wrappers so we can access keys directly
nbt = this.simplifyNBT(nbt);
const result = {};
if (nbt.Enchantments) {
result.enchantments = nbt.Enchantments.map(e => ({
id: e.id,
level: e.lvl
}));
let enchList = nbt.Enchantments;
if (Array.isArray(enchList)) {
result.enchantments = enchList.map(e => ({
id: e.id,
level: e.lvl
}));
}
}
if (nbt.Damage) {
@@ -344,7 +456,23 @@ class Scanner {
}
if (nbt.display?.Name) {
result.displayName = nbt.display.Name;
const name = nbt.display.Name;
if (typeof name === 'string') {
try { result.displayName = JSON.parse(name).text || name; } catch (e) { result.displayName = name; }
} else {
result.displayName = name?.text || String(name);
}
}
if (nbt.display?.Lore) {
let lore = nbt.display.Lore;
if (!Array.isArray(lore)) lore = [lore];
result.lore = lore.map(l => {
if (typeof l === 'string') {
try { return JSON.parse(l).text || l; } catch (e) { return l; }
}
return l?.text || String(l);
});
}
if (nbt.CustomModelData) {
@@ -357,6 +485,18 @@ class Scanner {
return Object.keys(result).length > 0 ? result : null;
}
/**
* Check if parsed NBT data indicates a "special" item — one with a custom
* display name, lore, or custom model data that should be stored separately.
*/
static isSpecialItem(nbtData) {
if (!nbtData) return false;
if (typeof nbtData === 'string') {
try { nbtData = JSON.parse(nbtData); } catch (e) { return false; }
}
return !!(nbtData.displayName || nbtData.lore || nbtData.customModelData);
}
}
module.exports = Scanner;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -71,4 +71,8 @@ class Swing{
}
}
Swing.getStatus = function(instance) {
return { active: !!instance.intervalStop, target: 'guardian' };
};
module.exports = Swing;
+787
View File
@@ -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,'&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 +
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();