const fs = require("fs"); const path = require("path"); const { spawnSync } = require("child_process"); const { PLUGIN_ROOT, resolveData } = require("./paths"); const INDEX_FILE = "index.json"; const REPOSITORY_URL = "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi"; const SOURCE_EXTENSIONS = new Set([".js", ".ejs", ".json", ".md"]); const ROUTE_ALIASES = Object.freeze({ "/admin/twitch-wizard": "twitch configuration config settings wizard location where integration", "/admin/discord-wizard": "discord configuration config settings wizard location where integration", "/admin/youtube-wizard": "youtube configuration config settings wizard location where integration", "/admin/plugins": "plugin plugins settings configuration management", "/admin/commands": "command commands custom command settings management", "/plugins/echonomy-framework": "economy echonomy currency points banking settings", "/plugins/moderation": "moderation mod tools sanctions bans timeouts settings" }); function repoRoot() { return path.resolve(PLUGIN_ROOT, "..", ".."); } function refreshIndex(root = repoRoot()) { const routes = []; const documents = []; const plugins = []; const commands = []; walk(root, (file) => { const relative = normalize(path.relative(root, file)); if (relative.startsWith("plugins/lumi_ai/data/") || relative.startsWith("updates/") || relative.includes("/node_modules/")) return; const extension = path.extname(file).toLowerCase(); if (!SOURCE_EXTENSIONS.has(extension)) return; const content = safeRead(file); if (!content) return; if (extension === ".js") collectRoutes(content, relative, routes); if (relative.endsWith("/plugin.json")) collectPlugin(content, relative, plugins); if (relative.endsWith("/cmds.json")) collectCommands(content, relative, commands); if (extension === ".md" || extension === ".ejs") { documents.push({ path: relative, title: firstTitle(content) || path.basename(file), summary: summarize(content) }); } }); const index = { schema_version: 1, source: "local", repository: REPOSITORY_URL, root: normalize(root), indexed_at: new Date().toISOString(), commit: readGitCommit(root), routes: uniqueBy(routes, (entry) => `${entry.method}:${entry.path}`), plugins: uniqueBy(plugins, (entry) => entry.id), commands: uniqueBy(commands, (entry) => `${entry.plugin}:${entry.name}`), documents }; const target = resolveData("repo_index", INDEX_FILE); fs.writeFileSync(target, `${JSON.stringify(index, null, 2)}\n`); return index; } function refreshPublicIndex() { const staging = resolveData("repo_index", `public-${Date.now()}`); try { const result = spawnSync("git", ["clone", "--depth", "1", REPOSITORY_URL, staging], { encoding: "utf8", timeout: 120000, windowsHide: true, shell: false }); if (result.status !== 0) { throw new Error((result.stderr || result.stdout || "Git clone failed.").trim().slice(0, 1000)); } const index = refreshIndex(staging); index.source = "public"; index.root = REPOSITORY_URL; fs.writeFileSync(resolveData("repo_index", INDEX_FILE), `${JSON.stringify(index, null, 2)}\n`); return index; } finally { const resolved = path.resolve(staging); const parent = path.resolve(resolveData("repo_index")); if (resolved.startsWith(`${parent}${path.sep}`)) fs.rmSync(resolved, { recursive: true, force: true }); } } function loadIndex() { const target = resolveData("repo_index", INDEX_FILE); if (!fs.existsSync(target)) return null; try { return JSON.parse(fs.readFileSync(target, "utf8")); } catch { return null; } } function indexStatus(index = loadIndex()) { return index ? { present: true, indexed_at: index.indexed_at, commit: index.commit, route_count: index.routes?.length || 0, plugin_count: index.plugins?.length || 0, command_count: index.commands?.length || 0, stale: Date.now() - new Date(index.indexed_at).getTime() > 7 * 24 * 60 * 60 * 1000 } : { present: false, indexed_at: null, commit: null, route_count: 0, plugin_count: 0, command_count: 0, stale: true }; } function lookupSupport(message, index = loadIndex()) { if (!message) return null; const query = String(message).toLowerCase(); const terms = query.split(/[^a-z0-9]+/).filter((term) => term.length > 2); if (isCommunityContactQuery(query)) { return { type: "contact", text: "Use the Discord community server to contact OokamiKunTV, Jenni, administrators, moderators, or community staff. I could not verify a Lumi WebUI, notification, direct-message, or Throne contact workflow." }; } const ambiguous = /\b(this|that|it|option|setting|settings)\b/i.test(query) && !/\b(lumi|bot|webui|plugin|twitch|discord|youtube|moderation|command)\b/i.test(query); if (ambiguous) { return { type: "clarification", text: "Do you mean a setting in Lumi Bot, or a setting on the external platform?" }; } if (!index) { return isLumiFeatureQuery(query) ? { type: "unknown", text: "I could not verify that Lumi feature or WebUI location because the current repository and help index is unavailable. Which exact feature, plugin, or page do you mean?" } : null; } const candidates = augmentedRoutes(index) .filter((route) => route.method === "GET") .map((route) => ({ ...route, score: scoreRoute(route, terms) })) .filter((route) => route.score > 0) .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path)); if (!candidates.length || candidates[0].score < 2) { if (!isLumiFeatureQuery(query)) return null; return { type: "unknown", text: "I could not verify that Lumi feature or WebUI location in the current repository and help index. It may not exist or may use a different name. Which exact feature, plugin, or page do you mean?" }; } const best = candidates[0]; return { type: "route", text: `${routeLabel(best.path)} is available in Lumi's WebUI.`, links: [{ href: best.path, label: routeLabel(best.path) }], source: { path: best.source, confidence: best.score >= 5 ? "high" : "medium" } }; } function isCommunityContactQuery(query) { const contact = /\b(contact|reach|message|dm|notify|notification|talk\s+to|send\s+(?:a\s+)?message)\b/i.test(query); const person = /\b(ookamikuntv|ookami|jenni|admin|admins|administrator|administrators|moderator|moderators|mods?|community\s+staff|staff)\b/i.test(query); return contact && person; } function isLumiFeatureQuery(query) { const lumiSubject = /\b(lumi|webui|plugin|plugins|feature|features|route|routes|page|pages|menu|setting|settings|command|commands|throne|notification|notifications|messaging|messages?|direct messages?|dm|inbox)\b/i.test(query); const featureIntent = /\b(where|find|open|navigate|location|located|does|is there|can lumi|how (?:do|can)|support|supports|have|has|enable|configure|setup|set up)\b/i.test(query); return lumiSubject && featureIntent; } function verifyInternalLinks(links, index = loadIndex()) { const known = new Set((index?.routes || []).filter((route) => route.method === "GET").map((route) => route.path)); return (Array.isArray(links) ? links : []).filter((link) => link && typeof link.href === "string" && link.href.startsWith("/") && known.has(link.href) ); } function supportContext(message, index = loadIndex(), limit = 8, role = "user", allowModeratorCodeHelp = false) { if (typeof limit === "string") { allowModeratorCodeHelp = role === true; role = limit; limit = 8; } if (!index || !message) return []; const terms = String(message).toLowerCase().split(/[^a-z0-9]+/).filter((term) => term.length > 2); const technical = role === "admin"; const snippetsAllowed = role === "mod" && allowModeratorCodeHelp; const rows = [ ...augmentedRoutes(index).map((entry) => ({ text: technical ? `Lumi route ${entry.path}; source ${entry.source || "curated route index"}` : `${routeLabel(entry.path)}: ${entry.path}`, value: `${entry.path} ${entry.source || ""} ${ROUTE_ALIASES[entry.path] || ""}` })), ...(index.plugins || []).map((entry) => ({ text: technical ? `Plugin ${entry.name}: ${entry.description} (${entry.path})` : `Plugin ${entry.name}: ${entry.description}`, value: `${entry.name} ${entry.description} ${entry.id}` })), ...(index.commands || []).map((entry) => ({ text: technical || snippetsAllowed ? `Command ${entry.name}: ${entry.description}${technical ? ` (${entry.plugin})` : ""}` : `Command ${entry.name}: ${entry.description}`, value: `${entry.name} ${entry.description} ${entry.plugin}` })), ...(technical ? index.documents || [] : []).map((entry) => ({ text: `${entry.title}: ${entry.summary} (${entry.path})`, value: `${entry.title} ${entry.summary} ${entry.path}` })) ]; return rows.map((row) => ({ text: row.text, score: terms.reduce((score, term) => score + (row.value.toLowerCase().includes(term) ? 1 : 0), 0) })).filter((row) => row.score > 0) .sort((a, b) => b.score - a.score) .slice(0, limit) .map((row) => row.text); } function collectRoutes(content, source, output) { const pattern = /\b(?:app|router)\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g; let match; while ((match = pattern.exec(content))) { if (!match[2].includes(":") && !match[2].includes("${")) { const pluginMatch = source.match(/^plugins\/([^/]+)\/index\.js$/); const routePath = pluginMatch && match[2].startsWith("/") ? `/plugins/${pluginMatch[1]}${match[2] === "/" ? "" : match[2]}` : match[2]; output.push({ method: match[1].toUpperCase(), path: routePath, source }); } } } function collectPlugin(content, source, output) { try { const manifest = JSON.parse(content); if (manifest.id) output.push({ id: manifest.id, name: manifest.name || manifest.id, description: manifest.description || "", path: source }); } catch {} } function collectCommands(content, source, output) { try { const parsed = JSON.parse(content); const rows = Array.isArray(parsed) ? parsed : parsed.commands || []; for (const command of rows) { const name = command.name || command.command || command.trigger; if (name) output.push({ plugin: source.split("/")[1] || "core", name, description: command.description || "" }); } } catch {} } function walk(dir, visit) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if ([".git", "node_modules", "updates"].includes(entry.name)) continue; const target = path.join(dir, entry.name); if (entry.isDirectory()) walk(target, visit); else if (entry.isFile()) visit(target); } } function readGitCommit(root) { try { const git = path.join(root, ".git"); const head = fs.readFileSync(path.join(git, "HEAD"), "utf8").trim(); if (!head.startsWith("ref: ")) return head; return fs.readFileSync(path.join(git, head.slice(5)), "utf8").trim(); } catch { return null; } } function scoreRoute(route, terms) { const haystack = `${route.path} ${route.source || ""} ${ROUTE_ALIASES[route.path] || ""}`.toLowerCase(); const generic = new Set(["lumi", "webui", "setting", "settings", "admin", "page"]); return terms.reduce((score, term) => { if (!haystack.includes(term)) return score; if (route.path.includes(term) || ROUTE_ALIASES[route.path]?.includes(term)) return score + (generic.has(term) ? 1 : 4); return score + 1; }, 0); } function augmentedRoutes(index) { return [...(index.routes || [])].filter((route) => route.method === "GET"); } function verifiedRoutePaths(index = loadIndex()) { return [...new Set(augmentedRoutes(index).map((route) => route.path))]; } function routeLabel(route) { const known = { "/admin/twitch-wizard": "Twitch Configuration Wizard", "/admin/discord-wizard": "Discord Configuration Wizard", "/admin/youtube-wizard": "YouTube Configuration Wizard", "/admin/plugins": "Plugin Management", "/admin/commands": "Command Management", "/admin/settings": "Bot Settings" }; const segment = route.split("/").filter(Boolean).pop() || "Lumi Home"; return known[route] || segment.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); } function firstTitle(content) { return content.match(/^\s*#\s+(.+)$/m)?.[1]?.trim() || content.match(/