Lumi/src/services/top.js
2026-06-17 22:32:51 +02:00

640 lines
19 KiB
JavaScript

const fs = require("fs");
const path = require("path");
const { db } = require("./db");
const { getSetting } = require("./settings");
const { getEnabledPlatformIds } = require("./platforms");
const { getPluginLeaderboards } = require("./plugin-stats");
const { getPlugins, pluginsDir } = require("./plugins");
const coreProviders = new Map();
const coreOrder = [];
let coreRegistered = false;
function registerTopProvider(provider) {
if (!provider || !provider.id) {
return;
}
const id = normalizeProviderId(provider.id);
if (!id) {
return;
}
const entry = {
id,
label: provider.label || id,
section: provider.section || "Community",
description: provider.description || "",
valueLabel: provider.valueLabel || "Total",
rowType: provider.rowType || "user",
aliases: Array.isArray(provider.aliases) ? provider.aliases : [],
order: Number.isFinite(provider.order) ? provider.order : coreOrder.length,
getRows: typeof provider.getRows === "function" ? provider.getRows : null
};
if (!coreProviders.has(id)) {
coreOrder.push(id);
}
coreProviders.set(id, entry);
}
function ensureCoreProviders() {
if (coreRegistered) {
return;
}
coreRegistered = true;
registerTopProvider({
id: "messages",
label: "Top messages",
section: "Community Interaction",
valueLabel: "Messages",
description: "Users with the most messages.",
getRows: ({ limit }) => buildStatRows("messages", limit)
});
registerTopProvider({
id: "commands",
label: "Top commands",
section: "Community Interaction",
valueLabel: "Commands",
description: "Users who ran the most commands.",
getRows: ({ limit }) => buildStatRows("commands", limit)
});
registerTopProvider({
id: "modage",
label: "Top mod age",
section: "Moderation",
valueLabel: "Time",
description: "Moderators with the longest mod tenure.",
getRows: ({ limit }) => buildModAgeRows(limit)
});
registerTopProvider({
id: "coins",
label: "Top currency",
section: "Economy",
valueLabel: getCurrencyLabel(),
description: "Top balances from the currency framework.",
getRows: ({ limit }) => buildCurrencyRows(limit)
});
registerTopProvider({
id: "interactors",
label: "Top interactors",
section: "Expression Interaction",
valueLabel: "Interactions",
description: "Most interaction actions given.",
getRows: ({ limit }) => buildExpressionRows(limit, "given")
});
registerTopProvider({
id: "interactions_received",
label: "Top interactions received",
section: "Expression Interaction",
valueLabel: "Interactions",
description: "Most interaction actions received.",
getRows: ({ limit }) => buildExpressionRows(limit, "received")
});
registerTopProvider({
id: "commands_run",
label: "Top commands run",
section: "Commands",
valueLabel: "Runs",
rowType: "command",
description: "Most popular commands across platforms.",
getRows: ({ limit }) => buildCommandUsageRows(limit)
});
registerTopProvider({
id: "followage",
label: "Top followage",
section: "Platforms",
valueLabel: "Days",
description: "Longest follower durations.",
getRows: () => ({
rows: [],
emptyMessage: "Followage tracking is not configured yet."
})
});
registerTopProvider({
id: "watchtime",
label: "Top watchtime",
section: "Platforms",
valueLabel: "Hours",
description: "Most watch time recorded.",
getRows: () => ({
rows: [],
emptyMessage: "Watchtime tracking is not configured yet."
})
});
registerTopProvider({
id: "games",
label: "Top games",
section: "Games",
valueLabel: "Mentions",
rowType: "game",
description: "Most popular games tracked by plugins.",
getRows: () => ({
rows: [],
emptyMessage: "Game tracking is not configured yet."
})
});
}
function getTopProviders({ includePlugins = true, limit = 10 } = {}) {
ensureCoreProviders();
const providers = coreOrder.map((id) => coreProviders.get(id)).filter(Boolean);
if (!includePlugins) {
return providers;
}
const pluginProviders = buildPluginProviders({ limit });
return mergeProviders(providers, pluginProviders);
}
function getTopBoards({ limit = 10 } = {}) {
const providers = getTopProviders({ includePlugins: true, limit });
return providers.map((provider) => {
if (provider.getRows) {
const result = provider.getRows({ db, limit, settings: { getSetting } });
return normalizeProviderResult(provider, result);
}
return normalizeProviderResult(provider, {
rows: provider.rows || [],
rowType: provider.rowType,
valueLabel: provider.valueLabel,
emptyMessage: provider.emptyMessage
});
});
}
function getLeaderboardSections({ limit = 10 } = {}) {
const boards = getTopBoards({ limit });
const sections = [];
const sectionMap = new Map();
boards.forEach((board) => {
const title = board.section || "Leaderboards";
if (!sectionMap.has(title)) {
sectionMap.set(title, { title, boards: [] });
sections.push(sectionMap.get(title));
}
sectionMap.get(title).boards.push(board);
});
return sections;
}
function getTopCommandOptions() {
return getTopProviders({ includePlugins: true }).map((provider) => ({
id: provider.id,
label: provider.label,
description: provider.description || "",
aliases: provider.aliases || []
}));
}
function registerTopCommand({ commandRouter, settings }) {
if (!commandRouter) {
return;
}
ensureCoreProviders();
const platforms = getEnabledPlatformIds();
commandRouter.registerCommands("core", [
{
id: "top",
triggers: ["top"],
platforms,
handler: (ctx) => handleTopCommand({ ctx, settings })
}
]);
}
async function handleTopCommand({ ctx, settings }) {
const prefix = settings.getSetting("command_prefix", "!");
const rawId = (ctx.args[0] || "").trim().toLowerCase();
if (!rawId || ["help", "list"].includes(rawId)) {
await ctx.reply(buildTopHelp(prefix));
return true;
}
const board = findTopBoard(rawId, 5);
if (!board) {
await ctx.reply(buildTopHelp(prefix));
return true;
}
if (!board.rows.length) {
await ctx.reply(board.emptyMessage || "No data recorded yet.");
return true;
}
const list = board.rows
.slice(0, 5)
.map((entry, index) => {
const label = entry.label || entry.username || entry.name || "Unknown";
return `${index + 1}) ${label} (${entry.value})`;
})
.join(" | ");
await ctx.reply(`${board.label}: ${list}`);
return true;
}
function findTopBoard(id, limit) {
const boards = getTopBoards({ limit });
const normalized = id.toLowerCase();
return boards.find((board) => {
if (board.id === normalized) {
return true;
}
return Array.isArray(board.aliases)
? board.aliases.map((alias) => alias.toLowerCase()).includes(normalized)
: false;
});
}
function buildTopHelp(prefix) {
const options = getTopCommandOptions().map((entry) => entry.id);
if (!options.length) {
return "No top categories are available yet.";
}
return `Usage: ${prefix}top <category>. Available: ${options.join(", ")}`;
}
function mergeProviders(coreList, pluginList) {
const merged = coreList.slice();
const indexMap = new Map(merged.map((provider, index) => [provider.id, index]));
for (const provider of pluginList) {
const existingIndex = indexMap.get(provider.id);
if (existingIndex !== undefined) {
if (provider.override) {
merged[existingIndex] = provider;
}
continue;
}
indexMap.set(provider.id, merged.length);
merged.push(provider);
}
return merged;
}
function normalizeProviderResult(provider, result) {
const rows = normalizeRows(result?.rows || []);
return {
id: provider.id,
label: provider.label,
section: provider.section,
rowType: result?.rowType || provider.rowType,
valueLabel: result?.valueLabel || provider.valueLabel || "Total",
rows,
emptyMessage:
result?.emptyMessage || provider.emptyMessage || "No data recorded yet.",
aliases: result?.aliases || provider.aliases || []
};
}
function normalizeRows(rows) {
return (rows || []).map((row) => {
const value = row.value ?? row.total ?? row.count ?? 0;
return {
username: row.username || row.user || row.label || row.name || null,
label: row.label || row.username || row.name || null,
href: row.href || row.url || null,
value: formatNumber(value),
rawValue: Number(value) || 0
};
});
}
function buildPluginProviders({ limit }) {
const pluginSections = getPluginLeaderboards(limit);
const providers = [];
pluginSections.forEach((section) => {
const sectionId = slugify(section.id || section.title || "plugin");
const sectionLabel = section.title || "Plugin";
(section.boards || []).forEach((board) => {
const boardKey = slugify(board.id || board.title || "board");
const topId = board.topId ? normalizeProviderId(board.topId) : "";
const id = topId || `${sectionId}-${boardKey}`;
const rows = board.rows || [];
const aliases = Array.isArray(board.topAliases)
? board.topAliases.map(normalizeProviderId).filter(Boolean)
: Array.isArray(board.aliases)
? board.aliases.map(normalizeProviderId).filter(Boolean)
: [];
providers.push({
id,
label: board.title || boardKey,
section: sectionLabel,
description: board.description || "",
rowType: board.rowType || "user",
valueLabel: board.valueLabel || "Total",
rows,
emptyMessage: board.emptyMessage || section.emptyMessage || "No data yet.",
aliases,
override: Boolean(board.topOverride)
});
});
});
return providers;
}
function slugify(value) {
const slug = (value || "")
.toString()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.trim();
return slug || "command";
}
function normalizeProviderId(value) {
return (value || "")
.toString()
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function buildStatRows(column, limit) {
const rows = db
.prepare(
`SELECT user_profiles.internal_username AS username, stats.${column} AS value ` +
"FROM stats " +
"JOIN user_profiles ON user_profiles.id = stats.user_id " +
`ORDER BY stats.${column} DESC LIMIT ?`
)
.all(limit);
return { rows };
}
function buildModAgeRows(limit) {
if (!tableExists("mod_role_periods")) {
return { rows: [], emptyMessage: "No moderator history recorded yet." };
}
const now = Date.now();
const rows = db
.prepare(
"SELECT mod_role_periods.user_id AS user_id, " +
"SUM(CASE WHEN mod_role_periods.end_at IS NULL THEN ? - mod_role_periods.start_at ELSE mod_role_periods.end_at - mod_role_periods.start_at END) AS total_ms " +
"FROM mod_role_periods " +
"GROUP BY mod_role_periods.user_id " +
"ORDER BY total_ms DESC LIMIT ?"
)
.all(now, limit);
const mapped = rows
.map((row) => {
const profile = db
.prepare("SELECT internal_username FROM user_profiles WHERE id = ?")
.get(row.user_id);
return {
username: profile?.internal_username || row.user_id,
value: formatDuration(row.total_ms || 0)
};
})
.filter((row) => row.username);
return { rows: mapped, valueLabel: "Time" };
}
function buildExpressionRows(limit, type) {
if (!tableExists("expression_user_stats")) {
return { rows: [], emptyMessage: "No interactions recorded yet." };
}
const column = type === "received" ? "received_count" : "given_count";
const rows = db
.prepare(
`SELECT user_profiles.internal_username AS username, SUM(expression_user_stats.${column}) AS value ` +
"FROM expression_user_stats " +
"JOIN user_profiles ON user_profiles.id = expression_user_stats.user_id " +
"GROUP BY expression_user_stats.user_id " +
`ORDER BY value DESC LIMIT ?`
)
.all(limit);
return { rows };
}
function buildCommandUsageRows(limit) {
if (!tableExists("command_usage")) {
return { rows: [], emptyMessage: "No command usage recorded yet." };
}
const commandIndex = buildCommandIndex();
const prefix = getSetting("command_prefix", "!");
const rows = db
.prepare("SELECT command_id, count FROM command_usage ORDER BY count DESC LIMIT ?")
.all(limit)
.map((row) => {
const command = commandIndex.get(row.command_id);
return {
label: command?.label || formatCommandId(row.command_id, prefix),
href: command?.href || null,
value: row.count
};
});
return { rows, rowType: "command" };
}
function buildCurrencyRows(limit) {
if (!tableExists("economy_accounts")) {
return { rows: [], emptyMessage: "Currency framework not active." };
}
const rows = db
.prepare(
"SELECT user_profiles.internal_username AS username, economy_accounts.balance AS value " +
"FROM economy_accounts " +
"JOIN user_profiles ON user_profiles.id = economy_accounts.user_id " +
"ORDER BY economy_accounts.balance DESC LIMIT ?"
)
.all(limit);
return { rows, valueLabel: getCurrencyLabel() };
}
function buildCommandIndex() {
const prefix = getSetting("command_prefix", "!");
const index = new Map();
const addEntry = (ids, label, commandPageId) => {
const cleanLabel = normalizeCommandDisplay(label, prefix);
const href = `/commands#cmd-${slugify(commandPageId)}`;
ids
.map((id) => (id || "").toString().trim())
.filter(Boolean)
.forEach((id) => {
if (!index.has(id)) {
index.set(id, { label: cleanLabel, href });
}
});
};
addEntry(["top"], `${prefix}top`, "top");
for (const option of getTopCommandOptions()) {
const subcommand = normalizeSubcommand(option.id);
if (subcommand) {
addEntry([`top:${subcommand}`], `${prefix}top ${subcommand}`, `top:${subcommand}`);
}
}
if (tableExists("custom_commands")) {
const customRows = db
.prepare("SELECT trigger FROM custom_commands WHERE enabled = 1 ORDER BY trigger")
.all();
for (const row of customRows) {
const trigger = normalizeCommandTrigger(row.trigger);
if (trigger) {
addEntry([`custom:${trigger}`], `${prefix}${trigger}`, `custom:${trigger}`);
}
}
}
for (const plugin of listCommandPlugins()) {
const manifestPath = path.join(plugin.path, "cmds.json");
const manifest = readJsonSafe(manifestPath);
if (!manifest || !Array.isArray(manifest.commands)) {
continue;
}
const pluginSettings = getPluginSettingsMap(plugin.id);
for (const command of manifest.commands) {
if (!command || !command.trigger) {
continue;
}
const override = command.triggerKey ? pluginSettings[command.triggerKey] : "";
const trigger = normalizeCommandTrigger(override, command.trigger);
if (!trigger) {
continue;
}
const subcommand = normalizeSubcommand(command.subcommand);
const commandId = `${plugin.id}:${command.id || trigger}`;
const ids = [commandId, command.id];
if (command.id && !command.id.toString().includes(":")) {
ids.push(`${plugin.id}:${command.id}`);
}
if (plugin.id === "economy-framework" && command.id === "root") {
ids.push("economy:root");
}
if (plugin.id === "economy-games" && command.id === "mysterybox") {
ids.push("economy-games:mystery");
}
const label = subcommand ? `${prefix}${trigger} ${subcommand}` : `${prefix}${trigger}`;
addEntry(ids, label, commandId);
}
}
return index;
}
function listCommandPlugins() {
try {
const enabled = getPlugins()
.filter((plugin) => plugin.enabled)
.map((plugin) => ({
id: plugin.id,
name: plugin.name,
path: plugin.path
}))
.filter((plugin) => plugin.id && plugin.path);
if (enabled.length) {
return enabled;
}
} catch {
// Fall back to filesystem manifests below.
}
if (!fs.existsSync(pluginsDir)) {
return [];
}
return fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const dir = path.join(pluginsDir, entry.name);
const manifest = readJsonSafe(path.join(dir, "plugin.json"));
return manifest?.id ? { id: manifest.id, name: manifest.name, path: dir } : null;
})
.filter(Boolean);
}
function readJsonSafe(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
function getPluginSettingsMap(pluginId) {
if (!tableExists("plugin_settings")) {
return {};
}
const rows = db
.prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?")
.all(pluginId);
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
function normalizeCommandTrigger(value, fallback = "") {
const raw = (value || fallback || "").toString().trim().toLowerCase();
const firstToken = raw.replace(/^!+/, "").split(/\s+/)[0] || "";
return firstToken.replace(/[^a-z0-9_-]/g, "");
}
function normalizeSubcommand(value) {
const raw = (value || "").toString().trim().toLowerCase();
const firstToken = raw.replace(/^!+/, "").split(/\s+/)[0] || "";
return firstToken.replace(/[^a-z0-9_-]/g, "");
}
function normalizeCommandDisplay(value, prefix) {
const raw = (value || "").toString().trim();
if (!raw) {
return `${prefix}unknown`;
}
return raw.startsWith(prefix) ? raw : `${prefix}${raw.replace(/^!+/, "")}`;
}
function formatCommandId(commandId, prefix = "!") {
if (!commandId) {
return "Unknown";
}
if (commandId.startsWith("custom:")) {
return `${prefix}${commandId.slice(7)}`;
}
return commandId;
}
function getCurrencyLabel() {
const plural = getPluginSetting("economy-framework", "currency_name_plural");
const singular = getPluginSetting("economy-framework", "currency_name");
return plural || singular || "Coins";
}
function getPluginSetting(pluginId, key) {
const row = db
.prepare("SELECT value FROM plugin_settings WHERE plugin_id = ? AND key = ?")
.get(pluginId, key);
return row?.value ? row.value.toString() : null;
}
function tableExists(name) {
const row = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
return Boolean(row);
}
function formatNumber(value) {
const number = Number(value);
if (!Number.isFinite(number)) {
return value ?? "0";
}
return number.toLocaleString("en-US");
}
function formatDuration(totalMs) {
const totalSeconds = Math.max(0, Math.floor(totalMs / 1000));
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
module.exports = {
registerTopProvider,
getTopProviders,
getTopBoards,
getLeaderboardSections,
getTopCommandOptions,
registerTopCommand
};