const fs = require("fs"); const path = require("path"); const metadata = require("../tool_info.json"); const ORIGINS = new Set(["webui", "discord", "twitch", "youtube", "kick", "other"]); function defaults() { return Object.fromEntries( Object.entries(metadata.settings_schema).map(([key, field]) => [key, structuredClone(field.default)]) ); } function readSettings(dataDir) { const fallback = defaults(); try { const stored = JSON.parse(fs.readFileSync(settingsPath(dataDir), "utf8")); return normalizeSettings(migrateSettings(stored, fallback)); } catch { return normalizeSettings(fallback); } } function ensureSettings(dataDir) { if (!fs.existsSync(settingsPath(dataDir))) return writeSettings(dataDir, defaults()); return readSettings(dataDir); } function writeSettings(dataDir, value) { const normalized = normalizeSettings(migrateSettings(value, defaults())); fs.mkdirSync(dataDir, { recursive: true }); const file = settingsPath(dataDir); const temporary = `${file}.${process.pid}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 }); try { fs.chmodSync(temporary, 0o600); } catch {} replaceFile(temporary, file); return normalized; } function migrateSettings(value = {}, fallback = defaults()) { const migrated = { ...fallback, ...value }; if (!value.provider) migrated.provider = value.provider_endpoint ? "external_json" : "lumi_search_broker"; if (value.enabled != null && value.enable_search == null) migrated.enable_search = value.enabled === true; if (value.safe_search && value.safe_search_level == null) migrated.safe_search_level = value.safe_search; if (value.provider_adapter && value.external_provider_adapter == null) { migrated.external_provider_adapter = value.provider_adapter; } if (value.provider_endpoint && value.external_provider_endpoint == null) { migrated.external_provider_endpoint = value.provider_endpoint; } if (value.provider_api_key && value.external_provider_api_key == null) { migrated.external_provider_api_key = value.provider_api_key; } if (value.provider_api_key_header && value.external_provider_api_key_header == null) { migrated.external_provider_api_key_header = value.provider_api_key_header; } if (value.provider_api_key_prefix && value.external_provider_api_key_prefix == null) { migrated.external_provider_api_key_prefix = value.provider_api_key_prefix; } if (value.provider_query_parameter && value.external_provider_query_parameter == null) { migrated.external_provider_query_parameter = value.provider_query_parameter; } if (value.requests_per_minute && value.per_user_per_minute == null) { migrated.per_user_per_minute = value.requests_per_minute; } if (typeof value.show_source_links === "boolean") { for (const origin of ["webui", "discord", "twitch"]) { const key = `show_source_links_${origin}`; if (value[key] == null) migrated[key] = value.show_source_links; } } return migrated; } function normalizeSettings(value) { return { provider: value.provider === "external_json" ? "external_json" : "lumi_search_broker", enable_search: value.enable_search !== false, enable_fetch_url: value.enable_fetch_url !== false, enable_summarize_url: value.enable_summarize_url !== false, enable_headless_browser_fallback: value.enable_headless_browser_fallback === true, policy_mode: value.policy_mode === "whitelist" ? "whitelist" : "blacklist", url_rules: stringList(value.url_rules, 200), max_results: integer(value.max_results, 1, 10, 5), search_timeout_ms: integer(value.search_timeout_ms, 1000, 30000, 8000), fetch_timeout_ms: integer(value.fetch_timeout_ms, 1000, 30000, 10000), max_fetch_bytes: integer(value.max_fetch_bytes, 65536, 4194304, 1048576), max_extracted_chars: integer(value.max_extracted_chars, 1000, 50000, 12000), max_redirects: integer(value.max_redirects, 0, 8, 3), cache_ttl_seconds: integer(value.cache_ttl_seconds, 0, 86400, 900), safe_search_level: ["off", "moderate", "strict"].includes(value.safe_search_level) ? value.safe_search_level : "moderate", allowed_origins: stringList(value.allowed_origins, 6).filter((origin) => ORIGINS.has(origin)), show_source_links_webui: value.show_source_links_webui !== false, show_source_links_discord: value.show_source_links_discord !== false, show_source_links_twitch: value.show_source_links_twitch === true, webui_output_chars: integer(value.webui_output_chars, 300, 12000, 2500), discord_output_chars: integer(value.discord_output_chars, 200, 4000, 1200), twitch_output_chars: integer(value.twitch_output_chars, 120, 1000, 450), youtube_output_chars: integer(value.youtube_output_chars, 120, 3000, 1200), kick_output_chars: integer(value.kick_output_chars, 120, 1000, 450), other_output_chars: integer(value.other_output_chars, 120, 3000, 700), per_user_per_minute: integer(value.per_user_per_minute, 1, 60, 6), per_origin_per_minute: integer(value.per_origin_per_minute, 1, 300, 30), per_server_per_minute: integer(value.per_server_per_minute, 1, 300, 20), external_provider_adapter: value.external_provider_adapter === "generic_json" ? "generic_json" : "searxng_json", external_provider_endpoint: String(value.external_provider_endpoint || "").trim(), external_provider_api_key: String(value.external_provider_api_key || "").trim(), external_provider_api_key_header: value.external_provider_api_key_header === "Authorization" ? "Authorization" : "X-API-Key", external_provider_api_key_prefix: String(value.external_provider_api_key_prefix || "").trim().slice(0, 32), external_provider_query_parameter: /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test( String(value.external_provider_query_parameter || "") ) ? String(value.external_provider_query_parameter) : "q" }; } function capabilityEnabled(settings, capability) { return { search: settings.enable_search, fetch_url: settings.enable_fetch_url, summarize_url: settings.enable_summarize_url }[capability] === true; } function capabilityAvailable(settings, capability) { if (!capabilityEnabled(settings, capability)) return false; if (capability === "search" && settings.provider === "external_json" && !settings.external_provider_endpoint) return false; return true; } function sourceLinksEnabled(settings, origin) { if (origin === "twitch") return settings.show_source_links_twitch; if (origin === "discord") return settings.show_source_links_discord; return settings.show_source_links_webui; } function settingsPath(dataDir) { return path.join(dataDir, "settings.json"); } function statusPath(dataDir) { return path.join(dataDir, "status.json"); } function readStatus(dataDir) { try { return JSON.parse(fs.readFileSync(statusPath(dataDir), "utf8")); } catch { return {}; } } function writeStatus(dataDir, patch) { const next = { ...readStatus(dataDir), ...patch, updated_at: new Date().toISOString() }; fs.mkdirSync(dataDir, { recursive: true }); const file = statusPath(dataDir); const temporary = `${file}.${process.pid}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`); replaceFile(temporary, file); return next; } function replaceFile(source, destination) { try { fs.renameSync(source, destination); } catch (error) { if (!["EEXIST", "EPERM"].includes(error.code)) throw error; fs.rmSync(destination, { force: true }); fs.renameSync(source, destination); } } function integer(value, minimum, maximum, fallback) { const number = Number.parseInt(value, 10); return Number.isFinite(number) ? Math.max(minimum, Math.min(maximum, number)) : fallback; } function stringList(value, limit) { const rows = Array.isArray(value) ? value : String(value || "").split(/\r?\n|,/); return [...new Set(rows.map((entry) => String(entry).trim()).filter(Boolean))].slice(0, limit); } module.exports = { capabilityAvailable, capabilityEnabled, defaults, ensureSettings, migrateSettings, normalizeSettings, readSettings, readStatus, settingsPath, sourceLinksEnabled, statusPath, writeSettings, writeStatus };