'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: `
Select a category to view settings
`,
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 += '';
});
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 = 'Failed to load settings: '+escHtml(e.message)+'
';
}
}
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 = 'Failed to load bot settings: '+escHtml(e.message)+'
';
}
}
function renderBotSettings() {
if (!botData.length) {
document.getElementById('settingsMain').innerHTML = 'No bots configured
';
return;
}
let html = '';
botData.forEach(bot => {
const autoConnect = bot.autoConnect?.value;
const onDemand = bot.onDemand?.value;
const isReady = bot.isReady ? ' (online)' : '';
let summaryParts = [];
if (autoConnect) summaryParts.push('
auto-connect ');
else summaryParts.push('
no auto-connect ');
if (onDemand) summaryParts.push('
on-demand ');
if (bot.commands?.value && Array.isArray(bot.commands.value)) {
summaryParts.push('
cmds: '+escHtml(bot.commands.value.join(','))+' ');
}
html += '
';
});
html += '
';
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 = 'Select a category
';
return;
}
const items = settingsData.filter(s => s.category === category);
if (items.length === 0) {
document.getElementById('settingsMain').innerHTML = 'No settings in this category
';
return;
}
let html = '';
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 = '
'+
names.map(n => ''+escHtml(n)+' ').join('')+
' ';
} else if (s.key === 'ai.prompCustom') {
inputHtml = '
';
} else if (s.type === 'boolean') {
const checked = s.value === true ? ' checked' : '';
inputHtml = '
';
} else if (isSecret) {
inputHtml = '
';
} else if (s.type === 'json') {
const jsonStr = s.value ? JSON.stringify(s.value, null, 2) : '';
inputHtml = '
';
} else if (s.type === 'number') {
inputHtml = '
';
} else {
inputHtml = '
';
}
html += '
'+
''+
'
'+desc+'
'+
'
'+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 += 'Save ';
}
html += '
';
});
html += '
';
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 ''+
'
'+
''+
'
'+
'
Edit template for: none '+
'
'+
'
'+
'Save Template '+
' '+
'
'+
'
Template variables: \${name} \${interval} \${currentPlayers} \${toolsDocs} \${memoryContext} \${timeInfo} \${custom}
'+
'
'+
'
'+
'
';
}
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 =>
''+
''+escHtml(name)+' '+
'× '+
'
'
).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 };