'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, };