375 lines
12 KiB
JavaScript
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
|
|
};
|