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