640 lines
19 KiB
JavaScript
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
|
|
};
|