forked from wmantly/mc-bot-town
412 lines
19 KiB
JavaScript
412 lines
19 KiB
JavaScript
'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,
|
|
};
|