diff --git a/data/runtime-config.json b/data/runtime-config.json new file mode 100644 index 0000000..b58ccac --- /dev/null +++ b/data/runtime-config.json @@ -0,0 +1,4 @@ +{ + "PRICE_TARGETS": "BTC:ABOVE:95000,GLD:BELOW:420", + "ETF_SYMBOLS": "GLD,SLV,BNO,SMH" +} \ No newline at end of file diff --git a/src/bot/commandHandlers.js b/src/bot/commandHandlers.js index cb7d175..be8f785 100644 --- a/src/bot/commandHandlers.js +++ b/src/bot/commandHandlers.js @@ -3,6 +3,12 @@ const { sendTelegramMessage } = require("../telegram"); const { fetchYahooQuotes } = require("../providers/yahoo"); const { formatPrice, formatPercent, escapeHtml } = require("../utils/formatters"); const { formatRecentEvents } = require("../utils/eventHistory"); +const { + getEditableSettings, + getEditableSettingKeys, + setEditableSetting, + resetEditableSetting, +} = require("../runtimeConfig"); async function handleCommand(text, state, handlers) { const parts = text.trim().split(/\s+/); @@ -113,6 +119,82 @@ async function handleCommand(text, state, handlers) { return; } + if (command === "/config") { + const settings = getEditableSettings(); + const top = settings + .slice(0, 14) + .map( + (item) => + `• ${escapeHtml(item.key)} = ${escapeHtml(item.value || "")}${ + item.overridden ? " (runtime)" : "" + }` + ) + .join("\n"); + await sendTelegramMessage( + `⚙️ Runtime Config\n\n` + + `${top}\n\n` + + `Usa /set KEY VALUE para cambiar y /unset KEY para restaurar desde .env.`, + { parseMode: "HTML" } + ); + return; + } + + if (command === "/set") { + const match = text.match(/^\/set(?:@\w+)?\s+(\S+)\s+([\s\S]+)$/i); + if (!match) { + await sendTelegramMessage( + `Uso: /set KEY VALUE\nEjemplo: /set ETF_WARNING_THRESHOLD_PERCENT 0.8`, + { parseMode: "HTML" } + ); + return; + } + + const envKey = String(match[1] || "").toUpperCase(); + const rawValue = String(match[2] || "").trim(); + + try { + const updated = await setEditableSetting(envKey, rawValue); + await sendTelegramMessage( + `✅ ${escapeHtml(updated.key)} actualizado a ${escapeHtml( + updated.value + )}\nCambio aplicado en runtime y persistido.`, + { parseMode: "HTML" } + ); + } catch (error) { + const validKeys = getEditableSettingKeys().slice(0, 18).join(", "); + await sendTelegramMessage( + `⚠️ No se pudo actualizar: ${escapeHtml(error.message)}\n\n` + + `Claves soportadas (resumen):\n${escapeHtml(validKeys)}`, + { parseMode: "HTML" } + ); + } + return; + } + + if (command === "/unset") { + if (!arg) { + await sendTelegramMessage( + `Uso: /unset KEY\nEjemplo: /unset ETF_WARNING_THRESHOLD_PERCENT`, + { parseMode: "HTML" } + ); + return; + } + + const envKey = String(arg || "").toUpperCase(); + try { + const updated = await resetEditableSetting(envKey); + await sendTelegramMessage( + `↩️ ${escapeHtml(updated.key)} restaurado a ${escapeHtml( + updated.value + )} desde .env`, + { parseMode: "HTML" } + ); + } catch (error) { + await sendTelegramMessage(`⚠️ ${escapeHtml(error.message)}`, { parseMode: "HTML" }); + } + return; + } + if (command === "/ayuda" || command === "/help") { await sendTelegramMessage( `🤖 Market Watcher — Commands\n\n` + @@ -120,6 +202,9 @@ async function handleCommand(text, state, handlers) { `/price SYMBOL — Current price for an asset\n` + `/status — Bot status and last check\n` + `/events — Recent alert and schedule history\n` + + `/config — Show current runtime config\n` + + `/set KEY VALUE — Update runtime config\n` + + `/unset KEY — Restore value from .env\n` + `/help — This help\n\n` + `Examples: /price BTC /price GLD /price AAPL`, { parseMode: "HTML" } diff --git a/src/config.js b/src/config.js index e28f0ff..7d94bc4 100644 --- a/src/config.js +++ b/src/config.js @@ -99,7 +99,7 @@ const config = { apiRetryMaxDelayMs: parseNumber(process.env.API_RETRY_MAX_DELAY_MS, 4000), requestTimeoutMs: 15000, webEnabled: (process.env.WEB_ENABLED || "true").toLowerCase() !== "false", - webPort: parseNumber(process.env.WEB_PORT, 1903), + webPort: parseNumber(process.env.WEB_PORT, 1904), maxCsvSizeMb: parseNumber(process.env.MAX_CSV_SIZE_MB, 20), }; diff --git a/src/index.js b/src/index.js index 73b12db..f487153 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ const { runCycle, buildDailyReport, fetchAllQuotes } = require("./marketMonitor" const { sendTelegramMessage } = require("./telegram"); const { pollAndHandle } = require("./telegramCommands"); const { startWebServer } = require("./webServer"); +const { loadRuntimeConfig } = require("./runtimeConfig"); const { logger } = require("./utils/logger"); let cycleRunning = false; @@ -47,6 +48,7 @@ async function safeRunCycle(state, options) { } async function main() { + await loadRuntimeConfig(); validateConfig(); const state = await loadState(); @@ -57,19 +59,23 @@ async function main() { logger.info("Starting market monitor..."); await safeRunCycle(state, { isStartup: true }); - setInterval(async () => { + const runCycleLoop = async () => { await safeRunCycle(state, { isStartup: false }); - }, config.checkIntervalMs); + setTimeout(runCycleLoop, config.checkIntervalMs); + }; + setTimeout(runCycleLoop, config.checkIntervalMs); // Telegram command polling (/report, /price, /status, /help) const commandHandlers = { buildDailyReport, fetchAllQuotes }; - setInterval(async () => { + const pollLoop = async () => { try { await pollAndHandle(state, commandHandlers); } catch (error) { logger.warn(`Telegram polling error: ${error.message}`); } - }, config.commandPollIntervalMs); + setTimeout(pollLoop, config.commandPollIntervalMs); + }; + setTimeout(pollLoop, config.commandPollIntervalMs); logger.info( `Background monitor active. Interval: ${Math.round(config.checkIntervalMs / 60000)} minutes.` diff --git a/src/runtimeConfig.js b/src/runtimeConfig.js new file mode 100644 index 0000000..36d032f --- /dev/null +++ b/src/runtimeConfig.js @@ -0,0 +1,463 @@ +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { config, validateConfig } = require("./config"); + +const RUNTIME_CONFIG_FILE = path.join(process.cwd(), "data", "runtime-config.json"); + +const baseConfigSnapshot = JSON.parse(JSON.stringify(config)); +let overrides = {}; + +const editableSettings = { + CRYPTO_IDS: { + path: ["cryptoIds"], + type: "csv", + description: "Crypto IDs from CoinGecko (comma separated)", + }, + ETF_SYMBOLS: { + path: ["etfSymbols"], + type: "csv", + description: "ETF tickers (comma separated)", + }, + INDEX_FUND_SYMBOLS: { + path: ["indexFundSymbols"], + type: "csv", + description: "Index fund tickers (comma separated)", + }, + STOCK_SYMBOLS: { + path: ["stockSymbols"], + type: "csv", + description: "Stock tickers (comma separated)", + }, + PRICE_TARGETS: { + path: ["priceTargets"], + type: "priceTargets", + description: "Price targets: SYMBOL:ABOVE|BELOW:VALUE", + }, + CHECK_INTERVAL_MINUTES: { + path: ["checkIntervalMs"], + type: "minutesToMs", + min: 1, + description: "Main market check interval in minutes", + }, + CRYPTO_WARNING_THRESHOLD_PERCENT: { + path: ["thresholds", "crypto", "warning"], + type: "number", + min: 0.01, + description: "Crypto warning threshold (%)", + }, + CRYPTO_STRONG_THRESHOLD_PERCENT: { + path: ["thresholds", "crypto", "strong"], + type: "number", + min: 0.01, + description: "Crypto strong threshold (%)", + }, + CRYPTO_CRITICAL_THRESHOLD_PERCENT: { + path: ["thresholds", "crypto", "critical"], + type: "number", + min: 0.01, + description: "Crypto critical threshold (%)", + }, + ETF_WARNING_THRESHOLD_PERCENT: { + path: ["thresholds", "etf", "warning"], + type: "number", + min: 0.01, + description: "ETF warning threshold (%)", + }, + ETF_STRONG_THRESHOLD_PERCENT: { + path: ["thresholds", "etf", "strong"], + type: "number", + min: 0.01, + description: "ETF strong threshold (%)", + }, + ETF_CRITICAL_THRESHOLD_PERCENT: { + path: ["thresholds", "etf", "critical"], + type: "number", + min: 0.01, + description: "ETF critical threshold (%)", + }, + INDEX_WARNING_THRESHOLD_PERCENT: { + path: ["thresholds", "index", "warning"], + type: "number", + min: 0.01, + description: "Index warning threshold (%)", + }, + INDEX_STRONG_THRESHOLD_PERCENT: { + path: ["thresholds", "index", "strong"], + type: "number", + min: 0.01, + description: "Index strong threshold (%)", + }, + INDEX_CRITICAL_THRESHOLD_PERCENT: { + path: ["thresholds", "index", "critical"], + type: "number", + min: 0.01, + description: "Index critical threshold (%)", + }, + STOCK_WARNING_THRESHOLD_PERCENT: { + path: ["thresholds", "stock", "warning"], + type: "number", + min: 0.01, + description: "Stock warning threshold (%)", + }, + STOCK_STRONG_THRESHOLD_PERCENT: { + path: ["thresholds", "stock", "strong"], + type: "number", + min: 0.01, + description: "Stock strong threshold (%)", + }, + STOCK_CRITICAL_THRESHOLD_PERCENT: { + path: ["thresholds", "stock", "critical"], + type: "number", + min: 0.01, + description: "Stock critical threshold (%)", + }, + ACCUMULATED_CHANGE_WINDOW_MINUTES: { + path: ["accumulatedChangeWindowMs"], + type: "minutesToMs", + min: 1, + description: "Window for accumulated change (minutes)", + }, + PRICE_HISTORY_RETENTION_HOURS: { + path: ["priceHistoryRetentionMs"], + type: "hoursToMs", + min: 1, + description: "Price history retention (hours)", + }, + ALERT_COOLDOWN_MINUTES: { + path: ["alertCooldownMs"], + type: "minutesToMs", + min: 0, + description: "Cooldown between alerts for same asset", + }, + MARKET_OPEN_HOUR: { + path: ["marketOpenHour"], + type: "int", + min: 0, + max: 23, + description: "Market open hour (local)", + }, + MARKET_OPEN_MINUTE: { + path: ["marketOpenMinute"], + type: "int", + min: 0, + max: 59, + description: "Market open minute (local)", + }, + MARKET_CLOSE_HOUR: { + path: ["marketCloseHour"], + type: "int", + min: 0, + max: 23, + description: "Market close hour (local)", + }, + MARKET_CLOSE_MINUTE: { + path: ["marketCloseMinute"], + type: "int", + min: 0, + max: 59, + description: "Market close minute (local)", + }, + COMMAND_POLL_INTERVAL_SECONDS: { + path: ["commandPollIntervalMs"], + type: "secondsToMs", + min: 1, + description: "Telegram command polling interval", + }, + TELEGRAM_LONG_POLLING_TIMEOUT_SECONDS: { + path: ["telegramLongPollingTimeoutSeconds"], + type: "number", + min: 0, + max: 50, + description: "Telegram long polling timeout", + }, + TELEGRAM_LONG_POLLING_GRACE_SECONDS: { + path: ["telegramLongPollingGraceSeconds"], + type: "number", + min: 0, + description: "Telegram long polling grace time", + }, + TELEGRAM_UPDATES_LIMIT: { + path: ["telegramUpdatesLimit"], + type: "int", + min: 1, + description: "Telegram updates limit per poll", + }, + MAX_QUOTE_AGE_MINUTES: { + path: ["maxQuoteAgeMinutes"], + type: "number", + min: 1, + description: "Max quote age for freshness checks", + }, + RECENT_EVENTS_LIMIT: { + path: ["recentEventsLimit"], + type: "int", + min: 0, + description: "Max events stored in state", + }, + DAILY_REPORT_RECENT_EVENTS_LIMIT: { + path: ["dailyReportRecentEventsLimit"], + type: "int", + min: 0, + description: "Events shown in daily report", + }, + API_RETRY_ATTEMPTS: { + path: ["apiRetryAttempts"], + type: "int", + min: 1, + description: "API retry attempts", + }, + API_RETRY_BASE_DELAY_MS: { + path: ["apiRetryBaseDelayMs"], + type: "int", + min: 0, + description: "API retry base delay (ms)", + }, + API_RETRY_BACKOFF_MULTIPLIER: { + path: ["apiRetryBackoffMultiplier"], + type: "number", + min: 1, + description: "API retry backoff multiplier", + }, + API_RETRY_MAX_DELAY_MS: { + path: ["apiRetryMaxDelayMs"], + type: "int", + min: 0, + description: "API retry max delay (ms)", + }, + WEB_ENABLED: { + path: ["webEnabled"], + type: "bool", + description: "Enable web dashboard", + }, + MAX_CSV_SIZE_MB: { + path: ["maxCsvSizeMb"], + type: "number", + min: 1, + description: "CSV rotation size threshold (MB)", + }, +}; + +function deepClone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function getByPath(obj, pathParts) { + return pathParts.reduce((acc, key) => (acc == null ? undefined : acc[key]), obj); +} + +function setByPath(obj, pathParts, value) { + let current = obj; + for (let i = 0; i < pathParts.length - 1; i += 1) { + current = current[pathParts[i]]; + } + current[pathParts[pathParts.length - 1]] = value; +} + +function parseCsv(value) { + return String(value || "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parsePriceTargets(rawValue) { + const value = String(rawValue || "").trim(); + if (!value) return []; + + return value + .split(",") + .map((segment) => { + const parts = segment.trim().split(":"); + if (parts.length !== 3) { + throw new Error(`Invalid PRICE_TARGETS entry: ${segment}`); + } + const [symbol, direction, thresholdRaw] = parts; + const threshold = Number(thresholdRaw); + const dir = direction.trim().toUpperCase(); + if (!["ABOVE", "BELOW"].includes(dir) || !Number.isFinite(threshold)) { + throw new Error(`Invalid PRICE_TARGETS entry: ${segment}`); + } + return { + symbol: symbol.trim().toUpperCase(), + direction: dir, + threshold, + }; + }) + .filter(Boolean); +} + +function parseNumberWithLimits(rawValue, schema, parseIntMode = false) { + const num = parseIntMode ? Number.parseInt(String(rawValue), 10) : Number(rawValue); + if (!Number.isFinite(num)) { + throw new Error(`Invalid numeric value for ${schema.envKey}`); + } + if (Number.isFinite(schema.min) && num < schema.min) { + throw new Error(`${schema.envKey} must be >= ${schema.min}`); + } + if (Number.isFinite(schema.max) && num > schema.max) { + throw new Error(`${schema.envKey} must be <= ${schema.max}`); + } + return num; +} + +function parseBoolean(rawValue) { + const value = String(rawValue || "") + .trim() + .toLowerCase(); + if (["1", "true", "yes", "on"].includes(value)) return true; + if (["0", "false", "no", "off"].includes(value)) return false; + throw new Error("Boolean value expected (true/false)"); +} + +function parseSettingValue(schema, rawValue) { + switch (schema.type) { + case "csv": + return parseCsv(rawValue); + case "priceTargets": + return parsePriceTargets(rawValue); + case "number": + return parseNumberWithLimits(rawValue, schema, false); + case "int": + return parseNumberWithLimits(rawValue, schema, true); + case "minutesToMs": + return parseNumberWithLimits(rawValue, schema, false) * 60 * 1000; + case "hoursToMs": + return parseNumberWithLimits(rawValue, schema, false) * 60 * 60 * 1000; + case "secondsToMs": + return parseNumberWithLimits(rawValue, schema, false) * 1000; + case "bool": + return parseBoolean(rawValue); + default: + throw new Error(`Unsupported setting type for ${schema.envKey}`); + } +} + +function serializeSettingValue(schema, value) { + switch (schema.type) { + case "csv": + return Array.isArray(value) ? value.join(",") : ""; + case "priceTargets": + return Array.isArray(value) + ? value + .map((entry) => `${entry.symbol}:${entry.direction}:${entry.threshold}`) + .join(",") + : ""; + case "minutesToMs": + return String(Number(value) / (60 * 1000)); + case "hoursToMs": + return String(Number(value) / (60 * 60 * 1000)); + case "secondsToMs": + return String(Number(value) / 1000); + case "bool": + return value ? "true" : "false"; + default: + return String(value); + } +} + +async function saveOverrides() { + await fs.mkdir(path.dirname(RUNTIME_CONFIG_FILE), { recursive: true }); + await fs.writeFile(RUNTIME_CONFIG_FILE, JSON.stringify(overrides, null, 2), "utf8"); +} + +async function loadRuntimeConfig() { + try { + const content = await fs.readFile(RUNTIME_CONFIG_FILE, "utf8"); + const parsed = JSON.parse(content); + overrides = parsed && typeof parsed === "object" ? parsed : {}; + } catch (error) { + if (error.code !== "ENOENT") { + throw error; + } + overrides = {}; + } + + for (const [envKey, rawValue] of Object.entries(overrides)) { + const schema = editableSettings[envKey]; + if (!schema) continue; + schema.envKey = envKey; + try { + const parsed = parseSettingValue(schema, rawValue); + setByPath(config, schema.path, parsed); + } catch { + // Ignore invalid persisted value and keep default from .env + } + } + + validateConfig(); +} + +function getEditableSettings() { + return Object.entries(editableSettings).map(([envKey, schema]) => { + const currentValue = getByPath(config, schema.path); + return { + key: envKey, + description: schema.description, + value: serializeSettingValue(schema, currentValue), + overridden: Object.prototype.hasOwnProperty.call(overrides, envKey), + }; + }); +} + +function getEditableSettingKeys() { + return Object.keys(editableSettings); +} + +async function setEditableSetting(envKey, rawValue) { + const schema = editableSettings[envKey]; + if (!schema) { + throw new Error(`Unsupported setting: ${envKey}`); + } + schema.envKey = envKey; + + const previousValue = deepClone(getByPath(config, schema.path)); + const parsed = parseSettingValue(schema, rawValue); + + try { + setByPath(config, schema.path, parsed); + validateConfig(); + overrides[envKey] = serializeSettingValue(schema, parsed); + await saveOverrides(); + } catch (error) { + setByPath(config, schema.path, previousValue); + throw error; + } + + return { + key: envKey, + value: serializeSettingValue(schema, getByPath(config, schema.path)), + }; +} + +async function resetEditableSetting(envKey) { + const schema = editableSettings[envKey]; + if (!schema) { + throw new Error(`Unsupported setting: ${envKey}`); + } + + const previousValue = deepClone(getByPath(config, schema.path)); + const baseValue = deepClone(getByPath(baseConfigSnapshot, schema.path)); + + try { + setByPath(config, schema.path, baseValue); + validateConfig(); + delete overrides[envKey]; + await saveOverrides(); + } catch (error) { + setByPath(config, schema.path, previousValue); + throw error; + } + + return { + key: envKey, + value: serializeSettingValue(schema, getByPath(config, schema.path)), + }; +} + +module.exports = { + loadRuntimeConfig, + getEditableSettings, + getEditableSettingKeys, + setEditableSetting, + resetEditableSetting, +}; diff --git a/src/webServer.js b/src/webServer.js index cd37b0e..897173f 100644 --- a/src/webServer.js +++ b/src/webServer.js @@ -1,6 +1,11 @@ const http = require("node:http"); const { config } = require("./config"); const { logger } = require("./utils/logger"); +const { + getEditableSettings, + setEditableSetting, + resetEditableSetting, +} = require("./runtimeConfig"); function safePercent(value) { if (!Number.isFinite(value)) return "N/D"; @@ -269,12 +274,209 @@ function htmlPage() { font-size: 0.84rem; text-align: right; } + .config-controls { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 10px; + align-items: stretch; + } + .config-controls select, + .config-controls input { + border: 1px solid var(--line); + border-radius: 10px; + padding: 8px 10px; + font-size: 0.9rem; + background: #fff; + color: var(--text); + } + .btn { + border: 1px solid transparent; + border-radius: 10px; + padding: 8px 11px; + font-size: 0.86rem; + cursor: pointer; + font-weight: 650; + } + .btn-primary { + background: #e7f2fa; + border-color: #bbd8ed; + color: #0f4d78; + } + .btn-secondary { + background: #f3f5f8; + border-color: #d7e0e8; + color: #3c4f5f; + } + .config-status { + color: var(--muted); + font-size: 0.84rem; + margin-bottom: 8px; + min-height: 1.1rem; + } + .config-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; + font-size: 0.85rem; + } + .config-item { + border: 1px solid var(--line); + border-radius: 10px; + padding: 8px; + background: #fbfeff; + } + .config-item .key { + font-weight: 700; + font-size: 0.78rem; + color: #27455d; + margin-bottom: 4px; + } + .config-item .value { + font-family: Consolas, Menlo, monospace; + font-size: 0.78rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .runtime-flag { + color: #956200; + font-size: 0.74rem; + margin-top: 3px; + } + .config-help { + font-size: 0.82rem; + color: var(--muted); + margin-bottom: 8px; + } + .quick-picks { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; + } + .quick-btn { + border: 1px solid #d7e0e8; + background: #f7fbfd; + color: #234258; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + padding: 4px 9px; + cursor: pointer; + } + .config-content { + display: grid; + grid-template-columns: 1fr minmax(320px, 420px); + gap: 14px; + margin-top: 10px; + min-width: 0; + width: 100%; + } + .config-left { + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px; + background: #fbfeff; + min-width: 0; + overflow: auto; + max-width: 100%; + } + .config-right { + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px; + background: #f9fcfe; + min-height: 420px; + display: flex; + flex-direction: column; + min-width: 0; + max-width: 420px; + width: 100%; + overflow: auto; + } + @media (max-width: 1100px) { + .config-content { + display: flex; + flex-direction: column; + gap: 14px; + } + .config-left, .config-right { + max-width: 100%; + min-width: 0; + } + } + .config-list-title { + font-size: 0.88rem; + font-weight: 700; + color: #27455d; + margin-bottom: 8px; + } + .config-list-wrap { + overflow: auto; + max-height: 56vh; + padding-right: 4px; + min-width: 0; + } + .config-modal { + display: none; + position: fixed; + inset: 0; + z-index: 50; + background: rgba(16, 30, 45, 0.45); + padding: 18px; + align-items: center; + justify-content: center; + } + .config-modal.open { + display: flex; + } + .config-modal-body { + width: min(1240px, 98vw); + max-height: 92vh; + overflow: auto; + } + .config-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; + } + .config-close { + border: 1px solid #d7e0e8; + background: #fff; + color: #3c4f5f; + border-radius: 8px; + padding: 4px 8px; + font-size: 0.8rem; + cursor: pointer; + font-weight: 600; + } @media (max-width: 720px) { .wrap { padding: 14px; } .top { align-items: flex-start; flex-direction: column; } .chip-group { width: 100%; justify-content: flex-start; } .card { padding: 12px; } .footer { text-align: left; } + .config-controls { + grid-template-columns: 1fr; + } + .config-content { + grid-template-columns: 1fr; + } + .config-right { + min-height: 240px; + } + .config-list-wrap { + max-height: 36vh; + } + .config-modal { + padding: 10px; + } + .config-modal-body { + max-height: 94vh; + } } @@ -288,6 +490,7 @@ function htmlPage() {
Live Panel Port 1903 +
@@ -308,6 +511,43 @@ function htmlPage() { +
+
+
+

Configuracion Rapida

+ +
+
+
+
1) Elige que quieres cambiar, 2) escribe el valor, 3) pulsa Guardar.
+
+ + + + + +
+
+ + +
+ + +
+
+
+
+
+
+
Variables actuales
+
+
+
+
+
+
+
+ `; } +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + if (raw.length > 1024 * 1024) { + reject(new Error("Request body too large")); + } + }); + req.on("end", () => { + if (!raw.trim()) { + resolve({}); + return; + } + try { + resolve(JSON.parse(raw)); + } catch { + reject(new Error("Invalid JSON body")); + } + }); + req.on("error", reject); + }); +} + function startWebServer(state) { const server = http.createServer((req, res) => { const url = req.url || "/"; @@ -543,6 +968,37 @@ function startWebServer(state) { return; } + if (url === "/api/config" && req.method === "GET") { + res.writeHead(200, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(JSON.stringify({ settings: getEditableSettings() })); + return; + } + + if (url === "/api/config" && req.method === "POST") { + readJsonBody(req) + .then(async (payload) => { + const key = String(payload.key || "").toUpperCase(); + if (!key) { + throw new Error("Missing key"); + } + + const result = payload.unset + ? await resetEditableSetting(key) + : await setEditableSetting(key, String(payload.value || "")); + + res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(result)); + }) + .catch((error) => { + res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: error.message })); + }); + return; + } + if (url === "/") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(htmlPage());