const path = require("path"); const fs = require("fs"); const { spawnSync } = require("child_process"); const { db } = require("./db"); const placeholders = require("./placeholders"); const { createLogger } = require("./logger"); const pluginLifecycleLog = createLogger("core:plugins", { category: "lifecycle" }); const pluginsDir = path.join(__dirname, "..", "..", "plugins"); const cleanupHandlers = []; const legacyEconomyStem = ["echo", "nomy"].join(""); const economyPluginAliases = { "economy-framework": [`${legacyEconomyStem}-framework`], "economy-games": [`${legacyEconomyStem}-games`] }; const economyCommandAliases = [ { from: `${legacyEconomyStem}:root`, to: "economy:root" }, { from: `${legacyEconomyStem}-games:hotpotato`, to: "economy-games:hotpotato" }, { from: `${legacyEconomyStem}-games:coinflip`, to: "economy-games:coinflip" }, { from: `${legacyEconomyStem}-games:mystery`, to: "economy-games:mystery" } ]; const pluginCanonicalAliases = Object.fromEntries( Object.entries(economyPluginAliases).flatMap(([canonicalId, legacyIds]) => legacyIds.map((legacyId) => [legacyId, canonicalId]) ) ); function canonicalPluginId(id) { const raw = String(id || "").trim(); return pluginCanonicalAliases[raw] || raw; } function pluginLegacyIds(id) { return economyPluginAliases[canonicalPluginId(id)] || []; } function readJson(filePath) { const raw = fs.readFileSync(filePath, "utf8"); return JSON.parse(raw); } function scanPluginDirectories() { if (!fs.existsSync(pluginsDir)) { return []; } const entries = fs.readdirSync(pluginsDir, { withFileTypes: true }); const plugins = new Map(); for (const entry of entries) { if (!entry.isDirectory()) { continue; } if (/^lumi_ai_.+/i.test(entry.name) && fs.existsSync(path.join(pluginsDir, entry.name, "tool_info.json"))) { continue; } const manifestPath = path.join(pluginsDir, entry.name, "plugin.json"); if (!fs.existsSync(manifestPath)) { continue; } try { const manifest = readJson(manifestPath); const id = canonicalPluginId(manifest.id); const plugin = { id, manifestId: manifest.id, name: manifest.name || manifest.id, version: manifest.version || "0.0.0", description: manifest.description || "", main: manifest.main || "index.js", dir: path.join(pluginsDir, entry.name), legacyIds: pluginLegacyIds(manifest.id) }; const existing = plugins.get(id); if (!existing || isCanonicalPluginDirectory(plugin, entry.name, existing)) { plugins.set(id, plugin); } } catch { continue; } } return Array.from(plugins.values()); } function isCanonicalPluginDirectory(plugin, directoryName, existing) { if (directoryName === plugin.id || plugin.manifestId === plugin.id) { return true; } return existing.manifestId !== existing.id && existing.dir !== path.join(pluginsDir, existing.id); } function syncPluginRegistry() { const now = Date.now(); const plugins = scanPluginDirectories(); const insert = db.prepare( "INSERT INTO plugins (id, name, version, enabled, source, path, installed_at, updated_at) " + "VALUES (?, ?, ?, 1, ?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET name = excluded.name, version = excluded.version, path = excluded.path, updated_at = excluded.updated_at" ); for (const plugin of plugins) { migratePluginAliases(plugin, now); insert.run( plugin.id, plugin.name, plugin.version, "local", plugin.dir, now, now ); } migrateCommandUsageAliases(); return plugins; } function migratePluginAliases(plugin, now) { for (const legacyId of plugin.legacyIds || []) { const existing = db.prepare("SELECT * FROM plugins WHERE id = ?").get(plugin.id); const legacy = db.prepare("SELECT * FROM plugins WHERE id = ?").get(legacyId); if (legacy && !existing) { db.prepare( "INSERT INTO plugins (id, name, version, enabled, source, path, installed_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)" ).run( plugin.id, plugin.name, plugin.version, legacy.enabled, legacy.source || "local", plugin.dir, legacy.installed_at || now, now ); } else if (legacy && existing) { db.prepare("UPDATE plugins SET enabled = ?, updated_at = ? WHERE id = ?").run( legacy.enabled, now, plugin.id ); } const settingsRows = db .prepare("SELECT key, value, updated_at FROM plugin_settings WHERE plugin_id = ?") .all(legacyId); const copySetting = db.prepare( "INSERT OR IGNORE INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?)" ); for (const row of settingsRows) { copySetting.run(plugin.id, row.key, row.value, row.updated_at || now); } db.prepare("DELETE FROM plugin_settings WHERE plugin_id = ?").run(legacyId); db.prepare("DELETE FROM plugins WHERE id = ?").run(legacyId); } } function migrateCommandUsageAliases() { const table = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'command_usage'") .get(); if (!table) { return; } for (const alias of economyCommandAliases) { const oldRow = db.prepare("SELECT count, updated_at FROM command_usage WHERE command_id = ?").get(alias.from); if (!oldRow) { continue; } db.prepare( "INSERT INTO command_usage (command_id, count, updated_at) VALUES (?, ?, ?) " + "ON CONFLICT(command_id) DO UPDATE SET count = command_usage.count + excluded.count, updated_at = MAX(command_usage.updated_at, excluded.updated_at)" ).run(alias.to, oldRow.count || 0, oldRow.updated_at || Date.now()); db.prepare("DELETE FROM command_usage WHERE command_id = ?").run(alias.from); } } function getPlugins() { return db.prepare("SELECT * FROM plugins ORDER BY name").all(); } function setPluginEnabled(id, enabled) { db.prepare("UPDATE plugins SET enabled = ?, updated_at = ? WHERE id = ?").run( enabled ? 1 : 0, Date.now(), id ); pluginLifecycleLog.info(`Plugin ${enabled ? "enabled" : "disabled"}`, { plugin_id: id }, { event: enabled ? "plugin_enabled" : "plugin_disabled" }); } function removePlugin(id) { db.prepare("DELETE FROM plugins WHERE id = ?").run(id); db.prepare("DELETE FROM plugin_settings WHERE plugin_id = ?").run(id); pluginLifecycleLog.warn("Plugin registry entry removed", { plugin_id: id }, { event: "plugin_removed" }); } function clearPluginCache(pluginPath) { for (const key of Object.keys(require.cache)) { if (key.startsWith(pluginPath)) { delete require.cache[key]; } } } function loadEnabled({ app, discordClient, twitchClient, youtubeClient, settings, web, webhooks, commandRouter }) { const installed = scanPluginDirectories(); syncPluginRegistry(); const enabled = new Set( db .prepare("SELECT id FROM plugins WHERE enabled = 1") .all() .map((row) => row.id) ); for (const plugin of installed) { if (!enabled.has(plugin.id)) { continue; } const mainPath = path.join(plugin.dir, plugin.main); if (!fs.existsSync(mainPath)) { continue; } clearPluginCache(plugin.dir); const pluginLog = createLogger(`plugin:${plugin.id}`, { category: "plugin" }); try { pluginLog.run({ event: "plugin_load" }, () => { const mod = require(mainPath); if (mod && typeof mod.init === "function") { const cleanup = mod.init({ app, discordClient, twitchClient, youtubeClient, settings, web, webhooks, db, plugin, commandRouter, placeholders, logger: pluginLog }); if (typeof cleanup === "function") { cleanupHandlers.push({ id: plugin.id, cleanup }); } else if (cleanup && typeof cleanup.stop === "function") { cleanupHandlers.push({ id: plugin.id, cleanup: () => cleanup.stop() }); } } }); pluginLog.info("Plugin loaded", { version: plugin.version }, { event: "plugin_loaded" }); } catch (error) { pluginLog.error("Plugin failed to load", error, { event: "plugin_load_failed" }); } } } async function stopPlugins() { const handlers = cleanupHandlers.splice(0).reverse(); for (const handler of handlers) { try { await handler.cleanup(); pluginLifecycleLog.info("Plugin stopped", { plugin_id: handler.id }, { event: "plugin_stopped" }); } catch (error) { createLogger(`plugin:${handler.id}`, { category: "lifecycle" }) .error("Plugin failed to stop", error, { event: "plugin_stop_failed" }); } } } function installFromGit(url, targetFolder) { if (!fs.existsSync(pluginsDir)) { fs.mkdirSync(pluginsDir, { recursive: true }); } const folderName = targetFolder || url .split("/") .pop() .replace(/\.git$/i, "") .replace(/[^a-zA-Z0-9-_]/g, ""); const targetPath = path.join(pluginsDir, folderName); if (fs.existsSync(targetPath)) { throw new Error("Plugin folder already exists."); } const result = spawnSync("git", ["clone", url, targetPath], { stdio: "pipe", encoding: "utf8" }); if (result.status !== 0) { throw new Error(result.stderr || "Git clone failed."); } pluginLifecycleLog.info("Plugin cloned from repository", { plugin_id: folderName }, { event: "plugin_installed" }); return targetPath; } function updatePluginFromGit(pluginPath) { const result = spawnSync("git", ["-C", pluginPath, "pull"], { stdio: "pipe", encoding: "utf8" }); if (result.status !== 0) { throw new Error(result.stderr || "Git pull failed."); } pluginLifecycleLog.info("Plugin repository updated", { plugin_id: path.basename(pluginPath) }, { event: "plugin_updated" }); return result.stdout; } function createLocalPlugin({ id, name, description }) { const safeId = id.replace(/[^a-zA-Z0-9-_]/g, ""); if (!safeId) { throw new Error("Invalid plugin id."); } const pluginDir = path.join(pluginsDir, safeId); if (fs.existsSync(pluginDir)) { throw new Error("Plugin already exists."); } fs.mkdirSync(pluginDir, { recursive: true }); const safeName = name || safeId; const manifest = { id: safeId, name: safeName, version: "0.1.0", description: description || "", main: "index.js" }; const manifestPath = path.join(pluginDir, "plugin.json"); fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8"); const mainPath = path.join(pluginDir, "index.js"); const escapedName = safeName.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const starter = `module.exports = { id: "${safeId}", init({ web, commandRouter }) { const router = web.createRouter(); router.get("/", (req, res) => { res.render("plugin-page", { title: "${escapedName}", content: "Edit this plugin to add features." }); }); web.mount("/plugins/${safeId}", router, { label: "${escapedName}", role: "admin" }); } };\n`; fs.writeFileSync(mainPath, starter, "utf8"); pluginLifecycleLog.info("Local plugin created", { plugin_id: safeId }, { event: "plugin_created" }); return pluginDir; } module.exports = { pluginsDir, scanPluginDirectories, syncPluginRegistry, getPlugins, setPluginEnabled, removePlugin, loadEnabled, stopPlugins, installFromGit, updatePluginFromGit, createLocalPlugin, canonicalPluginId, pluginLegacyIds };