Lumi/src/services/plugins.js
2026-07-18 20:24:26 +02:00

375 lines
12 KiB
JavaScript

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
};