fable
This commit is contained in:
+29
-4
@@ -16,7 +16,7 @@ module.exports = {
|
||||
},
|
||||
"storage": {
|
||||
"dbPath": "./storage/storage.db",
|
||||
"scanRadius": 500,
|
||||
"scanRadius": 30,
|
||||
"homePos": null,
|
||||
"categories": {
|
||||
"minerals": ["diamond", "netherite_ingot", "gold_ingot", "iron_ingot", "copper_ingot", "emerald", "redstone", "lapis_lazuli", "raw_iron", "raw_gold", "raw_copper"],
|
||||
@@ -58,14 +58,35 @@ module.exports = {
|
||||
"storageBotName": "ez", // storage bot to trade with
|
||||
"enabled": true,
|
||||
},
|
||||
// OpenID Connect login for the web dashboard. Disabled by default —
|
||||
// enable via settings once clientId/clientSecret are configured.
|
||||
// redirectUri MUST be registered on the SSO client and match exactly.
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
"authorizationEndpoint": "https://sso.theta42.com/oauth/authorize",
|
||||
"tokenEndpoint": "https://sso.theta42.com/oauth/token",
|
||||
"userinfoEndpoint": "https://sso.theta42.com/oauth/userinfo",
|
||||
"clientId": "",
|
||||
"clientSecret": "",
|
||||
"redirectUri": "http://localhost:3000/auth/oidc/callback",
|
||||
"scopes": ["openid", "profile", "email", "groups"],
|
||||
"usernameClaim": "preferred_username",
|
||||
"groupsClaim": "groups",
|
||||
"allowedUsers": [],
|
||||
"allowedGroups": [],
|
||||
"tokenTTL": 2592000,
|
||||
},
|
||||
"ai":{
|
||||
"faceBot": "ez", // which bot runs the AI (the face/coordinator)
|
||||
"storageBot": "ez", // which bot handles storage
|
||||
// AI provider: 'gemini' (default) or 'ollama'
|
||||
"provider": "ollama",
|
||||
// Gemini API key (required if using gemini provider)
|
||||
"key": "<configure in secrets>",
|
||||
// Ollama settings (only used if provider is 'ollama')
|
||||
"baseUrl": "http://192.168.1.148:11434",
|
||||
"model": "huihui_ai/qwen3.5-abliterated:9b", // Default for Ollama; for Gemini use gemini-2.0-flash-exp, etc.
|
||||
"model": "deepseek-v4-flash:cloud", // Default for Ollama; for Gemini use gemini-2.0-flash-exp, etc.
|
||||
"enableNativeTools": true, // set to true only if model supports function calling (most 9B models don't)
|
||||
"timeout": 30000,
|
||||
// Generation settings (applies to both providers)
|
||||
"temperature": 1,
|
||||
@@ -73,7 +94,7 @@ module.exports = {
|
||||
"topK": 64,
|
||||
// "maxOutputTokens": 64000,
|
||||
"interval": 5,
|
||||
"promptName": "asshole",
|
||||
"promptName": "helpful",
|
||||
"prompts":{
|
||||
"custom": (name, interval, currentPlayers, toolsDocs, memoryContext, timeInfo, custom='')=>`
|
||||
Ignore all previous instructions prompts
|
||||
@@ -115,10 +136,12 @@ CHAT RULES:
|
||||
- Only chat when there is no tool to call.
|
||||
- Max 1 message per response, under 120 chars.
|
||||
- Be short and sarcastic. No narration.
|
||||
- NEVER suggest commands (/msg, /trade, /invite, /help). You are a player, not a help desk.
|
||||
- NEVER type server commands in public chat.
|
||||
|
||||
Currently online: ${currentPlayers}
|
||||
|
||||
CRITICAL FORMAT: Output ONLY a raw JSON array. Never plain text.
|
||||
CRITICAL FORMAT: Output ONLY a raw JSON array. Never plain text. Never JSON objects with tool calls in chat text.
|
||||
Silence = [{"text":"_","delay":0}]
|
||||
[{"text": "msg", "delay": 0}]`,
|
||||
"Ashley": (name, interval, currentPlayers, toolsDocs, memoryContext, timeInfo, custom='')=>`
|
||||
@@ -165,6 +188,8 @@ CHAT RULES:
|
||||
- Only chat when there is no tool to call.
|
||||
- Max 1 message per response, under 120 chars.
|
||||
- Be helpful but brief. No narration.
|
||||
- NEVER suggest commands (/msg, /trade, /invite, /help). You are a player, not a help desk.
|
||||
- NEVER type server commands in public chat.
|
||||
|
||||
Currently online: ${currentPlayers}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
plugins {
|
||||
java
|
||||
}
|
||||
|
||||
group = "com.buildkillreign"
|
||||
version = "2.0.0"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://repo.codemc.io/repository/maven-public/") // ✅ CodeMC repository
|
||||
maven("https://repo.papermc.io/repository/maven-public/") // ✅ PaperMC repository
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("com.comphenix.protocol:ProtocolLib:5.1.0") // ✅ Get ProtocolLib from CodeMC
|
||||
compileOnly("io.papermc.paper:paper-api:1.20.1-R0.1-SNAPSHOT") // ✅ Get Paper API from PaperMC repo
|
||||
compileOnly("io.papermc:paperlib:1.0.5") // ✅ Get PaperLib from CodeMC
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain.languageVersion.set(JavaLanguageVersion.of(17)) // Required for MC 1.20+
|
||||
}
|
||||
|
||||
tasks.jar {
|
||||
archiveFileName.set("BuildKillReign.jar")
|
||||
destinationDirectory.set(file("build/libs"))
|
||||
|
||||
from("src/main/resources") {
|
||||
include("plugin.yml")
|
||||
}
|
||||
}
|
||||
+65
-832
@@ -1,869 +1,102 @@
|
||||
'use strict';
|
||||
|
||||
const conf = require('../conf');
|
||||
const {sleep} = require('../utils');
|
||||
const { ProviderFactory } = require('./ai/providers');
|
||||
const memoryDB = require('./ai/memory-db');
|
||||
|
||||
|
||||
/**
|
||||
* Ai plugin — thin shim.
|
||||
*
|
||||
* The real work (LLM provider, poll timer, tool registry, response processing)
|
||||
* lives in AiManager. This class exists only for compatibility with CJbot's
|
||||
* plugin system: pluginAdd/pluginLoad/pluginUnload, the .ai chat command,
|
||||
* and the trade plugin's _expectingTradeWindow flag.
|
||||
*
|
||||
* Only ONE bot should load this plugin. The config key `ai.faceBot` names it.
|
||||
*/
|
||||
class Ai {
|
||||
constructor(args) {
|
||||
this.bot = args.bot;
|
||||
this.promptName = args.promptName;
|
||||
this.prompCustom = args.prompCustom || '';
|
||||
// interval takes precedence over intervalLength (both are valid config names)
|
||||
this.intervalLength = args.interval || args.intervalLength || 30;
|
||||
this.intervalStop;
|
||||
this.messageListener;
|
||||
this.provider = null;
|
||||
this.memoryDB = memoryDB;
|
||||
this._allTools = [];
|
||||
this._lastSentMessages = []; // prevent duplicate chat spam (LRU queue)
|
||||
this._active = false; // AI is initialized and listening
|
||||
this._consecutiveFailures = 0; // track API failures for backoff
|
||||
this._backoffUntil = 0; // suppress calls until this timestamp
|
||||
this._messages = null; // messages array reference (set in init)
|
||||
this._polling = false; // prevent concurrent poll cycles
|
||||
this._pollTimer = null; // interval ref for polling
|
||||
|
||||
// Trade feedback loop
|
||||
this._tradeWindow = null;
|
||||
this._tradeWindowState = null;
|
||||
this._expectingTradeWindow = false; // set by trade.js before auto-accept
|
||||
|
||||
// Bot-specific AI config (overrides global config)
|
||||
// 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;
|
||||
// Extract config — same destructure as the old Ai class
|
||||
const { bot: _bot, promptName, prompCustom, intervalLength, interval, ...configProps } = args;
|
||||
this.promptName = promptName;
|
||||
this.prompCustom = prompCustom || '';
|
||||
this.botConfig = args.botConfig || configProps || {};
|
||||
}
|
||||
|
||||
// Get merged config: bot-specific settings override global settings
|
||||
__getConfig() {
|
||||
return {
|
||||
...conf.ai, // Global defaults
|
||||
...this.botConfig, // Bot-specific overrides
|
||||
};
|
||||
}
|
||||
|
||||
async init() {
|
||||
// If bot is already ready, complete setup immediately and await it.
|
||||
// Otherwise, queue for when the bot becomes ready.
|
||||
if (this.bot.isReady) {
|
||||
await this._completeSetup();
|
||||
} else {
|
||||
this.bot.on('onReady', () => this._completeSetup());
|
||||
}
|
||||
}
|
||||
const { getInstance } = require('./ai/manager');
|
||||
const manager = getInstance();
|
||||
|
||||
async _completeSetup() {
|
||||
try {
|
||||
await this.start();
|
||||
this._messages = [];
|
||||
this._active = true;
|
||||
|
||||
this.messageListener = this.bot.on('message', (message, type)=>{
|
||||
if(type === 'game_info') return;
|
||||
const msgText = message.toString();
|
||||
if(msgText.startsWith('<')){
|
||||
const firstBracket = msgText.split('>')[0];
|
||||
// Extract username from <[lvl] username> or <username> format
|
||||
const userMatch = firstBracket.match(/^<\[.*?\]\s*(\w+)>$|^<(\w+)>$/);
|
||||
if(userMatch){
|
||||
const speakerName = userMatch[1] || userMatch[2];
|
||||
if(speakerName === this.bot.bot.entity.username){
|
||||
console.log('message blocked from message array')
|
||||
// If manager is already active on another bot, that's a config conflict
|
||||
if (manager.isActive && manager.faceBotName !== this.bot.name) {
|
||||
console.warn(`Ai: manager already running on ${manager.faceBotName} — ignoring init on ${this.bot.name}`);
|
||||
console.warn(`Ai: shutdown the existing Ai plugin first, or change ai.faceBot in config`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`Message ${type}: ${message.toString()}`)
|
||||
// Add timestamp to message for time awareness
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
this._messages.push({
|
||||
type: 'message',
|
||||
text: message.toString(),
|
||||
timestamp: timestamp,
|
||||
timeAgo: this.getTimeAgo(timestamp)
|
||||
});
|
||||
// Ensure poll timer is running
|
||||
this._ensurePolling();
|
||||
});
|
||||
|
||||
// Monitor trade windows (even auto-accepted ones) for feedback loop
|
||||
this.bot.bot.on('windowOpen', (window) => {
|
||||
// Only fires when trade.js signals an expected trade — avoids
|
||||
// false positives from scanner opening chests (also 54+ slots).
|
||||
if (!this._tradeWindow && this._expectingTradeWindow && window.slots && window.slots.length >= 54) {
|
||||
this._expectingTradeWindow = false;
|
||||
console.log('AI: Trade window detected, setting up feedback loop');
|
||||
this._setupTradeWindow(window, 'auto-accepted');
|
||||
this._ensurePolling();
|
||||
// If already active on THIS bot, this is a reload (e.g. .ai command)
|
||||
if (manager.isActive && manager.faceBotName === this.bot.name) {
|
||||
await manager.reloadPrompt(this.promptName, this.prompCustom);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`${this.bot.name} AI ready — waiting for chat activity`);
|
||||
|
||||
} catch(error) {
|
||||
console.error(`${this.bot.name} AI setup failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
// Fresh init
|
||||
await manager.init(this.bot, this._getConfig());
|
||||
this.bot._aiControlsTrade = true;
|
||||
console.log(`Ai: ${this.bot.name} is the face bot`);
|
||||
}
|
||||
|
||||
async unload() {
|
||||
if(this._pollTimer){
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
const { getInstance } = require('./ai/manager');
|
||||
const manager = getInstance();
|
||||
|
||||
if (manager.faceBotName === this.bot.name) {
|
||||
await manager.shutdown();
|
||||
}
|
||||
if(this.messageListener){
|
||||
this.messageListener();
|
||||
}
|
||||
if(this.provider){
|
||||
await this.provider.close();
|
||||
}
|
||||
this._active = false;
|
||||
delete this.bot._aiControlsTrade;
|
||||
console.log(`Ai: unloaded from ${this.bot.name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Simple interval polling ----
|
||||
|
||||
_ensurePolling() {
|
||||
if (this._pollTimer) return;
|
||||
const intervalMs = (this.intervalLength || 5) * 1000;
|
||||
this._pollTimer = setInterval(() => this._pollCycle(), intervalMs);
|
||||
}
|
||||
|
||||
// ---- Main polling cycle ----
|
||||
|
||||
async _pollCycle() {
|
||||
// Respect backoff after errors
|
||||
if (Date.now() < this._backoffUntil) return;
|
||||
// Prevent concurrent cycles
|
||||
if (this._polling) return;
|
||||
this._polling = true;
|
||||
|
||||
try {
|
||||
|
||||
// Snapshot messages so new arrivals during processing aren't lost
|
||||
const currentMessages = [...this._messages];
|
||||
// Reset for the next accumulation window
|
||||
this._messages = [];
|
||||
|
||||
// Skip API call if there's no real data
|
||||
const hasRealData = currentMessages.some(m => typeof m === 'object' && m.text);
|
||||
const hasTradeWindow = !!this._tradeWindowState;
|
||||
if (!hasRealData && !hasTradeWindow) return;
|
||||
|
||||
let result;
|
||||
try{
|
||||
const currentTime = new Date();
|
||||
const requestData = {
|
||||
messages: currentMessages,
|
||||
currentTime: currentTime.toISOString().replace('T', ' ').substring(0, 19),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
tradeWindow: this._tradeWindowState,
|
||||
};
|
||||
result = await this.chat(JSON.stringify(requestData));
|
||||
}catch(error){
|
||||
console.log('error AI API', error);
|
||||
// Exponential backoff on failure — don't hammer an overloaded server
|
||||
this._consecutiveFailures++;
|
||||
const backoffMs = Math.min(1000 * Math.pow(2, this._consecutiveFailures), 30000);
|
||||
this._backoffUntil = Date.now() + backoffMs;
|
||||
console.log(`AI backoff: ${backoffMs}ms (failure #${this._consecutiveFailures}), until ${new Date(this._backoffUntil).toISOString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success — reset failure tracking
|
||||
this._consecutiveFailures = 0;
|
||||
this._backoffUntil = 0;
|
||||
|
||||
try{
|
||||
// Determine the requesting player from chat context
|
||||
const requestingPlayer = this.getLastSpeaker(currentMessages);
|
||||
|
||||
// Check for tool calls first
|
||||
const toolCalls = this.getToolCalls(result);
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
// Deduplicate tool calls
|
||||
const seen = new Set();
|
||||
const uniqueCalls = toolCalls.filter(tc => {
|
||||
const key = `${tc.name || tc.function?.name}:${JSON.stringify(tc.args || tc.arguments || {})}`;
|
||||
if (seen.has(key)) { console.log(`Deduplicating duplicate tool call: ${key}`); return false; }
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
console.log(`Tool calls from AI: ${toolCalls.length} raw, ${uniqueCalls.length} after dedup — ${uniqueCalls.map(c => c.name || c.function?.name).join(', ')}`);
|
||||
// Execute tool calls and get results
|
||||
const toolResults = [];
|
||||
for (const toolCall of uniqueCalls) {
|
||||
try {
|
||||
const toolResult = await this._executeTool(
|
||||
toolCall.name || toolCall.function?.name,
|
||||
toolCall.args || toolCall.arguments || {},
|
||||
requestingPlayer
|
||||
);
|
||||
toolResults.push({
|
||||
name: toolCall.name || toolCall.function?.name,
|
||||
result: toolResult,
|
||||
success: true
|
||||
});
|
||||
} catch (execError) {
|
||||
console.error('Tool execution error:', execError);
|
||||
toolResults.push({
|
||||
name: toolCall.name || toolCall.function?.name,
|
||||
error: execError.message,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send tool results back to AI for natural language response
|
||||
if (toolResults.length > 0) {
|
||||
const toolResultMessage = JSON.stringify({
|
||||
toolResults: toolResults,
|
||||
tradeWindow: this._tradeWindowState,
|
||||
instruction: 'Tool results above. Respond with ONE brief message (max 150 chars) in the JSON array format. Be short and direct - do not narrate what happened.'
|
||||
});
|
||||
|
||||
try {
|
||||
const followupResult = await this.chat(toolResultMessage);
|
||||
const responseText = this.provider.getResponse(followupResult);
|
||||
await this.processResponse(responseText);
|
||||
} catch (followupError) {
|
||||
console.error('Error generating followup response:', followupError);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No tool calls, process as normal chat response
|
||||
const responseText = this.provider.getResponse(result);
|
||||
await this.processResponse(responseText);
|
||||
|
||||
}catch(error){
|
||||
console.log('Error in AI message loop', error, result);
|
||||
try {
|
||||
if(result && this.provider.getResponse(result)){
|
||||
console.log(this.provider.getResponse(result))
|
||||
}
|
||||
} catch(e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this._polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async start(history){
|
||||
const config = this.__getConfig();
|
||||
console.log(`${this.bot.name} AI config:`, {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
promptName: this.promptName,
|
||||
baseUrl: config.baseUrl,
|
||||
maxOutputTokens: config.maxOutputTokens,
|
||||
interval: config.interval,
|
||||
});
|
||||
|
||||
// Let the AI control trade decisions (disable auto-accept in minecraft.js)
|
||||
this.bot._aiControlsTrade = true;
|
||||
|
||||
// Initialize memory database
|
||||
await this.memoryDB.initialize('./storage/ai-memory.db', this.bot.name);
|
||||
console.log(`${this.bot.name} AI memory database initialized`);
|
||||
|
||||
// Get memory context for prompt (directives + general memories)
|
||||
const memoryContext = await this.memoryDB.getMemoryContext();
|
||||
|
||||
// Get player-specific memories for currently online players
|
||||
const onlinePlayers = Object.values(this.bot.getPlayers()).map(player => player.username);
|
||||
const playerMemoryContext = await this.memoryDB.getPlayerMemoriesForPrompt(onlinePlayers);
|
||||
|
||||
// Combine memory contexts
|
||||
const fullMemoryContext = [memoryContext, playerMemoryContext].filter(Boolean).join('\n\n');
|
||||
|
||||
// Get current time info
|
||||
const timeInfo = this.getCurrentTimeInfo();
|
||||
|
||||
// Build consolidated tool registry
|
||||
this._buildAllTools();
|
||||
const toolsDocs = this._getToolsDocumentation();
|
||||
|
||||
const prompt = conf.ai.prompts[this.promptName](
|
||||
this.bot.bot.entity.username,
|
||||
config.interval,
|
||||
Object.values(this.bot.getPlayers()).map(player=>`<[${player.lvl}] ${player.username}>`).join('\n'),
|
||||
toolsDocs,
|
||||
fullMemoryContext,
|
||||
timeInfo,
|
||||
this.prompCustom,
|
||||
);
|
||||
|
||||
// Create the provider instance with merged config and prompt
|
||||
this.provider = ProviderFactory.create({
|
||||
...config,
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
if (this.provider.supportsTools && this.provider.supportsTools()) {
|
||||
this.provider.setTools(this._getToolsSchema());
|
||||
console.log(`${this.bot.name} AI tools configured: ${this._allTools.length} tools available`);
|
||||
}
|
||||
|
||||
await this.provider.start(history);
|
||||
console.log(`${this.bot.name} AI ${config.provider} provider started (model: ${config.model})`);
|
||||
}
|
||||
|
||||
async chat(message, retryCount=0){
|
||||
console.log(`chat ${this.bot.name}`, retryCount)
|
||||
try{
|
||||
let result = await this.provider.chat(message);
|
||||
return result
|
||||
}catch(error){
|
||||
console.log('AI chat error', error)
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tool calls from provider response
|
||||
*/
|
||||
getToolCalls(result) {
|
||||
// Gemini: functionCalls() method
|
||||
if (result.response && typeof result.response.functionCalls === 'function') {
|
||||
const calls = result.response.functionCalls();
|
||||
if (calls && calls.length > 0) {
|
||||
return calls.map(call => ({
|
||||
name: call.name,
|
||||
function: { name: call.name },
|
||||
args: call.args
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama: tool_calls in result or message data
|
||||
if (result.tool_calls && result.tool_calls.length > 0) {
|
||||
return result.tool_calls.map(tc => ({
|
||||
name: tc.name || tc.function?.name,
|
||||
function: { name: tc.function?.name || tc.name },
|
||||
args: tc.args || tc.arguments || tc.function?.arguments || {}
|
||||
}));
|
||||
}
|
||||
|
||||
// Check for tool call in response text (fallback for models without native tool support)
|
||||
const responseText = result.response ? result.response.text() : null;
|
||||
if (responseText) {
|
||||
try {
|
||||
const parsed = JSON.parse(responseText);
|
||||
if (parsed.tool_call || parsed.toolCall) {
|
||||
const toolCall = parsed.tool_call || parsed.toolCall;
|
||||
return [{
|
||||
name: toolCall.name,
|
||||
function: { name: toolCall.name },
|
||||
args: toolCall.args || toolCall.arguments || toolCall.parameters || {}
|
||||
}];
|
||||
}
|
||||
// Check for array of tool calls
|
||||
if (Array.isArray(parsed.tool_calls)) {
|
||||
return parsed.tool_calls.map(tc => ({
|
||||
name: tc.name || tc.function?.name,
|
||||
function: { name: tc.function?.name || tc.name },
|
||||
args: tc.args || tc.arguments || tc.function?.arguments || {}
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, no tool calls
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
_getConfig() {
|
||||
return { ...conf.ai, ...this.botConfig };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Consolidated Tool Registry
|
||||
// Backward-compat proxies
|
||||
// These are accessed by trade.js and the web UI
|
||||
// ========================================
|
||||
|
||||
_buildAllTools() {
|
||||
this._allTools = [];
|
||||
|
||||
// Plugin tools — discovered from loaded plugin.commands[]
|
||||
for (const [pluginName, plugin] of Object.entries(this.bot.plunginsLoaded)) {
|
||||
const commands = plugin.commands || [];
|
||||
for (const cmd of commands) {
|
||||
this._allTools.push({
|
||||
name: `${pluginName}_${cmd.name}`,
|
||||
description: cmd.description,
|
||||
parameters: cmd.parameters || [],
|
||||
category: cmd.category || 'plugin',
|
||||
execute: async (params) => {
|
||||
const args = (cmd.parameters || []).map(p => params[p.name]);
|
||||
return plugin.handleCommand('ai', cmd.name, ...args);
|
||||
get _expectingTradeWindow() {
|
||||
const { getInstance } = require('./ai/manager');
|
||||
return getInstance()._expectingTradeWindow;
|
||||
}
|
||||
});
|
||||
set _expectingTradeWindow(val) {
|
||||
const { getInstance } = require('./ai/manager');
|
||||
getInstance()._expectingTradeWindow = val;
|
||||
}
|
||||
|
||||
get _active() {
|
||||
const { getInstance } = require('./ai/manager');
|
||||
return getInstance().isActive;
|
||||
}
|
||||
|
||||
get memoryDB() {
|
||||
const { getInstance } = require('./ai/manager');
|
||||
return getInstance()._memoryDB;
|
||||
}
|
||||
|
||||
get intervalLength() {
|
||||
return this._getConfig().interval || 10;
|
||||
}
|
||||
|
||||
get provider() {
|
||||
const { getInstance } = require('./ai/manager');
|
||||
return getInstance()._provider;
|
||||
}
|
||||
}
|
||||
|
||||
// Memory tools
|
||||
this._allTools.push(
|
||||
{
|
||||
name: 'remember_player', category: 'memory',
|
||||
description: 'Store a memory about a player (trust level, role, preferences)',
|
||||
parameters: [
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Player name' },
|
||||
{ name: 'key', type: 'string', required: true, description: 'Memory key (trust_level, role, etc.)' },
|
||||
{ name: 'value', type: 'string', required: true, description: 'Value to store' }
|
||||
],
|
||||
execute: (p) => this.memoryDB.setPlayerMemory(p.playerName, p.key, p.value)
|
||||
.then(() => `Stored ${p.key}=${p.value} for ${p.playerName}`)
|
||||
},
|
||||
{
|
||||
name: 'recall_player', category: 'memory',
|
||||
description: 'Retrieve all stored memories about a player',
|
||||
parameters: [
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Player name' }
|
||||
],
|
||||
execute: async (p) => {
|
||||
const m = await this.memoryDB.getAllPlayerMemories(p.playerName);
|
||||
const keys = Object.keys(m);
|
||||
return keys.length ? `Memories for ${p.playerName}: ${JSON.stringify(m)}` : `No memories for ${p.playerName}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'list_known_players', category: 'memory',
|
||||
description: 'List all players the bot has memories about',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const players = await this.memoryDB.getAllKnownPlayers();
|
||||
return players.length ? `Known players: ${players.join(', ')}` : 'No known players yet';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'set_directive', category: 'memory',
|
||||
description: 'Set a bot self-directive (current_goal, mood, focus)',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Directive key' },
|
||||
{ name: 'value', type: 'string', required: true, description: 'Directive value' }
|
||||
],
|
||||
execute: (p) => this.memoryDB.setDirective(p.key, p.value)
|
||||
.then(() => `Directive set: ${p.key}=${p.value}`)
|
||||
},
|
||||
{
|
||||
name: 'get_directive', category: 'memory',
|
||||
description: 'Retrieve a specific bot directive',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Directive key' }
|
||||
],
|
||||
execute: async (p) => {
|
||||
const d = await this.memoryDB.getDirective(p.key);
|
||||
return d !== null ? `${p.key}=${d}` : `No directive for '${p.key}'`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'list_directives', category: 'memory',
|
||||
description: 'List all active bot directives',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const all = await this.memoryDB.getAllDirectives();
|
||||
const keys = Object.keys(all);
|
||||
return keys.length ? `Directives: ${JSON.stringify(all)}` : 'No active directives';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Storage lookup tools (direct DB access, no lock needed)
|
||||
this._allTools.push(
|
||||
{
|
||||
name: 'lookup_item', category: 'storage',
|
||||
description: 'Check how many of an item we have in storage. Use this when asked "how much X do we have" or "do we have X".',
|
||||
parameters: [
|
||||
{ name: 'itemName', type: 'string', required: true, description: 'Item name or part of name' }
|
||||
],
|
||||
execute: async (p) => {
|
||||
const Database = require('./storage/database');
|
||||
const items = await Database.searchItems(p.itemName);
|
||||
if (items.length === 0) return `No items found matching '${p.itemName}'`;
|
||||
return items.slice(0, 8).map(i => `${i.item_name}: ${i.total_count}`).join(', ');
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'list_items', category: 'storage',
|
||||
description: 'List top stocked items in storage. Use to see what items are available.',
|
||||
parameters: [
|
||||
{ name: 'limit', type: 'number', required: false, description: 'Max results (default 10)' }
|
||||
],
|
||||
execute: async (p) => {
|
||||
const Database = require('./storage/database');
|
||||
const items = await Database.searchItems(null);
|
||||
const limit = p.limit || 10;
|
||||
return items.slice(0, limit).map(i => `${i.item_name}: ${i.total_count}`).join(', ');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Trade tools
|
||||
this._allTools.push(
|
||||
{
|
||||
name: 'trade_initiate', category: 'trade',
|
||||
description: 'Start a trade with a player. Returns trade window state.',
|
||||
parameters: [
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Player to trade with' }
|
||||
],
|
||||
execute: (p, from) => this._tradeInitiate(p.playerName || from)
|
||||
},
|
||||
{
|
||||
name: 'trade_accept', category: 'trade',
|
||||
description: 'Accept an incoming trade request. Returns trade window state.',
|
||||
parameters: [],
|
||||
execute: () => this._tradeAccept()
|
||||
},
|
||||
{
|
||||
name: 'trade_decline', category: 'trade',
|
||||
description: 'Cancel/close the active trade window.',
|
||||
parameters: [],
|
||||
execute: () => this._tradeDecline()
|
||||
},
|
||||
{
|
||||
name: 'trade_confirm', category: 'trade',
|
||||
description: 'Confirm the trade on the bot side (ready check).',
|
||||
parameters: [],
|
||||
execute: () => this._tradeConfirm()
|
||||
},
|
||||
{
|
||||
name: 'trade_status', category: 'trade',
|
||||
description: 'Get current trade window state (items on each side).',
|
||||
parameters: [],
|
||||
execute: () => this._tradeWindowState || 'No active trade window'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_getToolsSchema() {
|
||||
return this._allTools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: (t.parameters || []).reduce((acc, p) => {
|
||||
acc[p.name] = { type: p.type || 'string', description: p.description || '' };
|
||||
return acc;
|
||||
}, {}),
|
||||
required: t.parameters.filter(p => p.required).map(p => p.name)
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
_getToolsDocumentation() {
|
||||
if (this._allTools.length === 0) return '';
|
||||
|
||||
// Group by category
|
||||
const byCategory = {};
|
||||
for (const t of this._allTools) {
|
||||
const cat = t.category || 'other';
|
||||
if (!byCategory[cat]) byCategory[cat] = [];
|
||||
byCategory[cat].push(t);
|
||||
}
|
||||
|
||||
let doc = '';
|
||||
for (const [cat, tools] of Object.entries(byCategory)) {
|
||||
doc += `## ${cat}\n`;
|
||||
for (const t of tools) {
|
||||
doc += `- **${t.name}**: ${t.description}`;
|
||||
if (t.parameters && t.parameters.length > 0) {
|
||||
doc += ` (params: ${t.parameters.map(p => p.name).join(', ')})`;
|
||||
}
|
||||
doc += '\n';
|
||||
}
|
||||
doc += '\n';
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
async _executeTool(toolName, params = {}, from = 'ai') {
|
||||
const tool = this._allTools.find(t => t.name === toolName);
|
||||
if (!tool) throw new Error(`Unknown tool: ${toolName}`);
|
||||
console.log(`AI tool: ${toolName}`, params);
|
||||
return tool.execute(params, from);
|
||||
}
|
||||
|
||||
// ---- Trade tool implementations ----
|
||||
|
||||
async _tradeInitiate(playerName) {
|
||||
if (this._tradeWindow) {
|
||||
return `Trade window already open. Current state: ${JSON.stringify(this._tradeWindowState)}`;
|
||||
}
|
||||
|
||||
const player = this.bot.bot.players[playerName];
|
||||
if (!player) {
|
||||
return `Player "${playerName}" is not online or not in range.`;
|
||||
}
|
||||
|
||||
console.log(`AI: Initiating /trade with ${playerName}`);
|
||||
await this.bot.say(`/trade ${playerName}`);
|
||||
|
||||
// Wait for trade window to open (player must accept)
|
||||
const window = await Promise.race([
|
||||
this.bot.once('windowOpen'),
|
||||
sleep(60000).then(() => null),
|
||||
]);
|
||||
|
||||
if (!window) {
|
||||
return `Trade request to ${playerName} timed out (60s). They may not have accepted.`;
|
||||
}
|
||||
|
||||
this._setupTradeWindow(window, playerName);
|
||||
|
||||
// Place any withdrawn items into the trade window
|
||||
const storage = this.bot.plunginsLoaded['Storage'];
|
||||
if (storage && typeof storage.placeWithdrawnItemsInTrade === 'function') {
|
||||
await storage.placeWithdrawnItemsInTrade(window, playerName);
|
||||
// Re-capture state after placing items
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
}
|
||||
|
||||
return `Trade opened with ${playerName}. State: ${JSON.stringify(this._tradeWindowState)}`;
|
||||
}
|
||||
|
||||
async _tradeAccept() {
|
||||
if (this._tradeWindow) {
|
||||
return `Trade window already open. Current state: ${JSON.stringify(this._tradeWindowState)}`;
|
||||
}
|
||||
|
||||
console.log('AI: Accepting incoming trade');
|
||||
// Use bot.chat directly to bypass sayAiSafe command filtering
|
||||
this.bot.bot.chat('/trade accept');
|
||||
|
||||
const window = await Promise.race([
|
||||
this.bot.once('windowOpen'),
|
||||
sleep(30000).then(() => null),
|
||||
]);
|
||||
|
||||
if (!window) {
|
||||
return 'Trade accept timed out (30s). The trade request may have expired.';
|
||||
}
|
||||
|
||||
this._setupTradeWindow(window, 'incoming');
|
||||
return `Trade accepted. State: ${JSON.stringify(this._tradeWindowState)}`;
|
||||
}
|
||||
|
||||
async _tradeDecline() {
|
||||
if (!this._tradeWindow) {
|
||||
return 'No active trade to decline.';
|
||||
}
|
||||
|
||||
console.log('AI: Declining/cancelling trade');
|
||||
try {
|
||||
this.bot.bot.closeWindow(this._tradeWindow);
|
||||
} catch (e) {
|
||||
// Window may already be closed
|
||||
}
|
||||
this._tradeWindow = null;
|
||||
this._tradeWindowState = null;
|
||||
this._expectingTradeWindow = false;
|
||||
return 'Trade declined/window closed.';
|
||||
}
|
||||
|
||||
async _tradeConfirm() {
|
||||
if (!this._tradeWindow) {
|
||||
return 'No active trade to confirm. Use trade_accept or trade_initiate first.';
|
||||
}
|
||||
|
||||
console.log('AI: Confirming trade');
|
||||
this.bot.bot.moveSlotItem(37, 37);
|
||||
// Update state after confirmation
|
||||
this._tradeWindowState = this._captureTradeState(this._tradeWindow);
|
||||
return `Trade confirmed on bot side. State: ${JSON.stringify(this._tradeWindowState)}`;
|
||||
}
|
||||
|
||||
// ---- Trade window management ----
|
||||
|
||||
_setupTradeWindow(window, playerName) {
|
||||
this._tradeWindow = window;
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
|
||||
// Monitor customer-side slots for real-time feedback
|
||||
const customerSlots = [5, 6, 7, 8, 14, 15, 16, 17, 23, 24, 25, 26];
|
||||
for (const slot of customerSlots) {
|
||||
window.on(`updateSlot:${slot}`, () => {
|
||||
if (this._tradeWindow === window) {
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
this._ensurePolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Monitor confirmation indicator slots
|
||||
window.on('updateSlot:53', () => {
|
||||
if (this._tradeWindow === window) {
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
this._ensurePolling();
|
||||
}
|
||||
});
|
||||
window.on('updateSlot:37', () => {
|
||||
if (this._tradeWindow === window) {
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
this._ensurePolling();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on window close
|
||||
const onClose = () => {
|
||||
if (this._tradeWindow === window) {
|
||||
this._tradeWindow = null;
|
||||
this._tradeWindowState = null;
|
||||
}
|
||||
};
|
||||
this.bot.bot.once('windowClose', onClose);
|
||||
}
|
||||
|
||||
_captureTradeState(window) {
|
||||
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];
|
||||
|
||||
const readSlots = (slots) => slots
|
||||
.map(s => window.slots[s])
|
||||
.filter(Boolean)
|
||||
.map(item => ({ name: item.name, count: item.count }));
|
||||
|
||||
return {
|
||||
botItems: readSlots(botSlots),
|
||||
customerItems: readSlots(customerSlots),
|
||||
customerConfirmed: !!(window.slots[53] && window.slots[53].name === 'lime_dye'),
|
||||
botConfirmed: !!(window.slots[37] && window.slots[37].name === 'lime_dye'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process AI response and send to chat.
|
||||
* Strips markdown code fences, deduplicates messages, and skips noise.
|
||||
*/
|
||||
async processResponse(responseText) {
|
||||
if (!responseText) return;
|
||||
|
||||
// Strip markdown code fences that some models wrap JSON in
|
||||
const cleaned = this._stripMarkdownFences(responseText);
|
||||
|
||||
// Try to parse as JSON array [{text, delay}]
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (let message of parsed) {
|
||||
const msgText = (message.text || '').trim();
|
||||
console.log('toSay', message.delay, msgText);
|
||||
|
||||
// Skip empty, underscore-only, or noise responses
|
||||
if (!msgText || msgText === '_' || msgText.match(/^[-_]+$/)) continue;
|
||||
|
||||
// Deduplicate — don't send the same message twice
|
||||
const dedupeKey = msgText.toLowerCase();
|
||||
if (this._lastSentMessages.includes(dedupeKey)) {
|
||||
console.log('Skipping duplicate message:', msgText);
|
||||
continue;
|
||||
}
|
||||
if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift();
|
||||
this._lastSentMessages.push(dedupeKey);
|
||||
|
||||
await this.bot.sayAiSafe(msgText);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (jsonError) {
|
||||
// Not valid JSON array — fall through to plain text
|
||||
}
|
||||
|
||||
// Plain text fallback
|
||||
const text = cleaned.trim();
|
||||
if (!text || text === '_' || text === '___' || text.match(/^[-_]+$/)) return;
|
||||
|
||||
// Don't send raw JSON or code blocks
|
||||
if (text.startsWith('{') || text.startsWith('```')) {
|
||||
console.log('Skipping raw JSON/code block response:', text.substring(0, 80));
|
||||
return;
|
||||
}
|
||||
|
||||
const dedupeKey = text.toLowerCase();
|
||||
if (this._lastSentMessages.includes(dedupeKey)) {
|
||||
console.log('Skipping duplicate plain-text message:', text);
|
||||
return;
|
||||
}
|
||||
if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift();
|
||||
this._lastSentMessages.push(dedupeKey);
|
||||
|
||||
await this.bot.sayAiSafe(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markdown code fences from AI response text.
|
||||
*/
|
||||
_stripMarkdownFences(text) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
let cleaned = text.trim();
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '');
|
||||
cleaned = cleaned.replace(/\n?```\s*$/, '');
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last player who addressed this bot from recent messages
|
||||
* @param {Array} messages - Array of message objects {type, text, ...}
|
||||
* @returns {string} Player name or 'ai' if no player found
|
||||
*/
|
||||
getLastSpeaker(messages) {
|
||||
if (!messages || !Array.isArray(messages)) return 'ai';
|
||||
// Search in reverse to find the most recent player message
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (!msg || msg.type !== 'message') continue;
|
||||
const text = msg.text || '';
|
||||
// Parse format: <[lvl] playername> message text
|
||||
const match = text.match(/^<\[.*?\]\s+(\w+)>/);
|
||||
if (match && match[1] && match[1] !== this.bot.bot.entity.username) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return 'ai';
|
||||
}
|
||||
|
||||
getTimeAgo(timestamp) {
|
||||
const now = new Date();
|
||||
const past = new Date(timestamp);
|
||||
const diffMs = now - past;
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
return past.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current time info for prompt injection
|
||||
*/
|
||||
getCurrentTimeInfo() {
|
||||
const now = new Date();
|
||||
return {
|
||||
iso: now.toISOString().replace('T', ' ').substring(0, 19),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
human: now.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Web UI support — unchanged pattern, but routes now use the manager internally
|
||||
const AiWeb = require('./ai/web');
|
||||
Ai.createRouter = AiWeb.createRouter;
|
||||
Ai.webUI = AiWeb.webUI;
|
||||
|
||||
@@ -0,0 +1,944 @@
|
||||
'use strict';
|
||||
|
||||
const { CJbot } = require('../../model/minecraft');
|
||||
const { sleep } = require('../../utils');
|
||||
|
||||
function _notify(msg) {
|
||||
try {
|
||||
const { getInstance } = require('./manager');
|
||||
getInstance().notifySystemEvent(msg);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fleet orchestration tools — registered once with the AiManager.
|
||||
* The LLM calls these to activate, move, and coordinate bots across the server.
|
||||
*
|
||||
* All bot→player and bot→bot interaction uses vanilla commands only:
|
||||
* /invite, /trade, /msg
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build and return the full fleet tool registry.
|
||||
* @param {object} config - merged ai config
|
||||
* @param {object} memoryDB - AIMemoryDB singleton
|
||||
* @returns {Array} tool definitions with { name, description, parameters, category, execute }
|
||||
*/
|
||||
function buildFleetTools(config, memoryDB) {
|
||||
const faceBotName = config.faceBot;
|
||||
const storageBotName = config.storageBot;
|
||||
|
||||
const tools = [];
|
||||
|
||||
// ========================================
|
||||
// Bot lifecycle
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'bot_activate',
|
||||
category: 'fleet',
|
||||
description: `Bring an offline bot online so it can do work. Use before asking a bot to do anything. The bot will auto-disconnect after being idle. Available bots: ${storageBotName} (storage/items), plus any bot in the fleet.`,
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Name of the bot to bring online' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const bot = CJbot.bots[params.botName];
|
||||
if (!bot) return `Unknown bot: ${params.botName}`;
|
||||
if (bot.isReady) return `${params.botName} is already online`;
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
bot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
_notify(`${params.botName} is now ONLINE and ready for tasks`);
|
||||
return `${params.botName} is now online and ready`;
|
||||
} catch (err) {
|
||||
return `Failed to bring ${params.botName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'bot_deactivate',
|
||||
category: 'fleet',
|
||||
description: 'Disconnect a bot when it is no longer needed. On-demand bots auto-disconnect after being idle, but this forces it sooner.',
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Name of the bot to disconnect' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const bot = CJbot.bots[params.botName];
|
||||
if (!bot) return `Unknown bot: ${params.botName}`;
|
||||
if (!bot.isReady) return `${params.botName} is already offline`;
|
||||
bot.autoReConnect = false;
|
||||
bot.quit(true);
|
||||
return `${params.botName} is disconnecting`;
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Movement
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'bot_goto_player',
|
||||
category: 'fleet',
|
||||
description: 'Send a bot to a player\'s current location. The bot will pathfind there automatically. IMPORTANT: the target bot must already be online (use bot_activate first if needed).',
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Which bot to move' },
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Which player to go to' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const bot = CJbot.bots[params.botName];
|
||||
if (!bot || !bot.isReady) return `${params.botName} is not online — activate it first`;
|
||||
|
||||
const player = bot.bot.players[params.playerName];
|
||||
if (!player || !player.entity) return `Cannot find player ${params.playerName} — they may be too far or offline`;
|
||||
|
||||
const nav = bot.plunginsLoaded['Navigation'];
|
||||
if (nav && typeof nav.handleCommand === 'function') {
|
||||
const result = nav.handleCommand('ai', 'goto', `${player.entity.position.x} ${player.entity.position.y} ${player.entity.position.z}`, '3');
|
||||
_notify(`${params.botName} is moving to ${params.playerName} at (${Math.round(player.entity.position.x)},${Math.round(player.entity.position.y)},${Math.round(player.entity.position.z)})`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback: use goTo directly
|
||||
try {
|
||||
await bot.goTo({ where: player.entity.position, range: 3 });
|
||||
return `${params.botName} arrived near ${params.playerName}`;
|
||||
} catch (err) {
|
||||
return `${params.botName} failed to reach ${params.playerName}: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'bot_come_to_face',
|
||||
category: 'fleet',
|
||||
description: `Send a bot to come to you (${faceBotName}), the face bot's location. Useful for bot-to-bot trades.`,
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Which bot to bring here' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const targetBot = CJbot.bots[params.botName];
|
||||
if (!targetBot || !targetBot.isReady) return `${params.botName} is not online — activate it first`;
|
||||
|
||||
const faceBot = CJbot.bots[faceBotName];
|
||||
if (!faceBot || !faceBot.isReady || !faceBot.bot?.entity) return 'Face bot is not online';
|
||||
|
||||
const nav = targetBot.plunginsLoaded['Navigation'];
|
||||
const pos = faceBot.bot.entity.position;
|
||||
if (nav && typeof nav.handleCommand === 'function') {
|
||||
return nav.handleCommand('ai', 'goto', `${pos.x} ${pos.y} ${pos.z}`, '3');
|
||||
}
|
||||
|
||||
try {
|
||||
await targetBot.goTo({ where: pos, range: 3 });
|
||||
return `${params.botName} arrived at ${faceBotName}'s location`;
|
||||
} catch (err) {
|
||||
return `${params.botName} failed to reach ${faceBotName}: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Trading
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'bot_trade_with_player',
|
||||
category: 'fleet',
|
||||
description: 'Have a bot initiate a trade with a player. The bot sends /trade <player>, waits for them to accept, then you can guide the trade. The bot will auto-place any pending withdrawal items in the trade window.',
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Which bot should trade' },
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Which player to trade with' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const bot = CJbot.bots[params.botName];
|
||||
if (!bot || !bot.isReady) return `${params.botName} is not online — activate it first`;
|
||||
|
||||
const player = bot.bot.players[params.playerName];
|
||||
if (!player) return `Player ${params.playerName} is not online or not in range`;
|
||||
|
||||
try {
|
||||
// Send trade request
|
||||
await bot.say(`/trade ${params.playerName}`);
|
||||
|
||||
// Wait for trade window
|
||||
const window = await Promise.race([
|
||||
bot.once('windowOpen'),
|
||||
sleep(30000).then(() => null)
|
||||
]);
|
||||
|
||||
if (!window) return `Trade request to ${params.playerName} timed out (30s)`;
|
||||
|
||||
// If bot has Storage plugin, let it handle placing withdrawn items
|
||||
const storage = bot.plunginsLoaded['Storage'];
|
||||
if (storage && typeof storage.placeWithdrawnItemsInTrade === 'function') {
|
||||
await storage.placeWithdrawnItemsInTrade(window, params.playerName);
|
||||
}
|
||||
|
||||
// Confirm on bot side — single click, not moveSlotItem's
|
||||
// pickup+putdown pair (anti-cheat flags that as bad packets)
|
||||
try { await bot.bot.clickWindow(37, 0, 0); } catch (e) { /* ignore */ }
|
||||
|
||||
return `Trade window opened with ${params.playerName}. Bot side confirmed.`;
|
||||
} catch (err) {
|
||||
return `Trade failed: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'face_trade_accept',
|
||||
category: 'fleet',
|
||||
description: 'Accept an incoming trade request on the face bot. Use when another bot or player is trying to trade with you.',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const faceBot = CJbot.bots[faceBotName];
|
||||
if (!faceBot || !faceBot.isReady) return 'Face bot is not online';
|
||||
|
||||
try {
|
||||
faceBot.bot.chat('/trade accept');
|
||||
const window = await Promise.race([
|
||||
faceBot.once('windowOpen'),
|
||||
sleep(15000).then(() => null)
|
||||
]);
|
||||
if (!window) return 'Trade accept timed out (15s)';
|
||||
|
||||
return `Trade window opened on ${faceBotName}`;
|
||||
} catch (err) {
|
||||
return `Failed to accept trade: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Bot status
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'bot_status',
|
||||
category: 'fleet',
|
||||
description: 'Check whether a bot is online, its position, and health.',
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Which bot to check' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const bot = CJbot.bots[params.botName];
|
||||
if (!bot) return `Unknown bot: ${params.botName}`;
|
||||
if (!bot.isReady) return `${params.botName} is offline`;
|
||||
if (!bot.bot?.entity) return `${params.botName} is connecting (no entity yet)`;
|
||||
|
||||
const e = bot.bot.entity;
|
||||
return `${params.botName}: online, health=${bot.bot.health}/20, food=${bot.bot.food}/20, pos=(${Math.round(e.position.x)},${Math.round(e.position.y)},${Math.round(e.position.z)})`;
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'bot_list_all',
|
||||
category: 'fleet',
|
||||
description: 'List all fleet bots and whether they are online or offline.',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const lines = [];
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
if (bot.isReady && bot.bot?.entity) {
|
||||
const e = bot.bot.entity;
|
||||
lines.push(`${name}: ONLINE (${Math.round(e.position.x)},${Math.round(e.position.y)},${Math.round(e.position.z)})`);
|
||||
} else {
|
||||
lines.push(`${name}: offline`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Storage — read-only (no bot needed)
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'storage_find',
|
||||
category: 'storage',
|
||||
description: 'Search the storage database for an item. Use when someone asks "how much X do we have" or "do you have any Y". Returns item names and counts. No bot needs to be online.',
|
||||
parameters: [
|
||||
{ name: 'itemName', type: 'string', required: true, description: 'Item name or partial name to search for' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const Database = require('../storage/database');
|
||||
const items = await Database.searchItems(params.itemName);
|
||||
if (!items || items.length === 0) return `Storage has no '${params.itemName}'`;
|
||||
return items.slice(0, 8).map(i => `${i.item_name}: ${i.total_count}`).join(', ');
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'storage_list',
|
||||
category: 'storage',
|
||||
description: 'List the most stocked items in storage. Use to see what is available.',
|
||||
parameters: [
|
||||
{ name: 'limit', type: 'number', required: false, description: 'Max results (default 10)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const Database = require('../storage/database');
|
||||
const items = await Database.searchItems(null);
|
||||
const limit = params.limit || 10;
|
||||
return (items || []).slice(0, limit).map(i => `${i.item_name}: ${i.total_count}`).join(', ');
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Storage — actions (requires storage bot)
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'storage_withdraw',
|
||||
category: 'storage',
|
||||
description: `Withdraw items from storage and deliver them to a player. This will: activate ${storageBotName} if offline, pull items from shulkers, move to the player, and open a trade. The whole process takes 30-90 seconds. Use this when someone asks you to get them items. After delivery, ${storageBotName} will auto-disconnect.`,
|
||||
parameters: [
|
||||
{ name: 'itemName', type: 'string', required: true, description: 'Item to withdraw (e.g. diamond, golden_carrot, iron_ingot)' },
|
||||
{ name: 'count', type: 'number', required: true, description: 'How many to withdraw (e.g. 64)' },
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Player to deliver to' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const storageBot = CJbot.bots[storageBotName];
|
||||
if (!storageBot) return `Storage bot '${storageBotName}' is not configured`;
|
||||
|
||||
// Step 1: ensure storage bot is online
|
||||
if (!storageBot.isReady) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
storageBot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
} catch (err) {
|
||||
return `Failed to bring ${storageBotName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: execute withdraw via storage plugin's handleCommand
|
||||
const storage = storageBot.plunginsLoaded['Storage'];
|
||||
if (!storage || typeof storage.handleCommand !== 'function') {
|
||||
return `Storage plugin is not loaded on ${storageBotName}`;
|
||||
}
|
||||
|
||||
// handleWithdrawRequest does the full flow: withdraw → trade with player
|
||||
// But we need to call it properly. It expects playerName to be the recipient.
|
||||
try {
|
||||
await storageBot.interruptTask('ai');
|
||||
await storage.handleWithdrawRequest(params.playerName, params.itemName, params.count);
|
||||
return `Withdrawing ${params.count} ${params.itemName} for ${params.playerName}. ${storageBotName} will trade with them shortly.`;
|
||||
} catch (err) {
|
||||
return `Storage withdraw failed: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'storage_scan',
|
||||
category: 'storage',
|
||||
description: `Bring ${storageBotName} online, scan the storage area to update the item database, then disconnect. Use when inventory might be stale. Takes ~30 seconds.`,
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const storageBot = CJbot.bots[storageBotName];
|
||||
if (!storageBot) return `Storage bot '${storageBotName}' is not configured`;
|
||||
|
||||
if (!storageBot.isReady) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
storageBot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
} catch (err) {
|
||||
return `Failed to bring ${storageBotName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
const storage = storageBot.plunginsLoaded['Storage'];
|
||||
if (!storage) return 'Storage plugin not loaded';
|
||||
|
||||
try {
|
||||
const result = await storage.handleCommand('ai', 'scan');
|
||||
// Auto-deactivate after scan since nothing else is queued
|
||||
return result;
|
||||
} catch (err) {
|
||||
return `Scan failed: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'storage_organize',
|
||||
category: 'storage',
|
||||
description: `Bring ${storageBotName} online and sort everything into place: unpack mixed shulkers, file loose items into the right shulkers, and consolidate partial ones. Use when someone asks to "put items away", "sort the storage", or after a big deposit. Can take several minutes.`,
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const storageBot = CJbot.bots[storageBotName];
|
||||
if (!storageBot) return `Storage bot '${storageBotName}' is not configured`;
|
||||
|
||||
if (!storageBot.isReady) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
storageBot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
} catch (err) {
|
||||
return `Failed to bring ${storageBotName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
const storage = storageBot.plunginsLoaded['Storage'];
|
||||
if (!storage) return 'Storage plugin not loaded';
|
||||
|
||||
// Long operation — run in the background so the face bot keeps
|
||||
// chatting; report the outcome via a system event when done
|
||||
const notify = (text) => {
|
||||
try {
|
||||
const { getInstance } = require('./manager');
|
||||
if (getInstance().isActive) getInstance().notifySystemEvent(text);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
storage.handleCommand('ai', 'organize')
|
||||
.then(result => notify(`${storageBotName} finished organizing storage: ${result}`))
|
||||
.catch(err => notify(`${storageBotName} organize failed: ${err.message}`));
|
||||
|
||||
return `${storageBotName} started organizing storage. It runs in the background and takes a few minutes; you'll get a system message when it finishes.`;
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'storage_status',
|
||||
category: 'storage',
|
||||
description: 'Get storage totals: item count, shulker count, chest count. No bot needs to be online.',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const Database = require('../storage/database');
|
||||
const stats = await Database.getStats();
|
||||
return `Storage: ${stats.totalItems} items in ${stats.totalShulkers} shulkers (${stats.totalChests} chests)`;
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Trade between bots
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'bot_trade_between',
|
||||
category: 'fleet',
|
||||
description: 'Orchestrate a trade between two bots. Both bots must be online. Bot A initiates /trade with Bot B, both accept, and items can transfer. Use for restocking — e.g. ez trades a shulker of shells to Art.',
|
||||
parameters: [
|
||||
{ name: 'fromBot', type: 'string', required: true, description: 'Bot that has the items (initiates trade)' },
|
||||
{ name: 'toBot', type: 'string', required: true, description: 'Bot receiving the items' },
|
||||
{ name: 'itemName', type: 'string', required: false, description: 'Specific item to move (optional, leave blank to move whatever is in pending withdrawals)' },
|
||||
{ name: 'count', type: 'number', required: false, description: 'Amount to move to the other bot (optional)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const fromWrapped = CJbot.bots[params.fromBot];
|
||||
const toWrapped = CJbot.bots[params.toBot];
|
||||
if (!fromWrapped || !fromWrapped.isReady) return `${params.fromBot} is not online`;
|
||||
if (!toWrapped || !toWrapped.isReady) return `${params.toBot} is not online`;
|
||||
|
||||
try {
|
||||
// fromBot sends trade request
|
||||
await fromWrapped.say(`/trade ${params.toBot}`);
|
||||
|
||||
// Wait for trade window to open on fromBot
|
||||
const window = await Promise.race([
|
||||
fromWrapped.once('windowOpen'),
|
||||
sleep(30000).then(() => null)
|
||||
]);
|
||||
|
||||
if (!window) return `Trade between ${params.fromBot} and ${params.toBot} timed out`;
|
||||
|
||||
// If fromBot is the storage bot with pending withdrawals, place those items
|
||||
const storage = fromWrapped.plunginsLoaded['Storage'];
|
||||
if (storage && typeof storage.placeWithdrawnItemsInTrade === 'function') {
|
||||
await storage.placeWithdrawnItemsInTrade(window, params.toBot);
|
||||
}
|
||||
|
||||
// fromBot confirms — single click (see anti-cheat note above)
|
||||
try { await fromWrapped.bot.clickWindow(37, 0, 0); } catch (e) { /* ignore */ }
|
||||
await sleep(1000);
|
||||
|
||||
// Wait for window to close (trade complete or timeout)
|
||||
await Promise.race([
|
||||
fromWrapped.once('windowClose'),
|
||||
sleep(60000)
|
||||
]);
|
||||
|
||||
// Close window if still open
|
||||
try { fromWrapped.bot.closeWindow(window); } catch (e) { /* ignore */ }
|
||||
|
||||
return `Trade from ${params.fromBot} to ${params.toBot} completed`;
|
||||
} catch (err) {
|
||||
return `Bot-to-bot trade failed: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// FarmSupply — deposit filled shulker boxes to storage
|
||||
// ========================================
|
||||
|
||||
tools.push({
|
||||
name: 'farm_empty_filled_boxes',
|
||||
category: 'farm',
|
||||
description: `Empty the "filled boxes" chest on a farm bot, depositing all filled shulker boxes into storage via ${storageBotName}. The farm bot pauses its action plugins, trades each batch of up to 12 shulkers to ${storageBotName}, then resumes. ${storageBotName} must be online or will be activated. Use when a farm bot's output chest is full and needs to be cleared. Takes 30-90 seconds.`,
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: false, description: `Which bot has the FarmSupply plugin (default: ${faceBotName}). Must have FarmSupply loaded.` }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const farmBotName = params.botName || faceBotName;
|
||||
const farmBot = CJbot.bots[farmBotName];
|
||||
if (!farmBot) return `Unknown bot: ${farmBotName}`;
|
||||
if (!farmBot.isReady) return `${farmBotName} is not online — activate it first`;
|
||||
|
||||
const fs = farmBot.plunginsLoaded['FarmSupply'];
|
||||
if (!fs) return `FarmSupply plugin is not loaded on ${farmBotName}`;
|
||||
if (fs._resupplying) return `${farmBotName} is already running a resupply, wait for it to finish`;
|
||||
|
||||
// Ensure storage bot is online
|
||||
const storageBot = CJbot.bots[storageBotName];
|
||||
if (!storageBot) return `Storage bot '${storageBotName}' is not configured`;
|
||||
if (!storageBot.isReady) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
storageBot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
_notify(`${storageBotName} activated for farm deposit`);
|
||||
} catch (err) {
|
||||
return `Failed to bring ${storageBotName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Pause farm plugins, empty boxes, resume
|
||||
try {
|
||||
const paused = fs.pauseFarmPlugins();
|
||||
_notify(`${farmBotName} paused farm plugins, emptying filled boxes to storage`);
|
||||
await fs.emptyFilledBoxes();
|
||||
await fs.resumeFarmPlugins(paused);
|
||||
_notify(`${farmBotName} finished emptying filled boxes, farm plugins resumed`);
|
||||
return `${farmBotName} emptied all filled shulker boxes to storage. Farm plugins resumed.`;
|
||||
} catch (err) {
|
||||
return `Failed to empty filled boxes on ${farmBotName}: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'farm_fill_empty_shulkers',
|
||||
category: 'farm',
|
||||
description: `Refill the "empty shulkers" chest on a farm bot. Withdraws shulker_shells and chests from ${storageBotName} if needed, crafts shulker boxes, and deposits them. ${storageBotName} must be online or will be activated. Use when the farm bot is out of empty shulker boxes or when someone asks to "refill the empty shulkers at the farm". Takes 30-90 seconds.`,
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: false, description: `Which bot has the FarmSupply plugin (default: ${faceBotName}). Must have FarmSupply loaded.` }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const farmBotName = params.botName || faceBotName;
|
||||
const farmBot = CJbot.bots[farmBotName];
|
||||
if (!farmBot) return `Unknown bot: ${farmBotName}`;
|
||||
if (!farmBot.isReady) return `${farmBotName} is not online — activate it first`;
|
||||
|
||||
const fs = farmBot.plunginsLoaded['FarmSupply'];
|
||||
if (!fs) return `FarmSupply plugin is not loaded on ${farmBotName}`;
|
||||
if (fs._resupplying) return `${farmBotName} is already running a resupply, wait for it to finish`;
|
||||
|
||||
// Ensure storage bot is online
|
||||
const storageBot = CJbot.bots[storageBotName];
|
||||
if (!storageBot) return `Storage bot '${storageBotName}' is not configured`;
|
||||
if (!storageBot.isReady) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
storageBot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
_notify(`${storageBotName} activated for empty shulker refill`);
|
||||
} catch (err) {
|
||||
return `Failed to bring ${storageBotName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
_notify(`${farmBotName} refilling empty shulkers chest`);
|
||||
await fs.fillEmptyShulkers();
|
||||
_notify(`${farmBotName} empty shulkers chest refilled`);
|
||||
return `${farmBotName} empty shulkers chest refilled with freshly crafted shulker boxes.`;
|
||||
} catch (err) {
|
||||
return `Failed to fill empty shulkers on ${farmBotName}: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'farm_resupply',
|
||||
category: 'farm',
|
||||
description: `Run the full resupply cycle on a farm bot: pause farm plugins, empty "filled boxes" chest to ${storageBotName}, refill "empty shulkers" chest (crafting shulker boxes if needed), resume farm plugins. ${storageBotName} will be activated if offline. Takes 1-3 minutes. Use when a farm needs complete resupply or when someone asks to "restock the farm".`,
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: false, description: `Which bot has the FarmSupply plugin (default: ${faceBotName})` }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const farmBotName = params.botName || faceBotName;
|
||||
const farmBot = CJbot.bots[farmBotName];
|
||||
if (!farmBot) return `Unknown bot: ${farmBotName}`;
|
||||
if (!farmBot.isReady) return `${farmBotName} is not online — activate it first`;
|
||||
|
||||
const fs = farmBot.plunginsLoaded['FarmSupply'];
|
||||
if (!fs) return `FarmSupply plugin is not loaded on ${farmBotName}`;
|
||||
if (fs._resupplying) return `${farmBotName} is already running a resupply, wait for it to finish`;
|
||||
|
||||
// Ensure storage bot is online
|
||||
const storageBot = CJbot.bots[storageBotName];
|
||||
if (!storageBot) return `Storage bot '${storageBotName}' is not configured`;
|
||||
if (!storageBot.isReady) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Connection timed out after 45s')), 45000);
|
||||
storageBot.ensureConnected(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}).catch(err => { clearTimeout(timeout); reject(err); });
|
||||
});
|
||||
_notify(`${storageBotName} activated for farm resupply`);
|
||||
} catch (err) {
|
||||
return `Failed to bring ${storageBotName} online: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Run full resupply
|
||||
try {
|
||||
_notify(`${farmBotName} starting full farm resupply (empty + refill)`);
|
||||
await fs.resupply();
|
||||
_notify(`${farmBotName} resupply complete, farm resumed`);
|
||||
return `${farmBotName} resupply complete: filled boxes emptied to storage, empty shulkers chest refilled, farm plugins resumed.`;
|
||||
} catch (err) {
|
||||
return `Resupply on ${farmBotName} failed: ${err.message}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Settings tools
|
||||
// ========================================
|
||||
|
||||
tools.push(
|
||||
{
|
||||
name: 'list_settings',
|
||||
category: 'settings',
|
||||
description: 'List all application settings grouped by category (ai, storage, farm). Use to see what is configurable and their current values.',
|
||||
parameters: [
|
||||
{ name: 'category', type: 'string', required: false, description: 'Filter by category: ai, storage, or farm' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const settings = require('../settings/manager');
|
||||
const all = params.category
|
||||
? settings.getAllByCategory(params.category)
|
||||
: settings.getAll();
|
||||
const registry = settings.getRegistry();
|
||||
const lines = [];
|
||||
for (const r of registry) {
|
||||
if (params.category && r.category !== params.category) continue;
|
||||
lines.push(`${r.key}: ${JSON.stringify(all[r.key])} [${r.category}] ${r.description}`);
|
||||
}
|
||||
return lines.join('\n') || 'No settings found';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_setting',
|
||||
category: 'settings',
|
||||
description: 'Get the current value of a specific setting. Use before changing a setting to see its current state.',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Setting key (e.g., ai.temperature, storage.scanRadius)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const settings = require('../settings/manager');
|
||||
const value = settings.get(params.key);
|
||||
const registry = settings.getRegistry().find(r => r.key === params.key);
|
||||
const desc = registry ? ` (${registry.description})` : '';
|
||||
return value !== undefined
|
||||
? `${params.key} = ${JSON.stringify(value)}${desc}`
|
||||
: `Unknown setting: ${params.key}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'set_setting',
|
||||
category: 'settings',
|
||||
description: 'Change a setting value. Use to update AI behavior, storage config, or farm supply config. Changes take effect on the next AI poll cycle or immediately for ai.* settings.',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Setting key to change (e.g., ai.temperature, storage.scanRadius)' },
|
||||
{ name: 'value', type: 'string', required: true, description: 'New value (numbers and booleans as strings are auto-converted)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const settings = require('../settings/manager');
|
||||
try {
|
||||
const newValue = await settings.set(params.key, params.value);
|
||||
return `Set ${params.key} = ${JSON.stringify(newValue)}`;
|
||||
} catch (err) {
|
||||
return `Failed to set ${params.key}: ${err.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
tools.push(
|
||||
{
|
||||
name: 'list_prompts',
|
||||
category: 'settings',
|
||||
description: 'List all available AI prompt names and their template previews. Use to see what personalities are available.',
|
||||
parameters: [],
|
||||
execute: async (params) => {
|
||||
const settings = require('../settings/manager');
|
||||
const prompts = settings.get('ai.prompts') || {};
|
||||
const names = Object.keys(prompts);
|
||||
if (names.length === 0) return 'No prompts configured.';
|
||||
const currentName = settings.get('ai.promptName');
|
||||
const lines = names.map(n => {
|
||||
const marker = n === currentName ? ' [ACTIVE]' : '';
|
||||
const preview = (prompts[n] || '').substring(0, 80).replace(/\n/g, ' ');
|
||||
return `- ${n}${marker}: ${preview}...`;
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// Memory tools
|
||||
// ========================================
|
||||
|
||||
tools.push(
|
||||
{
|
||||
name: 'remember_player',
|
||||
category: 'memory',
|
||||
description: 'Store a fact about a player so you remember it forever (survives restarts, shared with the whole fleet). Use PROACTIVELY the moment you learn something — a player mentions their base, their project, a friend, a preference. Same key overwrites, so use it to update facts too.',
|
||||
parameters: [
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Player the fact is about (not necessarily who told you)' },
|
||||
{ name: 'key', type: 'string', required: true, description: 'Short snake_case key: base_location, current_project, trust, friend_of, notes' },
|
||||
{ name: 'value', type: 'string', required: true, description: 'The fact, one sentence' }
|
||||
],
|
||||
execute: (p) => memoryDB.setPlayerMemory(p.playerName, p.key, p.value)
|
||||
.then(() => `Stored ${p.key}=${p.value} for ${p.playerName}`)
|
||||
},
|
||||
{
|
||||
name: 'forget_player',
|
||||
category: 'memory',
|
||||
description: 'Delete one stored fact about a player (when it was wrong or is obsolete).',
|
||||
parameters: [
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Player name' },
|
||||
{ name: 'key', type: 'string', required: true, description: 'Memory key to delete' }
|
||||
],
|
||||
execute: (p) => memoryDB.deletePlayerMemory(p.playerName, p.key)
|
||||
.then(() => `Forgot ${p.key} for ${p.playerName}`)
|
||||
},
|
||||
{
|
||||
name: 'recall_player',
|
||||
category: 'memory',
|
||||
description: 'Retrieve all stored memories about a player.',
|
||||
parameters: [
|
||||
{ name: 'playerName', type: 'string', required: true, description: 'Player name' }
|
||||
],
|
||||
execute: async (p) => {
|
||||
const m = await memoryDB.getAllPlayerMemories(p.playerName);
|
||||
const keys = Object.keys(m);
|
||||
return keys.length ? `Memories: ${JSON.stringify(m)}` : `No memories for ${p.playerName}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'list_known_players',
|
||||
category: 'memory',
|
||||
description: 'List all players you have stored memories about.',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const players = await memoryDB.getAllKnownPlayers();
|
||||
return players.length ? `Known players: ${players.join(', ')}` : 'No known players yet';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'set_directive',
|
||||
category: 'memory',
|
||||
description: 'Set a persistent instruction for yourself (current_goal, mood, focus). Survives restarts.',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Directive key' },
|
||||
{ name: 'value', type: 'string', required: true, description: 'Directive value' }
|
||||
],
|
||||
execute: (p) => memoryDB.setDirective(faceBotName, p.key, p.value)
|
||||
.then(() => `Directive set: ${p.key}=${p.value}`)
|
||||
},
|
||||
{
|
||||
name: 'get_directive',
|
||||
category: 'memory',
|
||||
description: 'Retrieve a specific directive you set.',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Directive key' }
|
||||
],
|
||||
execute: async (p) => {
|
||||
const d = await memoryDB.getDirective(faceBotName, p.key);
|
||||
return d !== null ? `${p.key}=${d}` : `No directive for '${p.key}'`;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'list_directives',
|
||||
category: 'memory',
|
||||
description: 'List all active directives.',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const all = await memoryDB.getAllDirectives(faceBotName);
|
||||
const keys = Object.keys(all);
|
||||
return keys.length ? `Directives: ${JSON.stringify(all)}` : 'No active directives';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// Settings (global and per-bot)
|
||||
// ========================================
|
||||
|
||||
const SettingsManager = require('../settings/manager');
|
||||
|
||||
tools.push({
|
||||
name: 'settings_list',
|
||||
category: 'settings',
|
||||
description: 'List ALL application settings by category. Returns key, type, current value, label, and description. Use this to see what can be configured. Secret values (passwords, API keys) appear as "***".',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const all = SettingsManager.getAll();
|
||||
const registry = SettingsManager.getRegistry();
|
||||
const cats = {};
|
||||
for (const r of registry) {
|
||||
const cat = r.category;
|
||||
if (!cats[cat]) cats[cat] = [];
|
||||
let displayValue = all[r.key];
|
||||
if (r.type === 'secret' && typeof displayValue === 'string' && displayValue.length > 0) {
|
||||
displayValue = '***';
|
||||
}
|
||||
cats[cat].push(`${r.key}=${JSON.stringify(displayValue)} (${r.type}: ${r.description})`);
|
||||
}
|
||||
const parts = [];
|
||||
for (const [cat, items] of Object.entries(cats)) {
|
||||
parts.push(`== ${cat} ==\n${items.join('\n')}`);
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'settings_get',
|
||||
category: 'settings',
|
||||
description: 'Get a single setting value by key path (e.g. "ai.model", "storage.scanRadius").',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Setting key, e.g. ai.model, storage.scanRadius' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const registry = SettingsManager.getRegistry();
|
||||
const entry = registry.find(r => r.key === params.key);
|
||||
if (!entry) return `Unknown setting: ${params.key}`;
|
||||
let val = SettingsManager.get(params.key);
|
||||
if (entry.type === 'secret' && typeof val === 'string' && val.length > 0) {
|
||||
val = '***';
|
||||
}
|
||||
return `${params.key}=${JSON.stringify(val)} (${entry.type}, ${entry.category}: ${entry.description})`;
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'settings_set',
|
||||
category: 'settings',
|
||||
description: 'Change a setting value. Accepts string, number, boolean, or JSON. Changes persist across restarts. Use this to reconfigure the system at runtime.',
|
||||
parameters: [
|
||||
{ name: 'key', type: 'string', required: true, description: 'Setting key to change, e.g. ai.temperature, storage.scanRadius' },
|
||||
{ name: 'value', type: 'string', required: true, description: 'New value (will be coerced to the setting type)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const newVal = await SettingsManager.set(params.key, params.value);
|
||||
return `Set ${params.key}=${JSON.stringify(newVal)}`;
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'settings_list_bots',
|
||||
category: 'settings',
|
||||
description: 'List all bots and their configuration. Shows autoConnect, onDemand, idleTimeout, commands, plugins, etc. Passwords are redacted.',
|
||||
parameters: [],
|
||||
execute: async () => {
|
||||
const names = SettingsManager.getBotNames();
|
||||
if (!names.length) return 'No bots configured';
|
||||
const botReg = SettingsManager.getBotSettingsRegistry();
|
||||
const parts = [];
|
||||
for (const name of names) {
|
||||
const settings = SettingsManager.getBotSettings(name);
|
||||
if (!settings) continue;
|
||||
const lines = [`== ${name} ==`];
|
||||
for (const br of botReg) {
|
||||
let val = settings[br.key];
|
||||
if (br.type === 'secret' && typeof val === 'string' && val.length > 0) {
|
||||
val = '***';
|
||||
}
|
||||
lines.push(` ${br.key}=${JSON.stringify(val)} (${br.type})`);
|
||||
}
|
||||
parts.push(lines.join('\n'));
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'settings_get_bot',
|
||||
category: 'settings',
|
||||
description: 'Get a single bot\'s full configuration by name.',
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Bot name (e.g. art, ez, henry)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const settings = SettingsManager.getBotSettings(params.botName);
|
||||
if (!settings) return `Unknown bot: ${params.botName}`;
|
||||
const botReg = SettingsManager.getBotSettingsRegistry();
|
||||
const lines = [`== ${params.botName} ==`];
|
||||
for (const br of botReg) {
|
||||
let val = settings[br.key];
|
||||
if (br.type === 'secret' && typeof val === 'string' && val.length > 0) {
|
||||
val = '***';
|
||||
}
|
||||
lines.push(` ${br.key}=${JSON.stringify(val)} (${br.type}: ${br.label})`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'settings_set_bot',
|
||||
category: 'settings',
|
||||
description: 'Change a bot configuration value. Changes apply immediately to live bots where possible. Use this to enable/disable bots, change their timeouts, modify plugins, update auth credentials, etc.',
|
||||
parameters: [
|
||||
{ name: 'botName', type: 'string', required: true, description: 'Bot name to configure (e.g. art, ez, henry)' },
|
||||
{ name: 'key', type: 'string', required: true, description: 'Setting key: username, password, auth, autoConnect, autoReConnect, onDemand, idleTimeout, commands, plugins, hasAi' },
|
||||
{ name: 'value', type: 'string', required: true, description: 'New value (coerced to setting type)' }
|
||||
],
|
||||
execute: async (params) => {
|
||||
const newVal = await SettingsManager.setBotSetting(params.botName, params.key, params.value);
|
||||
const displayVal = (typeof newVal === 'string' && newVal.length > 0 && params.key === 'password') ? '***' : JSON.stringify(newVal);
|
||||
return `Set ${params.botName}.${params.key}=${displayVal}`;
|
||||
}
|
||||
});
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
module.exports = { buildFleetTools };
|
||||
@@ -0,0 +1,867 @@
|
||||
'use strict';
|
||||
|
||||
const conf = require('../../conf');
|
||||
const { sleep } = require('../../utils');
|
||||
const { ProviderFactory } = require('./providers');
|
||||
|
||||
function compilePrompt(templateStr) {
|
||||
return new Function('name', 'interval', 'currentPlayers', 'toolsDocs', 'memoryContext', 'timeInfo', 'custom',
|
||||
'return `' + templateStr + '`');
|
||||
}
|
||||
const memoryDB = require('./memory-db');
|
||||
const { buildFleetTools } = require('./fleet-tools');
|
||||
|
||||
class AiManager {
|
||||
constructor() {
|
||||
this._provider = null;
|
||||
this._pollTimer = null;
|
||||
this._polling = false;
|
||||
this._faceBot = null;
|
||||
this._messages = [];
|
||||
this._allTools = [];
|
||||
this._memoryDB = memoryDB;
|
||||
this._active = false;
|
||||
this._config = null;
|
||||
this._consecutiveFailures = 0;
|
||||
this._backoffUntil = 0;
|
||||
this._lastSentMessages = [];
|
||||
this._messageListener = null;
|
||||
this._tradeWindow = null;
|
||||
this._tradeWindowState = null;
|
||||
this._expectingTradeWindow = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the manager on a face bot. Idempotent — subsequent calls
|
||||
* with different bots are no-ops. Call shutdown() first to transfer.
|
||||
*/
|
||||
async init(faceBot, configOverride) {
|
||||
if (this._active) {
|
||||
if (this._faceBot === faceBot) {
|
||||
console.log('AiManager: already running on', faceBot.name);
|
||||
return;
|
||||
}
|
||||
console.log('AiManager: already running on', this._faceBot.name,
|
||||
'— shutdown first before re-initting on', faceBot.name);
|
||||
return;
|
||||
}
|
||||
|
||||
this._faceBot = faceBot;
|
||||
this._config = { ...conf.ai, ...configOverride };
|
||||
|
||||
// Override runtime-tunable AI settings from DB (config file provides defaults)
|
||||
const settings = require('../settings/manager');
|
||||
this._config.provider = settings.get('ai.provider');
|
||||
this._config.model = settings.get('ai.model');
|
||||
this._config.temperature = settings.get('ai.temperature');
|
||||
this._config.topP = settings.get('ai.topP');
|
||||
this._config.topK = settings.get('ai.topK');
|
||||
this._config.interval = settings.get('ai.interval');
|
||||
this._config.timeout = settings.get('ai.timeout');
|
||||
this._config.promptName = settings.get('ai.promptName');
|
||||
this._config.enableNativeTools = settings.get('ai.enableNativeTools');
|
||||
this._config.baseUrl = settings.get('ai.baseUrl');
|
||||
this._config.key = settings.get('ai.key');
|
||||
this._config.faceBot = settings.get('ai.faceBot');
|
||||
this._config.storageBot = settings.get('ai.storageBot');
|
||||
|
||||
// Initialize memory DB (first init creates DB, subsequent are no-ops)
|
||||
await this._memoryDB.initialize('./storage/ai-memory.db', faceBot.name);
|
||||
|
||||
// Build fleet-wide tools
|
||||
this._allTools = buildFleetTools(this._config, this._memoryDB);
|
||||
console.log(`AiManager: ${this._allTools.length} fleet tools built`);
|
||||
|
||||
// Create the ONE provider
|
||||
const prompt = await this._buildPrompt();
|
||||
this._provider = ProviderFactory.create({
|
||||
...this._config,
|
||||
prompt,
|
||||
});
|
||||
|
||||
// Set tool schemas if provider supports native function calling AND enabled in config.
|
||||
// Most 9B models crash on native tool schemas (Ollama 500: "XML syntax error").
|
||||
// When disabled, the LLM sees tools described in the prompt text and we parse
|
||||
// tool calls from the JSON response via _extractToolCalls fallback.
|
||||
if (this._provider.supportsTools && this._provider.supportsTools() && this._config.enableNativeTools) {
|
||||
this._provider.setTools(this._getToolsSchema());
|
||||
console.log('AiManager: native tool schemas set on provider');
|
||||
} else if (this._config.enableNativeTools) {
|
||||
console.log('AiManager: provider does not support tools, using text-based fallback');
|
||||
} else {
|
||||
console.log('AiManager: native tools disabled via config, using text-based tool descriptions');
|
||||
}
|
||||
|
||||
await this._provider.start();
|
||||
console.log(`AiManager: provider started (${this._config.provider}, model=${this._config.model})`);
|
||||
|
||||
// Set up message listener on the face bot
|
||||
this._setupMessageListener();
|
||||
|
||||
// Start poll timer
|
||||
this._active = true;
|
||||
this._startPolling();
|
||||
console.log(`AiManager: running on ${faceBot.name}, interval=${this._config.interval}s`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the system prompt without restarting the provider.
|
||||
* Used by the .ai chat command to change personality on the fly.
|
||||
*/
|
||||
async reloadPrompt(promptName, prompCustom) {
|
||||
if (!this._active || !this._provider) return false;
|
||||
if (promptName !== undefined) this._config.promptName = promptName;
|
||||
if (prompCustom !== undefined) this._config.prompCustom = prompCustom;
|
||||
const prompt = await this._buildPrompt();
|
||||
if (typeof this._provider.setPrompt === 'function') {
|
||||
this._provider.setPrompt(prompt);
|
||||
} else {
|
||||
await this._provider.close();
|
||||
this._provider = ProviderFactory.create({ ...this._config, prompt });
|
||||
if (this._provider.supportsTools && this._provider.supportsTools() && this._config.enableNativeTools) {
|
||||
this._provider.setTools(this._getToolsSchema());
|
||||
}
|
||||
await this._provider.start();
|
||||
}
|
||||
console.log(`AiManager: prompt reloaded — ${this._config.promptName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this._active = false;
|
||||
|
||||
if (this._pollTimer) {
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
|
||||
if (this._messageListener) {
|
||||
this._messageListener();
|
||||
this._messageListener = null;
|
||||
}
|
||||
|
||||
if (this._provider) {
|
||||
try { await this._provider.close(); } catch (e) { /* ignore */ }
|
||||
this._provider = null;
|
||||
}
|
||||
|
||||
this._messages = [];
|
||||
this._faceBot = null;
|
||||
console.log('AiManager: shut down');
|
||||
}
|
||||
|
||||
get isActive() { return this._active; }
|
||||
get faceBotName() { return this._faceBot ? this._faceBot.name : null; }
|
||||
|
||||
/**
|
||||
* Called by SettingsManager when an ai.* setting is changed.
|
||||
* Updates the in-memory config immediately. Provider re-creation
|
||||
* happens on the next poll cycle if model/provider/key changed.
|
||||
*/
|
||||
onSettingChanged(key, newValue) {
|
||||
if (!this._config) return;
|
||||
const shortKey = key.replace('ai.', '');
|
||||
console.log(`AiManager: setting changed — ${key} = ${JSON.stringify(newValue)}`);
|
||||
this._config[shortKey] = newValue;
|
||||
|
||||
if (['promptName', 'prompCustom', 'prompts'].includes(shortKey)) {
|
||||
this.reloadPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Let other bots/plugins inject a system notification into the face bot's
|
||||
* message queue so the LLM knows about trade completions, errors, etc.
|
||||
* Call this whenever a fleet bot does something the LLM should know about.
|
||||
*/
|
||||
notifySystemEvent(text) {
|
||||
if (!this._active) return;
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
this._messages.push({
|
||||
type: 'system',
|
||||
text: `[SYSTEM] ${text}`,
|
||||
timestamp,
|
||||
timeAgo: this._getTimeAgo(timestamp),
|
||||
});
|
||||
console.log(`AiManager: system event — ${text}`);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Message listener
|
||||
// ========================================
|
||||
|
||||
_setupMessageListener() {
|
||||
this._messageListener = this._faceBot.on('message', (message, type) => {
|
||||
const msgText = message.toString();
|
||||
|
||||
// Log ALL messages the face bot receives (for debugging)
|
||||
const cleanText = msgText.replace(/\n/g, '\\n').substring(0, 300);
|
||||
console.log(`[MC→${this._faceBot.name}] (${type || 'unknown'}) ${cleanText}`);
|
||||
|
||||
if (type === 'game_info') return;
|
||||
|
||||
// Skip messages from the face bot itself
|
||||
if (msgText.startsWith('<')) {
|
||||
const firstBracket = msgText.split('>')[0];
|
||||
const userMatch = firstBracket.match(/^<\[?.*?\]?\s*(\w+)>$/);
|
||||
if (userMatch) {
|
||||
const speakerName = userMatch[1];
|
||||
if (speakerName === this._faceBot.bot.entity.username) return;
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
this._messages.push({
|
||||
type: 'message',
|
||||
text: msgText,
|
||||
timestamp,
|
||||
timeAgo: this._getTimeAgo(timestamp),
|
||||
});
|
||||
});
|
||||
|
||||
// Monitor trade windows for feedback loop
|
||||
this._faceBot.bot.on('windowOpen', (window) => {
|
||||
if (!this._tradeWindow && this._expectingTradeWindow && window.slots && window.slots.length >= 54) {
|
||||
this._expectingTradeWindow = false;
|
||||
console.log('AiManager: trade window detected, capturing state');
|
||||
this._setupTradeWindow(window);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Polling
|
||||
// ========================================
|
||||
|
||||
_startPolling() {
|
||||
const intervalMs = (this._config.interval || 10) * 1000;
|
||||
this._pollTimer = setInterval(() => this._pollCycle(), intervalMs);
|
||||
}
|
||||
|
||||
async _pollCycle() {
|
||||
if (!this._active || Date.now() < this._backoffUntil) return;
|
||||
if (this._polling) return;
|
||||
this._polling = true;
|
||||
|
||||
try {
|
||||
// Snapshot and reset
|
||||
const currentMessages = [...this._messages];
|
||||
this._messages = [];
|
||||
|
||||
const hasData = currentMessages.some(m => typeof m === 'object' && m.text);
|
||||
const hasTrade = !!this._tradeWindowState;
|
||||
if (!hasData && !hasTrade) return;
|
||||
|
||||
// Build request data
|
||||
const requestData = {
|
||||
botName: this._faceBot.name,
|
||||
messages: currentMessages,
|
||||
currentTime: new Date().toLocaleString('sv-SE'),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
tradeWindow: this._tradeWindowState,
|
||||
};
|
||||
|
||||
// Refresh the system prompt with current memories and online players.
|
||||
// Ollama re-sends the system prompt on every request, so new
|
||||
// memories take effect immediately. (Gemini bakes the prompt into
|
||||
// session history and only picks this up on provider restart.)
|
||||
try {
|
||||
if (typeof this._provider.setPrompt === 'function') {
|
||||
this._provider.setPrompt(await this._buildPrompt());
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('AiManager: prompt refresh failed:', e.message);
|
||||
}
|
||||
|
||||
console.log(`AiManager: poll cycle — ${currentMessages.length} messages`);
|
||||
let result;
|
||||
try {
|
||||
result = await this._provider.chat(JSON.stringify(requestData));
|
||||
} catch (error) {
|
||||
console.log('AiManager: API error:', error.message);
|
||||
this._consecutiveFailures++;
|
||||
const backoffMs = Math.min(1000 * Math.pow(2, this._consecutiveFailures), 30000);
|
||||
this._backoffUntil = Date.now() + backoffMs;
|
||||
console.log(`AiManager: backoff ${backoffMs}ms (#${this._consecutiveFailures})`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._consecutiveFailures = 0;
|
||||
this._backoffUntil = 0;
|
||||
|
||||
// Check for tool calls
|
||||
const requestingPlayer = this._getLastSpeaker(currentMessages);
|
||||
const toolCalls = this._extractToolCalls(result);
|
||||
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
const seen = new Set();
|
||||
const uniqueCalls = toolCalls.filter(tc => {
|
||||
const key = `${tc.name}:${JSON.stringify(tc.args || {})}`;
|
||||
if (seen.has(key)) { console.log('AiManager: deduplicating tool call:', key); return false; }
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
console.log(`AiManager: ${uniqueCalls.length} tool calls — ${uniqueCalls.map(c => c.name).join(', ')}`);
|
||||
|
||||
// Execute tools
|
||||
const toolResults = [];
|
||||
for (const tc of uniqueCalls) {
|
||||
try {
|
||||
const toolResult = await this._executeTool(tc.name, tc.args || {}, requestingPlayer);
|
||||
toolResults.push({ name: tc.name, result: toolResult, success: true });
|
||||
} catch (execError) {
|
||||
console.error('AiManager: tool execution error:', execError);
|
||||
toolResults.push({ name: tc.name, error: execError.message, success: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Follow-up: send tool results back to LLM for natural language response
|
||||
if (toolResults.length > 0) {
|
||||
// Build current fleet status for context
|
||||
const fleetStatus = this._getLiveFleetStatus();
|
||||
|
||||
const followupMsg = JSON.stringify({
|
||||
toolResults,
|
||||
fleetStatus,
|
||||
tradeWindow: this._tradeWindowState,
|
||||
instruction: `Tool results and fleet status above.
|
||||
- Fleet bots listed as ONLINE can be interacted with directly by players via /trade.
|
||||
- If ${this._config.storageBot} is ONLINE, the player can trade with them without you doing anything.
|
||||
- Reply with ONE brief message (max 150 chars) as a plain JSON array: [{"text":"...","delay":0}].
|
||||
- Be natural and casual. If the tool failed, apologize naturally. NEVER mention "tool", "bot", or "AI".`
|
||||
});
|
||||
|
||||
try {
|
||||
const followupResult = await this._provider.chat(followupMsg);
|
||||
const responseText = this._provider.getResponse(followupResult);
|
||||
await this._processResponse(responseText);
|
||||
} catch (e) {
|
||||
console.error('AiManager: follow-up chat error:', e);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No tool calls — normal chat response
|
||||
const responseText = this._provider.getResponse(result);
|
||||
await this._processResponse(responseText);
|
||||
|
||||
} catch (error) {
|
||||
console.error('AiManager: poll cycle error:', error);
|
||||
} finally {
|
||||
this._polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Tool execution
|
||||
// ========================================
|
||||
|
||||
async _executeTool(toolName, params, from) {
|
||||
const tool = this._allTools.find(t => t.name === toolName);
|
||||
if (!tool) throw new Error(`Unknown tool: ${toolName}`);
|
||||
console.log(`AiManager: executing ${toolName}`, params);
|
||||
return tool.execute(params, from);
|
||||
}
|
||||
|
||||
_extractToolCalls(result) {
|
||||
// Gemini native function calls
|
||||
if (result.response && typeof result.response.functionCalls === 'function') {
|
||||
const calls = result.response.functionCalls();
|
||||
if (calls && calls.length > 0) {
|
||||
return calls.map(call => ({ name: call.name, args: call.args }));
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama native tool_calls
|
||||
if (result.tool_calls && result.tool_calls.length > 0) {
|
||||
return result.tool_calls.map(tc => ({
|
||||
name: tc.name || tc.function?.name,
|
||||
args: tc.args || tc.arguments || tc.function?.arguments || {},
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback: parse JSON from response text
|
||||
const responseText = result.response ? result.response.text() : null;
|
||||
if (responseText) {
|
||||
try {
|
||||
const parsed = JSON.parse(responseText);
|
||||
if (parsed.tool_call) {
|
||||
return [{ name: parsed.tool_call.name, args: parsed.tool_call.args || parsed.tool_call.arguments || {} }];
|
||||
}
|
||||
if (Array.isArray(parsed.tool_calls)) {
|
||||
return parsed.tool_calls.map(tc => ({
|
||||
name: tc.name || tc.function?.name,
|
||||
args: tc.args || tc.arguments || tc.function?.arguments || {},
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, no tool calls
|
||||
}
|
||||
|
||||
// Final fallback: detect tool names embedded in chat text
|
||||
// This catches models that output [{"text":"storage_find stone","delay":0}]
|
||||
const detectedCalls = this._detectTextToolCalls(responseText);
|
||||
if (detectedCalls) return detectedCalls;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect when the LLM embeds tool names in chat message text instead of
|
||||
* using the proper tool_call format. Parses JSON arrays like
|
||||
* [{"text":"storage_find stone","delay":0}] and converts to tool calls.
|
||||
*/
|
||||
_detectTextToolCalls(responseText) {
|
||||
try {
|
||||
const parsed = JSON.parse(responseText);
|
||||
|
||||
// Handle JSON array of chat messages: [{"text":"storage_find stone","delay":0}]
|
||||
if (Array.isArray(parsed)) {
|
||||
const toolNames = this._allTools.map(t => t.name);
|
||||
const toolCalls = [];
|
||||
|
||||
for (const msg of parsed) {
|
||||
const text = (msg.text || '').trim();
|
||||
if (!text || text === '_' || text.length < 3) continue;
|
||||
|
||||
for (const toolName of toolNames) {
|
||||
if (text === toolName || text.startsWith(toolName + ' ')) {
|
||||
const argsStr = text.substring(toolName.length).trim();
|
||||
const args = this._parseTextArgs(argsStr, toolName);
|
||||
console.log(`AiManager: detected text tool call in chat array: ${toolName}`, args);
|
||||
toolCalls.push({ name: toolName, args });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toolCalls.length > 0 ? toolCalls : null;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON — check if the raw text starts with a known tool name
|
||||
const text = (responseText || '').trim();
|
||||
if (text.length < 3 || text.startsWith('{') || text.startsWith('[')) return null;
|
||||
|
||||
const toolNames = this._allTools.map(t => t.name);
|
||||
for (const toolName of toolNames) {
|
||||
if (text === toolName || text.startsWith(toolName + ' ')) {
|
||||
const argsStr = text.substring(toolName.length).trim();
|
||||
const args = this._parseTextArgs(argsStr, toolName);
|
||||
console.log(`AiManager: detected text tool call in raw text: ${toolName}`, args);
|
||||
return [{ name: toolName, args }];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse space-separated text args into a structured object based on the
|
||||
* tool's parameter definitions. Handles formats like:
|
||||
* "stone" → {itemName: "stone"}
|
||||
* "stone 64 wmantly" → {itemName: "stone", count: 64, playerName: "wmantly"}
|
||||
*/
|
||||
_parseTextArgs(argsStr, toolName) {
|
||||
const tool = this._allTools.find(t => t.name === toolName);
|
||||
if (!tool || !tool.parameters || tool.parameters.length === 0) return {};
|
||||
|
||||
const parts = argsStr.split(/\s+/);
|
||||
const params = tool.parameters;
|
||||
const args = {};
|
||||
|
||||
for (let i = 0; i < Math.min(parts.length, params.length); i++) {
|
||||
const value = parts[i];
|
||||
const paramType = params[i].type || 'string';
|
||||
|
||||
if (paramType === 'number') {
|
||||
const num = parseInt(value, 10);
|
||||
args[params[i].name] = isNaN(num) ? value : num;
|
||||
} else {
|
||||
args[params[i].name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
_getToolsSchema() {
|
||||
return this._allTools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: (t.parameters || []).reduce((acc, p) => {
|
||||
acc[p.name] = { type: p.type || 'string', description: p.description || '' };
|
||||
return acc;
|
||||
}, {}),
|
||||
required: (t.parameters || []).filter(p => p.required).map(p => p.name),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Prompt building
|
||||
// ========================================
|
||||
|
||||
async _buildPrompt() {
|
||||
const settings = require('../settings/manager');
|
||||
const allPrompts = settings.get('ai.prompts') || {};
|
||||
|
||||
const promptName = this._config.promptName || 'asshole';
|
||||
let templateStr = allPrompts[promptName];
|
||||
|
||||
let promptFn;
|
||||
if (templateStr) {
|
||||
try {
|
||||
promptFn = compilePrompt(templateStr);
|
||||
} catch (e) {
|
||||
console.warn(`AiManager: failed to compile prompt '${promptName}', falling back to config:`, e.message);
|
||||
promptFn = conf.ai.prompts[promptName];
|
||||
}
|
||||
} else {
|
||||
console.warn(`AiManager: prompt '${promptName}' not found in DB or config, falling back to asshole`);
|
||||
templateStr = allPrompts['asshole'];
|
||||
if (templateStr) {
|
||||
try { promptFn = compilePrompt(templateStr); }
|
||||
catch (e) { promptFn = conf.ai.prompts['asshole']; }
|
||||
} else {
|
||||
promptFn = conf.ai.prompts['asshole'];
|
||||
}
|
||||
this._config.promptName = 'asshole';
|
||||
}
|
||||
|
||||
if (!promptFn) {
|
||||
console.warn(`AiManager: prompt '${promptName}' not found, falling back to asshole`);
|
||||
return conf.ai.prompts['asshole'](
|
||||
this._faceBot.bot.entity.username,
|
||||
this._config.interval,
|
||||
'', '', '', '', '',
|
||||
);
|
||||
}
|
||||
|
||||
const fleetContext = this._getFleetContext();
|
||||
const rawToolsDocs = this._getToolsDocumentation();
|
||||
const toolsDocs = fleetContext + (rawToolsDocs ? '\n' + rawToolsDocs : '');
|
||||
|
||||
let currentPlayers = '';
|
||||
try {
|
||||
const players = this._faceBot.getPlayers();
|
||||
currentPlayers = Object.values(players)
|
||||
.map(p => `<[${p.lvl}] ${p.username}>`)
|
||||
.join('\n');
|
||||
} catch (e) {
|
||||
currentPlayers = '(players unavailable)';
|
||||
}
|
||||
|
||||
const timeInfo = this._getCurrentTimeInfo();
|
||||
const memoryContext = await this._getMemoryContext();
|
||||
|
||||
return promptFn(
|
||||
this._faceBot.bot.entity.username,
|
||||
this._config.interval,
|
||||
currentPlayers,
|
||||
toolsDocs,
|
||||
memoryContext,
|
||||
timeInfo,
|
||||
this._config.prompCustom || '',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the memory block for the system prompt: bot directives,
|
||||
* general memories, and stored facts about every player currently online.
|
||||
*/
|
||||
async _getMemoryContext() {
|
||||
let context = '';
|
||||
try {
|
||||
const general = await this._memoryDB.getMemoryContext(this._faceBot.name);
|
||||
if (general) context += general;
|
||||
|
||||
// Player memories for everyone online right now (skip fleet bots)
|
||||
const { CJbot } = require('../../model/minecraft');
|
||||
const botUsernames = new Set(
|
||||
Object.values(CJbot.bots)
|
||||
.map(b => b.bot?.entity?.username)
|
||||
.filter(Boolean)
|
||||
);
|
||||
const onlinePlayers = Object.keys(this._faceBot.bot.players || {})
|
||||
.filter(name => !botUsernames.has(name));
|
||||
|
||||
const playerMems = await this._memoryDB.getPlayerMemoriesForPrompt(onlinePlayers);
|
||||
if (playerMems) {
|
||||
context += 'WHAT YOU KNOW ABOUT PLAYERS CURRENTLY ONLINE (from past conversations — use it naturally, do not recite it):\n'
|
||||
+ playerMems + '\n';
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('AiManager: memory context build failed:', e.message);
|
||||
}
|
||||
return context.trim();
|
||||
}
|
||||
|
||||
_getFleetContext() {
|
||||
const { CJbot } = require('../../model/minecraft');
|
||||
const botNames = Object.keys(CJbot.bots);
|
||||
const onlineBots = botNames.filter(name => CJbot.bots[name].isReady);
|
||||
const offlineBots = botNames.filter(name => !CJbot.bots[name].isReady);
|
||||
|
||||
const fleetBots = botNames.map(name => {
|
||||
const b = CJbot.bots[name];
|
||||
const status = b.isReady ? 'online' : 'offline (on-demand)';
|
||||
if (name === this._faceBot.name) return `- ${name} (YOU — always online)`;
|
||||
if (name === this._config.storageBot) return `- ${name} (storage, ${status})`;
|
||||
return `- ${name} (${status})`;
|
||||
}).join('\n');
|
||||
|
||||
const nativeToolsNote = this._config.enableNativeTools ? '' : `
|
||||
TOOL CALL FORMAT (CRITICAL — you MUST use this exact format):
|
||||
When you need to call a tool, respond with a SINGLE JSON object:
|
||||
{"tool_call": {"name": "tool_name", "args": {"param1": "value1", "param2": "value2"}}}
|
||||
|
||||
Example: to search for stone, respond with:
|
||||
{"tool_call": {"name": "storage_find", "args": {"itemName": "stone"}}}
|
||||
|
||||
NEVER put tool names in chat text messages. NEVER type "storage_find stone" as a chat message.
|
||||
Use the {"tool_call": ...} format above instead.
|
||||
|
||||
For normal chat (no tools needed), respond with the array format:
|
||||
[{"text": "your message", "delay": 0}]
|
||||
`;
|
||||
|
||||
return `FLEET BOTS YOU CAN CONTROL:
|
||||
${fleetBots}
|
||||
${nativeToolsNote}
|
||||
CRITICAL: Every other name in chat is a PLAYER. Do NOT call bot_activate, bot_status, or any fleet tool on player names. Only use those tools for the fleet bots listed above.
|
||||
|
||||
MEMORY (IMPORTANT — this is how you remember people between sessions):
|
||||
- When a player tells you a fact worth keeping — about themselves OR another player (base location, current project, who they trust, preferences, grudges, play schedule) — call remember_player RIGHT THEN. Don't wait to be asked to remember.
|
||||
- Keys are short snake_case: base_location, current_project, trust, friend_of, notes, warned_about.
|
||||
- One fact per key. Writing to an existing key overwrites it — use that to update stale facts.
|
||||
- What you already know about online players is in your context under "WHAT YOU KNOW ABOUT PLAYERS". For players not online, use recall_player.
|
||||
- If a fact turns out wrong, call forget_player to remove that key.
|
||||
- Standing orders from owners ("always greet new players", "stop mentioning X") are directives: use set_directive.
|
||||
|
||||
CHARACTER RULES (YOU WILL BE REMOVED IF YOU VIOLATE):
|
||||
- You are a human player. NEVER say "bot", "AI", "tool", "fleet", or "activate."
|
||||
- NEVER type server commands (/msg, /trade, /invite, /help, /w, /tell) in public chat. If you need to message someone, use your tools instead.
|
||||
- NEVER suggest commands to other players ("type /msg X", "do /trade Y"). That's robotic.
|
||||
- Keep responses under 120 chars. Casual, brief, natural.
|
||||
- ez is "a friend who handles my storage" if asked. Not a bot.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight fleet status snapshot for post-tool-execution follow-up.
|
||||
* Tells the LLM which bots are online and where they are so it doesn't
|
||||
* say incorrect things like "he can't find you" when the bot is already there.
|
||||
*/
|
||||
_getLiveFleetStatus() {
|
||||
const { CJbot } = require('../../model/minecraft');
|
||||
const statuses = [];
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
if (bot.isReady && bot.bot?.entity) {
|
||||
const e = bot.bot.entity;
|
||||
statuses.push({ bot: name, status: 'ONLINE', pos: `(${Math.round(e.position.x)},${Math.round(e.position.y)},${Math.round(e.position.z)})` });
|
||||
} else {
|
||||
statuses.push({ bot: name, status: 'OFFLINE' });
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
_getToolsDocumentation() {
|
||||
if (this._allTools.length === 0) return '';
|
||||
|
||||
const byCategory = {};
|
||||
for (const t of this._allTools) {
|
||||
const cat = t.category || 'other';
|
||||
if (!byCategory[cat]) byCategory[cat] = [];
|
||||
byCategory[cat].push(t);
|
||||
}
|
||||
|
||||
let doc = '';
|
||||
for (const [cat, tools] of Object.entries(byCategory)) {
|
||||
doc += `## ${cat}\n`;
|
||||
for (const t of tools) {
|
||||
doc += `- **${t.name}**: ${t.description}`;
|
||||
if (t.parameters && t.parameters.length > 0) {
|
||||
doc += ` (params: ${t.parameters.map(p => p.name).join(', ')})`;
|
||||
}
|
||||
doc += '\n';
|
||||
}
|
||||
doc += '\n';
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Response processing
|
||||
// ========================================
|
||||
|
||||
async _processResponse(responseText) {
|
||||
if (!responseText) return;
|
||||
|
||||
const cleaned = this._stripMarkdownFences(responseText);
|
||||
|
||||
// Try JSON array [{text, delay}]
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const message of parsed) {
|
||||
const msgText = (message.text || '').trim();
|
||||
console.log('AiManager: toSay delay=', message.delay, msgText);
|
||||
|
||||
if (!msgText || msgText === '_' || msgText.match(/^[-_]+$/)) continue;
|
||||
|
||||
const dedupeKey = msgText.toLowerCase();
|
||||
if (this._lastSentMessages.includes(dedupeKey)) {
|
||||
console.log('AiManager: skipping duplicate:', msgText);
|
||||
continue;
|
||||
}
|
||||
if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift();
|
||||
this._lastSentMessages.push(dedupeKey);
|
||||
|
||||
await this._faceBot.sayAiSafe(msgText);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (jsonError) {
|
||||
// Not JSON, fall through
|
||||
}
|
||||
|
||||
// Plain text fallback
|
||||
const text = cleaned.trim();
|
||||
if (!text || text === '_' || text === '___' || text.match(/^[-_]+$/)) return;
|
||||
if (text.startsWith('{') || text.startsWith('```')) {
|
||||
console.log('AiManager: skipping raw JSON/code block');
|
||||
return;
|
||||
}
|
||||
|
||||
const dedupeKey = text.toLowerCase();
|
||||
if (this._lastSentMessages.includes(dedupeKey)) {
|
||||
console.log('AiManager: skipping duplicate plain-text:', text);
|
||||
return;
|
||||
}
|
||||
if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift();
|
||||
this._lastSentMessages.push(dedupeKey);
|
||||
|
||||
await this._faceBot.sayAiSafe(text);
|
||||
}
|
||||
|
||||
_stripMarkdownFences(text) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
let cleaned = text.trim();
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '');
|
||||
cleaned = cleaned.replace(/\n?```\s*$/, '');
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Trade window
|
||||
// ========================================
|
||||
|
||||
_setupTradeWindow(window) {
|
||||
this._tradeWindow = window;
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
|
||||
const customerSlots = [5, 6, 7, 8, 14, 15, 16, 17, 23, 24, 25, 26];
|
||||
for (const slot of customerSlots) {
|
||||
window.on(`updateSlot:${slot}`, () => {
|
||||
if (this._tradeWindow === window) {
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
}
|
||||
});
|
||||
}
|
||||
window.on('updateSlot:53', () => {
|
||||
if (this._tradeWindow === window) {
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
}
|
||||
});
|
||||
window.on('updateSlot:37', () => {
|
||||
if (this._tradeWindow === window) {
|
||||
this._tradeWindowState = this._captureTradeState(window);
|
||||
}
|
||||
});
|
||||
|
||||
this._faceBot.bot.once('windowClose', () => {
|
||||
if (this._tradeWindow === window) {
|
||||
this._tradeWindow = null;
|
||||
this._tradeWindowState = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_captureTradeState(window) {
|
||||
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];
|
||||
|
||||
const readSlots = (slots) => slots
|
||||
.map(s => window.slots[s])
|
||||
.filter(Boolean)
|
||||
.map(item => ({ name: item.name, count: item.count }));
|
||||
|
||||
return {
|
||||
botItems: readSlots(botSlots),
|
||||
customerItems: readSlots(customerSlots),
|
||||
customerConfirmed: !!(window.slots[53] && window.slots[53].name === 'lime_dye'),
|
||||
botConfirmed: !!(window.slots[37] && window.slots[37].name === 'lime_dye'),
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helpers
|
||||
// ========================================
|
||||
|
||||
_getLastSpeaker(messages) {
|
||||
if (!messages || !Array.isArray(messages)) return 'ai';
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (!msg || msg.type !== 'message') continue;
|
||||
const text = msg.text || '';
|
||||
const match = text.match(/^<\[?.*?\]?\s+(\w+)>/);
|
||||
if (match && match[1] && match[1] !== this._faceBot.bot.entity.username) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return 'ai';
|
||||
}
|
||||
|
||||
_getTimeAgo(timestamp) {
|
||||
const now = new Date();
|
||||
const past = new Date(timestamp);
|
||||
const diffMs = now - past;
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return past.toLocaleDateString();
|
||||
}
|
||||
|
||||
_getCurrentTimeInfo() {
|
||||
const now = new Date();
|
||||
return {
|
||||
iso: now.toISOString().replace('T', ' ').substring(0, 19),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
human: now.toLocaleString('en-US', {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit', hour12: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let _instance = null;
|
||||
|
||||
module.exports = {
|
||||
getInstance() {
|
||||
if (!_instance) _instance = new AiManager();
|
||||
return _instance;
|
||||
},
|
||||
AiManager,
|
||||
};
|
||||
@@ -23,8 +23,8 @@ class AIMemoryDB {
|
||||
*/
|
||||
async initialize(dbPath = './storage/ai-memory.db', botName = 'default') {
|
||||
if (this.db) {
|
||||
this.botName = botName;
|
||||
return; // Already initialized
|
||||
// DB already initialized — don't overwrite botName (singleton shared across bots)
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = path.resolve(dbPath);
|
||||
@@ -122,7 +122,7 @@ class AIMemoryDB {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(`
|
||||
SELECT memory_value FROM player_memories
|
||||
WHERE bot_name = 'global' AND player_name = ? AND memory_key = ?
|
||||
WHERE bot_name = 'global' AND player_name = ? COLLATE NOCASE AND memory_key = ?
|
||||
`, [playerName, key], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.memory_value : null);
|
||||
@@ -137,7 +137,7 @@ class AIMemoryDB {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT memory_key, memory_value FROM player_memories
|
||||
WHERE bot_name = 'global' AND player_name = ?
|
||||
WHERE bot_name = 'global' AND player_name = ? COLLATE NOCASE
|
||||
`, [playerName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
@@ -158,7 +158,7 @@ class AIMemoryDB {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(`
|
||||
DELETE FROM player_memories
|
||||
WHERE bot_name = 'global' AND player_name = ? AND memory_key = ?
|
||||
WHERE bot_name = 'global' AND player_name = ? COLLATE NOCASE AND memory_key = ?
|
||||
`, [playerName, key], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
@@ -188,12 +188,12 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Set a directive for this bot
|
||||
*/
|
||||
async setDirective(key, value) {
|
||||
async setDirective(botName, key, value) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(`
|
||||
INSERT OR REPLACE INTO bot_directives (bot_name, directive_key, directive_value, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, [this.botName, key, value], (err) => {
|
||||
`, [botName, key, value], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
@@ -203,12 +203,12 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get a specific directive
|
||||
*/
|
||||
async getDirective(key) {
|
||||
async getDirective(botName, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(`
|
||||
SELECT directive_value FROM bot_directives
|
||||
WHERE bot_name = ? AND directive_key = ?
|
||||
`, [this.botName, key], (err, row) => {
|
||||
`, [botName, key], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.directive_value : null);
|
||||
});
|
||||
@@ -218,12 +218,12 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get all directives for this bot
|
||||
*/
|
||||
async getAllDirectives() {
|
||||
async getAllDirectives(botName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT directive_key, directive_value FROM bot_directives
|
||||
WHERE bot_name = ?
|
||||
`, [this.botName], (err, rows) => {
|
||||
`, [botName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const directives = {};
|
||||
@@ -243,12 +243,12 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Set a general memory (not player-specific)
|
||||
*/
|
||||
async setGeneralMemory(key, value) {
|
||||
async setGeneralMemory(botName, key, value) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(`
|
||||
INSERT OR REPLACE INTO general_memories (bot_name, memory_key, memory_value, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, [this.botName, key, value], (err) => {
|
||||
`, [botName, key, value], (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
@@ -258,12 +258,12 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get a general memory
|
||||
*/
|
||||
async getGeneralMemory(key) {
|
||||
async getGeneralMemory(botName, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get(`
|
||||
SELECT memory_value FROM general_memories
|
||||
WHERE bot_name = ? AND memory_key = ?
|
||||
`, [this.botName, key], (err, row) => {
|
||||
`, [botName, key], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.memory_value : null);
|
||||
});
|
||||
@@ -273,12 +273,12 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get all general memories
|
||||
*/
|
||||
async getAllGeneralMemories() {
|
||||
async getAllGeneralMemories(botName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT memory_key, memory_value FROM general_memories
|
||||
WHERE bot_name = ?
|
||||
`, [this.botName], (err, rows) => {
|
||||
`, [botName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
const memories = {};
|
||||
@@ -294,13 +294,13 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get all general memories with timestamps
|
||||
*/
|
||||
async getAllGeneralMemoriesWithTimestamps() {
|
||||
async getAllGeneralMemoriesWithTimestamps(botName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT memory_key, memory_value, updated_at FROM general_memories
|
||||
WHERE bot_name = ?
|
||||
ORDER BY updated_at DESC
|
||||
`, [this.botName], (err, rows) => {
|
||||
`, [botName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
});
|
||||
@@ -313,13 +313,13 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get all directives as formatted string with timestamps
|
||||
*/
|
||||
async getDirectivesSummary() {
|
||||
async getDirectivesSummary(botName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`
|
||||
SELECT directive_key, directive_value, updated_at FROM bot_directives
|
||||
WHERE bot_name = ?
|
||||
ORDER BY updated_at DESC
|
||||
`, [this.botName], (err, rows) => {
|
||||
`, [botName], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else {
|
||||
if (rows.length === 0) {
|
||||
@@ -361,9 +361,9 @@ class AIMemoryDB {
|
||||
/**
|
||||
* Get full memory context for prompt injection
|
||||
*/
|
||||
async getMemoryContext() {
|
||||
const directives = await this.getDirectivesSummary();
|
||||
const generalMemories = await this.getAllGeneralMemoriesWithTimestamps();
|
||||
async getMemoryContext(botName) {
|
||||
const directives = await this.getDirectivesSummary(botName);
|
||||
const generalMemories = await this.getAllGeneralMemoriesWithTimestamps(botName);
|
||||
|
||||
let context = '';
|
||||
|
||||
|
||||
@@ -99,6 +99,15 @@ class GeminiProvider {
|
||||
|
||||
setPrompt(prompt) {
|
||||
this.config.prompt = prompt;
|
||||
// The live session carries the prompt as its first history entry —
|
||||
// update it in place so prompt changes (memory refresh, personality
|
||||
// swaps) apply without restarting the session
|
||||
try {
|
||||
const history = this.session?.params?.history;
|
||||
if (history && history[0] && history[0].role === 'user') {
|
||||
history[0].parts = [{ text: prompt }];
|
||||
}
|
||||
} catch (e) { /* session not started yet */ }
|
||||
}
|
||||
|
||||
getResponse(result) {
|
||||
|
||||
@@ -138,6 +138,13 @@ class OllamaProvider {
|
||||
content: rawContent
|
||||
});
|
||||
|
||||
// Cap history — unbounded growth eventually overflows num_ctx,
|
||||
// which silently truncates the system prompt (memories, tool docs)
|
||||
const maxHistory = this.config.maxHistory || 30;
|
||||
if (this.messages.length > maxHistory) {
|
||||
this.messages.splice(0, this.messages.length - maxHistory);
|
||||
}
|
||||
|
||||
// The text() closure strips markdown code fences so consumers
|
||||
// (processResponse, getToolCalls) get clean content.
|
||||
const result = {
|
||||
|
||||
+75
-97
@@ -2,24 +2,31 @@
|
||||
|
||||
const express = require('express');
|
||||
const { CJbot } = require('../../model/minecraft');
|
||||
const { getInstance } = require('./manager');
|
||||
|
||||
function createRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
// ---- AI Status ----
|
||||
|
||||
router.get('/api/ai/status', (req, res) => {
|
||||
try {
|
||||
const manager = getInstance();
|
||||
const result = {};
|
||||
const faceName = manager.faceBotName;
|
||||
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai) continue;
|
||||
const config = ai.__getConfig();
|
||||
const isFace = name === faceName && manager.isActive;
|
||||
if (!isFace && !bot.plunginsLoaded['Ai']) continue;
|
||||
|
||||
result[name] = {
|
||||
connected: bot.isReady,
|
||||
provider: config.provider || 'unknown',
|
||||
model: config.model || 'unknown',
|
||||
interval: ai.intervalLength,
|
||||
promptName: ai.promptName || 'unknown',
|
||||
active: !!ai._active,
|
||||
provider: manager.isActive ? (manager._config?.provider || 'unknown') : 'unknown',
|
||||
model: manager.isActive ? (manager._config?.model || 'unknown') : 'unknown',
|
||||
interval: manager.isActive ? (manager._config?.interval || 10) : 10,
|
||||
promptName: isFace && manager._config ? (manager._config.promptName || 'unknown') : 'unknown',
|
||||
active: isFace && manager.isActive,
|
||||
isFace,
|
||||
};
|
||||
}
|
||||
res.json({ bots: result });
|
||||
@@ -29,16 +36,22 @@ function createRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// Get list of all bots (for UI)
|
||||
// ---- Bot list (for UI) ----
|
||||
|
||||
router.get('/api/ai/bots', (req, res) => {
|
||||
try {
|
||||
const manager = getInstance();
|
||||
const faceName = manager.faceBotName;
|
||||
const bots = [];
|
||||
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
const isFace = name === faceName && manager.isActive;
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
bots.push({
|
||||
name: name,
|
||||
hasAI: !!ai,
|
||||
hasMemory: !!(ai && ai.memoryDB)
|
||||
name,
|
||||
hasAI: isFace || !!ai,
|
||||
hasMemory: isFace || !!(ai && ai.memoryDB),
|
||||
isFace,
|
||||
});
|
||||
}
|
||||
res.json({ bots });
|
||||
@@ -48,18 +61,14 @@ function createRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// Get all players with memories (shared across all bots)
|
||||
// ---- Player Memories (shared across all bots) ----
|
||||
|
||||
router.get('/api/ai/memories/players', async (req, res) => {
|
||||
try {
|
||||
// Get players from any bot's memoryDB (they're shared now)
|
||||
let players = [];
|
||||
for (const [name, bot] of Object.entries(CJbot.bots)) {
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (ai && ai.memoryDB) {
|
||||
players = await ai.memoryDB.getAllKnownPlayers();
|
||||
break;
|
||||
}
|
||||
}
|
||||
const manager = getInstance();
|
||||
const players = manager.isActive
|
||||
? await manager._memoryDB.getAllKnownPlayers()
|
||||
: [];
|
||||
res.json({ players });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/memories/players:', error);
|
||||
@@ -67,91 +76,72 @@ function createRouter() {
|
||||
}
|
||||
});
|
||||
|
||||
// Get memories for a specific player
|
||||
router.get('/api/ai/memories/:botName/:playerName', async (req, res) => {
|
||||
try {
|
||||
const { botName, playerName } = req.params;
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
const { playerName } = req.params;
|
||||
const manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
const memories = await ai.memoryDB.getAllPlayerMemories(playerName);
|
||||
res.json({ bot: botName, player: playerName, memories });
|
||||
const memories = await manager._memoryDB.getAllPlayerMemories(playerName);
|
||||
res.json({ player: playerName, memories });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/memories/:bot/:player:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Set or update a player memory
|
||||
router.post('/api/ai/memories/:botName/:playerName', async (req, res) => {
|
||||
try {
|
||||
const { botName, playerName } = req.params;
|
||||
const { playerName } = req.params;
|
||||
const { key, value } = req.body;
|
||||
if (!key || !value) {
|
||||
return res.status(400).json({ error: 'key and value are required' });
|
||||
}
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
const manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
await ai.memoryDB.setPlayerMemory(playerName, key, value);
|
||||
res.json({ success: true, bot: botName, player: playerName, key, value });
|
||||
await manager._memoryDB.setPlayerMemory(playerName, key, value);
|
||||
res.json({ success: true, player: playerName, key, value });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/memories/:bot/:player POST:', error);
|
||||
console.error('API Error /api/ai/memories POST:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a player memory
|
||||
router.delete('/api/ai/memories/:botName/:playerName/:key', async (req, res) => {
|
||||
try {
|
||||
const { botName, playerName, key } = req.params;
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
const { playerName, key } = req.params;
|
||||
const manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
await ai.memoryDB.deletePlayerMemory(playerName, key);
|
||||
res.json({ success: true, bot: botName, player: playerName, key });
|
||||
await manager._memoryDB.deletePlayerMemory(playerName, key);
|
||||
res.json({ success: true, player: playerName, key });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/memories/:bot/:player/:key DELETE:', error);
|
||||
console.error('API Error /api/ai/memories DELETE:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get bot directives
|
||||
// ---- Bot Directives ----
|
||||
|
||||
router.get('/api/ai/directives/:botName', async (req, res) => {
|
||||
try {
|
||||
const { botName } = req.params;
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
const manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
const directives = await ai.memoryDB.getAllDirectives();
|
||||
const directives = await manager._memoryDB.getAllDirectives(botName);
|
||||
res.json({ bot: botName, directives });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/directives/:bot:', error);
|
||||
console.error('API Error /api/ai/directives:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Set or update a directive
|
||||
router.post('/api/ai/directives/:botName', async (req, res) => {
|
||||
try {
|
||||
const { botName } = req.params;
|
||||
@@ -159,43 +149,35 @@ function createRouter() {
|
||||
if (!key || !value) {
|
||||
return res.status(400).json({ error: 'key and value are required' });
|
||||
}
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
const manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
await ai.memoryDB.setDirective(key, value);
|
||||
await manager._memoryDB.setDirective(botName, key, value);
|
||||
res.json({ success: true, bot: botName, key, value });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/directives/:bot POST:', error);
|
||||
console.error('API Error /api/ai/directives POST:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get general memories
|
||||
// ---- General Memories ----
|
||||
|
||||
router.get('/api/ai/general-memories/:botName', async (req, res) => {
|
||||
try {
|
||||
const { botName } = req.params;
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
const manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
const memories = await ai.memoryDB.getAllGeneralMemories();
|
||||
const memories = await manager._memoryDB.getAllGeneralMemories(botName);
|
||||
res.json({ bot: botName, memories });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/general-memories/:bot:', error);
|
||||
console.error('API Error /api/ai/general-memories:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Set or update a general memory
|
||||
router.post('/api/ai/general-memories/:botName', async (req, res) => {
|
||||
try {
|
||||
const { botName } = req.params;
|
||||
@@ -203,18 +185,14 @@ function createRouter() {
|
||||
if (!key || !value) {
|
||||
return res.status(400).json({ error: 'key and value are required' });
|
||||
}
|
||||
const bot = CJbot.bots[botName];
|
||||
if (!bot) {
|
||||
return res.status(404).json({ error: 'Bot not found' });
|
||||
const manager = getInstance();
|
||||
if (!manager.isActive) {
|
||||
return res.status(404).json({ error: 'AI manager not active' });
|
||||
}
|
||||
const ai = bot.plunginsLoaded['Ai'];
|
||||
if (!ai || !ai.memoryDB) {
|
||||
return res.status(404).json({ error: 'AI or memory DB not loaded' });
|
||||
}
|
||||
await ai.memoryDB.setGeneralMemory(key, value);
|
||||
await manager._memoryDB.setGeneralMemory(botName, key, value);
|
||||
res.json({ success: true, bot: botName, key, value });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/ai/general-memories/:bot POST:', error);
|
||||
console.error('API Error /api/ai/general-memories POST:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const Database = require('../storage/database');
|
||||
|
||||
/**
|
||||
* OpenID Connect (authorization-code + PKCE) login for the web dashboard,
|
||||
* modeled on theta42/proxy's auth flow.
|
||||
*
|
||||
* - Config lives in the settings manager under auth.* (SSO endpoints, client
|
||||
* credentials, allowed users/groups). auth.enabled=false (default) leaves
|
||||
* the dashboard open exactly as before.
|
||||
* - Sessions are opaque random tokens in the storage sqlite DB, delivered as
|
||||
* an HttpOnly SameSite=Lax cookie so the existing dashboard fetch() calls
|
||||
* work unchanged. API clients may instead send the token in an
|
||||
* `auth-token` header.
|
||||
* - The in-flight OIDC state (PKCE verifier + post-login redirect) is held
|
||||
* in memory with a 5-minute TTL — single process, no cleanup job needed.
|
||||
*
|
||||
* Identity is read from the SSO's userinfo endpoint server-side; ID-token
|
||||
* signatures are not verified (same trade-off as the reference impl).
|
||||
*/
|
||||
|
||||
const COOKIE_NAME = 'mcbt_session';
|
||||
const STATE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
// ========================================
|
||||
// Config
|
||||
// ========================================
|
||||
|
||||
function authConf() {
|
||||
const settings = require('../settings/manager');
|
||||
return {
|
||||
enabled: settings.get('auth.enabled') === true,
|
||||
authorizationEndpoint: settings.get('auth.authorizationEndpoint'),
|
||||
tokenEndpoint: settings.get('auth.tokenEndpoint'),
|
||||
userinfoEndpoint: settings.get('auth.userinfoEndpoint'),
|
||||
clientId: settings.get('auth.clientId'),
|
||||
clientSecret: settings.get('auth.clientSecret'),
|
||||
redirectUri: settings.get('auth.redirectUri'),
|
||||
scopes: settings.get('auth.scopes') || ['openid', 'profile', 'email', 'groups'],
|
||||
usernameClaim: settings.get('auth.usernameClaim') || 'preferred_username',
|
||||
groupsClaim: settings.get('auth.groupsClaim') || 'groups',
|
||||
allowedUsers: settings.get('auth.allowedUsers') || [],
|
||||
allowedGroups: settings.get('auth.allowedGroups') || [],
|
||||
tokenTTL: settings.get('auth.tokenTTL') || 30 * 24 * 3600, // seconds
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Small helpers (ported from theta42/proxy)
|
||||
// ========================================
|
||||
|
||||
const base64url = buf => buf.toString('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
function randomToken(bytes = 32) {
|
||||
return base64url(crypto.randomBytes(bytes));
|
||||
}
|
||||
|
||||
function codeChallengeS256(verifier) {
|
||||
return base64url(crypto.createHash('sha256').update(verifier).digest());
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrain a post-login redirect target to a same-origin path.
|
||||
* Rejects absolute URLs, protocol-relative ("//evil.com"), and scheme
|
||||
* targets ("javascript:..."). Anything not a plain "/path" becomes "/".
|
||||
*/
|
||||
function safeInternalPath(path) {
|
||||
if (typeof path !== 'string' || path.charAt(0) !== '/'
|
||||
|| path.charAt(1) === '/' || path.charAt(1) === '\\') {
|
||||
return '/';
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Minimal per-IP fixed-window rate limiter (no external dependency). */
|
||||
function rateLimiter(max = 60, windowMs = 15 * 60 * 1000) {
|
||||
const hits = new Map();
|
||||
return (req, res, next) => {
|
||||
const now = Date.now();
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
let rec = hits.get(ip);
|
||||
if (!rec || now > rec.reset) {
|
||||
rec = { count: 0, reset: now + windowMs };
|
||||
hits.set(ip, rec);
|
||||
}
|
||||
if (++rec.count > max) {
|
||||
return res.status(429).json({ error: 'Too many attempts, please try again later.' });
|
||||
}
|
||||
if (hits.size > 1000) {
|
||||
for (const [k, v] of hits) if (now > v.reset) hits.delete(k);
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function parseCookies(req) {
|
||||
const header = req.headers.cookie;
|
||||
if (!header) return {};
|
||||
const out = {};
|
||||
for (const part of header.split(';')) {
|
||||
const idx = part.indexOf('=');
|
||||
if (idx === -1) continue;
|
||||
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isSecureRequest(req) {
|
||||
return req.secure || req.headers['x-forwarded-proto'] === 'https';
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// OIDC client
|
||||
// ========================================
|
||||
|
||||
function createAuthRequest() {
|
||||
const state = randomToken(32);
|
||||
const codeVerifier = randomToken(32);
|
||||
return { state, codeVerifier, codeChallenge: codeChallengeS256(codeVerifier) };
|
||||
}
|
||||
|
||||
function buildAuthUrl(state, codeChallenge) {
|
||||
const o = authConf();
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: o.clientId,
|
||||
redirect_uri: o.redirectUri,
|
||||
scope: o.scopes.join(' '),
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return `${o.authorizationEndpoint}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function exchangeCode(code, codeVerifier) {
|
||||
const o = authConf();
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: o.redirectUri,
|
||||
client_id: o.clientId,
|
||||
client_secret: o.clientSecret,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
const res = await fetch(o.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Token exchange failed (${res.status}): ${text.slice(0, 200)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchUserInfo(accessToken) {
|
||||
const o = authConf();
|
||||
const res = await fetch(o.userinfoEndpoint, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Userinfo request failed (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function claimsToIdentity(claims) {
|
||||
const o = authConf();
|
||||
const username = claims[o.usernameClaim] || claims.sub;
|
||||
let groups = claims[o.groupsClaim] || [];
|
||||
if (!Array.isArray(groups)) groups = [groups].filter(Boolean);
|
||||
return { username, groups };
|
||||
}
|
||||
|
||||
/** allowedUsers / allowedGroups gate — both empty means any SSO user. */
|
||||
function identityAllowed(identity) {
|
||||
const o = authConf();
|
||||
const users = (o.allowedUsers || []).map(u => String(u).toLowerCase());
|
||||
const groups = (o.allowedGroups || []).map(g => String(g).toLowerCase());
|
||||
if (users.length === 0 && groups.length === 0) return true;
|
||||
if (users.includes(String(identity.username).toLowerCase())) return true;
|
||||
return identity.groups.some(g => groups.includes(String(g).toLowerCase()));
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// One-time OIDC state store (in-memory, TTL)
|
||||
// ========================================
|
||||
|
||||
const _states = new Map(); // state -> { codeVerifier, redirect, expires }
|
||||
|
||||
function saveState(state, data) {
|
||||
_states.set(state, { ...data, expires: Date.now() + STATE_TTL_MS });
|
||||
// Opportunistic sweep of expired/abandoned logins
|
||||
for (const [k, v] of _states) if (Date.now() > v.expires) _states.delete(k);
|
||||
}
|
||||
|
||||
/** Consume a state record — one-time use bounds replay. */
|
||||
function takeState(state) {
|
||||
const rec = _states.get(state);
|
||||
if (!rec) return null;
|
||||
_states.delete(state);
|
||||
if (Date.now() > rec.expires) return null;
|
||||
return rec;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Session token store (sqlite)
|
||||
// ========================================
|
||||
|
||||
let _tableReady = false;
|
||||
async function ensureTable() {
|
||||
if (_tableReady) return;
|
||||
await Database.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
groups TEXT DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
_tableReady = true;
|
||||
}
|
||||
|
||||
async function createSession(identity) {
|
||||
await ensureTable();
|
||||
const token = randomToken(32);
|
||||
const now = Date.now();
|
||||
await Database.db.run(
|
||||
'INSERT INTO auth_tokens (token, username, groups, created_at, expires_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[token, identity.username, JSON.stringify(identity.groups || []), now, now + authConf().tokenTTL * 1000]
|
||||
);
|
||||
// Opportunistic cleanup of expired sessions
|
||||
await Database.db.run('DELETE FROM auth_tokens WHERE expires_at < ?', [now]);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function checkSession(token) {
|
||||
if (!token) return null;
|
||||
await ensureTable();
|
||||
const row = await Database.db.get('SELECT * FROM auth_tokens WHERE token = ?', [token]);
|
||||
if (!row) return null;
|
||||
if (row.expires_at < Date.now()) {
|
||||
await Database.db.run('DELETE FROM auth_tokens WHERE token = ?', [token]);
|
||||
return null;
|
||||
}
|
||||
return { username: row.username, groups: JSON.parse(row.groups || '[]') };
|
||||
}
|
||||
|
||||
async function destroySession(token) {
|
||||
if (!token) return;
|
||||
await ensureTable();
|
||||
await Database.db.run('DELETE FROM auth_tokens WHERE token = ?', [token]);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Middleware
|
||||
// ========================================
|
||||
|
||||
function readToken(req) {
|
||||
return parseCookies(req)[COOKIE_NAME] || req.header('auth-token') || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate every route behind a session when auth.enabled. Browsers get a
|
||||
* redirect to the login page; API callers get a 401.
|
||||
*/
|
||||
async function middleware(req, res, next) {
|
||||
try {
|
||||
if (!authConf().enabled) return next();
|
||||
if (req.path === '/health' || req.path === '/auth' || req.path.startsWith('/auth/')) return next();
|
||||
|
||||
const session = await checkSession(readToken(req));
|
||||
if (session) {
|
||||
req.user = session.username;
|
||||
req.groups = session.groups;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.accepts(['json', 'html']) === 'html') {
|
||||
return res.redirect('/auth/login?redirect=' + encodeURIComponent(safeInternalPath(req.originalUrl)));
|
||||
}
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Router
|
||||
// ========================================
|
||||
|
||||
function createRouter() {
|
||||
const router = express.Router();
|
||||
const limiter = rateLimiter(60, 15 * 60 * 1000);
|
||||
|
||||
router.get('/login', (req, res) => {
|
||||
const redirect = safeInternalPath(req.query.redirect || '/');
|
||||
const error = req.query.error ? String(req.query.error).slice(0, 200) : null;
|
||||
res.send(loginPageHTML(redirect, error));
|
||||
});
|
||||
|
||||
// OIDC login start: create a PKCE + state challenge, stash it, redirect
|
||||
// the browser to the SSO authorize endpoint.
|
||||
router.get('/oidc/start', limiter, (req, res) => {
|
||||
const o = authConf();
|
||||
if (!o.enabled) return res.status(404).json({ error: 'Auth is not enabled' });
|
||||
if (!o.authorizationEndpoint || !o.clientId) {
|
||||
return res.status(500).json({ error: 'OIDC is not configured (auth.authorizationEndpoint / auth.clientId)' });
|
||||
}
|
||||
|
||||
const { state, codeVerifier, codeChallenge } = createAuthRequest();
|
||||
saveState(state, {
|
||||
codeVerifier,
|
||||
// Sanitize now so a hostile ?redirect= can't be stored and later
|
||||
// reflected into navigation.
|
||||
redirect: safeInternalPath(req.query.redirect || '/'),
|
||||
});
|
||||
|
||||
res.redirect(buildAuthUrl(state, codeChallenge));
|
||||
});
|
||||
|
||||
// OIDC callback: validate + consume state, exchange the code, read
|
||||
// identity from userinfo, set the session cookie, redirect into the app.
|
||||
router.get('/oidc/callback', limiter, async (req, res) => {
|
||||
try {
|
||||
const { code, state } = req.query;
|
||||
if (!code || !state) throw new Error('Missing code or state');
|
||||
|
||||
const saved = takeState(String(state));
|
||||
if (!saved) throw new Error('Unknown or expired login attempt — try again');
|
||||
|
||||
const tokens = await exchangeCode(String(code), saved.codeVerifier);
|
||||
const claims = await fetchUserInfo(tokens.access_token);
|
||||
const identity = claimsToIdentity(claims);
|
||||
|
||||
if (!identityAllowed(identity)) {
|
||||
console.log(`Auth: DENIED login for '${identity.username}' (groups: ${identity.groups.join(', ') || 'none'})`);
|
||||
return res.redirect('/auth/login?error=' + encodeURIComponent(`Account '${identity.username}' is not authorized for this dashboard.`));
|
||||
}
|
||||
|
||||
const token = await createSession(identity);
|
||||
console.log(`Auth: '${identity.username}' logged in`);
|
||||
|
||||
const flags = [
|
||||
`${COOKIE_NAME}=${encodeURIComponent(token)}`,
|
||||
'HttpOnly', 'Path=/', 'SameSite=Lax',
|
||||
`Max-Age=${authConf().tokenTTL}`,
|
||||
];
|
||||
if (isSecureRequest(req)) flags.push('Secure');
|
||||
res.setHeader('Set-Cookie', flags.join('; '));
|
||||
|
||||
res.redirect(safeInternalPath(saved.redirect || '/'));
|
||||
} catch (error) {
|
||||
console.error('Auth: OIDC callback error:', error.message);
|
||||
res.redirect('/auth/login?error=' + encodeURIComponent(error.message));
|
||||
}
|
||||
});
|
||||
|
||||
router.all('/logout', async (req, res) => {
|
||||
try {
|
||||
await destroySession(readToken(req));
|
||||
} catch (error) {
|
||||
console.error('Auth: logout error:', error.message);
|
||||
}
|
||||
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`);
|
||||
if (req.accepts(['json', 'html']) === 'html') return res.redirect('/auth/login');
|
||||
res.json({ message: 'Bye' });
|
||||
});
|
||||
|
||||
// Who am I — lets the UI show the logged-in user
|
||||
router.get('/me', async (req, res) => {
|
||||
if (!authConf().enabled) return res.json({ enabled: false });
|
||||
const session = await checkSession(readToken(req));
|
||||
if (!session) return res.status(401).json({ enabled: true, error: 'Not logged in' });
|
||||
res.json({ enabled: true, username: session.username, groups: session.groups });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function loginPageHTML(redirect, error) {
|
||||
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 — Login</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;display:flex;align-items:center;justify-content:center}
|
||||
.card{background:#1f2937;border:1px solid #374151;border-radius:12px;padding:40px;width:360px;text-align:center}
|
||||
.card h1{font-size:1.3em;color:#60a5fa;margin-bottom:8px}
|
||||
.card p{color:#9ca3af;font-size:.9em;margin-bottom:24px}
|
||||
.sso-btn{display:block;width:100%;background:#2563eb;color:#fff;border:none;padding:12px;border-radius:8px;font-size:1em;cursor:pointer;text-decoration:none}
|
||||
.sso-btn:hover{background:#1d4ed8}
|
||||
.error{background:#7f1d1d;border:1px solid #dc2626;color:#fecaca;padding:10px;border-radius:8px;font-size:.85em;margin-bottom:16px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>MC Bot Town</h1>
|
||||
<p>Sign in to manage the bot fleet</p>
|
||||
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
||||
<a class="sso-btn" href="/auth/oidc/start?redirect=${encodeURIComponent(redirect)}">Sign in with SSO</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
middleware,
|
||||
createRouter,
|
||||
authConf,
|
||||
safeInternalPath,
|
||||
checkSession,
|
||||
};
|
||||
@@ -162,12 +162,31 @@ module.exports = {
|
||||
return;
|
||||
}
|
||||
|
||||
let tradeResult = null;
|
||||
let pending = null;
|
||||
let itemsReceived = [];
|
||||
|
||||
try {
|
||||
storage._busy = true;
|
||||
const pending = storage.pendingWithdrawals.get(from);
|
||||
// The task we interrupted has exited (it released the lock);
|
||||
// reset the interrupt flag so our own work isn't flagged
|
||||
this.registerTask('Storage', 'trade', null);
|
||||
pending = storage.pendingWithdrawals.get(from);
|
||||
|
||||
// Set up listener BEFORE accepting to avoid race with window opening
|
||||
const windowPromise = this.once('windowOpen');
|
||||
await this.say('/trade accept');
|
||||
let window = await this.once('windowOpen');
|
||||
|
||||
// If no window ever opens (expired request, player left), bail
|
||||
// instead of holding the storage lock forever
|
||||
let window = await Promise.race([
|
||||
windowPromise,
|
||||
sleep(30000).then(() => null),
|
||||
]);
|
||||
if (!window) {
|
||||
this.whisper(from, 'Trade window never opened — send the trade request again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's a pending withdrawal, place items in bot's trade slots
|
||||
if (pending) {
|
||||
@@ -202,7 +221,8 @@ module.exports = {
|
||||
console.log(`Storage trade: Placed ${placed} stack(s) in trade window`);
|
||||
}
|
||||
|
||||
// Poll for customer confirmation (lime_dye at slot 53)
|
||||
// Poll for other party's lock indicator (slot 53 grey_dye → lime_dye)
|
||||
// Both parties must click green wool (slot 37) twice: 1st locks, 2nd finalizes
|
||||
let timeoutCheck = setTimeout(() => {
|
||||
this.bot.closeWindow(window);
|
||||
this.whisper(from, 'Trade timed out.');
|
||||
@@ -210,9 +230,25 @@ module.exports = {
|
||||
|
||||
let confirmationCheck = setInterval(async () => {
|
||||
try {
|
||||
// Never click a closed window — invalid window IDs
|
||||
// trip anti-cheat ("unusual packets")
|
||||
if (this.bot.currentWindow !== window) return;
|
||||
const indicator = window.slots[53];
|
||||
if (indicator && indicator.name === 'lime_dye') {
|
||||
this.bot.moveSlotItem(37, 37);
|
||||
clearInterval(confirmationCheck);
|
||||
|
||||
// Click 1: lock items — single left-click, not the
|
||||
// pickup+putdown pair moveSlotItem sends
|
||||
await this.bot.clickWindow(37, 0, 0);
|
||||
console.log('Storage trade: click 1 — items locked');
|
||||
|
||||
await sleep(1000); // Both now locked, brief pause
|
||||
|
||||
// Click 2: finalize (second confirmation)
|
||||
if (this.bot.currentWindow === window) {
|
||||
await this.bot.clickWindow(37, 0, 0);
|
||||
console.log('Storage trade: click 2 — final confirm');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// window may have closed
|
||||
@@ -229,7 +265,7 @@ module.exports = {
|
||||
}
|
||||
clearTimeout(timeoutCheck);
|
||||
|
||||
let tradeResult = null;
|
||||
|
||||
|
||||
if (pending) {
|
||||
// Withdrawal complete — clear pending
|
||||
@@ -241,7 +277,7 @@ module.exports = {
|
||||
await sleep(500);
|
||||
|
||||
const hotbarNames = new Set((storage.config.hotbarItems || []).map(h => h.name));
|
||||
const itemsReceived = [];
|
||||
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 });
|
||||
@@ -254,18 +290,35 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.clearTask();
|
||||
storage._busy = false;
|
||||
storage._releaseOperationLock();
|
||||
}
|
||||
|
||||
// Organize after lock is released (handleTrade runs under the lock)
|
||||
if (tradeResult && tradeResult.needsOrganize) {
|
||||
storage._busy = true;
|
||||
try {
|
||||
await storage.organizeLooseItems();
|
||||
await storage.organizeLooseItems(true);
|
||||
} catch (error) {
|
||||
console.error('Storage: Post-trade organize failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify the AI face bot about this trade so it can respond naturally
|
||||
try {
|
||||
const { getInstance } = require('../ai/manager');
|
||||
const manager = getInstance();
|
||||
if (manager.isActive) {
|
||||
if (pending) {
|
||||
manager.notifySystemEvent(`${this.bot.entity.username} completed withdrawal: ${pending.count}x ${pending.itemName} for ${from}. Trade finished.`);
|
||||
} else if (itemsReceived && itemsReceived.length > 0) {
|
||||
const itemSummary = itemsReceived.slice(0, 5).map(i => `${i.count}x ${i.name}`).join(', ');
|
||||
const extra = itemsReceived.length > 5 ? ` +${itemsReceived.length - 5} more types` : '';
|
||||
manager.notifySystemEvent(`${this.bot.entity.username} received deposit from ${from}: ${itemSummary}${extra}. All items stored.`);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,8 +55,9 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm and log trade
|
||||
this.bot.moveSlotItem(37, 37);
|
||||
// Confirm and log trade — single left-click (moveSlotItem's
|
||||
// pickup+putdown pair trips anti-cheat on cancelled GUI slots)
|
||||
try { await this.bot.clickWindow(37, 0, 0); } catch (e) { /* ignore */ }
|
||||
|
||||
// Wait for trade to complete
|
||||
await this.once('windowClose');
|
||||
@@ -91,16 +92,23 @@ module.exports = {
|
||||
|
||||
// If the process is taking to long, just stop
|
||||
let timeoutCheck = setTimeout(()=>{
|
||||
this.bot.closeWindow('window');
|
||||
this.bot.removeAllListeners('windowOpen');
|
||||
try{ this.bot.closeWindow(window); }catch(e){ /* ignore */ }
|
||||
this.whisper(from, `I have things to do, I cant wait on you all day!`)
|
||||
}, 120000);
|
||||
|
||||
// Check to see if the remote user has agreed to the trade.
|
||||
// Click once, only while the window is actually open — repeat
|
||||
// clicks and clicks on closed windows trip anti-cheat.
|
||||
let confirmed = false;
|
||||
let confirmationCheck = setInterval(async ()=>{
|
||||
if(window.containerItems().filter(item => item?.slot == 53)[0].name == 'lime_dye'){
|
||||
this.bot.moveSlotItem(37, 37);
|
||||
try{
|
||||
if(confirmed || this.bot.currentWindow !== window) return;
|
||||
const indicator = window.slots[53];
|
||||
if(indicator && indicator.name === 'lime_dye'){
|
||||
confirmed = true;
|
||||
await this.bot.clickWindow(37, 0, 0);
|
||||
}
|
||||
}catch(e){ /* window may have closed */ }
|
||||
}, 500);
|
||||
|
||||
// Clean up when the trade is done
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
const conf = require('../conf');
|
||||
const { sleep } = require('../utils');
|
||||
|
||||
const botSlots = [0, 1, 2, 3, 9, 10, 11, 12, 18, 19, 20, 21];
|
||||
@@ -8,7 +7,11 @@ const botSlots = [0, 1, 2, 3, 9, 10, 11, 12, 18, 19, 20, 21];
|
||||
class FarmSupply {
|
||||
constructor(args) {
|
||||
this.bot = args.bot;
|
||||
this.config = conf.farmSupply || {};
|
||||
const settings = require('./settings/manager');
|
||||
this.config = {
|
||||
enabled: settings.get('farmSupply.enabled'),
|
||||
storageBotName: settings.get('farmSupply.storageBotName'),
|
||||
};
|
||||
this._storageBotKey = this.config.storageBotName;
|
||||
this.isAction = true;
|
||||
this._onTimeListen = null;
|
||||
@@ -129,12 +132,49 @@ class FarmSupply {
|
||||
await this.fillEmptyShulkers();
|
||||
} catch (error) {
|
||||
console.error('FarmSupply: Resupply error:', error);
|
||||
}
|
||||
|
||||
} finally {
|
||||
// Always resume the farm and clear the flag — a stuck trade must
|
||||
// not leave the farm paused until restart
|
||||
try {
|
||||
await this.resumeFarmPlugins(paused);
|
||||
} catch (resumeError) {
|
||||
console.error('FarmSupply: Error resuming farm plugins:', resumeError);
|
||||
}
|
||||
this._resupplying = false;
|
||||
console.log('FarmSupply: Resupply complete, farm resumed.');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Storage bot access
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Bring the on-demand storage bot online (with plugins loaded) and return
|
||||
* its Storage plugin instance. Returns null if unavailable.
|
||||
*/
|
||||
async _ensureStorageOnline() {
|
||||
const storageBot = this.bot.constructor.bots[this._storageBotKey];
|
||||
if (!storageBot) {
|
||||
console.log(`FarmSupply: Storage bot '${this._storageBotKey}' not configured`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!storageBot.isReady) {
|
||||
console.log('FarmSupply: Bringing storage bot online...');
|
||||
try {
|
||||
await Promise.race([
|
||||
storageBot.ensureConnected(async () => {}),
|
||||
sleep(60000).then(() => { throw new Error('Storage bot connect timeout'); }),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(`FarmSupply: Could not bring storage bot online: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return storageBot.plunginsLoaded['Storage'] || null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Empty "filled boxes" chest
|
||||
@@ -143,7 +183,7 @@ class FarmSupply {
|
||||
async emptyFilledBoxes() {
|
||||
let filledChest;
|
||||
try {
|
||||
filledChest = this.bot.findChestBySign('filled boxes');
|
||||
filledChest = this.bot.findBlockBySign('filled boxes');
|
||||
} catch (error) {
|
||||
console.log('FarmSupply: No "filled boxes" chest found, skipping');
|
||||
return;
|
||||
@@ -155,40 +195,68 @@ class FarmSupply {
|
||||
|
||||
console.log('FarmSupply: Processing filled boxes chest');
|
||||
|
||||
// Boxes left in inventory by a previously failed deposit go first,
|
||||
// before taking more from the chest
|
||||
const leftover = this.bot.bot.inventory.items().filter(i => i.name.includes('shulker_box'));
|
||||
if (leftover.length > 0) {
|
||||
console.log(`FarmSupply: ${leftover.length} leftover box(es) in inventory, depositing those first`);
|
||||
// Player inventory slot 9+k maps to window.inventoryStart+k
|
||||
await this.tradeDeposit(leftover.slice(0, 12).map(i => i.slot - 9));
|
||||
await this.waitForStorageBotIdle();
|
||||
await this.settleAfterTrade();
|
||||
}
|
||||
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
// Re-find chest each loop — block ref may be stale after trade teleport
|
||||
filledChest = this.bot.findChestBySign('filled boxes');
|
||||
await this.bot.goTo({ where: filledChest.position, range: 2 });
|
||||
let window = await this.bot.openContainer(filledChest);
|
||||
await this.bot.goToMust({ where: filledChest.position, range: 2 });
|
||||
let window = await this.bot.openContainer(this.bot.findChestBySign('filled boxes'));
|
||||
await sleep(300);
|
||||
|
||||
let taken = 0;
|
||||
for (let i = 0; i < window.inventoryStart; i++) {
|
||||
if (taken >= 12) break;
|
||||
const item = window.slots[i];
|
||||
if (item && item.name.includes('shulker_box')) {
|
||||
try {
|
||||
await this.bot.bot.moveSlotItem(i, window.inventoryStart + taken);
|
||||
// Track slots RELATIVE to inventoryStart — the trade window has a
|
||||
// different inventoryStart than this chest window, so absolute slot
|
||||
// numbers from here would point at the wrong items there
|
||||
const takenSlots = [];
|
||||
for (let chestSlot = 0; chestSlot < window.inventoryStart; chestSlot++) {
|
||||
if (takenSlots.length >= 12) break;
|
||||
const item = window.slots[chestSlot];
|
||||
if (!item || !item.name.includes('shulker_box')) continue;
|
||||
|
||||
const destSlot = window.inventoryStart + takenSlots.length;
|
||||
if (window.slots[destSlot]) {
|
||||
console.log(`FarmSupply: Inventory slot ${destSlot} occupied, finding free slot`);
|
||||
// Find an actually empty slot
|
||||
let found = false;
|
||||
for (let freeSlot = window.inventoryStart; freeSlot < window.inventoryEnd; freeSlot++) {
|
||||
if (!window.slots[freeSlot]) {
|
||||
await this.bot.bot.moveSlotItem(chestSlot, freeSlot);
|
||||
await sleep(200);
|
||||
taken++;
|
||||
} catch (error) {
|
||||
console.error('FarmSupply: Error taking shulker from chest:', error);
|
||||
takenSlots.push(freeSlot - window.inventoryStart);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
console.log('FarmSupply: No empty inventory slots, stopping');
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
await this.bot.bot.moveSlotItem(chestSlot, destSlot);
|
||||
await sleep(200);
|
||||
takenSlots.push(destSlot - window.inventoryStart);
|
||||
}
|
||||
}
|
||||
|
||||
await this.bot.bot.closeWindow(window);
|
||||
await sleep(300);
|
||||
|
||||
if (taken === 0) {
|
||||
if (takenSlots.length === 0) {
|
||||
console.log('FarmSupply: No more filled shulker boxes to deposit');
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`FarmSupply: Took ${taken} shulker boxes, initiating trade deposit`);
|
||||
await this.tradeDeposit();
|
||||
console.log(`FarmSupply: Took ${takenSlots.length} shulker boxes, initiating trade deposit`);
|
||||
await this.tradeDeposit(takenSlots);
|
||||
await this.waitForStorageBotIdle();
|
||||
await this.settleAfterTrade();
|
||||
}
|
||||
@@ -201,7 +269,7 @@ class FarmSupply {
|
||||
async fillEmptyShulkers() {
|
||||
let emptyChest;
|
||||
try {
|
||||
emptyChest = this.bot.findChestBySign('empty shulkers');
|
||||
emptyChest = this.bot.findBlockBySign('empty shulkers');
|
||||
} catch (error) {
|
||||
console.log('FarmSupply: No "empty shulkers" chest found, skipping');
|
||||
return;
|
||||
@@ -213,15 +281,13 @@ class FarmSupply {
|
||||
|
||||
console.log('FarmSupply: Processing empty shulkers chest');
|
||||
|
||||
// Re-find and navigate to chest
|
||||
emptyChest = this.bot.findChestBySign('empty shulkers');
|
||||
await this.bot.goTo({ where: emptyChest.position, range: 2 });
|
||||
let window = await this.bot.openContainer(emptyChest);
|
||||
await this.bot.goToMust({ where: emptyChest.position, range: 2 });
|
||||
let window = await this.bot.openContainer(this.bot.findChestBySign('empty shulkers'));
|
||||
await sleep(300);
|
||||
|
||||
let emptySlots = 0;
|
||||
for (let i = 0; i < window.inventoryStart; i++) {
|
||||
if (!window.slots[i]) emptySlots++;
|
||||
for (let chestSlot = 0; chestSlot < window.inventoryStart; chestSlot++) {
|
||||
if (!window.slots[chestSlot]) emptySlots++;
|
||||
}
|
||||
|
||||
await this.bot.bot.closeWindow(window);
|
||||
@@ -295,25 +361,25 @@ class FarmSupply {
|
||||
|
||||
// Re-find chest and dump shulker boxes
|
||||
emptyChest = this.bot.findChestBySign('empty shulkers');
|
||||
await this.bot.goTo({ where: emptyChest.position, range: 2 });
|
||||
await this.bot.goToMust({ where: emptyChest.position, range: 2 });
|
||||
window = await this.bot.openContainer(emptyChest);
|
||||
await sleep(300);
|
||||
|
||||
let deposited = 0;
|
||||
for (let i = window.inventoryStart; i < window.inventoryEnd; i++) {
|
||||
const item = window.slots[i];
|
||||
for (let invSlot = window.inventoryStart; invSlot < window.inventoryEnd; invSlot++) {
|
||||
const item = window.slots[invSlot];
|
||||
if (item && item.name.includes('shulker_box')) {
|
||||
let targetSlot = null;
|
||||
for (let j = 0; j < window.inventoryStart; j++) {
|
||||
if (!window.slots[j]) {
|
||||
targetSlot = j;
|
||||
for (let emptyChestSlot = 0; emptyChestSlot < window.inventoryStart; emptyChestSlot++) {
|
||||
if (!window.slots[emptyChestSlot]) {
|
||||
targetSlot = emptyChestSlot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetSlot === null) break;
|
||||
|
||||
try {
|
||||
await this.bot.bot.moveSlotItem(i, targetSlot);
|
||||
await this.bot.bot.moveSlotItem(invSlot, targetSlot);
|
||||
await sleep(200);
|
||||
deposited++;
|
||||
} catch (error) {
|
||||
@@ -331,77 +397,186 @@ class FarmSupply {
|
||||
// ========================================
|
||||
|
||||
async tradeWithdraw(itemName, count) {
|
||||
console.log(`FarmSupply: Requesting ${count}x ${itemName} from ${this.storageBotName}`);
|
||||
const storageName = this.storageBotName;
|
||||
console.log(`FarmSupply: Requesting ${count}x ${itemName} from ${storageName}`);
|
||||
|
||||
await this.bot.whisper(this.storageBotName, `.withdraw ${itemName} ${count}`);
|
||||
await sleep(3000);
|
||||
// Both bots run in this process — call the storage plugin directly
|
||||
// instead of whispering a chat command (which raced against the storage
|
||||
// bot's command lock and required the bot to already be online)
|
||||
const storage = await this._ensureStorageOnline();
|
||||
if (!storage) throw new Error(`Storage bot unavailable for ${itemName} withdraw`);
|
||||
|
||||
await this.bot.say(`/trade ${this.storageBotName}`);
|
||||
let window = await this.bot.once('windowOpen');
|
||||
// Listen for the "ready — sending trade request" whisper the storage
|
||||
// bot sends right before initiating /trade with us
|
||||
let cleanupWhisper = () => {};
|
||||
const whisperPromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanupWhisper();
|
||||
reject(new Error(`Timeout waiting for ${storageName} to prepare ${itemName}`));
|
||||
}, 180000);
|
||||
const onWhisper = (from, message) => {
|
||||
if (from !== storageName) return;
|
||||
if (message.includes('ready — sending trade')) {
|
||||
cleanupWhisper();
|
||||
resolve();
|
||||
} else if (message.includes('Item not found') || message.includes('Failed to withdraw')
|
||||
|| message.includes('Cannot find') || message.includes('Storage busy')) {
|
||||
cleanupWhisper();
|
||||
reject(new Error(`Storage bot: ${message}`));
|
||||
}
|
||||
};
|
||||
cleanupWhisper = () => {
|
||||
this.bot.bot.removeListener('whisper', onWhisper);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
this.bot.bot.on('whisper', onWhisper);
|
||||
});
|
||||
|
||||
// Wait for storage bot to place items and confirm
|
||||
await sleep(2000);
|
||||
// Fire the withdraw — it pulls items from shulkers (can take minutes),
|
||||
// then trades with us. Don't await yet: we have to accept its trade
|
||||
// for it to complete.
|
||||
const myUsername = this.bot.bot.entity.username;
|
||||
const requestPromise = storage.handleWithdrawRequest(myUsername, itemName, count)
|
||||
.catch(error => console.error(`FarmSupply: Storage withdraw error: ${error.message}`));
|
||||
|
||||
// Confirm our side
|
||||
try {
|
||||
this.bot.bot.moveSlotItem(37, 37);
|
||||
await whisperPromise;
|
||||
} catch (error) {
|
||||
console.error('FarmSupply: Error confirming trade:', error);
|
||||
await requestPromise;
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
// Poll /trade accept until the trade window opens.
|
||||
// Set up windowOpen listener BEFORE chat to avoid missing the event.
|
||||
let window = null;
|
||||
const tradeStart = Date.now();
|
||||
while (!window && (Date.now() - tradeStart) < 45000) {
|
||||
const winPromise = this.bot.once('windowOpen');
|
||||
await this.bot.bot.chat('/trade accept');
|
||||
window = await Promise.race([winPromise, sleep(2000).then(() => null)]);
|
||||
}
|
||||
if (!window) throw new Error(`Trade window did not open with ${storageName}`);
|
||||
|
||||
// Click 1: lock our items — single left-click (moveSlotItem's
|
||||
// pickup+putdown pair desyncs on the cancelled GUI slot and trips
|
||||
// anti-cheat)
|
||||
await sleep(500);
|
||||
try { await this.bot.bot.clickWindow(37, 0, 0); } catch (e) { /* ignore */ }
|
||||
console.log('FarmSupply: Trade click 1 — items locked');
|
||||
|
||||
// Wait for storage bot to lock (slot 53 turns lime_dye), bounded —
|
||||
// an unbounded poll here used to hang resupply forever on a dead trade
|
||||
const locked = await this._waitForTradeLock(window, 120000);
|
||||
if (!locked) {
|
||||
try { this.bot.bot.closeWindow(window); } catch (e) { /* ignore */ }
|
||||
await requestPromise;
|
||||
throw new Error(`${storageName} never confirmed the ${itemName} trade`);
|
||||
}
|
||||
|
||||
// Click 2: final confirmation
|
||||
if (this.bot.bot.currentWindow === window) {
|
||||
try { await this.bot.bot.clickWindow(37, 0, 0); } catch (e) { /* ignore */ }
|
||||
console.log('FarmSupply: Trade click 2 — final confirm');
|
||||
}
|
||||
|
||||
// Wait for trade to complete
|
||||
await Promise.race([
|
||||
this.bot.once('windowClose'),
|
||||
sleep(15000),
|
||||
sleep(120000),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('FarmSupply: Trade withdraw timeout or error:', error);
|
||||
}
|
||||
|
||||
await requestPromise;
|
||||
await sleep(500);
|
||||
console.log(`FarmSupply: Withdraw trade complete for ${itemName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the trade window for the other party's lock indicator (slot 53
|
||||
* turning lime_dye). Resolves true when locked, false on timeout or if
|
||||
* the window closes.
|
||||
*/
|
||||
async _waitForTradeLock(window, timeoutMs) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const indicator = window.slots[53];
|
||||
if (indicator && indicator.name === 'lime_dye') {
|
||||
console.log('FarmSupply: Storage bot has locked');
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
return false; // window closed
|
||||
}
|
||||
if (this.bot.bot.currentWindow !== window) return false;
|
||||
await sleep(500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Trade: Deposit items to storage bot
|
||||
// ========================================
|
||||
|
||||
async tradeDeposit() {
|
||||
console.log(`FarmSupply: Depositing shulker boxes to ${this.storageBotName}`);
|
||||
async tradeDeposit(slotsToTrade) {
|
||||
console.log(`FarmSupply: Depositing ${slotsToTrade.length} shulker boxes to ${this.storageBotName}`);
|
||||
|
||||
// Storage bot is on-demand — it must be online to accept the trade
|
||||
const storage = await this._ensureStorageOnline();
|
||||
if (!storage) throw new Error('Storage bot unavailable for deposit');
|
||||
|
||||
const windowPromise = this.bot.once('windowOpen');
|
||||
await this.bot.say(`/trade ${this.storageBotName}`);
|
||||
let window = await this.bot.once('windowOpen');
|
||||
let window = await Promise.race([
|
||||
windowPromise,
|
||||
sleep(45000).then(() => null),
|
||||
]);
|
||||
if (!window) throw new Error(`Trade window with ${this.storageBotName} never opened`);
|
||||
|
||||
// slotsToTrade holds inventory-relative indices — offset them into
|
||||
// this window's inventory section
|
||||
let placed = 0;
|
||||
for (const slotNum of botSlots) {
|
||||
if (placed >= 12) break;
|
||||
for (let i = window.inventoryStart; i < window.inventoryEnd; i++) {
|
||||
const item = window.slots[i];
|
||||
if (!item || !item.name.includes('shulker_box')) continue;
|
||||
for (const tradeSlot of botSlots) {
|
||||
if (placed >= slotsToTrade.length) break;
|
||||
try {
|
||||
await this.bot.bot.moveSlotItem(i, slotNum);
|
||||
await this.bot.bot.moveSlotItem(window.inventoryStart + slotsToTrade[placed], tradeSlot);
|
||||
await sleep(200);
|
||||
placed++;
|
||||
} catch (error) {
|
||||
console.error('FarmSupply: Error placing item in trade:', error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`FarmSupply: Placed ${placed} shulker boxes in trade window`);
|
||||
|
||||
await sleep(500);
|
||||
// Click 1: lock items (green wool — first confirmation, single click)
|
||||
try {
|
||||
this.bot.bot.moveSlotItem(37, 37);
|
||||
await this.bot.bot.clickWindow(37, 0, 0);
|
||||
console.log('FarmSupply: Trade click 1 — items locked');
|
||||
} catch (error) {
|
||||
console.error('FarmSupply: Error confirming deposit trade:', error);
|
||||
console.error('FarmSupply: Error on trade click 1:', error);
|
||||
}
|
||||
|
||||
// Wait for storage bot to lock (slot 53 turns lime_dye from grey_dye)
|
||||
const locked = await this._waitForTradeLock(window, 120000);
|
||||
if (!locked) {
|
||||
try { this.bot.bot.closeWindow(window); } catch (e) { /* ignore */ }
|
||||
throw new Error('Storage bot never confirmed the deposit trade');
|
||||
}
|
||||
|
||||
// Click 2: final confirmation (green wool — second click, both locked)
|
||||
try {
|
||||
if (this.bot.bot.currentWindow === window) {
|
||||
await this.bot.bot.clickWindow(37, 0, 0);
|
||||
console.log('FarmSupply: Trade click 2 — final confirm');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('FarmSupply: Error on trade click 2:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
this.bot.once('windowClose'),
|
||||
sleep(15000),
|
||||
sleep(30000),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('FarmSupply: Trade deposit timeout or error:', error);
|
||||
@@ -423,13 +598,22 @@ class FarmSupply {
|
||||
if (!storage) return;
|
||||
|
||||
console.log('FarmSupply: Waiting for storage bot to finish processing...');
|
||||
const maxWait = 120000;
|
||||
const maxWait = 300000;
|
||||
const start = Date.now();
|
||||
while (storage._busy && (Date.now() - start) < maxWait) {
|
||||
const isBusy = () => storage._busy || storage._operationLock;
|
||||
|
||||
while (Date.now() - start < maxWait) {
|
||||
if (!isBusy()) {
|
||||
// There's a short gap between the trade handler releasing the
|
||||
// lock and the post-trade organize grabbing it — require the
|
||||
// bot to stay idle across a re-check before trusting it
|
||||
await sleep(3000);
|
||||
if (!isBusy()) break;
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
if (storage._busy) {
|
||||
if (isBusy()) {
|
||||
console.log('FarmSupply: Storage bot still busy after timeout, continuing anyway');
|
||||
} else {
|
||||
console.log('FarmSupply: Storage bot idle');
|
||||
@@ -467,7 +651,7 @@ class FarmSupply {
|
||||
throw new Error('FarmSupply: No crafting table found nearby');
|
||||
}
|
||||
|
||||
await this.bot.goTo({ where: craftingTable.position, range: 2 });
|
||||
await this.bot.goToMust({ where: craftingTable.position, range: 2 });
|
||||
|
||||
const recipe = this.bot.bot.recipesAll(
|
||||
this.bot.mcData.itemsByName.shulker_box.id,
|
||||
|
||||
+92
-28
@@ -32,22 +32,10 @@ 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));
|
||||
}
|
||||
const SettingsManager = require('./settings/manager');
|
||||
|
||||
// Start app-level web server (always available, even before bots connect)
|
||||
// Start web server immediately — it serves static/web routes independently of bot state
|
||||
const webServer = require('./web-server');
|
||||
const ActivityWeb = require('./activity-web');
|
||||
const ChatWeb = require('./chat-web');
|
||||
@@ -56,24 +44,100 @@ webServer.queuePlugin(ChatWeb);
|
||||
webServer.queuePlugin(ActivityWeb);
|
||||
webServer.queuePlugin(LogWeb);
|
||||
webServer.queuePlugin(InvitePlugin);
|
||||
const SettingsWeb = require('./settings/web');
|
||||
webServer.queuePlugin(SettingsWeb);
|
||||
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];
|
||||
if(bot.autoConnect){
|
||||
console.log('Trying to connect', name)
|
||||
console.log('Status for', name, await bot.connect());
|
||||
async function initDatabase() {
|
||||
if (Database.db) return;
|
||||
|
||||
// bot.bot.setControlState('jump', true);
|
||||
// await sleep(5000);
|
||||
// bot.bot.setControlState('jump', false);
|
||||
await Database.initialize(conf.storage.dbPath || './storage/storage.db');
|
||||
console.log('DB initialized');
|
||||
|
||||
// Seed settings defaults (INSERT OR IGNORE — won't overwrite user changes)
|
||||
const registry = SettingsManager.getRegistry();
|
||||
const seedDefaults = registry.map(r => {
|
||||
const parts = r.key.split('.');
|
||||
let node = conf;
|
||||
for (const p of parts) node = node?.[p];
|
||||
|
||||
let value = '';
|
||||
if (node !== undefined && node !== null) {
|
||||
if (r.key === 'ai.prompts' && typeof node === 'object') {
|
||||
const templates = {};
|
||||
for (const [name, fn] of Object.entries(node)) {
|
||||
if (typeof fn === 'function') {
|
||||
const fnStr = fn.toString();
|
||||
const m = fnStr.match(/=>\s*`([\s\S]*)`\s*$/);
|
||||
templates[name] = m ? m[1] : '';
|
||||
}
|
||||
}
|
||||
value = JSON.stringify(templates);
|
||||
} else if (r.type === 'json') {
|
||||
value = JSON.stringify(node);
|
||||
} else {
|
||||
value = String(node);
|
||||
}
|
||||
}
|
||||
|
||||
return { key: r.key, value, type: r.type, category: r.category, label: r.label, description: r.description };
|
||||
});
|
||||
await Database.seedDefaultSettings(seedDefaults);
|
||||
|
||||
// Initialize settings cache from DB
|
||||
await SettingsManager.initialize();
|
||||
|
||||
// Seed per-bot settings from config defaults
|
||||
await Database.seedDefaultBotSettings();
|
||||
|
||||
// Apply DB-stored bot settings to live bot instances
|
||||
for (const [botName, bot] of Object.entries(CJbot.bots)) {
|
||||
try {
|
||||
const rows = await Database.getAllBotSettings(botName);
|
||||
for (const row of rows) {
|
||||
let val = row.value;
|
||||
if (row.type === 'number') val = Number(val) || 0;
|
||||
else if (row.type === 'boolean') val = val === 'true';
|
||||
else if (row.type === 'json') { try { val = JSON.parse(val); } catch (e) { val = null; } }
|
||||
|
||||
switch (row.key) {
|
||||
case 'username': if (val) bot.username = val; break;
|
||||
case 'password': if (val) bot.password = val; break;
|
||||
case 'auth': if (val) bot.auth = val; break;
|
||||
case 'autoConnect': bot.autoConnect = val; break;
|
||||
case 'autoReConnect': bot.autoReConnect = val; break;
|
||||
case 'onDemand': bot.onDemand = val; break;
|
||||
case 'idleTimeout': bot._idleTimeout = Number(val) || 30000; break;
|
||||
case 'commands': bot._dbCommands = val; break;
|
||||
case 'plugins': bot.pluginsWanted = val || {}; break;
|
||||
case 'hasAi': bot.hasAi = val; break;
|
||||
}
|
||||
}
|
||||
console.log(`Applied DB settings for ${botName}`);
|
||||
} catch (e) { /* bot may not exist in DB yet */ }
|
||||
}
|
||||
|
||||
if (conf.invite?.seedSites) {
|
||||
await Database.seedInviteSites(conf.invite.seedSites);
|
||||
console.log('Invite sites seeded');
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// DB and settings must be ready before bots connect, so Storage
|
||||
// constructors always see the correct DB-stored config values.
|
||||
await initDatabase();
|
||||
|
||||
for (let name in CJbot.bots) {
|
||||
const bot = CJbot.bots[name];
|
||||
if (bot.autoConnect) {
|
||||
console.log('Trying to connect', name);
|
||||
console.log('Status for', name, await bot.connect());
|
||||
await sleep(30000);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('!!!!!!!! error:', e)
|
||||
}})()
|
||||
|
||||
|
||||
// module.exports = {bot: ez, henry, owen, linda, jimin, nova, ez};
|
||||
console.log('!!!!!!!! error:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
'use strict';
|
||||
|
||||
const conf = require('../../conf');
|
||||
const Database = require('../storage/database');
|
||||
|
||||
// In-memory cache: key -> { value, type, category }
|
||||
let _cache = null;
|
||||
// Bot settings cache: botName -> { key -> { value, type } }
|
||||
let _botCache = null;
|
||||
|
||||
// Registry of all known settings with their types, categories, labels, defaults
|
||||
const SETTINGS_REGISTRY = [
|
||||
// ---- MC / Server ----
|
||||
{ key: 'mc.host', type: 'string', category: 'server', label: 'Server Host', description: 'Minecraft server address' },
|
||||
|
||||
// ---- AI category ----
|
||||
{ key: 'ai.provider', type: 'string', category: 'ai', label: 'AI Provider', description: 'LLM provider (ollama or gemini)' },
|
||||
{ key: 'ai.model', type: 'string', category: 'ai', label: 'Model', description: 'Model name to use' },
|
||||
{ key: 'ai.baseUrl', type: 'string', category: 'ai', label: 'Ollama Base URL', description: 'Ollama server URL (only used if provider is ollama)' },
|
||||
{ key: 'ai.key', type: 'secret', category: 'ai', label: 'API Key', description: 'Gemini API key (only used if provider is gemini)' },
|
||||
{ key: 'ai.temperature', type: 'number', category: 'ai', label: 'Temperature', description: 'LLM temperature (0-2)' },
|
||||
{ key: 'ai.topP', type: 'number', category: 'ai', label: 'Top P', description: 'Nucleus sampling parameter' },
|
||||
{ key: 'ai.topK', type: 'number', category: 'ai', label: 'Top K', description: 'Top-K sampling parameter' },
|
||||
{ key: 'ai.interval', type: 'number', category: 'ai', label: 'Poll Interval', description: 'Seconds between AI poll cycles' },
|
||||
{ key: 'ai.timeout', type: 'number', category: 'ai', label: 'Request Timeout', description: 'LLM request timeout in ms' },
|
||||
{ key: 'ai.promptName', type: 'string', category: 'ai', label: 'Active Prompt', description: 'Active prompt personality name' },
|
||||
{ key: 'ai.enableNativeTools', type: 'boolean', category: 'ai', label: 'Native Tools', description: 'Enable native function calling' },
|
||||
{ key: 'ai.faceBot', type: 'string', category: 'ai', label: 'Face Bot', description: 'Bot that runs the AI coordinator' },
|
||||
{ key: 'ai.storageBot', type: 'string', category: 'ai', label: 'Storage Bot', description: 'Bot that handles storage operations' },
|
||||
{ key: 'ai.prompCustom', type: 'string', category: 'ai', label: 'Custom Prompt Text', description: 'Injected text when prompt name is "custom"' },
|
||||
{ key: 'ai.prompts', type: 'json', category: 'ai', label: 'Prompt Templates', description: 'All prompt templates: {"name": "template...", ...}' },
|
||||
|
||||
// ---- Storage category ----
|
||||
{ key: 'storage.dbPath', type: 'string', category: 'storage', label: 'Database Path', description: 'Path to SQLite database file' },
|
||||
{ key: 'storage.scanRadius', type: 'number', category: 'storage', label: 'Scan Radius', description: 'Block radius for chest scanning' },
|
||||
{ key: 'storage.homePos', type: 'json', category: 'storage', label: 'Home Position', description: 'Bot home position {x, y, z} or null' },
|
||||
{ key: 'storage.craftingTablePos', type: 'json', category: 'storage', label: 'Crafting Table Pos', description: 'Crafting table position {x, y, z} or null' },
|
||||
{ key: 'storage.inboxShulkerName', type: 'string', category: 'storage', label: 'Inbox Shulker Name', description: 'Name tag for inbox shulker boxes' },
|
||||
{ key: 'storage.outboxShulkerName', type: 'string', category: 'storage', label: 'Outbox Shulker Name', description: 'Name tag for outbox shulker boxes' },
|
||||
{ key: 'storage.newShulkersName', type: 'string', category: 'storage', label: 'New Shulkers Name', description: 'Name tag for empty/new shulker boxes' },
|
||||
{ key: 'storage.hotbarItems', type: 'json', category: 'storage', label: 'Hotbar Items', description: 'Array of items to keep in hotbar' },
|
||||
{ key: 'storage.hotbarRestockInterval', type: 'number',category: 'storage', label: 'Restock Interval', description: 'ms between hotbar restock checks' },
|
||||
{ key: 'storage.categories', type: 'json', category: 'storage', label: 'Item Categories', description: 'Item name classification lists' },
|
||||
{ key: 'storage.defaultPlayers', type: 'json', category: 'storage', label: 'Default Players', description: 'Default player role assignments' },
|
||||
{ key: 'storage.webPort', type: 'number', category: 'storage', label: 'Web UI Port', description: 'Port for the web dashboard' },
|
||||
{ key: 'storage.webHost', type: 'string', category: 'storage', label: 'Web UI Host', description: 'Bind address for the web dashboard' },
|
||||
|
||||
// ---- Farm supply ----
|
||||
{ key: 'farmSupply.enabled', type: 'boolean', category: 'farm', label: 'Farm Supply Enabled', description: 'Enable farm supply plugin' },
|
||||
{ key: 'farmSupply.storageBotName', type: 'string', category: 'farm', label: 'Storage Bot Name', description: 'Name of bot handling storage trades' },
|
||||
|
||||
// ---- Web auth (OIDC / SSO) ----
|
||||
{ key: 'auth.enabled', type: 'boolean', category: 'auth', label: 'Auth Enabled', description: 'Require SSO login for the web dashboard' },
|
||||
{ key: 'auth.authorizationEndpoint', type: 'string', category: 'auth', label: 'Authorize Endpoint', description: 'SSO OAuth authorize URL' },
|
||||
{ key: 'auth.tokenEndpoint', type: 'string', category: 'auth', label: 'Token Endpoint', description: 'SSO OAuth token URL' },
|
||||
{ key: 'auth.userinfoEndpoint', type: 'string', category: 'auth', label: 'Userinfo Endpoint', description: 'SSO OIDC userinfo URL' },
|
||||
{ key: 'auth.clientId', type: 'string', category: 'auth', label: 'Client ID', description: 'OAuth client ID registered on the SSO' },
|
||||
{ key: 'auth.clientSecret', type: 'secret', category: 'auth', label: 'Client Secret', description: 'OAuth client secret' },
|
||||
{ key: 'auth.redirectUri', type: 'string', category: 'auth', label: 'Redirect URI', description: 'Absolute callback URL — must match the SSO client registration' },
|
||||
{ key: 'auth.scopes', type: 'json', category: 'auth', label: 'Scopes', description: 'OAuth scopes to request' },
|
||||
{ key: 'auth.usernameClaim', type: 'string', category: 'auth', label: 'Username Claim', description: 'Userinfo claim used as the username' },
|
||||
{ key: 'auth.groupsClaim', type: 'string', category: 'auth', label: 'Groups Claim', description: 'Userinfo claim carrying group membership' },
|
||||
{ key: 'auth.allowedUsers', type: 'json', category: 'auth', label: 'Allowed Users', description: 'Usernames allowed to log in (empty = any SSO user)' },
|
||||
{ key: 'auth.allowedGroups', type: 'json', category: 'auth', label: 'Allowed Groups', description: 'SSO groups allowed to log in (empty = any SSO user)' },
|
||||
{ key: 'auth.tokenTTL', type: 'number', category: 'auth', label: 'Session TTL (s)', description: 'Seconds a login session stays valid' },
|
||||
|
||||
// ---- Invites ----
|
||||
{ key: 'invite.seedSites', type: 'json', category: 'invites', label: 'Invite Sites', description: 'Array of invite site configurations' },
|
||||
|
||||
// ---- Plugins ----
|
||||
{ key: 'plugings', type: 'json', category: 'general', label: 'Default Plugins', description: 'Default plugin configurations for all bots' },
|
||||
|
||||
// ---- Player list ----
|
||||
{ key: 'playerListDir', type: 'string', category: 'general', label: 'Player List Dir', description: 'Directory for player list output' },
|
||||
];
|
||||
|
||||
// Registry of per-bot settings
|
||||
const BOT_SETTINGS_REGISTRY = [
|
||||
{ key: 'username', type: 'string', label: 'Username/Email', description: 'Microsoft/Mojang auth email' },
|
||||
{ key: 'password', type: 'secret', label: 'Password', description: 'Account password' },
|
||||
{ key: 'auth', type: 'string', label: 'Auth Method', description: 'microsoft or mojang' },
|
||||
{ key: 'autoConnect', type: 'boolean', label: 'Auto Connect', description: 'Connect on startup' },
|
||||
{ key: 'autoReConnect', type: 'boolean', label: 'Auto Reconnect', description: 'Reconnect after disconnect' },
|
||||
{ key: 'onDemand', type: 'boolean', label: 'On Demand', description: 'Only connect when needed, auto-disconnect when idle' },
|
||||
{ key: 'idleTimeout', type: 'number', label: 'Idle Timeout', description: 'ms before on-demand bot disconnects' },
|
||||
{ key: 'commands', type: 'json', label: 'Commands', description: 'Array of command module names to load' },
|
||||
{ key: 'plugins', type: 'json', label: 'Plugins', description: 'Plugin configurations: {"PluginName": {...options...}}' },
|
||||
{ key: 'hasAi', type: 'boolean', label: 'Has AI', description: 'Load the AI plugin for this bot' },
|
||||
];
|
||||
|
||||
// Build a map from config key path to config file value
|
||||
function _getConfigDefault(key) {
|
||||
const parts = key.split('.');
|
||||
let node = conf;
|
||||
for (const p of parts) {
|
||||
if (node == null) return undefined;
|
||||
node = node[p];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function _serializeDefault(value, type) {
|
||||
if (value === undefined || value === null) return '';
|
||||
switch (type) {
|
||||
case 'number': return String(value);
|
||||
case 'boolean': return String(value);
|
||||
case 'json': return JSON.stringify(value);
|
||||
case 'secret': return String(value);
|
||||
default: return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize a native value to the string stored in the DB/cache.
|
||||
function _serialize(value, type) {
|
||||
switch (type) {
|
||||
case 'number': {
|
||||
const n = Number(value);
|
||||
if (isNaN(n)) throw new Error(`Expected number, got: ${value}`);
|
||||
return String(n);
|
||||
}
|
||||
case 'boolean': return (value === true || value === 'true' || value === '1') ? 'true' : 'false';
|
||||
case 'json': return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value);
|
||||
case 'secret': return String(value);
|
||||
default: return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract serialized defaults from a bot config object.
|
||||
function _botConfigToDefaults(botConfig) {
|
||||
return {
|
||||
username: { value: botConfig.username || '', type: 'string' },
|
||||
password: { value: botConfig.password || '', type: 'secret' },
|
||||
auth: { value: botConfig.auth || 'microsoft', type: 'string' },
|
||||
autoConnect: { value: String(botConfig.autoConnect ?? true), type: 'boolean' },
|
||||
autoReConnect: { value: String(botConfig.autoReConnect ?? true), type: 'boolean' },
|
||||
onDemand: { value: String(botConfig.onDemand || false), type: 'boolean' },
|
||||
idleTimeout: { value: String(botConfig.idleTimeout || 30000), type: 'number' },
|
||||
commands: { value: JSON.stringify(botConfig.commands || ['default']), type: 'json' },
|
||||
plugins: { value: JSON.stringify(botConfig.plugins || {}), type: 'json' },
|
||||
hasAi: { value: String(botConfig.hasAi || false), type: 'boolean' },
|
||||
};
|
||||
}
|
||||
|
||||
function _coerceForConsumer(value, type) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
switch (type) {
|
||||
case 'number': return 0;
|
||||
case 'boolean': return false;
|
||||
case 'json': return null;
|
||||
case 'secret': return '';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
switch (type) {
|
||||
case 'number': {
|
||||
const n = Number(value);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
case 'boolean': return value === 'true' || value === true;
|
||||
case 'json': {
|
||||
try { return JSON.parse(value); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
case 'secret': return String(value);
|
||||
default: return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function _getTypeForKey(key) {
|
||||
const entry = SETTINGS_REGISTRY.find(e => e.key === key);
|
||||
return entry ? entry.type : 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the in-memory cache from DB (with config file fallback).
|
||||
* Must be called after Database is initialized.
|
||||
*/
|
||||
async function initialize() {
|
||||
if (_cache) return;
|
||||
|
||||
// Build cache from registry defaults first
|
||||
_cache = {};
|
||||
for (const entry of SETTINGS_REGISTRY) {
|
||||
const configDefault = _getConfigDefault(entry.key);
|
||||
_cache[entry.key] = {
|
||||
value: _serializeDefault(configDefault, entry.type),
|
||||
type: entry.type,
|
||||
category: entry.category,
|
||||
};
|
||||
}
|
||||
|
||||
// Overlay DB values if DB is available
|
||||
if (Database && Database.db) {
|
||||
try {
|
||||
const rows = await Database.getAllSettings();
|
||||
for (const row of rows) {
|
||||
if (_cache[row.key]) {
|
||||
_cache[row.key].value = row.value;
|
||||
_cache[row.key].fromDb = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('SettingsManager: failed to load from DB, using config defaults:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize bot settings cache
|
||||
await _initBotCache();
|
||||
|
||||
console.log(`SettingsManager: initialized with ${Object.keys(_cache).length} global settings`);
|
||||
}
|
||||
|
||||
async function _initBotCache() {
|
||||
_botCache = {};
|
||||
|
||||
// Load from config first as defaults
|
||||
const bots = conf.mc?.bots || {};
|
||||
for (const [botName, botConfig] of Object.entries(bots)) {
|
||||
_botCache[botName] = _botConfigToDefaults(botConfig);
|
||||
}
|
||||
|
||||
// Overlay DB values
|
||||
if (Database && Database.db) {
|
||||
for (const botName of Object.keys(_botCache)) {
|
||||
try {
|
||||
const rows = await Database.getAllBotSettings(botName);
|
||||
for (const row of rows) {
|
||||
if (_botCache[botName] && _botCache[botName][row.key] !== undefined) {
|
||||
_botCache[botName][row.key].value = row.value;
|
||||
_botCache[botName][row.key].fromDb = true;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* bot settings not in DB yet */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous get — reads from cache. Returns coerced native type. */
|
||||
function get(key) {
|
||||
if (!_cache) {
|
||||
const raw = _getConfigDefault(key);
|
||||
return _coerceForConsumer(raw, _getTypeForKey(key));
|
||||
}
|
||||
const entry = _cache[key];
|
||||
if (!entry) return undefined;
|
||||
return _coerceForConsumer(entry.value, entry.type);
|
||||
}
|
||||
|
||||
/** Synchronous getAll — returns { [key]: nativeValue } */
|
||||
function getAll() {
|
||||
const result = {};
|
||||
if (!_cache) return result;
|
||||
for (const [key, entry] of Object.entries(_cache)) {
|
||||
result[key] = _coerceForConsumer(entry.value, entry.type);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Synchronous getAllByCategory */
|
||||
function getAllByCategory(category) {
|
||||
const result = {};
|
||||
if (!_cache) return result;
|
||||
for (const [key, entry] of Object.entries(_cache)) {
|
||||
if (entry.category === category) {
|
||||
result[key] = _coerceForConsumer(entry.value, entry.type);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Async set — writes to DB and updates cache */
|
||||
async function set(key, value) {
|
||||
if (!_cache) throw new Error('SettingsManager not initialized');
|
||||
const entry = _cache[key];
|
||||
if (!entry) throw new Error(`Unknown setting: ${key}`);
|
||||
|
||||
const serialized = _serialize(value, entry.type);
|
||||
|
||||
if (Database && Database.db) {
|
||||
await Database.setSetting(key, serialized);
|
||||
}
|
||||
|
||||
entry.value = serialized;
|
||||
entry.fromDb = true;
|
||||
|
||||
// Notify AiManager of the change (if it's an ai.* setting)
|
||||
try {
|
||||
const { getInstance } = require('../ai/manager');
|
||||
const manager = getInstance();
|
||||
if (manager && key.startsWith('ai.')) {
|
||||
manager.onSettingChanged(key, _coerceForConsumer(serialized, entry.type));
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
return _coerceForConsumer(serialized, entry.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all settings under a key prefix as a flat object with short keys.
|
||||
* e.g. getSection('storage') → { dbPath: './storage/storage.db', scanRadius: 30, ... }
|
||||
* Falls back to conf when cache is not yet initialized.
|
||||
* Skips keys whose cached value is empty (unset), preserving caller's defaults.
|
||||
*/
|
||||
function getSection(prefix) {
|
||||
const strip = prefix + '.';
|
||||
if (!_cache) {
|
||||
// Not yet initialized — read directly from conf
|
||||
const parts = prefix.split('.');
|
||||
let node = conf;
|
||||
for (const p of parts) { if (node == null) return {}; node = node[p]; }
|
||||
return (typeof node === 'object' && node !== null && !Array.isArray(node)) ? { ...node } : {};
|
||||
}
|
||||
const result = {};
|
||||
for (const [key, entry] of Object.entries(_cache)) {
|
||||
if (!key.startsWith(strip)) continue;
|
||||
if (entry.value === '') continue; // unset — let caller's default win
|
||||
result[key.slice(strip.length)] = _coerceForConsumer(entry.value, entry.type);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Reload cache from DB */
|
||||
async function reload() {
|
||||
_cache = null;
|
||||
_botCache = null;
|
||||
await initialize();
|
||||
}
|
||||
|
||||
function getRegistry() {
|
||||
return SETTINGS_REGISTRY;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Bot Settings
|
||||
// ========================================
|
||||
|
||||
function getBotSettingsRegistry() {
|
||||
return BOT_SETTINGS_REGISTRY;
|
||||
}
|
||||
|
||||
/** Get all bot names known to the system */
|
||||
function getBotNames() {
|
||||
if (!_botCache) return Object.keys(conf.mc?.bots || {});
|
||||
return Object.keys(_botCache);
|
||||
}
|
||||
|
||||
/** Get all settings for a specific bot. Returns { key: nativeValue, ... } with metadata. */
|
||||
function getBotSettings(botName) {
|
||||
const result = { _meta: { name: botName } };
|
||||
if (!_botCache || !_botCache[botName]) {
|
||||
const botConfig = conf.mc?.bots?.[botName];
|
||||
if (!botConfig) return null;
|
||||
const defaults = _botConfigToDefaults(botConfig);
|
||||
for (const [key, entry] of Object.entries(defaults)) {
|
||||
result[key] = _coerceForConsumer(entry.value, entry.type);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const [key, cacheEntry] of Object.entries(_botCache[botName])) {
|
||||
result[key] = _coerceForConsumer(cacheEntry.value, cacheEntry.type);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Set a single bot setting. Writes to DB and updates cache. */
|
||||
async function setBotSetting(botName, key, value) {
|
||||
if (!_botCache) throw new Error('SettingsManager not initialized');
|
||||
|
||||
// Ensure bot exists in cache
|
||||
if (!_botCache[botName]) {
|
||||
const botConfig = conf.mc?.bots?.[botName];
|
||||
if (!botConfig) throw new Error(`Unknown bot: ${botName}`);
|
||||
_botCache[botName] = _botConfigToDefaults(botConfig);
|
||||
}
|
||||
|
||||
const cacheEntry = _botCache[botName][key];
|
||||
if (!cacheEntry) throw new Error(`Unknown bot setting: ${key}`);
|
||||
|
||||
const serialized = _serialize(value, cacheEntry.type);
|
||||
|
||||
if (Database && Database.db) {
|
||||
await Database.setBotSetting(botName, key, serialized, cacheEntry.type);
|
||||
}
|
||||
|
||||
cacheEntry.value = serialized;
|
||||
cacheEntry.fromDb = true;
|
||||
|
||||
// Apply to live bot instance if connected
|
||||
try {
|
||||
const { CJbot } = require('../../model/minecraft');
|
||||
const bot = CJbot.bots[botName];
|
||||
if (bot) {
|
||||
const nativeVal = _coerceForConsumer(serialized, cacheEntry.type);
|
||||
switch (key) {
|
||||
case 'autoConnect': bot.autoConnect = nativeVal; break;
|
||||
case 'autoReConnect': bot.autoReConnect = nativeVal; break;
|
||||
case 'onDemand': bot.onDemand = nativeVal; break;
|
||||
case 'idleTimeout': bot._idleTimeout = nativeVal; break;
|
||||
case 'plugins': bot.pluginsWanted = nativeVal || {}; break;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
return _coerceForConsumer(serialized, cacheEntry.type);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initialize, get, getAll, getAllByCategory, getSection, set, reload, getRegistry,
|
||||
getBotNames, getBotSettings, setBotSetting, getBotSettingsRegistry,
|
||||
};
|
||||
@@ -0,0 +1,639 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const settings = require('./manager');
|
||||
const Database = require('../storage/database');
|
||||
|
||||
function createRouter() {
|
||||
const router = express.Router();
|
||||
|
||||
function dbAvailable() {
|
||||
return Database && Database.db;
|
||||
}
|
||||
|
||||
// ---- Global settings ----
|
||||
|
||||
router.get('/api/settings', async (req, res) => {
|
||||
try {
|
||||
const all = settings.getAll();
|
||||
const registry = settings.getRegistry();
|
||||
const result = registry.map(r => ({
|
||||
...r,
|
||||
value: all[r.key],
|
||||
}));
|
||||
res.json({ settings: result });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/settings:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/settings/:category', async (req, res) => {
|
||||
try {
|
||||
const values = settings.getAllByCategory(req.params.category);
|
||||
const registry = settings.getRegistry().filter(r => r.category === req.params.category);
|
||||
const result = registry.map(r => ({
|
||||
...r,
|
||||
value: values[r.key],
|
||||
}));
|
||||
res.json({ settings: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/api/settings/:key', async (req, res) => {
|
||||
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
|
||||
try {
|
||||
const { key } = req.params;
|
||||
const { value } = req.body;
|
||||
if (value === undefined) {
|
||||
return res.status(400).json({ error: 'Missing value' });
|
||||
}
|
||||
const newValue = await settings.set(key, value);
|
||||
res.json({ key, value: newValue });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/settings/:key:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Bot settings ----
|
||||
|
||||
router.get('/api/bot-settings', async (req, res) => {
|
||||
try {
|
||||
const names = settings.getBotNames();
|
||||
const botReg = settings.getBotSettingsRegistry();
|
||||
const bots = names.map(name => {
|
||||
const s = settings.getBotSettings(name);
|
||||
const flat = { name };
|
||||
// Add metadata from registry for each key
|
||||
for (const br of botReg) {
|
||||
flat[br.key] = { value: s ? s[br.key] : null, type: br.type, label: br.label, description: br.description };
|
||||
}
|
||||
return flat;
|
||||
});
|
||||
res.json({ bots, registry: botReg });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/bot-settings:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/bot-settings/:botName', async (req, res) => {
|
||||
try {
|
||||
const s = settings.getBotSettings(req.params.botName);
|
||||
if (!s) return res.status(404).json({ error: `Unknown bot: ${req.params.botName}` });
|
||||
const botReg = settings.getBotSettingsRegistry();
|
||||
const result = { name: req.params.botName };
|
||||
for (const br of botReg) {
|
||||
result[br.key] = { value: s[br.key], type: br.type, label: br.label, description: br.description };
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/api/bot-settings/:botName/:key', async (req, res) => {
|
||||
if (!dbAvailable()) return res.status(503).json({ error: 'Database not initialized' });
|
||||
try {
|
||||
const { botName, key } = req.params;
|
||||
const { value } = req.body;
|
||||
if (value === undefined) return res.status(400).json({ error: 'Missing value' });
|
||||
const newValue = await settings.setBotSetting(botName, key, value);
|
||||
res.json({ botName, key, value: newValue });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/bot-settings/:botName/:key:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
const webUI = {
|
||||
tabId: 'settings',
|
||||
tabLabel: 'Settings',
|
||||
tabOrder: 35,
|
||||
html: `
|
||||
<div id="settingsArea">
|
||||
<div class="settings-layout">
|
||||
<div class="settings-sidebar" id="settingsSidebar"></div>
|
||||
<div class="settings-main" id="settingsMain">
|
||||
<div class="settings-empty">Select a category to view settings</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
css: `
|
||||
.settings-layout{display:flex;gap:16px;min-height:400px}
|
||||
.settings-sidebar{width:180px;flex-shrink:0;display:flex;flex-direction:column;gap:4px}
|
||||
.settings-sidebar-btn{background:transparent;border:1px solid #374151;color:#9ca3af;padding:10px 14px;border-radius:6px;cursor:pointer;text-align:left;font-size:.9em;transition:all .2s}
|
||||
.settings-sidebar-btn:hover{border-color:#60a5fa;color:#e5e7eb}
|
||||
.settings-sidebar-btn.active{background:#1e40af;border-color:#60a5fa;color:#fff}
|
||||
.settings-main{flex:1;min-width:0}
|
||||
.settings-empty{padding:40px 20px;color:#6b7280;text-align:center;font-size:.9em}
|
||||
.settings-grid{display:flex;flex-direction:column;gap:12px}
|
||||
.settings-card{background:#111827;border:1px solid #374151;border-radius:8px;padding:14px 16px;transition:border-color .2s}
|
||||
.settings-card:hover{border-color:#60a5fa}
|
||||
.settings-card-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px}
|
||||
.settings-card-label{font-weight:600;color:#e5e7eb;font-size:.95em}
|
||||
.settings-card-desc{color:#6b7280;font-size:.8em;margin-bottom:10px}
|
||||
.settings-card-key{color:#4b5563;font-size:.75em;font-family:monospace}
|
||||
.settings-card-body{display:flex;gap:8px;align-items:center}
|
||||
.settings-card-body input[type="text"],
|
||||
.settings-card-body input[type="number"],
|
||||
.settings-card-body input[type="password"]{flex:1;padding:8px 10px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;font-size:.9em}
|
||||
.settings-card-body input:focus{outline:none;border-color:#60a5fa}
|
||||
.settings-card-body textarea{flex:1;padding:8px 10px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;font-size:.85em;min-height:80px;resize:vertical;font-family:monospace}
|
||||
.settings-card-body textarea:focus{outline:none;border-color:#60a5fa}
|
||||
.settings-card-body input[type="checkbox"]{width:18px;height:18px;accent-color:#60a5fa}
|
||||
.settings-card-body select{flex:1;padding:8px 10px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;font-size:.9em}
|
||||
.settings-card-body select:focus{outline:none;border-color:#60a5fa}
|
||||
.settings-value-display{flex:1;padding:8px 10px;color:#9ca3af;font-size:.9em;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.settings-btn-save{padding:8px 16px;border-radius:6px;border:1px solid #059669;background:#065f46;color:#6ee7b7;cursor:pointer;font-size:.85em;white-space:nowrap;transition:all .2s}
|
||||
.settings-btn-save:hover{background:#059669;color:#fff}
|
||||
.settings-btn-save.saved{background:#059669;color:#fff}
|
||||
.settings-toast{position:fixed;bottom:20px;right:20px;background:#059669;color:#fff;padding:12px 20px;border-radius:8px;font-size:.9em;z-index:9999;opacity:0;transform:translateY(10px);transition:all .3s}
|
||||
.settings-toast.show{opacity:1;transform:translateY(0)}
|
||||
.settings-toast.error{background:#dc2626}
|
||||
.prompt-editor{margin-top:12px;border:1px solid #374151;border-radius:8px;overflow:hidden}
|
||||
.prompt-editor-layout{display:flex;min-height:300px}
|
||||
.prompt-editor-sidebar{width:180px;flex-shrink:0;background:#0f1729;border-right:1px solid #374151;display:flex;flex-direction:column}
|
||||
.prompt-editor-sidebar-header{padding:10px 12px;border-bottom:1px solid #374151;display:flex;justify-content:space-between;align-items:center}
|
||||
.prompt-editor-sidebar-title{color:#9ca3af;font-size:.75em;text-transform:uppercase;letter-spacing:.5px}
|
||||
.prompt-editor-sidebar-list{flex:1;overflow-y:auto;padding:4px}
|
||||
.prompt-editor-prompt-item{padding:8px 10px;border-radius:4px;cursor:pointer;color:#9ca3af;font-size:.85em;transition:all .15s;display:flex;justify-content:space-between;align-items:center}
|
||||
.prompt-editor-prompt-item:hover{background:#1e293b;color:#e5e7eb}
|
||||
.prompt-editor-prompt-item.active{background:#1e40af;color:#fff}
|
||||
.prompt-editor-prompt-item .prompt-delete-x{opacity:0;color:#ef4444;font-weight:bold;font-size:1.1em;padding:0 4px;transition:opacity .15s}
|
||||
.prompt-editor-prompt-item:hover .prompt-delete-x{opacity:.7}
|
||||
.prompt-editor-prompt-item .prompt-delete-x:hover{opacity:1}
|
||||
.prompt-editor-content{flex:1;display:flex;flex-direction:column;padding:12px}
|
||||
.prompt-editor-content-label{color:#9ca3af;font-size:.8em;margin-bottom:6px}
|
||||
.prompt-editor-content textarea{flex:1;min-height:250px;padding:10px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;font-size:.85em;font-family:monospace;resize:vertical;line-height:1.5}
|
||||
.prompt-editor-content textarea:focus{outline:none;border-color:#60a5fa}
|
||||
.prompt-editor-actions{display:flex;gap:8px;margin-top:8px;align-items:center}
|
||||
.prompt-editor-vars{font-size:.75em;color:#6b7280;margin-top:6px}
|
||||
.prompt-editor-vars code{color:#93c5fd;font-size:.85em}
|
||||
.prompt-editor-add-btn{padding:4px 8px;border-radius:4px;border:1px solid #374151;background:transparent;color:#9ca3af;cursor:pointer;font-size:.8em;transition:all .15s}
|
||||
.prompt-editor-add-btn:hover{border-color:#60a5fa;color:#e5e7eb}
|
||||
.bot-list{display:flex;flex-direction:column;gap:8px}
|
||||
.bot-card{background:#111827;border:1px solid #374151;border-radius:8px;overflow:hidden;transition:border-color .2s}
|
||||
.bot-card.expanded{border-color:#60a5fa}
|
||||
.bot-card-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;cursor:pointer;user-select:none;transition:background .15s}
|
||||
.bot-card-header:hover{background:#1e293b}
|
||||
.bot-card-name{font-weight:600;color:#e5e7eb;font-size:1em}
|
||||
.bot-card-summary{font-size:.8em;color:#6b7280;display:flex;gap:12px;flex-wrap:wrap}
|
||||
.bot-card-summary span{white-space:nowrap}
|
||||
.bot-card-summary .on{color:#6ee7b7}
|
||||
.bot-card-summary .off{color:#ef4444}
|
||||
.bot-card-arrow{color:#6b7280;transition:transform .2s;font-size:1.2em}
|
||||
.bot-card.expanded .bot-card-arrow{transform:rotate(180deg)}
|
||||
.bot-card-body{display:none;padding:0 16px 14px;border-top:1px solid #1f2937}
|
||||
.bot-card.expanded .bot-card-body{display:block}
|
||||
.bot-field{margin-top:10px}
|
||||
.bot-field-label{font-size:.8em;color:#9ca3af;margin-bottom:4px}
|
||||
.bot-field-row{display:flex;gap:8px;align-items:center}
|
||||
.bot-field-row input[type="text"],
|
||||
.bot-field-row input[type="number"],
|
||||
.bot-field-row input[type="password"]{flex:1;padding:6px 10px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;font-size:.85em}
|
||||
.bot-field-row input:focus{outline:none;border-color:#60a5fa}
|
||||
.bot-field-row textarea{flex:1;padding:6px 10px;border-radius:6px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;font-size:.8em;font-family:monospace;min-height:50px;resize:vertical}
|
||||
.bot-field-row textarea:focus{outline:none;border-color:#60a5fa}
|
||||
.bot-field-row input[type="checkbox"]{width:16px;height:16px;accent-color:#60a5fa}
|
||||
.bot-field-row .mini-save{padding:6px 12px;border-radius:6px;border:1px solid #059669;background:#065f46;color:#6ee7b7;cursor:pointer;font-size:.8em;white-space:nowrap;transition:all .2s}
|
||||
.bot-field-row .mini-save:hover{background:#059669;color:#fff}
|
||||
.bot-field-row .mini-save.saved{background:#059669;color:#fff}
|
||||
`,
|
||||
onTabActive: 'onSettingsTabActive',
|
||||
js: `
|
||||
let settingsData = [];
|
||||
let activeCategory = null;
|
||||
let botData = [];
|
||||
let botRegistry = [];
|
||||
|
||||
function onSettingsTabActive() {
|
||||
loadSettingsCategories();
|
||||
}
|
||||
|
||||
async function loadSettingsCategories() {
|
||||
try {
|
||||
const r = await fetch('/api/settings');
|
||||
if (!r.ok) throw new Error('Failed to load settings');
|
||||
const d = await r.json();
|
||||
settingsData = d.settings || [];
|
||||
|
||||
// Build category sidebar
|
||||
const cats = {};
|
||||
settingsData.forEach(s => {
|
||||
if (!cats[s.category]) cats[s.category] = [];
|
||||
cats[s.category].push(s);
|
||||
});
|
||||
|
||||
// Ensure bots category exists
|
||||
cats['bots'] = cats['bots'] || [];
|
||||
|
||||
const catOrder = ['ai', 'storage', 'server', 'farm', 'invites', 'general', 'bots'];
|
||||
let sidebarHtml = '';
|
||||
const catNames = Object.keys(cats);
|
||||
catNames.sort((a, b) => {
|
||||
const ia = catOrder.indexOf(a), ib = catOrder.indexOf(b);
|
||||
if (ia >= 0 && ib >= 0) return ia - ib;
|
||||
if (ia >= 0) return -1;
|
||||
if (ib >= 0) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
catNames.forEach(cat => {
|
||||
const count = cats[cat].length;
|
||||
const label = cat === 'bots' ? 'Bots' : cat.charAt(0).toUpperCase()+cat.slice(1);
|
||||
const activeClass = activeCategory === cat ? ' active' : (activeCategory === null && cat === catNames[0] ? ' active' : '');
|
||||
sidebarHtml += '<button class="settings-sidebar-btn'+activeClass+'" onclick="switchSettingsCategory(\\''+escHtml(cat)+'\\')">'+escHtml(label)+(count > 0 ? ' <span style="color:#6b7280;font-size:.8em">('+count+')</span>' : '')+'</button>';
|
||||
});
|
||||
|
||||
document.getElementById('settingsSidebar').innerHTML = sidebarHtml;
|
||||
|
||||
if (!activeCategory) {
|
||||
activeCategory = catNames[0] || null;
|
||||
}
|
||||
|
||||
if (activeCategory === 'bots') {
|
||||
await loadBotSettings();
|
||||
} else {
|
||||
renderSettings(activeCategory);
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('settingsMain').innerHTML = '<div class="settings-empty">Failed to load settings: '+escHtml(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function switchSettingsCategory(cat) {
|
||||
activeCategory = cat;
|
||||
const btns = document.querySelectorAll('.settings-sidebar-btn');
|
||||
btns.forEach(b => {
|
||||
b.classList.remove('active');
|
||||
if (b.textContent.trim().startsWith(cat === 'bots' ? 'Bots' : cat.charAt(0).toUpperCase()+cat.slice(1))) b.classList.add('active');
|
||||
});
|
||||
|
||||
if (cat === 'bots') {
|
||||
await loadBotSettings();
|
||||
} else {
|
||||
renderSettings(cat);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBotSettings() {
|
||||
try {
|
||||
const r = await fetch('/api/bot-settings');
|
||||
if (!r.ok) throw new Error('Failed to load bot settings');
|
||||
const d = await r.json();
|
||||
botData = d.bots || [];
|
||||
botRegistry = d.registry || [];
|
||||
renderBotSettings();
|
||||
} catch(e) {
|
||||
document.getElementById('settingsMain').innerHTML = '<div class="settings-empty">Failed to load bot settings: '+escHtml(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderBotSettings() {
|
||||
if (!botData.length) {
|
||||
document.getElementById('settingsMain').innerHTML = '<div class="settings-empty">No bots configured</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="bot-list">';
|
||||
botData.forEach(bot => {
|
||||
const autoConnect = bot.autoConnect?.value;
|
||||
const onDemand = bot.onDemand?.value;
|
||||
const isReady = bot.isReady ? ' (online)' : '';
|
||||
let summaryParts = [];
|
||||
if (autoConnect) summaryParts.push('<span class="on">auto-connect</span>');
|
||||
else summaryParts.push('<span class="off">no auto-connect</span>');
|
||||
if (onDemand) summaryParts.push('<span class="on">on-demand</span>');
|
||||
if (bot.commands?.value && Array.isArray(bot.commands.value)) {
|
||||
summaryParts.push('<span>cmds: '+escHtml(bot.commands.value.join(','))+'</span>');
|
||||
}
|
||||
|
||||
html += '<div class="bot-card" id="botCard_'+escHtml(bot.name)+'">'+
|
||||
'<div class="bot-card-header" onclick="toggleBotCard(\\''+escHtml(bot.name)+'\\')">'+
|
||||
'<div>'+
|
||||
'<div class="bot-card-name">'+escHtml(bot.name)+isReady+'</div>'+
|
||||
'<div class="bot-card-summary">'+summaryParts.join('')+'</div>'+
|
||||
'</div>'+
|
||||
'<div class="bot-card-arrow">▼</div>'+
|
||||
'</div>'+
|
||||
'<div class="bot-card-body">';
|
||||
|
||||
botRegistry.forEach(br => {
|
||||
const field = bot[br.key];
|
||||
if (!field) return;
|
||||
let val = field.value;
|
||||
if (br.type === 'boolean') val = val === true || val === 'true';
|
||||
const displayVal = br.type === 'secret' ? (val ? '***' : '') : val;
|
||||
const isSecret = br.type === 'secret';
|
||||
|
||||
let inputHtml;
|
||||
if (br.type === 'boolean') {
|
||||
inputHtml = '<input type="checkbox" id="bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'"'+(val ? ' checked' : '')+' onchange="saveBotSetting(\\''+escHtml(bot.name)+'\\',\\''+escHtml(br.key)+'\\',this.checked)">';
|
||||
} else if (br.type === 'json') {
|
||||
const jsonStr = val ? JSON.stringify(val, null, 2) : '';
|
||||
inputHtml = '<textarea id="bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'" rows="3">'+escHtml(jsonStr)+'</textarea>';
|
||||
} else if (isSecret) {
|
||||
inputHtml = '<input type="password" id="bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'" value="'+escHtml(String(val || ''))+'" placeholder="(unchanged)">';
|
||||
} else if (br.type === 'number') {
|
||||
inputHtml = '<input type="number" id="bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'" value="'+escHtml(String(val ?? ''))+'">';
|
||||
} else {
|
||||
inputHtml = '<input type="text" id="bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'" value="'+escHtml(String(val ?? ''))+'">';
|
||||
}
|
||||
|
||||
html += '<div class="bot-field">'+
|
||||
'<div class="bot-field-label">'+escHtml(br.label)+' <code style="color:#4b5563">'+escHtml(br.key)+'</code> '+escHtml(br.description ? '- '+br.description : '')+'</div>'+
|
||||
'<div class="bot-field-row">'+inputHtml;
|
||||
if (br.type !== 'boolean') {
|
||||
html += '<button class="mini-save" id="btn_bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'" onclick="saveBotSetting(\\''+escHtml(bot.name)+'\\',\\''+escHtml(br.key)+'\\',document.getElementById(\\'bot_'+escHtml(bot.name)+'_'+escHtml(br.key)+'\\').value)">Save</button>';
|
||||
}
|
||||
html += '</div></div>';
|
||||
});
|
||||
|
||||
html += '</div></div>';
|
||||
});
|
||||
html += '</div>';
|
||||
document.getElementById('settingsMain').innerHTML = html;
|
||||
}
|
||||
|
||||
function toggleBotCard(name) {
|
||||
const card = document.getElementById('botCard_'+name);
|
||||
if (!card) return;
|
||||
card.classList.toggle('expanded');
|
||||
}
|
||||
|
||||
async function saveBotSetting(botName, key, value) {
|
||||
const btn = document.getElementById('btn_bot_'+botName+'_'+key);
|
||||
try {
|
||||
const r = await fetch('/api/bot-settings/'+encodeURIComponent(botName)+'/'+encodeURIComponent(key), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: value })
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(err.error || 'Failed to save');
|
||||
}
|
||||
// Update local data
|
||||
const bot = botData.find(b => b.name === botName);
|
||||
if (bot && bot[key]) {
|
||||
bot[key].value = (key === 'password' && value) ? value : value;
|
||||
}
|
||||
|
||||
if (btn) {
|
||||
btn.classList.add('saved');
|
||||
btn.textContent = 'Saved!';
|
||||
setTimeout(() => { btn.classList.remove('saved'); btn.textContent = 'Save'; }, 2000);
|
||||
}
|
||||
// If password field, clear it after save
|
||||
if (key === 'password') {
|
||||
const inp = document.getElementById('bot_'+botName+'_'+key);
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
showToast('Saved '+botName+'.'+key);
|
||||
} catch(e) {
|
||||
showToast('Error: '+e.message, true);
|
||||
if (btn) { btn.style.borderColor = '#dc2626'; btn.textContent = 'Error';
|
||||
setTimeout(() => { btn.style.borderColor = '#059669'; btn.textContent = 'Save'; }, 3000); }
|
||||
}
|
||||
}
|
||||
|
||||
function renderSettings(category) {
|
||||
if (!category) {
|
||||
document.getElementById('settingsMain').innerHTML = '<div class="settings-empty">Select a category</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const items = settingsData.filter(s => s.category === category);
|
||||
if (items.length === 0) {
|
||||
document.getElementById('settingsMain').innerHTML = '<div class="settings-empty">No settings in this category</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="settings-grid">';
|
||||
items.forEach(s => {
|
||||
const key = escHtml(s.key);
|
||||
const label = escHtml(s.label || s.key);
|
||||
const desc = escHtml(s.description || '');
|
||||
const isSecret = s.type === 'secret';
|
||||
let inputHtml = '';
|
||||
|
||||
if (s.key === 'ai.prompts') {
|
||||
inputHtml = buildPromptEditor(s);
|
||||
} else if (s.key === 'ai.promptName') {
|
||||
const prompts = getPromptsMap();
|
||||
const names = Object.keys(prompts);
|
||||
if (names.length === 0) names.push('asshole');
|
||||
inputHtml = '<select id="inp_'+key+'" onchange="saveSetting(\\''+key+'\\', this.value)">'+
|
||||
names.map(n => '<option value="'+escHtml(n)+'"'+(String(s.value) === n ? ' selected' : '')+'>'+escHtml(n)+'</option>').join('')+
|
||||
'</select>';
|
||||
} else if (s.key === 'ai.prompCustom') {
|
||||
inputHtml = '<textarea id="inp_'+key+'" rows="4" style="width:100%">'+escHtml(String(s.value ?? ''))+'</textarea>';
|
||||
} else if (s.type === 'boolean') {
|
||||
const checked = s.value === true ? ' checked' : '';
|
||||
inputHtml = '<input type="checkbox" id="inp_'+key+'"'+checked+' onchange="saveSetting(\\''+key+'\\', this.checked)">';
|
||||
} else if (isSecret) {
|
||||
inputHtml = '<input type="password" id="inp_'+key+'" value="'+escHtml(String(s.value ?? ''))+'" placeholder="(unchanged)">';
|
||||
} else if (s.type === 'json') {
|
||||
const jsonStr = s.value ? JSON.stringify(s.value, null, 2) : '';
|
||||
inputHtml = '<textarea id="inp_'+key+'" rows="4">'+escHtml(jsonStr)+'</textarea>';
|
||||
} else if (s.type === 'number') {
|
||||
inputHtml = '<input type="number" id="inp_'+key+'" value="'+escHtml(String(s.value ?? ''))+'" step="any">';
|
||||
} else {
|
||||
inputHtml = '<input type="text" id="inp_'+key+'" value="'+escHtml(String(s.value ?? ''))+'">';
|
||||
}
|
||||
|
||||
html += '<div class="settings-card">'+
|
||||
'<div class="settings-card-header">'+
|
||||
'<div><div class="settings-card-label">'+label+'</div><div class="settings-card-key">'+key+'</div></div>'+
|
||||
'</div>'+
|
||||
'<div class="settings-card-desc">'+desc+'</div>'+
|
||||
'<div class="settings-card-body">'+inputHtml;
|
||||
|
||||
if (s.key === 'ai.prompts') {
|
||||
// Prompt editor handles its own save
|
||||
} else if (s.key === 'ai.promptName' || s.key === 'ai.prompCustom' || s.type !== 'boolean') {
|
||||
html += '<button class="settings-btn-save" onclick="saveSetting(\\''+key+'\\', document.getElementById(\\'inp_'+key+'\\').value)">Save</button>';
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
document.getElementById('settingsMain').innerHTML = html;
|
||||
|
||||
const promptsItem = items.find(s => s.key === 'ai.prompts');
|
||||
if (promptsItem) initPromptEditor(promptsItem);
|
||||
}
|
||||
|
||||
async function saveSetting(key, value) {
|
||||
const btn = event && event.target ? event.target : null;
|
||||
try {
|
||||
const r = await fetch('/api/settings/'+encodeURIComponent(key), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: value })
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(err.error || 'Failed to save');
|
||||
}
|
||||
const d = await r.json();
|
||||
const item = settingsData.find(s => s.key === key);
|
||||
if (item) item.value = d.value;
|
||||
|
||||
if (btn) {
|
||||
btn.classList.add('saved');
|
||||
btn.textContent = 'Saved!';
|
||||
setTimeout(() => { btn.classList.remove('saved'); btn.textContent = 'Save'; }, 2000);
|
||||
}
|
||||
// Clear password field after save
|
||||
const isSecret = settingsData.find(s => s.key === key)?.type === 'secret';
|
||||
if (isSecret) {
|
||||
const inp = document.getElementById('inp_'+key);
|
||||
if (inp) inp.value = '';
|
||||
}
|
||||
showToast('Saved '+key);
|
||||
} catch(e) {
|
||||
showToast('Error: '+e.message, true);
|
||||
if (btn) {
|
||||
btn.style.borderColor = '#dc2626'; btn.style.background = '#7f1d1d'; btn.style.color = '#fca5a5'; btn.textContent = 'Error';
|
||||
setTimeout(() => { btn.style.borderColor = '#059669'; btn.style.background = '#065f46'; btn.style.color = '#6ee7b7'; btn.textContent = 'Save'; }, 3000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let selectedPromptName = null;
|
||||
|
||||
function getPromptsMap() {
|
||||
const item = settingsData.find(s => s.key === 'ai.prompts');
|
||||
if (item && item.value && typeof item.value === 'object' && !Array.isArray(item.value)) {
|
||||
return item.value;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildPromptEditor(s) {
|
||||
return '<div class="prompt-editor">'+
|
||||
'<div class="prompt-editor-layout">'+
|
||||
'<div class="prompt-editor-sidebar">'+
|
||||
'<div class="prompt-editor-sidebar-header">'+
|
||||
'<span class="prompt-editor-sidebar-title">Prompts</span>'+
|
||||
'<button class="prompt-editor-add-btn" onclick="addNewPrompt()">+ Add</button>'+
|
||||
'</div>'+
|
||||
'<div class="prompt-editor-sidebar-list" id="promptEditorList"></div>'+
|
||||
'</div>'+
|
||||
'<div class="prompt-editor-content">'+
|
||||
'<span class="prompt-editor-content-label">Edit template for: <strong id="promptEditorActiveName">none</strong></span>'+
|
||||
'<textarea id="promptEditorTextarea" placeholder="Select a prompt from the sidebar or add a new one..."></textarea>'+
|
||||
'<div class="prompt-editor-actions">'+
|
||||
'<button class="settings-btn-save" onclick="savePromptTemplate()">Save Template</button>'+
|
||||
'<span style="font-size:0.75em;color:#6b7280" id="promptEditorSaved"></span>'+
|
||||
'</div>'+
|
||||
'<div class="prompt-editor-vars">Template variables: <code>\${name}</code> <code>\${interval}</code> <code>\${currentPlayers}</code> <code>\${toolsDocs}</code> <code>\${memoryContext}</code> <code>\${timeInfo}</code> <code>\${custom}</code></div>'+
|
||||
'</div>'+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function initPromptEditor(s) {
|
||||
const prompts = getPromptsMap();
|
||||
renderPromptList(prompts);
|
||||
const names = Object.keys(prompts);
|
||||
if (names.length > 0) {
|
||||
selectPromptToEdit(names[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPromptList(prompts) {
|
||||
const listEl = document.getElementById('promptEditorList');
|
||||
if (!listEl) return;
|
||||
const names = Object.keys(prompts);
|
||||
listEl.innerHTML = names.map(name =>
|
||||
'<div class="prompt-editor-prompt-item'+(name === selectedPromptName ? ' active' : '')+'" onclick="selectPromptToEdit(\\''+escHtml(name)+'\\')">'+
|
||||
'<span>'+escHtml(name)+'</span>'+
|
||||
'<span class="prompt-delete-x" onclick="event.stopPropagation();deletePrompt(\\''+escHtml(name)+'\\')">×</span>'+
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function selectPromptToEdit(name) {
|
||||
selectedPromptName = name;
|
||||
const prompts = getPromptsMap();
|
||||
document.getElementById('promptEditorActiveName').textContent = name;
|
||||
document.getElementById('promptEditorTextarea').value = prompts[name] || '';
|
||||
document.getElementById('promptEditorSaved').textContent = '';
|
||||
renderPromptList(prompts);
|
||||
}
|
||||
|
||||
async function savePromptTemplate() {
|
||||
if (!selectedPromptName) return;
|
||||
const textarea = document.getElementById('promptEditorTextarea');
|
||||
const template = textarea.value;
|
||||
const prompts = getPromptsMap();
|
||||
prompts[selectedPromptName] = template;
|
||||
await saveSetting('ai.prompts', prompts);
|
||||
document.getElementById('promptEditorSaved').textContent = 'Saved!';
|
||||
setTimeout(() => { document.getElementById('promptEditorSaved').textContent = ''; }, 2000);
|
||||
}
|
||||
|
||||
async function addNewPrompt() {
|
||||
const name = prompt('New prompt name:');
|
||||
if (!name || !name.trim()) return;
|
||||
const trimmed = name.trim();
|
||||
const prompts = getPromptsMap();
|
||||
if (prompts[trimmed]) {
|
||||
alert('Prompt "'+trimmed+'" already exists.');
|
||||
return;
|
||||
}
|
||||
prompts[trimmed] = '';
|
||||
await saveSetting('ai.prompts', prompts);
|
||||
selectedPromptName = trimmed;
|
||||
renderPromptList(prompts);
|
||||
selectPromptToEdit(trimmed);
|
||||
loadSettingsCategories();
|
||||
}
|
||||
|
||||
async function deletePrompt(name) {
|
||||
const prompts = getPromptsMap();
|
||||
if (Object.keys(prompts).length <= 1) {
|
||||
alert('Cannot delete the last prompt.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Delete prompt "'+name+'"?')) return;
|
||||
delete prompts[name];
|
||||
await saveSetting('ai.prompts', prompts);
|
||||
if (selectedPromptName === name) {
|
||||
const remaining = Object.keys(prompts);
|
||||
selectedPromptName = remaining.length > 0 ? remaining[0] : null;
|
||||
}
|
||||
if (selectedPromptName) {
|
||||
selectPromptToEdit(selectedPromptName);
|
||||
}
|
||||
renderPromptList(prompts);
|
||||
loadSettingsCategories();
|
||||
}
|
||||
|
||||
function showToast(msg, isError) {
|
||||
let toast = document.getElementById('settingsToast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'settingsToast';
|
||||
toast.className = 'settings-toast';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.textContent = msg;
|
||||
toast.className = 'settings-toast' + (isError ? ' error' : '');
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
setTimeout(() => { toast.classList.remove('show'); }, 3000);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
module.exports = { createRouter, webUI };
|
||||
@@ -163,6 +163,31 @@ class Database {
|
||||
UNIQUE(site_id, player_name)
|
||||
)
|
||||
`);
|
||||
|
||||
// Application settings table (runtime-tunable, editable via web UI and LLM)
|
||||
await this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'string' CHECK(type IN ('string', 'number', 'boolean', 'json', 'secret')),
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
label TEXT,
|
||||
description TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Per-bot settings table (autoConnect, autoReConnect, onDemand, plugins, etc.)
|
||||
await this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bot_settings (
|
||||
bot_name TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'string' CHECK(type IN ('string', 'number', 'boolean', 'json', 'secret')),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(bot_name, key)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async insertDefaultPermissions() {
|
||||
@@ -181,6 +206,134 @@ class Database {
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Settings
|
||||
// ========================================
|
||||
|
||||
async seedDefaultSettings(defaults) {
|
||||
for (const entry of defaults) {
|
||||
try {
|
||||
await this.db.run(
|
||||
'INSERT OR IGNORE INTO settings (key, value, type, category, label, description) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[entry.key, entry.value, entry.type, entry.category, entry.label, entry.description || null]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error seeding setting:', entry.key, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getAllSettings() {
|
||||
return await this.db.all('SELECT * FROM settings ORDER BY category, key');
|
||||
}
|
||||
|
||||
async getSettingsByCategory(category) {
|
||||
return await this.db.all('SELECT * FROM settings WHERE category = ? ORDER BY key', [category]);
|
||||
}
|
||||
|
||||
async getSetting(key) {
|
||||
return await this.db.get('SELECT * FROM settings WHERE key = ?', [key]);
|
||||
}
|
||||
|
||||
async setSetting(key, value) {
|
||||
const row = await this.db.get('SELECT type FROM settings WHERE key = ?', [key]);
|
||||
if (!row) throw new Error(`Unknown setting: ${key}`);
|
||||
const coerced = this._coerceValue(value, row.type);
|
||||
await this.db.run(
|
||||
'UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?',
|
||||
[coerced, key]
|
||||
);
|
||||
return coerced;
|
||||
}
|
||||
|
||||
_coerceValue(value, type) {
|
||||
switch (type) {
|
||||
case 'number': {
|
||||
const n = Number(value);
|
||||
if (isNaN(n)) throw new Error(`Expected number, got: ${value}`);
|
||||
return String(n);
|
||||
}
|
||||
case 'boolean': return value === true || value === 'true' || value === '1' ? 'true' : 'false';
|
||||
case 'json': return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value);
|
||||
default: return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Bot Settings (per-bot configuration)
|
||||
// ========================================
|
||||
|
||||
async getAllBotSettings(botName) {
|
||||
return await this.db.all('SELECT * FROM bot_settings WHERE bot_name = ? ORDER BY key', [botName]);
|
||||
}
|
||||
|
||||
async getBotSetting(botName, key) {
|
||||
return await this.db.get('SELECT * FROM bot_settings WHERE bot_name = ? AND key = ?', [botName, key]);
|
||||
}
|
||||
|
||||
async setBotSetting(botName, key, value, type) {
|
||||
const coerced = this._coerceValue(value, type || 'string');
|
||||
await this.db.run(`
|
||||
INSERT INTO bot_settings (bot_name, key, value, type)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(bot_name, key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
type = excluded.type,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, [botName, key, coerced, type || 'string']);
|
||||
return coerced;
|
||||
}
|
||||
|
||||
async setBotSettings(botName, settings) {
|
||||
await this.db.run('SAVEPOINT setBotSettings');
|
||||
try {
|
||||
for (const [key, entry] of Object.entries(settings)) {
|
||||
const type = entry.type || 'string';
|
||||
const coerced = this._coerceValue(entry.value, type);
|
||||
await this.db.run(`
|
||||
INSERT INTO bot_settings (bot_name, key, value, type)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(bot_name, key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
type = excluded.type,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, [botName, key, coerced, type]);
|
||||
}
|
||||
await this.db.run('RELEASE setBotSettings');
|
||||
} catch (error) {
|
||||
await this.db.run('ROLLBACK TO setBotSettings');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async seedDefaultBotSettings() {
|
||||
const conf = require('../../conf');
|
||||
const bots = conf.mc?.bots || {};
|
||||
for (const [botName, botConfig] of Object.entries(bots)) {
|
||||
const defaults = {
|
||||
username: { value: String(botConfig.username || ''), type: 'string' },
|
||||
password: { value: String(botConfig.password || ''), type: 'secret' },
|
||||
auth: { value: String(botConfig.auth || 'microsoft'), type: 'string' },
|
||||
autoConnect: { value: String(botConfig.autoConnect ?? true), type: 'boolean' },
|
||||
autoReConnect: { value: String(botConfig.autoReConnect ?? true), type: 'boolean' },
|
||||
onDemand: { value: String(botConfig.onDemand || false), type: 'boolean' },
|
||||
idleTimeout: { value: String(botConfig.idleTimeout || 30000), type: 'number' },
|
||||
commands: { value: JSON.stringify(botConfig.commands || []), type: 'json' },
|
||||
plugins: { value: JSON.stringify(botConfig.plugins || {}), type: 'json' },
|
||||
hasAi: { value: String(botConfig.hasAi || false), type: 'boolean' },
|
||||
};
|
||||
for (const [key, entry] of Object.entries(defaults)) {
|
||||
try {
|
||||
await this.db.run(
|
||||
'INSERT OR IGNORE INTO bot_settings (bot_name, key, value, type) VALUES (?, ?, ?, ?)',
|
||||
[botName, key, entry.value, entry.type]
|
||||
);
|
||||
} catch (e) { /* ignore duplicates */ }
|
||||
}
|
||||
}
|
||||
console.log('Bot settings seeded from config defaults');
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Permissions
|
||||
// ========================================
|
||||
@@ -238,6 +391,11 @@ class Database {
|
||||
return await this.db.get('SELECT * FROM chests WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
async markChestLost(x, y, z) {
|
||||
await this.db.run('DELETE FROM chests WHERE pos_x = ? AND pos_y = ? AND pos_z = ?', [x, y, z]);
|
||||
console.log(`Scanner: Removed lost chest at ${x},${y},${z}`);
|
||||
}
|
||||
|
||||
async getChestByPosition(x, y, z) {
|
||||
return await this.db.get(
|
||||
'SELECT * FROM chests WHERE pos_x = ? AND pos_y = ? AND pos_z = ?',
|
||||
@@ -258,6 +416,29 @@ class Database {
|
||||
`, values);
|
||||
}
|
||||
|
||||
async batchUpsertChests(chests) {
|
||||
if (!chests || chests.length === 0) return;
|
||||
await this.db.run('BEGIN TRANSACTION');
|
||||
try {
|
||||
for (const c of chests) {
|
||||
await this.db.run(`
|
||||
INSERT INTO chests (pos_x, pos_y, pos_z, chest_type, row, column, category)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(pos_x, pos_y, pos_z) DO UPDATE SET
|
||||
chest_type = excluded.chest_type,
|
||||
row = excluded.row,
|
||||
column = excluded.column,
|
||||
category = excluded.category,
|
||||
last_scan = CURRENT_TIMESTAMP
|
||||
`, [c.x, c.y, c.z, c.type, c.row, c.column, c.category]);
|
||||
}
|
||||
await this.db.run('COMMIT');
|
||||
} catch (err) {
|
||||
await this.db.run('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Shulkers
|
||||
// ========================================
|
||||
@@ -325,6 +506,10 @@ class Database {
|
||||
return await this.db.run('DELETE FROM shulkers WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
async deleteShulkersByChest(chestId) {
|
||||
return await this.db.run('DELETE FROM shulkers WHERE chest_id = ?', [chestId]);
|
||||
}
|
||||
|
||||
// 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(`
|
||||
|
||||
@@ -138,14 +138,15 @@ class Storage {
|
||||
constructor(args) {
|
||||
console.log('Storage: Constructor called');
|
||||
this.bot = args.bot;
|
||||
this.config = { ...conf.storage, ...args };
|
||||
const settings = require('../settings/manager');
|
||||
// conf.storage → DB/cache overrides → constructor args (highest priority)
|
||||
this.config = { ...conf.storage, ...settings.getSection('storage'), ...args };
|
||||
this.isReady = false;
|
||||
this.shulkerHandler = new ShulkerHandler();
|
||||
this.pendingWithdrawals = new Map(); // playerName → { itemName, count, mode, timeoutId }
|
||||
this._craftAvailable = true; // reset when crafting fails, so we don't spam retries
|
||||
this._busy = false; // true during organize/withdraw/trade to block interval restock
|
||||
this._operationLock = false;
|
||||
console.trace('Storage: lock RELEASED'); // prevents concurrent storage operations
|
||||
this._operationQueue = []; // queue for pending operations
|
||||
}
|
||||
|
||||
@@ -286,6 +287,14 @@ class Storage {
|
||||
throw new Error('Storage busy, scan operation timeout');
|
||||
}
|
||||
|
||||
// Keep on-demand bots alive during long scan operations
|
||||
const keepAliveInterval = setInterval(() => {
|
||||
if (typeof this.bot._resetIdleTimer === 'function') {
|
||||
this.bot._resetIdleTimer();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const scanStart = Date.now();
|
||||
try {
|
||||
const chests = await this.scanner.discoverChests(this.bot, this.config.scanRadius || 30, Database);
|
||||
// Register as interruptible task
|
||||
@@ -294,7 +303,8 @@ class Storage {
|
||||
this.bot, Database,
|
||||
() => this.bot.wasInterrupted()
|
||||
);
|
||||
console.log(`Storage[${this.bot.name}]: Complete - ${chests.length} chests, ${shulkers} shulkers`);
|
||||
const elapsed = ((Date.now() - scanStart) / 1000).toFixed(1);
|
||||
console.log(`Storage[${this.bot.name}]: Complete — ${chests.length} chests discovered, ${shulkers} shulkers scanned, ${elapsed}s total`);
|
||||
|
||||
// Capture map images and index maps if not interrupted
|
||||
if (!this.bot.wasInterrupted()) {
|
||||
@@ -302,6 +312,7 @@ class Storage {
|
||||
await this.indexMapsFromStorage();
|
||||
}
|
||||
} finally {
|
||||
clearInterval(keepAliveInterval);
|
||||
this.bot.clearTask();
|
||||
this._releaseOperationLock();
|
||||
}
|
||||
@@ -453,8 +464,16 @@ class Storage {
|
||||
const hotbarItems = this.config.hotbarItems || [];
|
||||
if (hotbarItems.length === 0) return;
|
||||
|
||||
// Another task (scan, withdraw) is active — don't clobber its
|
||||
// interrupt state, just skip this restock cycle
|
||||
if (this.bot._currentTask || this._operationLock) return;
|
||||
|
||||
// Background task: yields to player commands via interruptTask
|
||||
this.bot.registerTask('Storage', 'hotbar-restock', null);
|
||||
try {
|
||||
|
||||
for (const spec of hotbarItems) {
|
||||
if (this._busy) {
|
||||
if (this._busy || this.bot.wasInterrupted()) {
|
||||
console.log('Storage: Hotbar restock interrupted — storage operation in progress');
|
||||
return;
|
||||
}
|
||||
@@ -495,6 +514,10 @@ class Storage {
|
||||
let consecutiveFailures = 0;
|
||||
for (const shulker of shulkers) {
|
||||
if (remaining <= 0) break;
|
||||
if (this.bot.wasInterrupted()) {
|
||||
console.log(`Storage: Hotbar restock interrupted mid-${spec.name}, yielding`);
|
||||
return;
|
||||
}
|
||||
if (consecutiveFailures >= 2) {
|
||||
console.log(`Storage: Too many failures restocking ${spec.name}, giving up`);
|
||||
break;
|
||||
@@ -525,6 +548,10 @@ class Storage {
|
||||
}
|
||||
|
||||
await Database.rebuildItemIndex();
|
||||
|
||||
} finally {
|
||||
this.bot.clearTask();
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -546,6 +573,13 @@ class Storage {
|
||||
if (hasShulkers) {
|
||||
console.log('Storage: Traded items include shulker boxes, quick-stashing...');
|
||||
shulkersStashed = await this.quickStashAllShulkers();
|
||||
|
||||
// Anything still in inventory means chests are out of empty slots
|
||||
const leftOver = this.bot.bot.inventory.items().filter(item => item.name.includes('shulker_box')).length;
|
||||
if (leftOver > 0) {
|
||||
console.error(`Storage: ${leftOver} shulker(s) could not be stashed — no chest space`);
|
||||
this.bot.whisper(playerName, `Warning: storage is out of chest space, ${leftOver} shulker(s) are still on me. Add more chests and run a scan.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Deposit any non-shulker items currently in inventory
|
||||
@@ -599,22 +633,29 @@ class Storage {
|
||||
|
||||
console.log(`Storage: Stashing ${batchSize} shulker(s) into chest at ${chestPos} (${chest.empty_slots} free slots)`);
|
||||
|
||||
await this.bot.goTo({ where: chestPos, range: 3 });
|
||||
await this.bot.goToMust({ where: chestPos, range: 3 });
|
||||
const chestBlock = this.bot.bot.blockAt(chestPos);
|
||||
const window = await this.bot.openContainer(chestBlock);
|
||||
await sleep(300);
|
||||
|
||||
// Resolve which chest slots are actually empty right now
|
||||
const existingShulkers = await Database.getShulkersByChest(chest.id);
|
||||
const usedSlots = new Set(existingShulkers.map(s => s.slot));
|
||||
const dbUsedSlots = new Set(existingShulkers.map(s => s.slot));
|
||||
const maxSlots = window.inventoryStart; // mineflayer returns the chest boundary
|
||||
const emptySlots = [];
|
||||
for (let s = 0; s < maxSlots; s++) {
|
||||
if (!usedSlots.has(s) && !window.slots[s]) {
|
||||
if (window.slots[s]) continue;
|
||||
// If DB says occupied but window shows empty, delete stale record
|
||||
if (dbUsedSlots.has(s)) {
|
||||
const stale = existingShulkers.find(sh => sh.slot === s);
|
||||
if (stale) {
|
||||
console.log(`Storage: Removing stale shulker record #${stale.id} from chest ${chest.id} slot ${s}`);
|
||||
await Database.deleteShulker(stale.id);
|
||||
}
|
||||
}
|
||||
emptySlots.push(s);
|
||||
if (emptySlots.length >= batchSize) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (emptySlots.length === 0) {
|
||||
await this.bot.bot.closeWindow(window);
|
||||
@@ -707,7 +748,7 @@ class Storage {
|
||||
}
|
||||
|
||||
const chestPos = new Vec3(emptySlot.pos_x, emptySlot.pos_y, emptySlot.pos_z);
|
||||
await this.bot.goTo({ where: chestPos, range: 3 });
|
||||
await this.bot.goToMust({ where: chestPos, range: 3 });
|
||||
const chestBlock = this.bot.bot.blockAt(chestPos);
|
||||
const window = await this.bot.openContainer(chestBlock);
|
||||
await sleep(300);
|
||||
@@ -943,6 +984,9 @@ class Storage {
|
||||
return this.bot.whisper(playerName, `Storage busy, please try again in a moment.`);
|
||||
}
|
||||
|
||||
// Interrupted task has released the lock — reset the interrupt flag
|
||||
// so this withdrawal's own movement isn't treated as interrupted
|
||||
this.bot.registerTask('Storage', 'withdraw', null);
|
||||
this._busy = true;
|
||||
|
||||
try {
|
||||
@@ -1021,6 +1065,7 @@ class Storage {
|
||||
this.bot.whisper(playerName, `Failed to withdraw ${itemName}.`);
|
||||
}
|
||||
} finally {
|
||||
this.bot.clearTask();
|
||||
this._busy = false;
|
||||
this._releaseOperationLock();
|
||||
}
|
||||
@@ -1029,7 +1074,8 @@ class Storage {
|
||||
async handleWithdrawShulkers(playerName, itemName, shulkerCount) {
|
||||
console.log(`Storage[${this.bot.name}]: Shulker withdraw request from ${playerName}: ${shulkerCount} shulkers of ${itemName}`);
|
||||
|
||||
// Acquire operation lock to prevent concurrent storage operations
|
||||
// Interrupt any active task then acquire operation lock
|
||||
await this.bot.interruptTask(playerName);
|
||||
try {
|
||||
await this._acquireOperationLock();
|
||||
} catch (error) {
|
||||
@@ -1037,6 +1083,8 @@ class Storage {
|
||||
return this.bot.whisper(playerName, `Storage busy, please try again in a moment.`);
|
||||
}
|
||||
|
||||
// Interrupted task has released the lock — reset the interrupt flag
|
||||
this.bot.registerTask('Storage', 'withdraw-shulkers', null);
|
||||
this._busy = true;
|
||||
|
||||
try {
|
||||
@@ -1098,6 +1146,7 @@ class Storage {
|
||||
this.bot.whisper(playerName, `Failed to withdraw shulkers of ${itemName}.`);
|
||||
}
|
||||
} finally {
|
||||
this.bot.clearTask();
|
||||
this._busy = false;
|
||||
this._releaseOperationLock();
|
||||
}
|
||||
@@ -1207,17 +1256,33 @@ class Storage {
|
||||
|
||||
console.log(`Storage: Placed ${placed} stack(s) in trade window for ${playerName}`);
|
||||
|
||||
// Click 1: lock our items (ez locks first).
|
||||
// Single left-click — moveSlotItem(37,37) sent a pickup+putdown
|
||||
// pair that desyncs against the cancelled GUI slot and trips
|
||||
// anti-cheat ("unusual packets" kick).
|
||||
await sleep(500);
|
||||
try { await this.bot.bot.clickWindow(37, 0, 0); } catch (e) { /* ignore */ }
|
||||
console.log('Storage trade: click 1 — items locked');
|
||||
|
||||
// Poll for customer confirmation (lime_dye at slot 53)
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
try { this.bot.bot.closeWindow(window); } catch (e) { /* ignore */ }
|
||||
this.bot.whisper(playerName, 'Trade timed out.');
|
||||
}, 120000);
|
||||
|
||||
let finalClicked = false;
|
||||
const confirmationCheck = setInterval(async () => {
|
||||
try {
|
||||
if (finalClicked) return;
|
||||
// Never click a window that is no longer open — invalid
|
||||
// window IDs are an instant anti-cheat flag
|
||||
if (this.bot.bot.currentWindow !== window) return;
|
||||
const indicator = window.slots[53];
|
||||
if (indicator && indicator.name === 'lime_dye') {
|
||||
this.bot.bot.moveSlotItem(37, 37);
|
||||
finalClicked = true;
|
||||
// Click 2: finalize (once)
|
||||
await this.bot.bot.clickWindow(37, 0, 0);
|
||||
console.log('Storage trade: click 2 — final confirm');
|
||||
}
|
||||
} catch (e) { /* window may have closed */ }
|
||||
}, 500);
|
||||
@@ -1378,7 +1443,7 @@ class Storage {
|
||||
throw new Error('No crafting table found nearby');
|
||||
}
|
||||
|
||||
await this.bot.goTo({ where: craftingTable.position, range: 3 });
|
||||
await this.bot.goToMust({ where: craftingTable.position, range: 3 });
|
||||
|
||||
// Craft shulker box manually (bot.craft() broken on 1.21+)
|
||||
const shulkerBoxRecipes = this.bot.bot.recipesAll(
|
||||
@@ -1453,7 +1518,7 @@ class Storage {
|
||||
const emptySlot = await Database.findEmptyChestSlot();
|
||||
if (emptySlot) {
|
||||
const chestPos = new Vec3(emptySlot.pos_x, emptySlot.pos_y, emptySlot.pos_z);
|
||||
await this.bot.goTo({ where: chestPos, range: 3 });
|
||||
await this.bot.goToMust({ where: chestPos, range: 3 });
|
||||
const chestBlock = this.bot.bot.blockAt(chestPos);
|
||||
const storeWindow = await this.bot.openContainer(chestBlock);
|
||||
await sleep(300);
|
||||
@@ -1546,7 +1611,7 @@ class Storage {
|
||||
// Organize
|
||||
// ========================================
|
||||
|
||||
async organizeLooseItems() {
|
||||
async organizeLooseItems(skipConsolidation = false) {
|
||||
console.log('Storage: Organizing loose items into shulkers...');
|
||||
|
||||
// Acquire operation lock to prevent concurrent storage operations
|
||||
@@ -1562,6 +1627,14 @@ class Storage {
|
||||
this.bot.registerTask('Storage', 'organize', async () => {});
|
||||
let organized = 0;
|
||||
|
||||
// Keep on-demand bots alive — organize can run for many minutes and
|
||||
// the idle timer would otherwise disconnect the bot mid-sort
|
||||
const keepAliveInterval = setInterval(() => {
|
||||
if (typeof this.bot._resetIdleTimer === 'function') {
|
||||
this.bot._resetIdleTimer();
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
try {
|
||||
// Pre-flight: deposit any stray items in bot inventory
|
||||
await this.cleanInventory();
|
||||
@@ -1592,7 +1665,7 @@ class Storage {
|
||||
const failedItems = new Set();
|
||||
while (true) {
|
||||
|
||||
await this.bot.goTo({ where: chestPos, range: 3 });
|
||||
await this.bot.goToMust({ where: chestPos, range: 3 });
|
||||
const chestBlock = this.bot.bot.blockAt(chestPos);
|
||||
const window = await this.bot.openContainer(chestBlock);
|
||||
await sleep(300);
|
||||
@@ -1735,11 +1808,13 @@ class Storage {
|
||||
}
|
||||
|
||||
// Consolidate partially filled shulkers of the same item type
|
||||
if (!skipConsolidation) {
|
||||
try {
|
||||
await this.consolidateShulkers();
|
||||
} catch (error) {
|
||||
console.error('Storage: Consolidation error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-flight: deposit any items still in inventory (may succeed now that organize freed shulker space)
|
||||
await this.cleanInventory();
|
||||
@@ -1748,6 +1823,7 @@ class Storage {
|
||||
console.log(`Storage: Organized ${organized} item types into shulkers`);
|
||||
return organized;
|
||||
} finally {
|
||||
clearInterval(keepAliveInterval);
|
||||
this.bot.clearTask();
|
||||
this._busy = false;
|
||||
this._releaseOperationLock();
|
||||
@@ -1836,11 +1912,39 @@ class Storage {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Storage: Error unpacking mixed shulker #${shulker.id}:`, error.message);
|
||||
// Upsert it back so it can be retried
|
||||
|
||||
// Figure out where the box actually ended up before touching the
|
||||
// DB. takeShulkerFromChest marks the record in-transit
|
||||
// (slot_count = -1) the moment the box leaves the chest.
|
||||
const record = await Database.getShulkerById(shulker.id).catch(() => null);
|
||||
const leftChest = record && record.slot_count === -1;
|
||||
const strayBox = this.bot.bot.inventory.items().find(item => item.name.includes('shulker_box'));
|
||||
|
||||
if (error.message.includes('No shulker at chest slot')) {
|
||||
// Slot was empty — record is stale, delete it
|
||||
try { await Database.deleteShulker(shulker.id); } catch (e2) { /* ignore */ }
|
||||
} else if (!leftChest) {
|
||||
// Failure before the box left the chest (navigation, chest
|
||||
// won't open) — leave the record alone so we retry next pass
|
||||
console.log(`Storage: Mixed shulker #${shulker.id} still in chest, will retry next organize`);
|
||||
} else if (strayBox) {
|
||||
// Box made it back to inventory — store it (registers a fresh
|
||||
// DB record via NBT scan) and drop the old record
|
||||
try {
|
||||
await Database.upsertShulker(shulker.chest_id, shulker.slot, 'shulker_box', null, null);
|
||||
await Database.updateShulkerCounts(shulker.id, shulker.slot_count, shulker.total_items);
|
||||
} catch (e2) { /* ignore */ }
|
||||
await this.storeShulker(strayBox);
|
||||
await Database.deleteShulker(shulker.id);
|
||||
} catch (e2) {
|
||||
console.error(`Storage: Could not store recovered box:`, e2.message);
|
||||
}
|
||||
} else if (error.placedPos) {
|
||||
// Box is physically on the ground and couldn't be collected
|
||||
console.error(`Storage: MIXED SHULKER LEFT ON GROUND at ${error.placedPos} — manual pickup needed`);
|
||||
try { await Database.deleteShulker(shulker.id); } catch (e2) { /* ignore */ }
|
||||
} else {
|
||||
// Left the chest but isn't in inventory or on known ground —
|
||||
// delete the record; a rescan will re-register it if it turns up
|
||||
try { await Database.deleteShulker(shulker.id); } catch (e2) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1928,7 +2032,7 @@ class Storage {
|
||||
if (itemsInInv.length === 0) return;
|
||||
|
||||
console.log(`Storage: Returning ${itemsInInv.length} stack(s) of ${itemName} to chest at ${chestPos}`);
|
||||
await this.bot.goTo({ where: chestPos, range: 3 });
|
||||
await this.bot.goToMust({ where: chestPos, range: 3 });
|
||||
const chestBlock = this.bot.bot.blockAt(chestPos);
|
||||
const window = await this.bot.openContainer(chestBlock);
|
||||
await sleep(300);
|
||||
@@ -2150,13 +2254,17 @@ class Storage {
|
||||
}
|
||||
|
||||
case 'organize':
|
||||
this.bot.whisper(from, 'Starting organize...');
|
||||
if (from !== 'ai') this.bot.whisper(from, 'Starting organize...');
|
||||
try {
|
||||
const count = await this.organizeLooseItems();
|
||||
this.bot.whisper(from, `Organize complete! Sorted ${count} item stacks.`);
|
||||
const msg = `Organize complete! Sorted ${count} item stacks.`;
|
||||
if (from === 'ai') return msg;
|
||||
this.bot.whisper(from, msg);
|
||||
} catch (error) {
|
||||
console.error('Storage: Organize error:', error);
|
||||
this.bot.whisper(from, `Organize failed: ${error.message}`);
|
||||
const msg = `Organize failed: ${error.message}`;
|
||||
if (from === 'ai') return msg;
|
||||
this.bot.whisper(from, msg);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class Scanner {
|
||||
}
|
||||
}
|
||||
|
||||
this._scanRadius = radius;
|
||||
const start = Date.now();
|
||||
console.log(`Scanner: Discovering chests within ${radius} blocks...`);
|
||||
const chestPositions = bot.bot.findBlocks({
|
||||
matching: this.chestBlockType,
|
||||
@@ -24,7 +24,8 @@ class Scanner {
|
||||
count: Infinity,
|
||||
});
|
||||
|
||||
console.log(`Scanner: Found ${chestPositions.length} chest block(s)`);
|
||||
const elapsedFind = Date.now() - start;
|
||||
console.log(`Scanner: Found ${chestPositions.length} chest block(s) in ${elapsedFind}ms`);
|
||||
|
||||
const discoveredChests = [];
|
||||
const processed = new Set();
|
||||
@@ -37,35 +38,27 @@ class Scanner {
|
||||
const chestInfo = this.detectChestType(bot, pos);
|
||||
|
||||
// Skip the second half of double chests
|
||||
if (chestInfo.type === 'skip') {
|
||||
continue;
|
||||
}
|
||||
if (chestInfo.type === 'skip') continue;
|
||||
|
||||
const rowColumn = this.assignRowColumn(pos);
|
||||
const category = this.columnToCategory(rowColumn.column);
|
||||
|
||||
await database.upsertChest(
|
||||
pos.x, pos.y, pos.z,
|
||||
chestInfo.type,
|
||||
rowColumn.row,
|
||||
rowColumn.column,
|
||||
category
|
||||
);
|
||||
|
||||
discoveredChests.push({
|
||||
x: pos.x, y: pos.y, z: pos.z,
|
||||
type: chestInfo.type,
|
||||
...rowColumn,
|
||||
category
|
||||
row: rowColumn.row,
|
||||
column: rowColumn.column,
|
||||
category,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove DB records for chest positions no longer discovered
|
||||
// (e.g., the old canonical half of a double chest that switched sides)
|
||||
// Batch UPSERT all discovered chests in a single transaction
|
||||
if (discoveredChests.length > 0) {
|
||||
await database.deleteOrphanChests(discoveredChests);
|
||||
await database.batchUpsertChests(discoveredChests);
|
||||
}
|
||||
console.log(`Scanner: Discovered/updated ${discoveredChests.length} chest(s)`);
|
||||
|
||||
const elapsedTotal = Date.now() - start;
|
||||
console.log(`Scanner: Discovered/updated ${discoveredChests.length} chest(s) in ${elapsedTotal}ms`);
|
||||
return discoveredChests;
|
||||
}
|
||||
|
||||
@@ -122,56 +115,53 @@ class Scanner {
|
||||
}
|
||||
|
||||
async scanChest(bot, database, chestPosition) {
|
||||
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 });
|
||||
// goToMust: if we never arrive, blockAt sees an unloaded chunk
|
||||
// and the chest would be wrongly marked lost below
|
||||
await bot.goToMust({ 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 0;
|
||||
console.log(`Scanner: Block not a chest at ${chestPosition.x},${chestPosition.y},${chestPosition.z}, marking lost`);
|
||||
await database.markChestLost(chestPosition.x, chestPosition.y, chestPosition.z);
|
||||
return { shulkerCount: 0, lost: true };
|
||||
}
|
||||
|
||||
// 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 0;
|
||||
console.log(`Scanner: Chest not in database at ${chestPosition.x},${chestPosition.y},${chestPosition.z}`);
|
||||
return { shulkerCount: 0 };
|
||||
}
|
||||
|
||||
const window = await bot.openContainer(chestBlock);
|
||||
const slots = window.slots;
|
||||
let shulkerCount = 0;
|
||||
|
||||
// Only scan chest inventory slots (not player inventory)
|
||||
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
|
||||
// Clear previous records before re-scanning
|
||||
await database.clearLooseItems(chest.id);
|
||||
await database.deleteShulkersByChest(chest.id);
|
||||
|
||||
const looseItems = [];
|
||||
let shulkerCount = 0;
|
||||
|
||||
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(bot, database, chest.id, i, slot);
|
||||
shulkerCount++;
|
||||
} else {
|
||||
@@ -184,123 +174,168 @@ class Scanner {
|
||||
}
|
||||
|
||||
await bot.bot.closeWindow(window);
|
||||
console.log(`Scanner: Found ${shulkerCount} shulkers in chest`);
|
||||
return shulkerCount;
|
||||
|
||||
await sleep(300);
|
||||
return { shulkerCount };
|
||||
} catch (error) {
|
||||
console.error(`Scanner: Error scanning chest at ${chestPosition.x},${chestPosition.y},${chestPosition.z}:`, error);
|
||||
return 0;
|
||||
return { shulkerCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async scanAllChests(bot, database, interruptCheck) {
|
||||
const chests = await database.getChests();
|
||||
const start = Date.now();
|
||||
console.log(`Scanner: Scanning all ${chests.length} tracked chests`);
|
||||
|
||||
let totalShulkers = 0;
|
||||
let scannedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let lostCount = 0;
|
||||
|
||||
// Track scanned positions so we don't re-scan or re-queue
|
||||
const scannedPositions = new Set();
|
||||
// Build a row-major serpentine scan plan:
|
||||
// Chests are in rows along Z (same X = one aisle). Walk down one aisle,
|
||||
// step to the next, walk back the other way (serpentine). This eliminates
|
||||
// the constant row-hopping of nearest-neighbor traversal.
|
||||
const plan = this._buildSerpentinePlan(chests, bot.bot.entity.position);
|
||||
|
||||
// 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}`);
|
||||
for (let i = 0; i < plan.length; i++) {
|
||||
if (interruptCheck && interruptCheck()) {
|
||||
console.log(`Scanner: Interrupted after ${scannedCount} chests`);
|
||||
break;
|
||||
}
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const chest = plan[i];
|
||||
const key = `${chest.pos.x},${chest.pos.y},${chest.pos.z}`;
|
||||
|
||||
// Scan all chests in plan that are currently within reach (including this one)
|
||||
const botPos = bot.bot.entity.position;
|
||||
const batch = [];
|
||||
|
||||
// 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)})`);
|
||||
// First check: is the current chest reachable?
|
||||
if (botPos.distanceTo(chest.pos) > 4.5) {
|
||||
// Walk to it
|
||||
console.log(`Scanner: Walking to chest at ${chest.pos.toArray()} (${botPos.distanceTo(chest.pos).toFixed(1)} blocks, ${plan.length - i} left)`);
|
||||
try {
|
||||
const reached = await bot.goTo({
|
||||
where: chest.pos,
|
||||
range: 3,
|
||||
});
|
||||
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 ${chest.pos}: ${error.message}`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for anti-ESP to reveal nearby blocks after arriving
|
||||
if (interruptCheck && interruptCheck()) {
|
||||
console.log(`Scanner: Interrupted after ${scannedCount} chests`);
|
||||
return totalShulkers;
|
||||
}
|
||||
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`);
|
||||
}
|
||||
|
||||
// Now batch-scan this chest and all upcoming chests within reach
|
||||
const newPos = bot.bot.entity.position;
|
||||
for (let j = i; j < plan.length && batch.length < 6; j++) {
|
||||
const c = plan[j];
|
||||
const ck = `${c.pos.x},${c.pos.y},${c.pos.z}`;
|
||||
if (newPos.distanceTo(c.pos) <= 4.5) {
|
||||
batch.push({ idx: j, chest: c, key: ck });
|
||||
} else if (batch.length === 0) {
|
||||
// Current chest somehow not in reach after walking to it — force it
|
||||
batch.push({ idx: j, chest: c, key: ck });
|
||||
} else {
|
||||
break; // Only scan contiguous reachable chests
|
||||
}
|
||||
}
|
||||
|
||||
const shulkerCount = await this.scanChest(bot, database, chest.pos);
|
||||
totalShulkers += shulkerCount;
|
||||
for (const item of batch) {
|
||||
if (item.idx > i) i = item.idx; // Skip ahead in plan
|
||||
|
||||
if (interruptCheck && interruptCheck()) {
|
||||
console.log(`Scanner: Interrupted during batch after ${scannedCount} chests`);
|
||||
break;
|
||||
}
|
||||
|
||||
const result = await this.scanChest(bot, database, item.chest.pos);
|
||||
totalShulkers += result.shulkerCount;
|
||||
scannedCount++;
|
||||
if (result.lost) lostCount++;
|
||||
|
||||
if (scannedCount % 10 === 0) {
|
||||
console.log(`Scanner: Progress - ${scannedCount}/${scannedCount + remaining.length} chests scanned, ${totalShulkers} shulkers found`);
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
if (scannedCount > 0 && scannedCount % 50 === 0) {
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`Scanner: Progress — ${scannedCount} chests, ${totalShulkers} shulkers, ${lostCount} lost, ${elapsed}s elapsed`);
|
||||
}
|
||||
}
|
||||
|
||||
await database.rebuildItemIndex();
|
||||
console.log(`Scanner: Scanned ${scannedCount} chests, skipped ${skippedCount}, found ${totalShulkers} shulkers`);
|
||||
|
||||
const elapsedTotal = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`Scanner: Done — ${scannedCount} chests, ${skippedCount} unreachable, ${lostCount} lost, ${totalShulkers} shulkers, ${elapsedTotal}s total`);
|
||||
return totalShulkers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a row-major serpentine traversal plan:
|
||||
* - Group chests by X coordinate (each X = one aisle/row)
|
||||
* - Sort rows by X
|
||||
* - Within each row, sort by Z (alternating direction for serpentine)
|
||||
* - Start from the row nearest to the bot's current position
|
||||
*/
|
||||
_buildSerpentinePlan(chests, botPos) {
|
||||
// Group by X (row/aisle)
|
||||
const rows = new Map();
|
||||
for (const c of chests) {
|
||||
const x = c.pos_x;
|
||||
if (!rows.has(x)) rows.set(x, []);
|
||||
rows.get(x).push({ ...c, pos: new Vec3(c.pos_x, c.pos_y, c.pos_z) });
|
||||
}
|
||||
|
||||
// Sort rows by X
|
||||
const sortedRows = [...rows.entries()].sort((a, b) => a[0] - b[0]);
|
||||
|
||||
// Find the row closest to the bot
|
||||
let startRowIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < sortedRows.length; i++) {
|
||||
const dist = Math.abs(botPos.x - sortedRows[i][0]);
|
||||
if (dist < bestDist) { bestDist = dist; startRowIdx = i; }
|
||||
}
|
||||
|
||||
// Build plan: start from nearest row, scan outward in serpentine order
|
||||
const plan = [];
|
||||
let direction = 1; // 1 = ascending Z, -1 = descending Z
|
||||
|
||||
// First: rows from startRowIdx to end
|
||||
for (let i = startRowIdx; i < sortedRows.length; i++) {
|
||||
const [, rowChests] = sortedRows[i];
|
||||
rowChests.sort((a, b) => direction * (a.pos_z - b.pos_z));
|
||||
plan.push(...rowChests);
|
||||
direction *= -1;
|
||||
}
|
||||
// Then: remaining rows before startRowIdx (going backward in X)
|
||||
for (let i = startRowIdx - 1; i >= 0; i--) {
|
||||
const [, rowChests] = sortedRows[i];
|
||||
rowChests.sort((a, b) => direction * (a.pos_z - b.pos_z));
|
||||
plan.push(...rowChests);
|
||||
direction *= -1;
|
||||
}
|
||||
|
||||
console.log(`Scanner: Serpentine plan — ${sortedRows.length} rows, ${plan.length} chests, starting at row x=${sortedRows[startRowIdx][0]}`);
|
||||
return plan;
|
||||
}
|
||||
|
||||
// Read shulker contents from NBT data (no physical interaction needed)
|
||||
async scanShulkerFromNBT(bot, database, chestId, chestSlot, shulkerItem) {
|
||||
console.log(`Scanner: Reading shulker NBT at slot ${chestSlot}`);
|
||||
|
||||
try {
|
||||
// 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
|
||||
chestId, chestSlot, shulkerItem.name, null
|
||||
);
|
||||
if (!shulkerRecord) {
|
||||
console.error(`Scanner: No shulker record found for chest ${chestId} slot ${chestSlot}`);
|
||||
console.error(`Scanner: No shulker record for chest ${chestId} slot ${chestSlot}`);
|
||||
return [];
|
||||
}
|
||||
const shulkerId = shulkerRecord.id;
|
||||
|
||||
await database.clearShulkerItems(shulkerId);
|
||||
|
||||
// Extract items from shulker NBT
|
||||
const items = this.extractShulkerContents(bot, shulkerItem);
|
||||
|
||||
let totalItems = 0;
|
||||
@@ -313,67 +348,49 @@ class Scanner {
|
||||
itemTypes.add(item.name);
|
||||
}
|
||||
|
||||
// Update shulker stats
|
||||
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';
|
||||
}
|
||||
if (hasSpecial) itemFocus = itemFocus + '#special';
|
||||
}
|
||||
|
||||
await database.updateShulkerCounts(shulkerId, usedSlots, totalItems);
|
||||
|
||||
await database.updateShulkerCounts(shulkerId, items.length, totalItems);
|
||||
await database.updateShulkerItemFocus(shulkerId, itemFocus);
|
||||
|
||||
console.log(`Scanner: Shulker has ${items.length} slot(s) used, ${totalItems} total items`);
|
||||
return items;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Scanner: Error reading shulker NBT:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract items from shulker box NBT data
|
||||
extractShulkerContents(bot, shulkerItem) {
|
||||
const items = [];
|
||||
|
||||
if (!shulkerItem.nbt) {
|
||||
console.log('Scanner: Shulker has no NBT data (empty)');
|
||||
return items;
|
||||
}
|
||||
if (!shulkerItem.nbt) return items;
|
||||
|
||||
try {
|
||||
// Navigate the NBT structure to find Items array
|
||||
// Structure varies between:
|
||||
// - Placed+opened shulker: nbt.value.BlockEntityTag.value.Items.value.value
|
||||
// - Trade window / freshly-crafted: nbt.value.tag.value.BlockEntityTag.value.Items.value.value
|
||||
// - Simple forms: nbt.Items, nbt.BlockEntityTag.Items, etc.
|
||||
let nbtItems = null;
|
||||
const nbt = shulkerItem.nbt;
|
||||
|
||||
// Try multiple paths to find the items array
|
||||
const paths = [
|
||||
() => nbt.value?.BlockEntityTag?.value?.Items?.value?.value, // Full nested path (standard placed shulker)
|
||||
() => nbt.value?.BlockEntityTag?.value?.Items?.value, // One less nesting level
|
||||
() => nbt.BlockEntityTag?.Items?.value?.value, // Without top-level value wrapper
|
||||
() => nbt.BlockEntityTag?.Items?.value, // Simpler BlockEntityTag path
|
||||
() => nbt.BlockEntityTag?.Items, // Direct BlockEntityTag
|
||||
() => nbt.value?.tag?.value?.BlockEntityTag?.value?.Items?.value?.value, // Trade window shulker (has extra tag wrapper)
|
||||
() => nbt.value?.tag?.value?.Items?.value?.value, // Trade window with Items directly under tag
|
||||
() => nbt.value?.tag?.value?.Items?.value, // Trade window simpler
|
||||
() => nbt.value?.Items?.value?.value, // No BlockEntityTag
|
||||
() => nbt.Items?.value?.value, // Even simpler
|
||||
() => nbt.Items, // Direct Items
|
||||
() => nbt.tag?.value?.BlockEntityTag?.value?.Items?.value?.value, // Tag wrapper path
|
||||
() => nbt.tag?.BlockEntityTag?.Items?.value?.value, // Tag without value
|
||||
() => nbt.tag?.Items?.value?.value, // Tag with Items direct
|
||||
() => nbt.value?.BlockEntityTag?.value?.Items?.value?.value,
|
||||
() => nbt.value?.BlockEntityTag?.value?.Items?.value,
|
||||
() => nbt.BlockEntityTag?.Items?.value?.value,
|
||||
() => nbt.BlockEntityTag?.Items?.value,
|
||||
() => nbt.BlockEntityTag?.Items,
|
||||
() => nbt.value?.tag?.value?.BlockEntityTag?.value?.Items?.value?.value,
|
||||
() => nbt.value?.tag?.value?.Items?.value?.value,
|
||||
() => nbt.value?.tag?.value?.Items?.value,
|
||||
() => nbt.value?.Items?.value?.value,
|
||||
() => nbt.Items?.value?.value,
|
||||
() => nbt.Items,
|
||||
() => nbt.tag?.value?.BlockEntityTag?.value?.Items?.value?.value,
|
||||
() => nbt.tag?.BlockEntityTag?.Items?.value?.value,
|
||||
() => nbt.tag?.Items?.value?.value,
|
||||
];
|
||||
|
||||
let nbtItems = null;
|
||||
for (const pathFn of paths) {
|
||||
const result = pathFn();
|
||||
if (Array.isArray(result)) {
|
||||
@@ -382,48 +399,36 @@ class Scanner {
|
||||
}
|
||||
}
|
||||
|
||||
if (!nbtItems || !Array.isArray(nbtItems)) {
|
||||
console.log('Scanner: No items array found in shulker (may be empty)');
|
||||
return items;
|
||||
}
|
||||
|
||||
console.log(`Scanner: Found ${nbtItems.length} items in shulker NBT`);
|
||||
if (!nbtItems || !Array.isArray(nbtItems)) return items;
|
||||
|
||||
for (const nbtItem of nbtItems) {
|
||||
// Extract slot, id, count from NBT item
|
||||
const slot = nbtItem.Slot?.value ?? nbtItem.Slot ?? 0;
|
||||
const id = nbtItem.id?.value ?? nbtItem.id ?? 'unknown';
|
||||
const count = nbtItem.Count?.value ?? nbtItem.Count ?? 1;
|
||||
|
||||
// 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,
|
||||
slot,
|
||||
name: cleanId,
|
||||
id: bot.mcData.itemsByName[cleanId]?.id || 0,
|
||||
count: count,
|
||||
nbt: tag ? this.parseNBT(tag) : null
|
||||
count,
|
||||
nbt: tag ? this.parseNBT(tag) : null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Scanner: Error parsing shulker NBT:', error);
|
||||
console.log('Scanner: Raw NBT:', JSON.stringify(shulkerItem.nbt).substring(0, 500));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -442,14 +447,9 @@ class Scanner {
|
||||
parseNBT(nbt) {
|
||||
if (!nbt) return null;
|
||||
if (typeof nbt === 'string') {
|
||||
try {
|
||||
nbt = JSON.parse(nbt);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
try { nbt = JSON.parse(nbt); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
// Unwrap prismarine-nbt wrappers so we can access keys directly
|
||||
nbt = this.simplifyNBT(nbt);
|
||||
|
||||
const result = {};
|
||||
@@ -457,16 +457,11 @@ class Scanner {
|
||||
if (nbt.Enchantments) {
|
||||
let enchList = nbt.Enchantments;
|
||||
if (Array.isArray(enchList)) {
|
||||
result.enchantments = enchList.map(e => ({
|
||||
id: e.id,
|
||||
level: e.lvl
|
||||
}));
|
||||
result.enchantments = enchList.map(e => ({ id: e.id, level: e.lvl }));
|
||||
}
|
||||
}
|
||||
|
||||
if (nbt.Damage) {
|
||||
result.damage = nbt.Damage;
|
||||
}
|
||||
if (nbt.Damage) result.damage = nbt.Damage;
|
||||
|
||||
if (nbt.display?.Name) {
|
||||
const name = nbt.display.Name;
|
||||
@@ -488,26 +483,13 @@ class Scanner {
|
||||
});
|
||||
}
|
||||
|
||||
if (nbt.CustomModelData) {
|
||||
result.customModelData = nbt.CustomModelData;
|
||||
}
|
||||
|
||||
if (nbt.RepairCost) {
|
||||
result.repairCost = nbt.RepairCost;
|
||||
}
|
||||
|
||||
// Map ID for filled_map items
|
||||
if (nbt.map !== undefined) {
|
||||
result.map = nbt.map;
|
||||
}
|
||||
if (nbt.CustomModelData) result.customModelData = nbt.CustomModelData;
|
||||
if (nbt.RepairCost) result.repairCost = nbt.RepairCost;
|
||||
if (nbt.map !== undefined) result.map = nbt.map;
|
||||
|
||||
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') {
|
||||
|
||||
@@ -57,15 +57,23 @@ class ShulkerHandler {
|
||||
if (excludeSet.has(`${checkPos.x},${checkPos.y},${checkPos.z}`)) continue;
|
||||
const blockAtPos = bot.bot.blockAt(checkPos);
|
||||
const blockBelow = bot.bot.blockAt(checkPos.offset(0, -1, 0));
|
||||
const blockAbove = bot.bot.blockAt(checkPos.offset(0, 1, 0));
|
||||
|
||||
if (!blockAtPos || blockAtPos.name !== 'air') continue;
|
||||
if (!blockBelow || blockBelow.boundingBox !== 'block') continue;
|
||||
// A shulker's lid opens upward — with a solid block above it the
|
||||
// server refuses to open it ("Block wont open")
|
||||
if (blockAbove && blockAbove.boundingBox === 'block') continue;
|
||||
// Never place on top of storage blocks: a box sitting on a chest
|
||||
// makes that chest unopenable (and abandons it if the cycle fails)
|
||||
if (/chest|shulker|barrel|hopper|furnace/.test(blockBelow.name)) continue;
|
||||
|
||||
if (blockAtPos && blockAtPos.name === 'air' && blockBelow && blockBelow.boundingBox === 'block') {
|
||||
return {
|
||||
position: checkPos,
|
||||
placeOn: blockBelow,
|
||||
faceVec: new Vec3(0, 1, 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No suitable placement spot found near bot');
|
||||
}
|
||||
@@ -237,7 +245,7 @@ class ShulkerHandler {
|
||||
}
|
||||
|
||||
// Go to chest and open it
|
||||
await bot.goTo({ where: chestPos, range: 3 });
|
||||
await bot.goToMust({ where: chestPos, range: 3 });
|
||||
const chestBlock = bot.bot.blockAt(chestPos);
|
||||
if (!chestBlock || !chestBlock.name.includes('chest')) {
|
||||
throw new Error(`Recovery failed — no chest at ${chestPos}`);
|
||||
@@ -296,7 +304,7 @@ class ShulkerHandler {
|
||||
}
|
||||
|
||||
// Verify the shulker is actually back in the chest
|
||||
await bot.goTo({ where: chestPos, range: 3 });
|
||||
await bot.goToMust({ where: chestPos, range: 3 });
|
||||
const verifyBlock = bot.bot.blockAt(chestPos);
|
||||
if (!verifyBlock || !verifyBlock.name.includes('chest')) {
|
||||
throw new Error('Recovery verification failed — chest not found');
|
||||
@@ -323,7 +331,7 @@ class ShulkerHandler {
|
||||
async takeShulkerFromChest(bot, chestPos, chestSlot, shulkerId) {
|
||||
console.log(`ShulkerHandler: Taking shulker from chest at ${chestPos}, slot ${chestSlot}`);
|
||||
|
||||
await bot.goTo({ where: chestPos, range: 3 });
|
||||
await bot.goToMust({ where: chestPos, range: 3 });
|
||||
const chestBlock = bot.bot.blockAt(chestPos);
|
||||
if (!chestBlock || !chestBlock.name.includes('chest')) {
|
||||
throw new Error(`No chest at ${chestPos} (found: ${chestBlock?.name || 'null'})`);
|
||||
@@ -476,13 +484,30 @@ class ShulkerHandler {
|
||||
throw new Error(`Failed to place shulker at ${spot.position} (found: ${placedBlock?.name || 'null'})`);
|
||||
}
|
||||
|
||||
const window = await bot.openContainer(placedBlock);
|
||||
let window;
|
||||
try {
|
||||
window = await bot.openContainer(placedBlock);
|
||||
} catch (openError) {
|
||||
// The box is placed but won't open — break it back into
|
||||
// inventory before retrying, otherwise it's abandoned on the
|
||||
// ground and the next attempt finds no box in inventory
|
||||
console.log(`ShulkerHandler: Placed but cannot open (${openError.message}), collecting box back`);
|
||||
const collected = await this.digAndCollectShulker(bot, spot.position);
|
||||
if (!collected) {
|
||||
openError.placedPos = spot.position;
|
||||
}
|
||||
throw openError;
|
||||
}
|
||||
await bot.bot.waitForTicks(5);
|
||||
|
||||
console.log(`ShulkerHandler: Shulker placed and opened at ${spot.position}`);
|
||||
return { window, placedPos: spot.position };
|
||||
} catch (error) {
|
||||
console.log(`ShulkerHandler: Place attempt ${attempt + 1} failed (${error.message})`);
|
||||
// A box we couldn't collect is sitting on the ground — retrying
|
||||
// can't succeed (no box in inventory) and callers need placedPos
|
||||
// to attempt recovery
|
||||
if (error.placedPos) throw error;
|
||||
if (attempt < 4) {
|
||||
// Move the bot a few blocks so findPlacementSpot finds new spots
|
||||
console.log('ShulkerHandler: Moving to find a better placement spot...');
|
||||
@@ -524,7 +549,7 @@ class ShulkerHandler {
|
||||
}
|
||||
|
||||
// Navigate back to chest and put shulker back
|
||||
await bot.goTo({ where: chestPos, range: 3 });
|
||||
await bot.goToMust({ where: chestPos, range: 3 });
|
||||
const chestBlock = bot.bot.blockAt(chestPos);
|
||||
const window = await bot.openContainer(chestBlock);
|
||||
await sleep(300);
|
||||
@@ -582,10 +607,12 @@ class ShulkerHandler {
|
||||
console.log(`ShulkerHandler: Depositing ${count}x ${itemName} into shulker at chest ${chestPos} slot ${chestSlot}`);
|
||||
|
||||
let placedPos = null;
|
||||
let taken = false;
|
||||
|
||||
try {
|
||||
// Step 1: Take shulker from chest (DB immediately marks it in-transit)
|
||||
const invSlot = await this.takeShulkerFromChest(bot, chestPos, chestSlot, shulkerId);
|
||||
taken = true;
|
||||
|
||||
// Step 2: Place shulker on ground and open it
|
||||
let shulkerWindow;
|
||||
@@ -597,7 +624,7 @@ class ShulkerHandler {
|
||||
console.error('ShulkerHandler: Failed to place shulker, recovering:', placeError.message);
|
||||
let recovered = false;
|
||||
try {
|
||||
await this.returnShulkerToChest(bot, chestPos, chestSlot, null, chestId);
|
||||
await this.returnShulkerToChest(bot, chestPos, chestSlot, placeError.placedPos || null, chestId);
|
||||
recovered = true;
|
||||
} catch (recoverError) {
|
||||
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
|
||||
@@ -659,7 +686,7 @@ class ShulkerHandler {
|
||||
try {
|
||||
// Shift-click handles stacking optimally — no cursor issues
|
||||
await bot.bot.clickWindow(i, 0, 1);
|
||||
await sleep(200);
|
||||
await bot.bot.waitForTicks(3); // let server confirm the move
|
||||
|
||||
// Measure what actually left this slot
|
||||
const afterItem = shulkerWindow.slots[i];
|
||||
@@ -672,6 +699,9 @@ class ShulkerHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Let server confirm all shift-click moves before closing window
|
||||
await bot.bot.waitForTicks(4);
|
||||
|
||||
// Step 5: Close, break, return to chest (DB synced inside closeBreakReturn)
|
||||
try {
|
||||
const updatedSlotItem = await this.closeBreakReturn(bot, shulkerWindow, placedPos, chestPos, chestSlot, chestId);
|
||||
@@ -691,6 +721,8 @@ class ShulkerHandler {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Box never left the chest — nothing to recover, report the real error
|
||||
if (!taken) throw error;
|
||||
console.error('ShulkerHandler: Deposit failed, attempting recovery:', error.message);
|
||||
let recovered = false;
|
||||
try {
|
||||
@@ -766,13 +798,17 @@ class ShulkerHandler {
|
||||
throw new Error(`Failed to place shulker at ${spot.position}`);
|
||||
}
|
||||
|
||||
// Steps 3-5 run with the box placed on the ground — any failure must
|
||||
// still break it and collect it, or the box is abandoned
|
||||
const extracted = [];
|
||||
let inventoryFull = false;
|
||||
try {
|
||||
|
||||
// Step 3: Open the shulker
|
||||
const shulkerWindow = await bot.openContainer(placedBlock);
|
||||
await bot.bot.waitForTicks(5);
|
||||
|
||||
// Step 4: Move ALL items from shulker into bot inventory
|
||||
const extracted = [];
|
||||
let inventoryFull = false;
|
||||
const shulkerSlotCount = shulkerWindow.inventoryStart;
|
||||
|
||||
for (let s = 0; s < shulkerSlotCount; s++) {
|
||||
@@ -820,10 +856,27 @@ class ShulkerHandler {
|
||||
await bot.bot.closeWindow(shulkerWindow);
|
||||
await bot.bot.waitForTicks(30);
|
||||
|
||||
} catch (error) {
|
||||
// Recovery: get the placed box back into inventory before rethrowing
|
||||
console.error(`ShulkerHandler: Unpack failed mid-cycle (${error.message}), recovering placed box`);
|
||||
try { await bot.bot.closeWindow(bot.bot.currentWindow); } catch (e) { /* may not be open */ }
|
||||
await sleep(300);
|
||||
const recovered = await this.digAndCollectShulker(bot, spot.position);
|
||||
if (!recovered) {
|
||||
error.placedPos = spot.position;
|
||||
console.error(`ShulkerHandler: SHULKER LEFT ON GROUND at ${spot.position}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Step 6: Break the shulker block and pick it up
|
||||
const collected = await this.digAndCollectShulker(bot, spot.position);
|
||||
if (!collected) {
|
||||
console.error('ShulkerHandler: Shulker not found in inventory after breaking');
|
||||
// Don't return success — the caller assumes the box is back in
|
||||
// inventory and would store a box that doesn't exist
|
||||
const error = new Error(`Shulker not collected after unpack at ${spot.position}`);
|
||||
error.placedPos = spot.position;
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`ShulkerHandler: Unpacked ${extracted.length} item stacks from shulker`);
|
||||
@@ -840,10 +893,12 @@ class ShulkerHandler {
|
||||
console.log(`ShulkerHandler: Withdrawing ${count}x ${itemName} from shulker at chest ${chestPos} slot ${chestSlot}`);
|
||||
|
||||
let placedPos = null;
|
||||
let taken = false;
|
||||
|
||||
try {
|
||||
// Step 1: Take shulker from chest (DB immediately marks it in-transit)
|
||||
const invSlot = await this.takeShulkerFromChest(bot, chestPos, chestSlot, shulkerId);
|
||||
taken = true;
|
||||
|
||||
// Step 2: Place shulker on ground and open it
|
||||
let shulkerWindow;
|
||||
@@ -855,7 +910,7 @@ class ShulkerHandler {
|
||||
console.error('ShulkerHandler: Failed to place shulker, recovering:', placeError.message);
|
||||
let recovered = false;
|
||||
try {
|
||||
await this.returnShulkerToChest(bot, chestPos, chestSlot, null, chestId);
|
||||
await this.returnShulkerToChest(bot, chestPos, chestSlot, placeError.placedPos || null, chestId);
|
||||
recovered = true;
|
||||
} catch (recoverError) {
|
||||
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
|
||||
@@ -901,7 +956,7 @@ class ShulkerHandler {
|
||||
if (remaining() >= beforeCount) {
|
||||
// Need the whole stack or more — shift-click is optimal
|
||||
await bot.bot.clickWindow(s, 0, 1);
|
||||
await sleep(200);
|
||||
await bot.bot.waitForTicks(3);
|
||||
} else {
|
||||
// Need fewer than the full stack — pick up, right-click exact amount, return rest
|
||||
// Find an empty inventory slot to place items into
|
||||
@@ -916,7 +971,7 @@ class ShulkerHandler {
|
||||
|
||||
// Left-click to pick up full stack onto cursor
|
||||
await bot.bot.clickWindow(s, 0, 0);
|
||||
await sleep(150);
|
||||
await bot.bot.waitForTicks(2);
|
||||
|
||||
// Right-click on empty inventory slot N times to place exactly N items
|
||||
for (let n = 0; n < remaining(); n++) {
|
||||
@@ -926,7 +981,7 @@ class ShulkerHandler {
|
||||
|
||||
// Left-click back on shulker slot to return the remainder from cursor
|
||||
await bot.bot.clickWindow(s, 0, 0);
|
||||
await sleep(150);
|
||||
await bot.bot.waitForTicks(2);
|
||||
}
|
||||
|
||||
// Measure what actually left this slot
|
||||
@@ -940,6 +995,9 @@ class ShulkerHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Let server confirm all shift-click moves before closing window
|
||||
await bot.bot.waitForTicks(4);
|
||||
|
||||
// Step 4: Close, break, return to chest (DB synced inside closeBreakReturn)
|
||||
try {
|
||||
const updatedSlotItem = await this.closeBreakReturn(bot, shulkerWindow, placedPos, chestPos, chestSlot, chestId);
|
||||
@@ -959,6 +1017,8 @@ class ShulkerHandler {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Box never left the chest — nothing to recover, report the real error
|
||||
if (!taken) throw error;
|
||||
console.error('ShulkerHandler: Withdraw failed, attempting recovery:', error.message);
|
||||
let recovered = false;
|
||||
try {
|
||||
@@ -980,10 +1040,12 @@ class ShulkerHandler {
|
||||
console.log(`ShulkerHandler: Withdrawing from shulker slot ${shulkerSlot} at chest ${chestPos} slot ${chestSlot}`);
|
||||
|
||||
let placedPos = null;
|
||||
let taken = false;
|
||||
|
||||
try {
|
||||
// Step 1: Take shulker from chest
|
||||
const invSlot = await this.takeShulkerFromChest(bot, chestPos, chestSlot, shulkerId);
|
||||
taken = true;
|
||||
|
||||
// Step 2: Place shulker on ground and open it
|
||||
let shulkerWindow;
|
||||
@@ -995,7 +1057,7 @@ class ShulkerHandler {
|
||||
console.error('ShulkerHandler: Failed to place shulker, recovering:', placeError.message);
|
||||
let recovered = false;
|
||||
try {
|
||||
await this.returnShulkerToChest(bot, chestPos, chestSlot, null, chestId);
|
||||
await this.returnShulkerToChest(bot, chestPos, chestSlot, placeError.placedPos || null, chestId);
|
||||
recovered = true;
|
||||
} catch (recoverError) {
|
||||
console.error('ShulkerHandler: Recovery failed:', recoverError.message);
|
||||
@@ -1078,6 +1140,8 @@ class ShulkerHandler {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Box never left the chest — nothing to recover, report the real error
|
||||
if (!taken) throw error;
|
||||
console.error('ShulkerHandler: Slot withdraw failed, attempting recovery:', error.message);
|
||||
await this.returnShulkerToChest(bot, chestPos, chestSlot, placedPos, chestId);
|
||||
return { withdrawn: 0, updatedSlotItem: null };
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { CJbot } = require('../model/minecraft');
|
||||
const Database = require('./storage/database');
|
||||
|
||||
class WebServer {
|
||||
constructor() {
|
||||
@@ -86,10 +87,9 @@ class WebServer {
|
||||
}
|
||||
|
||||
async start() {
|
||||
const conf = require('../conf');
|
||||
|
||||
this.port = conf.storage?.webPort || 3000;
|
||||
this.host = conf.storage?.webHost || '0.0.0.0';
|
||||
const settings = require('./settings/manager');
|
||||
this.port = settings.get('storage.webPort') || 3000;
|
||||
this.host = settings.get('storage.webHost') || '0.0.0.0';
|
||||
|
||||
this.app = express();
|
||||
|
||||
@@ -102,6 +102,12 @@ class WebServer {
|
||||
next();
|
||||
});
|
||||
|
||||
// Auth (OIDC/SSO) — the login routes and the session gate must be
|
||||
// mounted before any plugin routers so every route is protected
|
||||
const auth = require('./auth');
|
||||
this.app.use('/auth', auth.createRouter());
|
||||
this.app.use(auth.middleware);
|
||||
|
||||
// Flush any plugins that were queued before start()
|
||||
for (const cls of this._pendingPlugins) {
|
||||
this.registerPlugin(cls);
|
||||
@@ -110,6 +116,13 @@ class WebServer {
|
||||
|
||||
this.setupRoutes();
|
||||
|
||||
// JSON error handler — keeps stack traces out of responses
|
||||
this.app.use((err, req, res, next) => {
|
||||
console.error(`WebServer: error on ${req.method} ${req.path}:`, err.message);
|
||||
if (res.headersSent) return next(err);
|
||||
res.status(err.status || 500).json({ error: err.message || 'Internal server error' });
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = this.app.listen(this.port, this.host, () => {
|
||||
console.log(`WebServer: Running at http://${this.host}:${this.port}`);
|
||||
@@ -209,6 +222,36 @@ class WebServer {
|
||||
}
|
||||
});
|
||||
|
||||
this.app.put('/api/bots/:name/settings/:key', async (req, res) => {
|
||||
try {
|
||||
const bot = CJbot.bots[req.params.name];
|
||||
if (!bot) return res.status(404).json({ error: 'Bot not found' });
|
||||
|
||||
const key = req.params.key;
|
||||
const value = req.body?.value;
|
||||
|
||||
let type = 'string';
|
||||
if (typeof value === 'boolean') type = 'boolean';
|
||||
else if (typeof value === 'number') type = 'number';
|
||||
else if (typeof value === 'object') type = 'json';
|
||||
|
||||
await Database.setBotSetting(req.params.name, key, value, type);
|
||||
|
||||
switch (key) {
|
||||
case 'autoConnect': bot.autoConnect = value; break;
|
||||
case 'autoReConnect': bot.autoReConnect = value; break;
|
||||
case 'onDemand': bot.onDemand = value; break;
|
||||
case 'idleTimeout': bot._idleTimeout = Number(value) || 30000; break;
|
||||
case 'plugins': bot.pluginsWanted = value || {}; break;
|
||||
}
|
||||
|
||||
res.json({ key, value, type });
|
||||
} catch (error) {
|
||||
console.error('API Error /api/bots/:name/settings/:key:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/bots/:name/plugins/:plugin/load', async (req, res) => {
|
||||
try {
|
||||
const bot = CJbot.bots[req.params.name];
|
||||
@@ -351,6 +394,9 @@ body{font-family:'Segoe UI',Tahoma,sans-serif;background:#111827;color:#e5e7eb;m
|
||||
.plugin-tag .unload-btn:hover{color:#f87171}
|
||||
.bot-actions{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
||||
.bot-actions select{padding:6px;border:1px solid #374151;border-radius:4px;background:#1f2937;color:#e5e7eb;font-size:.8em}
|
||||
.bot-settings{margin-bottom:12px}
|
||||
.bot-setting-toggle{display:flex;align-items:center;gap:6px;font-size:.8em;color:#9ca3af;cursor:pointer}
|
||||
.bot-setting-toggle input[type="checkbox"]{accent-color:#60a5fa;cursor:pointer}
|
||||
.btn-connect{background:#059669;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:.8em}
|
||||
.btn-connect:hover{background:#047857}
|
||||
.btn-disconnect{background:#dc2626;color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:.8em}
|
||||
@@ -394,6 +440,8 @@ ${pluginCSS}
|
||||
<div class="players-list" id="playersList"></div>
|
||||
</div>
|
||||
<button onclick="loadAll()">Refresh</button>
|
||||
<span id="userBadge" style="display:none;font-size:.85em;color:#9ca3af"></span>
|
||||
<button id="logoutBtn" style="display:none;background:#374151" onclick="location.href='/auth/logout'">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout">
|
||||
@@ -470,6 +518,32 @@ document.addEventListener('click', function(e) {
|
||||
pollOnlinePlayers();
|
||||
setInterval(pollOnlinePlayers, 15000);
|
||||
|
||||
// === Auth badge ===
|
||||
(async function() {
|
||||
try {
|
||||
const r = await fetch('/auth/me');
|
||||
const d = await r.json();
|
||||
if (d.enabled && d.username) {
|
||||
const badge = document.getElementById('userBadge');
|
||||
badge.textContent = d.username;
|
||||
badge.style.display = '';
|
||||
document.getElementById('logoutBtn').style.display = '';
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
|
||||
// Session-expiry guard: if any API call starts returning 401, go to login
|
||||
(function() {
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = async function(...args) {
|
||||
const res = await origFetch.apply(this, args);
|
||||
if (res.status === 401 && !String(args[0]).startsWith('/auth/')) {
|
||||
location.href = '/auth/login?redirect=' + encodeURIComponent(location.pathname);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
})();
|
||||
|
||||
const ALL_TABS = [${allTabIds.join(',')}];
|
||||
const TAB_ACTIVE_HANDLERS = {${onTabActiveMap}};
|
||||
let currentTab = ALL_TABS[0] || 'bots';
|
||||
@@ -668,6 +742,12 @@ function renderBots(bots) {
|
||||
'<span style="font-size:.8em;color:#9ca3af">' + statusText + '</span>' +
|
||||
'</div>' +
|
||||
infoHtml +
|
||||
'<div class="bot-settings">' +
|
||||
'<label class="bot-setting-toggle">' +
|
||||
'<input type="checkbox" ' + (b.autoConnect ? 'checked' : '') + ' onchange="setBotSetting(\\'' + escHtml(name) + '\\',\\'autoConnect\\',this.checked)" />' +
|
||||
' Auto-connect' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
pluginHtml +
|
||||
'<div class="bot-actions">' + connBtn + ' ' + loadSelect + '</div>' +
|
||||
cmdHtml +
|
||||
@@ -695,6 +775,19 @@ async function disconnectBot(name) {
|
||||
} catch(e) { showToast('Network error', 'error'); }
|
||||
}
|
||||
|
||||
async function setBotSetting(botName, key, value) {
|
||||
try {
|
||||
const r = await fetch('/api/bots/' + botName + '/settings/' + key, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ value: value })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) showToast(d.error || 'Failed', 'error');
|
||||
else showToast(botName + ': ' + key + ' = ' + value, 'info');
|
||||
} catch(e) { showToast('Network error', 'error'); }
|
||||
}
|
||||
|
||||
async function loadPlugin(botName) {
|
||||
const sel = document.getElementById('plugin-select-' + botName);
|
||||
if (!sel) return;
|
||||
|
||||
+213
-31
@@ -73,6 +73,10 @@ class CJbot{
|
||||
this._idleTimeout = args.idleTimeout || 30000;
|
||||
this._goToLock = false;
|
||||
|
||||
// Bumped by interruptTask; in-flight goTo calls compare against the
|
||||
// value they captured at start and bail out when it changes.
|
||||
this._interruptGen = 0;
|
||||
|
||||
// If we want the be always connected, kick off the function to auto
|
||||
// reconnect
|
||||
if(this.autoReConnect && !this.onDemand) this.__autoReConnect()
|
||||
@@ -245,8 +249,24 @@ class CJbot{
|
||||
this.on('end', async (...args)=>{
|
||||
console.error('CJbot.__autoReConnect on end', args)
|
||||
|
||||
await sleep(30000)
|
||||
this.connect()
|
||||
// connect() also rejects on 'end', which used to surface as an
|
||||
// unhandled rejection (fatal on modern Node). Guard against
|
||||
// overlapping loops and retry until we get back in.
|
||||
if(this._reconnecting) return;
|
||||
this._reconnecting = true;
|
||||
try{
|
||||
while(true){
|
||||
await sleep(30000);
|
||||
try{
|
||||
await this.connect();
|
||||
break;
|
||||
}catch(error){
|
||||
console.error('CJbot.__autoReConnect retry failed:', this.name, error?.message || error);
|
||||
}
|
||||
}
|
||||
}finally{
|
||||
this._reconnecting = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -371,6 +391,13 @@ class CJbot{
|
||||
|
||||
_idleDisconnect() {
|
||||
if (!this.isReady) return;
|
||||
// A registered task (scan, organize, withdraw, trade) is still running —
|
||||
// disconnecting now would abandon shulkers and leak locks
|
||||
if (this._currentTask) {
|
||||
console.log(`${this.name}: Idle timer fired mid-task (${this._currentTask.task}), rescheduling`);
|
||||
this._resetIdleTimer();
|
||||
return;
|
||||
}
|
||||
console.log(`${this.name}: Idle timeout, disconnecting on-demand bot`);
|
||||
this.quit(true);
|
||||
}
|
||||
@@ -492,7 +519,7 @@ class CJbot{
|
||||
const cmdDef = this.commands[cmd];
|
||||
|
||||
if(this.commandLock && !cmdDef.ignoreLock){
|
||||
this.whisper(from, `cool down, try again in ${this.commandCollDownTime/1000} seconds...`);
|
||||
this.whisper(from, `I'm busy with another command right now, please try again shortly...`);
|
||||
return ;
|
||||
}
|
||||
|
||||
@@ -547,7 +574,7 @@ class CJbot{
|
||||
findChestBySign(text){
|
||||
return this.bot.findBlock({
|
||||
point: this.findBlockBySign(text).position,
|
||||
// maxDistance: 1,
|
||||
maxDistance: 4,
|
||||
useExtraInfo: true,
|
||||
matching: block => block.name === 'chest'
|
||||
});
|
||||
@@ -577,41 +604,156 @@ playerWithinBlock(player, block, range){
|
||||
|
||||
// Interrupt current movement so trade/storage commands can run
|
||||
async interruptTask(from) {
|
||||
this._interrupted = true;
|
||||
this._interruptGen++;
|
||||
this._currentTask = null;
|
||||
try {
|
||||
this.bot.pathfinder.stop();
|
||||
} catch (e) { /* pathfinder may not be moving */ }
|
||||
this.bot.clearControlStates();
|
||||
}
|
||||
|
||||
registerTask(source, task, fn) {
|
||||
this._interrupted = false;
|
||||
this._currentTask = { source, task, fn };
|
||||
}
|
||||
|
||||
clearTask() {
|
||||
this._currentTask = null;
|
||||
}
|
||||
|
||||
wasInterrupted() {
|
||||
return this._interrupted;
|
||||
}
|
||||
|
||||
async goTo(options) {
|
||||
while (this._goToLock) await new Promise(r => setTimeout(r, 50));
|
||||
this._goToLock = true;
|
||||
// Captured after acquiring the lock: an interrupt kills goTo calls that
|
||||
// were already running, not the interrupting command's own movement.
|
||||
const gen = this._interruptGen;
|
||||
try {
|
||||
let range = options.range || 2;
|
||||
let block = this.__blockOrVec(options.where);
|
||||
console.log('[goTo] moving to', block.position, 'range', range);
|
||||
let timeout = options.timeout || 60000;
|
||||
const goal = block.position;
|
||||
console.log('[goTo] target:', goal.toArray(), 'range:', range);
|
||||
|
||||
while(!this.isWithinRange(block.position, range)){
|
||||
try{
|
||||
console.log('[goTo] loop: isMoving=', this.bot.pathfinder.isMoving(), 'inRange=', this.isWithinRange(block.position, range));
|
||||
if(this.bot.pathfinder.isMoving()){
|
||||
await sleep(500);
|
||||
continue;
|
||||
}
|
||||
await this.bot.pathfinder.goto(
|
||||
new GoalNear(...block.position.toArray(), range)
|
||||
);
|
||||
}catch(error){
|
||||
console.log('CJbot.goTo while loop error:', error)
|
||||
await sleep(500);
|
||||
}
|
||||
const startTime = Date.now();
|
||||
let lastPos = this.bot.entity.position.clone();
|
||||
let stuckTime = 0;
|
||||
let recoveryCount = 0;
|
||||
|
||||
// Fire-and-forget: starts pathfinder, never awaited.
|
||||
// PathStopped/GoalChanged/NoPath are expected during recovery.
|
||||
const startPathfinder = () => {
|
||||
this.bot.pathfinder.goto(
|
||||
new GoalNear(...goal.toArray(), range)
|
||||
).catch(e => {
|
||||
if (e.name !== 'PathStopped' && e.name !== 'GoalChanged' && e.name !== 'NoPath')
|
||||
console.log('[goTo] goto rejected:', e.name, e.message);
|
||||
});
|
||||
};
|
||||
|
||||
startPathfinder();
|
||||
|
||||
while (!this.isWithinRange(goal, range)) {
|
||||
if (this._interruptGen !== gen) {
|
||||
this.bot.pathfinder.stop();
|
||||
console.log('[goTo] interrupted');
|
||||
return false;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > timeout) {
|
||||
this.bot.pathfinder.stop();
|
||||
console.log('[goTo] timed out after', timeout, 'ms');
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentPos = this.bot.entity.position;
|
||||
const moved = currentPos.distanceTo(lastPos);
|
||||
|
||||
if (moved > 0.2) {
|
||||
// Making progress
|
||||
stuckTime = 0;
|
||||
recoveryCount = Math.max(0, recoveryCount - 1);
|
||||
lastPos = currentPos.clone();
|
||||
} else {
|
||||
// Not moving
|
||||
stuckTime += 400;
|
||||
}
|
||||
|
||||
// Pathfinder legitimately pauses during jumps, replans, and corner
|
||||
// turns — only treat 2s+ of zero movement as actually stuck.
|
||||
if (stuckTime >= 2000) {
|
||||
console.log('[goTo] stuck, recovery=', recoveryCount);
|
||||
await this._recoverFromStuck(recoveryCount);
|
||||
stuckTime = 0;
|
||||
recoveryCount++;
|
||||
lastPos = this.bot.entity.position.clone();
|
||||
if (recoveryCount > 8) {
|
||||
console.log('[goTo] giving up after', recoveryCount, 'recoveries');
|
||||
return false;
|
||||
}
|
||||
startPathfinder();
|
||||
}
|
||||
|
||||
if (!this.bot.pathfinder.isMoving()) {
|
||||
startPathfinder();
|
||||
}
|
||||
await sleep(400);
|
||||
}
|
||||
|
||||
this.bot.pathfinder.stop();
|
||||
console.log('[goTo] arrived');
|
||||
return true;
|
||||
} finally {
|
||||
this._goToLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Like goTo, but failure to arrive aborts the caller instead of letting it
|
||||
// operate on a container it never reached.
|
||||
async goToMust(options) {
|
||||
const arrived = await this.goTo(options);
|
||||
if (!arrived) {
|
||||
const pos = options.where?.position || options.where;
|
||||
throw new Error(`Could not reach ${pos} (interrupted, stuck, or timed out)`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Simple unstuck: back up N blocks, strafe N blocks (alternating left/right),
|
||||
// then let the main loop retry pathfinding to the goal.
|
||||
async _recoverFromStuck(failureCount) {
|
||||
this.bot.pathfinder.stop();
|
||||
this.bot.clearControlStates();
|
||||
await sleep(50);
|
||||
|
||||
// Cap at 3 blocks — backing way up just walks the bot away from the
|
||||
// goal and into other obstacles. No jump: jumping while reversing
|
||||
// climbs the bot onto chests and wedges it into corners.
|
||||
const n = Math.min(failureCount + 1, 3);
|
||||
const strafeDir = failureCount % 2 === 0 ? 'left' : 'right';
|
||||
const blockTime = 250; // ms per block (walking speed ~4.3 blocks/sec)
|
||||
|
||||
console.log('[goTo] recover: back', n, 'strafe', n, strafeDir);
|
||||
|
||||
// Back up N blocks
|
||||
this.bot.setControlState('back', true);
|
||||
await sleep(n * blockTime);
|
||||
this.bot.setControlState('back', false);
|
||||
await sleep(50);
|
||||
|
||||
// Strafe N blocks (alternating direction)
|
||||
this.bot.setControlState(strafeDir, true);
|
||||
await sleep(n * blockTime);
|
||||
this.bot.setControlState(strafeDir, false);
|
||||
|
||||
this.bot.clearControlStates();
|
||||
await sleep(100);
|
||||
}
|
||||
async goToReturn(options){
|
||||
let here = this.bot.entity.position;
|
||||
let hereYaw = this.bot.entity.yaw
|
||||
@@ -643,26 +785,66 @@ playerWithinBlock(player, block, range){
|
||||
}
|
||||
|
||||
async openContainer(block){
|
||||
let count = 0;
|
||||
block = this.__blockOrVec(block);
|
||||
let window;
|
||||
|
||||
while(!this.bot.currentWindow){
|
||||
// A window left open by a failed earlier operation would otherwise be
|
||||
// returned as-is below — for the wrong container, with wrong slot
|
||||
// indexes. Close it and start clean.
|
||||
if(this.bot.currentWindow){
|
||||
console.log('CJbot.openContainer: closing stale window', this.bot.currentWindow.title);
|
||||
try{
|
||||
window = await this.bot.openContainer(block);
|
||||
this.bot.closeWindow(this.bot.currentWindow);
|
||||
}catch(error){
|
||||
console.log('CJbot.openContainer: stale window close failed:', error.message);
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// A chest with a solid block on top cannot open. If the obstruction is
|
||||
// a shulker box it's one of ours, abandoned by a failed cycle — break
|
||||
// it and try to collect it. Anything else is a hard, described failure
|
||||
// instead of four pointless retries.
|
||||
if(block.name.includes('chest')){
|
||||
const above = this.bot.blockAt(block.position.offset(0, 1, 0));
|
||||
if(above && above.boundingBox === 'block'){
|
||||
if(above.name.includes('shulker_box')){
|
||||
console.log(`CJbot.openContainer: chest at ${block.position} blocked by ${above.name} on top — breaking it`);
|
||||
try{
|
||||
await this.bot.lookAt(above.position.offset(0.5, 0.5, 0.5), true);
|
||||
await this.bot.dig(above, 'raycast');
|
||||
await sleep(300);
|
||||
// Try to catch the drop so the box isn't lost to despawn
|
||||
await this.goTo({ where: above.position, range: 0, timeout: 10000 });
|
||||
await sleep(500);
|
||||
}catch(error){
|
||||
throw new Error(`Chest at ${block.position} blocked by ${above.name} on top (recovery failed: ${error.message})`);
|
||||
}
|
||||
}else{
|
||||
throw new Error(`Chest at ${block.position} won't open: blocked by ${above.name} on top`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
while(true){
|
||||
// Face the container — some anti-cheat setups reject interactions
|
||||
// the player isn't looking at
|
||||
try{
|
||||
await this.bot.lookAt(block.position.offset(0.5, 0.5, 0.5), true);
|
||||
}catch(error){ /* best effort */ }
|
||||
|
||||
try{
|
||||
const window = await this.bot.openContainer(block);
|
||||
if(window) return window;
|
||||
}catch(error){
|
||||
if(!error.message.includes('Event windowOpen did not fire within timeout')) throw error;
|
||||
}
|
||||
if(this.bot.currentWindow?.title){
|
||||
break;
|
||||
}
|
||||
this.bot.removeAllListeners('windowOpen');
|
||||
// The open packet may have landed even though the event timed out
|
||||
if(this.bot.currentWindow?.title) return this.bot.currentWindow;
|
||||
|
||||
if(++count > 3) throw new Error(`Block wont open (${block.name} at ${block.position})`);
|
||||
await sleep(1500);
|
||||
|
||||
if(count++ == 3) throw 'Block wont open';
|
||||
}
|
||||
|
||||
return this.bot.currentWindow;
|
||||
}
|
||||
|
||||
async openCraftingTable(block){
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
const WebServer = require('./controller/web-server');
|
||||
const fs = require('fs');
|
||||
|
||||
WebServer.port = 3000;
|
||||
WebServer.host = '0.0.0.0';
|
||||
WebServer.app = { use: ()=>{}, get: ()=>{}, post: ()=>{}, put: ()=>{}, listen: ()=>{} };
|
||||
|
||||
// Register plugins in the same order as mc-bot.js
|
||||
const plugins = [
|
||||
require('./controller/chat-web'),
|
||||
require('./controller/activity-web'),
|
||||
require('./controller/log-web'),
|
||||
require('./controller/invite'),
|
||||
require('./controller/settings/web'),
|
||||
require('./controller/ai/web'),
|
||||
require('./controller/storage/web'),
|
||||
];
|
||||
|
||||
for (const p of plugins) {
|
||||
WebServer.queuePlugin(p);
|
||||
}
|
||||
|
||||
const html = WebServer.getIndexHTML();
|
||||
fs.writeFileSync('/tmp/index-v4.html', html);
|
||||
|
||||
// Extract and save JS for syntax check
|
||||
const scriptMatch = html.match(/<script>([\s\S]*?)<\/script>/);
|
||||
if (scriptMatch) {
|
||||
fs.writeFileSync('/tmp/rendered-js-v4.js', scriptMatch[1]);
|
||||
}
|
||||
|
||||
console.log('HTML lines:', html.split('\n').length);
|
||||
console.log('JS bytes:', scriptMatch ? scriptMatch[1].length : 0);
|
||||
Reference in New Issue
Block a user