const path = require("path"); const fs = require("fs"); const crypto = require("crypto"); const express = require("express"); const multer = require("multer"); const EventEmitter = require("events"); const { ensureUserForIdentity } = require("../../src/services/users"); const PLUGIN_ID = "echonomy-framework"; const DEFAULT_SETTINGS = { currency_name: "Coin", currency_name_plural: "Coins", currency_icon_path: "", command_root: "coins", command_aliases: "", banking_label: "Banking", banking_enabled: "1", community_fund_name: "Community fund", community_fund_name_plural: "Community funds", platform_discord: "1", platform_twitch: "1", platform_youtube: "1", transfer_cooldown_seconds: "10", earn_discord_message_enabled: "1", earn_discord_message_amount: "1", earn_discord_message_cooldown: "30", earn_twitch_message_enabled: "1", earn_twitch_message_amount: "1", earn_twitch_message_cooldown: "30", earn_discord_voice_enabled: "0", earn_discord_voice_amount_per_min: "2", earn_discord_voice_tick_minutes: "1", tier_discord_booster_multiplier: "1.25", tier_twitch_sub_multiplier: "1.5", tier_twitch_mod_multiplier: "1.2", tier_twitch_vip_multiplier: "1.1", tier_twitch_broadcaster_multiplier: "2.0", custom_events: "[]", response_templates: "" }; const DEFAULT_RESPONSES = { balance_self: { label: "Balance (self)", mode: "random", replies: [ { text: "Your balance is {balance_text}.", weight: 1 }, { text: "You have {balance_text} available.", weight: 1 } ] }, top_list: { label: "Top balances", mode: "random", replies: [{ text: "Top balances: {lines}", weight: 1 }] }, top_empty: { label: "Top balances (empty)", mode: "random", replies: [{ text: "No balances yet.", weight: 1 }] }, stats: { label: "Global stats", mode: "random", replies: [ { text: "Total in circulation: {total_balance_text}. Total spent: {total_spent_text}.", weight: 1 } ] }, pay_success: { label: "Pay success", mode: "random", replies: [ { text: "Sent {amount_text} to {target}.", weight: 1 }, { text: "Transfer complete: {target} received {amount_text}.", weight: 1 } ] }, pay_missing: { label: "Pay missing arguments", mode: "random", replies: [{ text: "Usage: {usage}", weight: 1 }] }, pay_cooldown: { label: "Pay cooldown", mode: "random", replies: [{ text: "Please wait {cooldown}s before sending again.", weight: 1 }] }, pay_self: { label: "Pay self", mode: "random", replies: [{ text: "You cannot pay yourself.", weight: 1 }] }, pay_not_found: { label: "Pay user not found", mode: "random", replies: [{ text: "I couldn't find that user.", weight: 1 }] }, pay_insufficient: { label: "Pay insufficient balance", mode: "random", replies: [{ text: "{reason}", weight: 1 }] }, grant_success: { label: "Grant success", mode: "random", replies: [{ text: "Granted {amount_text} to {target}.", weight: 1 }] }, take_success: { label: "Take success", mode: "random", replies: [{ text: "Removed {amount_text} from {target}.", weight: 1 }] }, funds_list: { label: "Community funds list", mode: "random", replies: [{ text: "{funds_label}: {lines}", weight: 1 }] }, funds_empty: { label: "Community funds (empty)", mode: "random", replies: [{ text: "No {funds_label} are active yet.", weight: 1 }] }, fund_missing: { label: "Fund missing arguments", mode: "random", replies: [{ text: "Usage: {usage}", weight: 1 }] }, fund_not_found: { label: "Fund not found", mode: "random", replies: [{ text: "That {fund_label} is not active.", weight: 1 }] }, fund_donate_success: { label: "Fund donation success", mode: "random", replies: [ { text: "Donated {amount_text} to {fund}.", weight: 1 }, { text: "Thanks! {amount_text} added to {fund}.", weight: 1 } ] }, permission_denied: { label: "Permission denied", mode: "random", replies: [{ text: "You do not have permission to do that.", weight: 1 }] }, reward_missing: { label: "Event reward missing arguments", mode: "random", replies: [{ text: "Usage: {usage}", weight: 1 }] }, reward_not_found: { label: "Event reward not found", mode: "random", replies: [{ text: "That event is not configured.", weight: 1 }] }, reward_success: { label: "Event reward success", mode: "random", replies: [{ text: "Awarded {amount_text} to {target}.", weight: 1 }] }, help: { label: "Help", mode: "random", replies: [{ text: "{help}", weight: 1 }] } }; const emitter = new EventEmitter(); const messageCooldowns = new Map(); const transferCooldowns = new Map(); const voiceStates = new Map(); let voiceTimer = null; let activityFlushTimer = null; let cachedConfig = null; let cachedConfigAt = 0; let settingsApi = null; const ACTIVITY_REWARD_NOTE = "Activity Reward"; const ACTIVITY_REWARD_SOURCES = { discord_message: "Discord Message", twitch_message: "Twitch Message", discord_voice: "Discord Voice" }; module.exports = { id: PLUGIN_ID, init({ app, web, settings, db, commandRouter, discordClient, twitchClient }) { settingsApi = settings; ensureTables(db); ensureDefaults(db); startActivityRewardFlusher(db); const api = buildApi({ db }); registerFramework(api); const refreshCommands = registerCommands({ db, settings, commandRouter }); attachDiscordListeners({ db, settings, discordClient }); attachTwitchListeners({ db, settings, twitchClient }); installProfileHook(app, () => getConfig(db)); const repoRoot = path.join(__dirname, "..", ".."); const uploadDir = path.join(repoRoot, "data", "echonomy-framework"); fs.mkdirSync(uploadDir, { recursive: true }); const upload = multer({ dest: uploadDir, fileFilter: (_req, file, cb) => { if (file.mimetype === "image/png") { return cb(null, true); } cb(new Error("Only PNG files are allowed.")); } }); const router = web.createRouter(); router.use("/assets", express.static(uploadDir)); router.get("/", (req, res) => { const config = getConfig(db); const user = req.session.user || null; const isAdmin = Boolean(user?.isAdmin); const isMod = Boolean(user?.isAdmin || user?.isMod); const userBalance = user ? getBalance(db, user.id) : 0; const transactions = listTransactions(db, { userId: isAdmin ? null : user?.id, limit: 1000 }); const globalStats = buildGlobalStats(db); const topBalances = listTopBalances(db, 10); const funds = listFunds(db); const events = getCustomEvents(config); const responses = Object.values(config.responses || {}); res.render(path.join(__dirname, "views", "echonomy.ejs"), { title: "Echonomy Framework", config, user, isAdmin, isMod, userBalance, transactions, globalStats, topBalances, funds, events, responses }); }); router.post("/settings/currency", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } setPluginSetting(db, "currency_name", (req.body.currency_name || "").trim()); setPluginSetting( db, "currency_name_plural", (req.body.currency_name_plural || "").trim() ); setPluginSetting(db, "command_root", (req.body.command_root || "").trim()); setPluginSetting(db, "command_aliases", (req.body.command_aliases || "").trim()); invalidateConfigCache(); if (refreshCommands) { refreshCommands(); } req.session.flash = { type: "success", message: "Currency settings updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/settings/platforms", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } setPluginSetting(db, "platform_discord", req.body.platform_discord ? "1" : "0"); setPluginSetting(db, "platform_twitch", req.body.platform_twitch ? "1" : "0"); setPluginSetting(db, "platform_youtube", req.body.platform_youtube ? "1" : "0"); invalidateConfigCache(); if (refreshCommands) { refreshCommands(); } req.session.flash = { type: "success", message: "Platform settings updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/settings/earn", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } setPluginSetting( db, "earn_discord_message_enabled", req.body.earn_discord_message_enabled ? "1" : "0" ); setPluginSetting( db, "earn_discord_message_amount", (req.body.earn_discord_message_amount || "0").trim() ); setPluginSetting( db, "earn_discord_message_cooldown", (req.body.earn_discord_message_cooldown || "0").trim() ); setPluginSetting( db, "earn_twitch_message_enabled", req.body.earn_twitch_message_enabled ? "1" : "0" ); setPluginSetting( db, "earn_twitch_message_amount", (req.body.earn_twitch_message_amount || "0").trim() ); setPluginSetting( db, "earn_twitch_message_cooldown", (req.body.earn_twitch_message_cooldown || "0").trim() ); setPluginSetting( db, "earn_discord_voice_enabled", req.body.earn_discord_voice_enabled ? "1" : "0" ); setPluginSetting( db, "earn_discord_voice_amount_per_min", (req.body.earn_discord_voice_amount_per_min || "0").trim() ); setPluginSetting( db, "earn_discord_voice_tick_minutes", (req.body.earn_discord_voice_tick_minutes || "1").trim() ); invalidateConfigCache(); req.session.flash = { type: "success", message: "Earning rules updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/settings/tiers", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } setPluginSetting( db, "tier_discord_booster_multiplier", (req.body.tier_discord_booster_multiplier || "1").trim() ); setPluginSetting( db, "tier_twitch_sub_multiplier", (req.body.tier_twitch_sub_multiplier || "1").trim() ); setPluginSetting( db, "tier_twitch_mod_multiplier", (req.body.tier_twitch_mod_multiplier || "1").trim() ); setPluginSetting( db, "tier_twitch_vip_multiplier", (req.body.tier_twitch_vip_multiplier || "1").trim() ); setPluginSetting( db, "tier_twitch_broadcaster_multiplier", (req.body.tier_twitch_broadcaster_multiplier || "1").trim() ); invalidateConfigCache(); req.session.flash = { type: "success", message: "Tier multipliers updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/settings/banking", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } setPluginSetting(db, "banking_label", (req.body.banking_label || "").trim()); setPluginSetting( db, "banking_enabled", req.body.banking_enabled ? "1" : "0" ); setPluginSetting( db, "community_fund_name", (req.body.community_fund_name || "").trim() ); setPluginSetting( db, "community_fund_name_plural", (req.body.community_fund_name_plural || "").trim() ); invalidateConfigCache(); req.session.flash = { type: "success", message: "Banking labels updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/settings/responses", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } const key = (req.body.response_key || "").trim(); if (!key) { req.session.flash = { type: "error", message: "Response key is required." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const mode = (req.body.response_mode || "random").trim(); const texts = Array.isArray(req.body.response_text) ? req.body.response_text : [req.body.response_text]; const weights = Array.isArray(req.body.response_weight) ? req.body.response_weight : [req.body.response_weight]; const replies = (texts || []) .map((text, index) => ({ text: (text || "").trim(), weight: Number(weights?.[index] || 1) })) .filter((entry) => entry.text); const current = getResponseTemplates(db); current[key] = { ...current[key], mode: mode === "weighted" ? "weighted" : "random", replies: replies.length ? replies : current[key]?.replies || [] }; setPluginSetting(db, "response_templates", JSON.stringify(current)); invalidateConfigCache(); req.session.flash = { type: "success", message: "Responses updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/settings/icon", upload.single("currency_icon"), (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } if (!req.file) { req.session.flash = { type: "error", message: "Upload a PNG icon." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const ext = path.extname(req.file.originalname || "").toLowerCase(); if (ext && ext !== ".png") { fs.rmSync(req.file.path, { force: true }); req.session.flash = { type: "error", message: "Only PNG files are allowed." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const filename = `currency-${Date.now()}-${crypto.randomUUID()}.png`; const targetPath = path.join(uploadDir, filename); fs.renameSync(req.file.path, targetPath); const relativePath = `/plugins/${PLUGIN_ID}/assets/${filename}`; setPluginSetting(db, "currency_icon_path", relativePath); invalidateConfigCache(); req.session.flash = { type: "success", message: "Currency icon updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/accounts/adjust", (req, res) => { if (!req.session.user || !req.session.user.isMod) { return deny(res); } const targetName = (req.body.username || "").trim(); const amount = parseSignedAmount(req.body.amount); if (!targetName || !Number.isFinite(amount)) { req.session.flash = { type: "error", message: "Username and amount are required." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const target = findUserByInternalName(db, targetName); if (!target) { req.session.flash = { type: "error", message: "User not found." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const note = (req.body.note || "").trim(); if (amount === 0) { req.session.flash = { type: "error", message: "Amount must be non-zero." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } adjustBalance(db, { userId: target.id, amount, note, meta: { actorId: req.session.user.id, actorName: req.session.user.username } }); req.session.flash = { type: "success", message: "Balance updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/funds/create", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } const name = (req.body.name || "").trim(); const description = (req.body.description || "").trim(); const target = parseInt(req.body.target_amount || "0", 10); if (!name) { req.session.flash = { type: "error", message: "Fund name is required." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } createFund(db, { name, description, targetAmount: Number.isFinite(target) ? target : 0 }); req.session.flash = { type: "success", message: "Fund created." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/funds/:id/update", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } updateFund(db, { id: req.params.id, name: (req.body.name || "").trim(), description: (req.body.description || "").trim(), targetAmount: parseInt(req.body.target_amount || "0", 10), status: req.body.status || "active" }); req.session.flash = { type: "success", message: "Fund updated." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/events/create", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } const name = (req.body.name || "").trim(); const amount = parseInt(req.body.amount || "0", 10); if (!name || !Number.isFinite(amount)) { req.session.flash = { type: "error", message: "Event name and amount are required." }; return res.redirect(`/plugins/${PLUGIN_ID}`); } const config = getConfig(db); const events = getCustomEvents(config); events.push({ id: crypto.randomUUID(), name, amount }); setPluginSetting(db, "custom_events", JSON.stringify(events)); invalidateConfigCache(); req.session.flash = { type: "success", message: "Event added." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); router.post("/events/:id/delete", (req, res) => { if (!req.session.user?.isAdmin) { return deny(res); } const config = getConfig(db); const events = getCustomEvents(config).filter( (event) => event.id !== req.params.id ); setPluginSetting(db, "custom_events", JSON.stringify(events)); invalidateConfigCache(); req.session.flash = { type: "success", message: "Event removed." }; res.redirect(`/plugins/${PLUGIN_ID}`); }); const bankRouter = web.createRouter(); bankRouter.use((req, res, next) => { if (!req.session.user) { return res.redirect("/"); } const config = getConfig(db); if (!config.banking.enabled) { return res.redirect("/profile"); } req.bankingConfig = config; next(); }); bankRouter.get("/", (req, res) => { const config = req.bankingConfig || getConfig(db); const user = req.session.user; const userStats = buildUserStats(db, user.id); const transactions = listTransactions(db, { userId: user.id, limit: 1000 }); const funds = listFunds(db).filter((fund) => fund.status === "active"); const userDirectory = listUserDirectory(db); res.render(path.join(__dirname, "views", "banking.ejs"), { title: config.banking.label, config, user, userStats, transactions, funds, userDirectory }); }); bankRouter.post("/transfer", (req, res) => { if (!req.session.user) { return res.redirect("/"); } const config = req.bankingConfig || getConfig(db); const targetName = (req.body.username || "").trim(); const amount = parseAmount(req.body.amount); const note = (req.body.note || "").trim(); if (!targetName || !Number.isFinite(amount)) { req.session.flash = { type: "error", message: "Recipient and amount are required." }; return res.redirect("/profile/banking"); } const cooldownLeft = getCooldownLeft(req.session.user.id, config); if (cooldownLeft > 0) { req.session.flash = { type: "error", message: `Please wait ${cooldownLeft}s before sending again.` }; return res.redirect("/profile/banking"); } const target = findUserByInternalName(db, targetName.replace(/^@/, "")); if (!target) { req.session.flash = { type: "error", message: "User not found." }; return res.redirect("/profile/banking"); } if (target.id === req.session.user.id) { req.session.flash = { type: "error", message: "You cannot transfer funds to yourself." }; return res.redirect("/profile/banking"); } const success = transferBalance(db, { fromUserId: req.session.user.id, toUserId: target.id, amount, note, meta: { source: "banking_ui" } }); if (!success.ok) { req.session.flash = { type: "error", message: success.message }; return res.redirect("/profile/banking"); } setCooldown(req.session.user.id); req.session.flash = { type: "success", message: "Transfer completed." }; return res.redirect("/profile/banking"); }); bankRouter.post("/funds/:id/donate", (req, res) => { if (!req.session.user) { return res.redirect("/"); } const config = req.bankingConfig || getConfig(db); const amount = parseAmount(req.body.amount); const note = (req.body.note || "").trim(); if (!Number.isFinite(amount)) { req.session.flash = { type: "error", message: "Enter a valid amount." }; return res.redirect("/profile/banking"); } const cooldownLeft = getCooldownLeft(req.session.user.id, config); if (cooldownLeft > 0) { req.session.flash = { type: "error", message: `Please wait ${cooldownLeft}s before donating again.` }; return res.redirect("/profile/banking"); } const fund = db .prepare("SELECT * FROM echonomy_pots WHERE id = ?") .get(req.params.id); if (!fund || fund.status !== "active") { req.session.flash = { type: "error", message: "That fund is not active." }; return res.redirect("/profile/banking"); } const result = spendBalance(db, { userId: req.session.user.id, amount, note: note || `Donation to ${fund.name}`, meta: { fundId: fund.id, source: "banking_ui" } }); if (!result.ok) { req.session.flash = { type: "error", message: result.message }; return res.redirect("/profile/banking"); } addFundContribution(db, fund.id, req.session.user.id, amount); setCooldown(req.session.user.id); req.session.flash = { type: "success", message: "Donation completed." }; return res.redirect("/profile/banking"); }); web.mount(`/plugins/${PLUGIN_ID}`, router, { label: "Echonomy", role: "public", section: "plugins" }); web.mount("/profile/banking", bankRouter); } }; function deny(res) { return res.status(403).render("error", { title: "Access denied", message: "You do not have access to that page." }); } function ensureTables(db) { db.exec(` CREATE TABLE IF NOT EXISTS echonomy_accounts ( user_id TEXT PRIMARY KEY, balance INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS echonomy_transactions ( id TEXT PRIMARY KEY, type TEXT NOT NULL, amount INTEGER NOT NULL, from_user_id TEXT, to_user_id TEXT, note TEXT, meta TEXT, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS echonomy_pots ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT, target_amount INTEGER NOT NULL DEFAULT 0, current_amount INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS echonomy_pot_contributions ( id TEXT PRIMARY KEY, pot_id TEXT NOT NULL, user_id TEXT NOT NULL, amount INTEGER NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS echonomy_activity_reward_hourly ( user_id TEXT NOT NULL, hour_start INTEGER NOT NULL, source TEXT NOT NULL, amount INTEGER NOT NULL DEFAULT 0, hits INTEGER NOT NULL DEFAULT 0, minutes INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (user_id, hour_start, source) ); CREATE INDEX IF NOT EXISTS echonomy_transactions_created_at_idx ON echonomy_transactions (created_at); CREATE INDEX IF NOT EXISTS echonomy_activity_reward_hourly_hour_idx ON echonomy_activity_reward_hourly (hour_start); `); } function ensureDefaults(db) { const existing = getPluginSettings(db); for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { if (existing[key] === undefined) { setPluginSetting(db, key, value); } } } function getPluginSettings(db) { const rows = db .prepare("SELECT key, value FROM plugin_settings WHERE plugin_id = ?") .all(PLUGIN_ID); return rows.reduce((acc, row) => { acc[row.key] = row.value; return acc; }, {}); } function setPluginSetting(db, key, value) { db.prepare( "INSERT INTO plugin_settings (plugin_id, key, value, updated_at) VALUES (?, ?, ?, ?) " + "ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" ).run(PLUGIN_ID, key, value, Date.now()); } function parseBoolean(value, fallback) { if (value === undefined || value === null || value === "") { return fallback; } if (typeof value === "boolean") { return value; } const normalized = value.toString().toLowerCase(); return ["1", "true", "yes", "on"].includes(normalized); } function parseNumber(value, fallback) { if (value === undefined || value === null || value === "") { return fallback; } const number = Number(value); if (!Number.isFinite(number)) { return fallback; } return number; } function parseJson(value, fallback) { if (value === undefined || value === null || value === "") { return fallback; } try { return JSON.parse(value); } catch { return fallback; } } function parseList(value) { return (value || "") .split(/[\,\s]+/) .map((item) => item.trim()) .filter(Boolean); } function normalizeCommandRoot(value) { const raw = (value || "").trim().replace(/^!+/, ""); if (!raw) { return ""; } return raw.toLowerCase().replace(/\s+/g, "-"); } function buildPlural(name) { if (!name) { return ""; } if (name.endsWith("s")) { return name; } return `${name}s`; } function getConfig(db) { const now = Date.now(); if (cachedConfig && now - cachedConfigAt < 2000) { return cachedConfig; } const settings = getPluginSettings(db); const currencyName = settings.currency_name || DEFAULT_SETTINGS.currency_name; const currencyPlural = settings.currency_name_plural || buildPlural(currencyName) || DEFAULT_SETTINGS.currency_name_plural; const bankingLabel = settings.banking_label || DEFAULT_SETTINGS.banking_label || "Banking"; const bankingEnabled = parseBoolean(settings.banking_enabled, true); const fundName = settings.community_fund_name || DEFAULT_SETTINGS.community_fund_name; const fundPlural = settings.community_fund_name_plural || buildPlural(fundName) || DEFAULT_SETTINGS.community_fund_name_plural; const root = normalizeCommandRoot(settings.command_root || currencyPlural); const aliases = parseList(settings.command_aliases); const responseTemplates = buildResponseTemplates( parseJson(settings.response_templates, null) ); const config = { currency: { name: currencyName, plural: currencyPlural, icon: settings.currency_icon_path || "" }, banking: { label: bankingLabel, enabled: bankingEnabled }, communityFunds: { name: fundName || "Community fund", plural: fundPlural || "Community funds" }, command: { root: root || normalizeCommandRoot(currencyPlural) || "coins", aliases }, platforms: { discord: parseBoolean(settings.platform_discord, true), twitch: parseBoolean(settings.platform_twitch, true), youtube: parseBoolean(settings.platform_youtube, true) }, cooldownSeconds: parseNumber(settings.transfer_cooldown_seconds, 10), earn: { discordMessage: { enabled: parseBoolean(settings.earn_discord_message_enabled, true), amount: parseNumber(settings.earn_discord_message_amount, 1), cooldown: parseNumber(settings.earn_discord_message_cooldown, 30) }, twitchMessage: { enabled: parseBoolean(settings.earn_twitch_message_enabled, true), amount: parseNumber(settings.earn_twitch_message_amount, 1), cooldown: parseNumber(settings.earn_twitch_message_cooldown, 30) }, discordVoice: { enabled: parseBoolean(settings.earn_discord_voice_enabled, false), amountPerMin: parseNumber(settings.earn_discord_voice_amount_per_min, 2), tickMinutes: parseNumber(settings.earn_discord_voice_tick_minutes, 1) } }, tiers: { discordBooster: parseNumber(settings.tier_discord_booster_multiplier, 1.25), twitchSub: parseNumber(settings.tier_twitch_sub_multiplier, 1.5), twitchMod: parseNumber(settings.tier_twitch_mod_multiplier, 1.2), twitchVip: parseNumber(settings.tier_twitch_vip_multiplier, 1.1), twitchBroadcaster: parseNumber(settings.tier_twitch_broadcaster_multiplier, 2.0) }, responses: responseTemplates, eventsRaw: settings.custom_events || "[]" }; cachedConfig = config; cachedConfigAt = now; return config; } function getCustomEvents(config) { try { const events = JSON.parse(config.eventsRaw || "[]"); if (Array.isArray(events)) { return events .map((event) => ({ id: event.id, name: event.name, amount: Number(event.amount || 0) })) .filter((event) => event.id && event.name); } } catch { // ignore invalid custom event config } return []; } function normalizeReplies(list, fallback) { const source = Array.isArray(list) ? list : []; const cleaned = source .map((entry) => ({ text: (entry?.text || "").toString().trim(), weight: Number(entry?.weight || 1) })) .filter((entry) => entry.text); if (cleaned.length) { return cleaned; } const fallbackList = Array.isArray(fallback) ? fallback : []; return fallbackList.map((entry) => ({ text: (entry?.text || "").toString(), weight: Number(entry?.weight || 1) })); } function buildResponseTemplates(raw) { const parsed = raw && typeof raw === "object" ? raw : {}; const templates = {}; for (const [key, base] of Object.entries(DEFAULT_RESPONSES)) { const override = parsed[key] || {}; templates[key] = { key, label: base.label || key, mode: override.mode === "weighted" ? "weighted" : base.mode || "random", replies: normalizeReplies(override.replies, base.replies) }; } for (const [key, entry] of Object.entries(parsed)) { if (templates[key]) { continue; } templates[key] = { key, label: entry?.label || key, mode: entry?.mode === "weighted" ? "weighted" : "random", replies: normalizeReplies(entry?.replies, []) }; } return templates; } function getResponseTemplates(db) { const settings = getPluginSettings(db); return buildResponseTemplates(parseJson(settings.response_templates, {})); } function invalidateConfigCache() { cachedConfig = null; cachedConfigAt = 0; } function getHourStart(timestamp = Date.now()) { const hourMs = 60 * 60 * 1000; return Math.floor(timestamp / hourMs) * hourMs; } function queueActivityReward( db, { userId, source, amount, hits = 1, minutes = 0, occurredAt = Date.now() } ) { const numericAmount = Number(amount || 0); if (!userId || !source || !Number.isFinite(numericAmount) || numericAmount <= 0) { return; } const hourStart = getHourStart(occurredAt); const numericHits = Number.isFinite(Number(hits)) ? Number(hits) : 0; const numericMinutes = Number.isFinite(Number(minutes)) ? Number(minutes) : 0; db.prepare( "INSERT INTO echonomy_activity_reward_hourly (user_id, hour_start, source, amount, hits, minutes) " + "VALUES (?, ?, ?, ?, ?, ?) " + "ON CONFLICT(user_id, hour_start, source) DO UPDATE SET " + "amount = amount + excluded.amount, " + "hits = hits + excluded.hits, " + "minutes = minutes + excluded.minutes" ).run( userId, hourStart, source, Math.floor(numericAmount), Math.max(0, Math.floor(numericHits)), Math.max(0, Math.floor(numericMinutes)) ); } function startActivityRewardFlusher(db) { flushActivityRewards(db); if (activityFlushTimer) { return; } activityFlushTimer = setInterval(() => { try { flushActivityRewards(db); } catch (error) { console.error("Activity reward flush failed", error); } }, 60 * 1000); } function flushActivityRewards(db) { const currentHourStart = getHourStart(); const rows = db .prepare( "SELECT user_id, hour_start, source, amount, hits, minutes " + "FROM echonomy_activity_reward_hourly " + "WHERE hour_start < ? " + "ORDER BY hour_start ASC" ) .all(currentHourStart); if (!rows.length) { return; } const groups = new Map(); rows.forEach((row) => { const key = `${row.user_id}:${row.hour_start}`; if (!groups.has(key)) { groups.set(key, { userId: row.user_id, hourStart: row.hour_start, rows: [] }); } groups.get(key).rows.push(row); }); for (const group of groups.values()) { const rewards = group.rows.map((entry) => ({ source: entry.source, amount: Number(entry.amount || 0), hits: Number(entry.hits || 0), minutes: Number(entry.minutes || 0), label: ACTIVITY_REWARD_SOURCES[entry.source] || entry.source })); const totalAmount = rewards.reduce( (sum, entry) => sum + Math.max(0, Number(entry.amount || 0)), 0 ); if (totalAmount <= 0) { db.prepare( "DELETE FROM echonomy_activity_reward_hourly WHERE user_id = ? AND hour_start = ?" ).run(group.userId, group.hourStart); continue; } try { grantBalance(db, { userId: group.userId, amount: totalAmount, note: ACTIVITY_REWARD_NOTE, meta: { source: "activity_reward", hourStart: group.hourStart, hourEnd: group.hourStart + 60 * 60 * 1000, rewards } }); db.prepare( "DELETE FROM echonomy_activity_reward_hourly WHERE user_id = ? AND hour_start = ?" ).run(group.userId, group.hourStart); } catch (error) { console.error("Failed to apply queued activity reward", error); } } } function registerFramework(api) { if (!global.lumiFrameworks) { global.lumiFrameworks = {}; } global.lumiFrameworks.echonomy = api; } function buildApi({ db }) { return { getConfig: () => getConfig(db), getBalance: (userId) => getBalance(db, userId), addBalance: ({ userId, amount, note, meta, allowFrozen }) => grantBalance(db, { userId, amount, note, meta, allowFrozen }), removeBalance: ({ userId, amount, note, meta, allowFrozen }) => spendBalance(db, { userId, amount, note, meta, allowFrozen }), transferBalance: ({ fromUserId, toUserId, amount, note, meta, allowFrozen }) => transferBalance(db, { fromUserId, toUserId, amount, note, meta, allowFrozen }), createTransaction: (payload) => applyTransaction(db, payload), on: (event, handler) => emitter.on(event, handler), off: (event, handler) => emitter.off(event, handler) }; } function registerCommands({ db, settings, commandRouter }) { if (!commandRouter) { return null; } const rebuild = () => { const config = getConfig(db); const platforms = []; if (config.platforms.discord) { platforms.push("discord"); } if (config.platforms.twitch) { platforms.push("twitch"); } if (config.platforms.youtube) { platforms.push("youtube"); } if (!platforms.length) { commandRouter.registerCommands(PLUGIN_ID, []); return; } const triggers = [config.command.root, ...config.command.aliases]; commandRouter.registerCommands(PLUGIN_ID, [ { id: "echonomy:root", triggers, platforms, handler: (ctx) => handleCoinsCommand({ ctx, db, settings }) } ]); }; rebuild(); return rebuild; } async function handleCoinsCommand({ ctx, db, settings }) { const config = getConfig(db); const prefix = settings.getSetting("command_prefix", "!"); const root = config.command.root; const subcommand = (ctx.args[0] || "balance").toLowerCase(); const args = ctx.args.slice(1); const usageRoot = `${prefix}${root}`; const baseTokens = { currency_name: config.currency.name, currency_plural: config.currency.plural, funds_label: config.communityFunds.plural, fund_label: config.communityFunds.name }; if (subcommand === "help") { await respond(ctx, config, "help", { ...baseTokens, help: buildHelpText({ prefix, root }) }); return true; } if (["balance", "bal", "me"].includes(subcommand)) { const balance = getBalance(db, ctx.user.id); await respond(ctx, config, "balance_self", { ...baseTokens, balance, balance_text: formatCurrency(balance, config) }); return true; } if (["top", "leaderboard"].includes(subcommand)) { const top = listTopBalances(db, 5); if (!top.length) { await respond(ctx, config, "top_empty", baseTokens); return true; } const lines = top .map((entry, index) => `${index + 1}. ${entry.username}: ${entry.balance}`) .join(" | "); await respond(ctx, config, "top_list", { ...baseTokens, lines }); return true; } if (subcommand === "stats") { const stats = buildGlobalStats(db); await respond(ctx, config, "stats", { ...baseTokens, total_balance: stats.totalBalance, total_balance_text: formatCurrency(stats.totalBalance, config), total_spent: stats.totalSpent, total_spent_text: formatCurrency(stats.totalSpent, config) }); return true; } if (["pay", "give", "transfer"].includes(subcommand)) { const targetToken = args[0]; const amount = parseAmount(args[1]); if (!targetToken || !Number.isFinite(amount)) { await respond(ctx, config, "pay_missing", { ...baseTokens, usage: `${usageRoot} pay [note]` }); return true; } const cooldownLeft = getCooldownLeft(ctx.user.id, config); if (cooldownLeft > 0) { await respond(ctx, config, "pay_cooldown", { ...baseTokens, cooldown: cooldownLeft }); return true; } const note = args.slice(2).join(" ").trim(); const target = await resolveTargetUser(db, ctx, targetToken); if (!target) { await respond(ctx, config, "pay_not_found", baseTokens); return true; } if (target.profile.id === ctx.user.id) { await respond(ctx, config, "pay_self", baseTokens); return true; } const success = transferBalance(db, { fromUserId: ctx.user.id, toUserId: target.profile.id, amount, note, meta: { platform: ctx.platform } }); if (!success.ok) { await respond(ctx, config, "pay_insufficient", { ...baseTokens, reason: success.message || "Transfer failed." }); return true; } setCooldown(ctx.user.id); await respond(ctx, config, "pay_success", { ...baseTokens, amount, amount_text: formatCurrency(amount, config), target: target.label }); return true; } if (["grant", "giveadmin"].includes(subcommand)) { const role = getRoleFlags(ctx); if (!role.isAdmin && !role.isMod) { await respond(ctx, config, "permission_denied", baseTokens); return true; } const targetToken = args[0]; const amount = parseAmount(args[1]); if (!targetToken || !Number.isFinite(amount)) { await respond(ctx, config, "pay_missing", { ...baseTokens, usage: `${usageRoot} grant [note]` }); return true; } const note = args.slice(2).join(" ").trim(); const target = await resolveTargetUser(db, ctx, targetToken); if (!target) { await respond(ctx, config, "pay_not_found", baseTokens); return true; } grantBalance(db, { userId: target.profile.id, amount, note, meta: { actorId: ctx.user.id, platform: ctx.platform } }); await respond(ctx, config, "grant_success", { ...baseTokens, amount, amount_text: formatCurrency(amount, config), target: target.label }); return true; } if (["take", "remove"].includes(subcommand)) { const role = getRoleFlags(ctx); if (!role.isAdmin && !role.isMod) { await respond(ctx, config, "permission_denied", baseTokens); return true; } const targetToken = args[0]; const amount = parseAmount(args[1]); if (!targetToken || !Number.isFinite(amount)) { await respond(ctx, config, "pay_missing", { ...baseTokens, usage: `${usageRoot} take [note]` }); return true; } const note = args.slice(2).join(" ").trim(); const target = await resolveTargetUser(db, ctx, targetToken); if (!target) { await respond(ctx, config, "pay_not_found", baseTokens); return true; } spendBalance(db, { userId: target.profile.id, amount, note, meta: { actorId: ctx.user.id, platform: ctx.platform } }); await respond(ctx, config, "take_success", { ...baseTokens, amount, amount_text: formatCurrency(amount, config), target: target.label }); return true; } if (["funds", "fund", "goals"].includes(subcommand)) { const funds = listFunds(db); if (!funds.length) { await respond(ctx, config, "funds_empty", baseTokens); return true; } const lines = funds .map((fund) => `${fund.name}: ${fund.current_amount}/${fund.target_amount}`) .join(" | "); await respond(ctx, config, "funds_list", { ...baseTokens, lines }); return true; } if (subcommand === "donate") { const fundName = args[0]; const amount = parseAmount(args[1]); if (!fundName || !Number.isFinite(amount)) { await respond(ctx, config, "fund_missing", { ...baseTokens, usage: `${usageRoot} donate ` }); return true; } const cooldownLeft = getCooldownLeft(ctx.user.id, config); if (cooldownLeft > 0) { await respond(ctx, config, "pay_cooldown", { ...baseTokens, cooldown: cooldownLeft }); return true; } const fund = findFund(db, fundName); if (!fund || fund.status !== "active") { await respond(ctx, config, "fund_not_found", baseTokens); return true; } const success = spendBalance(db, { userId: ctx.user.id, amount, note: `Donation to ${fund.name}`, meta: { fundId: fund.id } }); if (!success.ok) { await respond(ctx, config, "pay_insufficient", { ...baseTokens, reason: success.message || "Donation failed." }); return true; } addFundContribution(db, fund.id, ctx.user.id, amount); setCooldown(ctx.user.id); await respond(ctx, config, "fund_donate_success", { ...baseTokens, amount, amount_text: formatCurrency(amount, config), fund: fund.name }); return true; } if (subcommand === "reward") { const role = getRoleFlags(ctx); if (!role.isAdmin && !role.isMod) { await respond(ctx, config, "permission_denied", baseTokens); return true; } const eventKey = args[0]; const targetToken = args[1]; if (!eventKey || !targetToken) { await respond(ctx, config, "reward_missing", { ...baseTokens, usage: `${usageRoot} reward ` }); return true; } const event = getCustomEvents(config).find( (entry) => entry.id === eventKey || entry.name.toLowerCase() === eventKey.toLowerCase() ); if (!event) { await respond(ctx, config, "reward_not_found", baseTokens); return true; } const target = await resolveTargetUser(db, ctx, targetToken); if (!target) { await respond(ctx, config, "pay_not_found", baseTokens); return true; } grantBalance(db, { userId: target.profile.id, amount: event.amount, note: `Event reward: ${event.name}`, meta: { eventId: event.id } }); await respond(ctx, config, "reward_success", { ...baseTokens, amount: event.amount, amount_text: formatCurrency(event.amount, config), target: target.label }); return true; } await respond(ctx, config, "help", { ...baseTokens, help: buildHelpText({ prefix, root }) }); return true; } function buildHelpText({ prefix, root }) { return ( `Commands: ${prefix}${root} balance | ${prefix}${root} pay | ` + `${prefix}${root} top | ${prefix}${root} stats | ${prefix}${root} funds | ` + `${prefix}${root} donate ` ); } function getRoleFlags(ctx) { if (ctx.platform === "discord") { const roles = ctx.meta?.message?.member?.roles?.cache; if (!roles) { return { isAdmin: false, isMod: false }; } const adminIds = parseList(settingsApi?.getSetting?.("discord_admin_role_id")); const modIds = parseList(settingsApi?.getSetting?.("discord_mod_role_id")); const roleIds = Array.from(roles.keys()); const isAdmin = roleIds.some((roleId) => adminIds.includes(roleId)); const isMod = roleIds.some((roleId) => modIds.includes(roleId)); return { isAdmin, isMod }; } if (ctx.platform === "twitch") { const badges = ctx.meta?.tags?.badges || {}; const isAdmin = Boolean(badges.broadcaster); const isMod = Boolean(ctx.meta?.tags?.mod || badges.moderator); return { isAdmin, isMod }; } if (ctx.platform === "youtube") { const author = ctx.meta?.author || {}; const isAdmin = Boolean(author.isChatOwner); const isMod = Boolean(author.isChatModerator); return { isAdmin, isMod }; } return { isAdmin: false, isMod: false }; } function parseAmount(value) { if (value === undefined || value === null) { return NaN; } const number = Number(value); if (!Number.isFinite(number)) { return NaN; } if (number <= 0) { return NaN; } return Math.floor(number); } function parseSignedAmount(value) { if (value === undefined || value === null) { return NaN; } const number = Number(value); if (!Number.isFinite(number)) { return NaN; } if (number === 0) { return 0; } const rounded = number > 0 ? Math.floor(number) : Math.ceil(number); return rounded; } function formatCurrency(amount, config) { const name = amount === 1 ? config.currency.name : config.currency.plural; return `${amount} ${name}`; } function pickResponse(template) { const replies = Array.isArray(template?.replies) ? template.replies : []; if (!replies.length) { return ""; } if (template.mode === "weighted") { const total = replies.reduce( (sum, entry) => sum + Math.max(0, Number(entry.weight || 0)), 0 ); if (total > 0) { let roll = Math.random() * total; for (const entry of replies) { roll -= Math.max(0, Number(entry.weight || 0)); if (roll <= 0) { return entry.text || ""; } } } } const fallback = replies[Math.floor(Math.random() * replies.length)]; return fallback?.text || ""; } function renderTemplate(text, tokens) { const safeText = (text || "").toString(); return safeText.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => { if (Object.prototype.hasOwnProperty.call(tokens, key)) { return tokens[key]; } return `{${key}}`; }); } function buildResponse(config, key, tokens) { const template = config.responses?.[key] || DEFAULT_RESPONSES[key]; if (!template) { return ""; } const text = pickResponse(template); if (!text) { return ""; } return renderTemplate(text, tokens); } async function respond(ctx, config, key, tokens) { const message = buildResponse(config, key, tokens); if (!message) { return; } await ctx.reply(message); } function escapeHtml(value) { return (value || "") .toString() .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function installProfileHook(app, getConfig) { if (!app || app.__echonomyProfileHookInstalled) { return; } app.__echonomyProfileHookInstalled = true; const originalRender = app.render.bind(app); app.render = (view, options, callback) => { if (typeof options === "function") { callback = options; options = {}; } if (typeof callback !== "function" || view !== "profile") { return originalRender(view, options, callback); } const config = getConfig ? getConfig() : null; if (!config?.banking?.enabled) { return originalRender(view, options, callback); } return originalRender(view, options, (err, html) => { if (err) { return callback(err); } try { if (!html.includes('href="/profile/banking"')) { const label = escapeHtml(config.banking.label || "Banking"); const marker = '
'; if (html.includes(marker)) { html = html.replace( marker, `${marker}\n ${label}` ); } } } catch { // ignore injection errors } return callback(null, html); }); }; } function getCooldownLeft(userId, config) { const last = transferCooldowns.get(userId) || 0; const now = Date.now(); const cooldown = (config.cooldownSeconds || 10) * 1000; const diff = cooldown - (now - last); return diff > 0 ? Math.ceil(diff / 1000) : 0; } function setCooldown(userId) { transferCooldowns.set(userId, Date.now()); } async function resolveTargetUser(db, ctx, token) { if (!token) { return null; } if (ctx.platform === "discord") { const message = ctx.meta?.message; if (message?.mentions?.users?.first) { const mention = message.mentions.users.first(); const display = mention.globalName || mention.username || mention.tag || mention.id; const profile = ensureUserForIdentity({ provider: "discord", providerUserId: mention.id, displayName: display, avatar: mention.avatar ? `https://cdn.discordapp.com/avatars/${mention.id}/${mention.avatar}.png?size=128` : null }); return { profile, label: `<@${mention.id}>` }; } const idMatch = token.match(/^<@!?(\d+)>$/) || token.match(/^(\d{15,})$/); if (idMatch) { const profile = ensureUserForIdentity({ provider: "discord", providerUserId: idMatch[1], displayName: idMatch[1] }); return { profile, label: `<@${idMatch[1]}>` }; } } const cleaned = token.replace(/^@/, "").trim(); if (!cleaned) { return null; } const internal = findUserByInternalName(db, cleaned); if (internal) { return { profile: internal, label: internal.internal_username }; } if (ctx.platform === "twitch") { const profile = ensureUserForIdentity({ provider: "twitch_login", providerUserId: cleaned.toLowerCase(), displayName: cleaned, fallbackName: cleaned }); return { profile, label: `@${cleaned}` }; } if (ctx.platform === "youtube") { const profile = ensureUserForIdentity({ provider: "youtube_name", providerUserId: cleaned.toLowerCase(), displayName: cleaned, fallbackName: cleaned }); return { profile, label: cleaned }; } const profile = ensureUserForIdentity({ provider: "echonomy_name", providerUserId: cleaned.toLowerCase(), displayName: cleaned, fallbackName: cleaned }); return { profile, label: cleaned }; } function findUserByInternalName(db, name) { return db .prepare( "SELECT id, internal_username FROM user_profiles WHERE lower(internal_username) = lower(?)" ) .get(name); } function ensureAccount(db, userId) { db.prepare( "INSERT INTO echonomy_accounts (user_id, balance, updated_at) VALUES (?, 0, ?) " + "ON CONFLICT(user_id) DO UPDATE SET updated_at = excluded.updated_at" ).run(userId, Date.now()); } function getBalance(db, userId) { if (!userId) { return 0; } const row = db .prepare("SELECT balance FROM echonomy_accounts WHERE user_id = ?") .get(userId); return row?.balance ?? 0; } function updateBalance(db, userId, delta) { ensureAccount(db, userId); db.prepare( "UPDATE echonomy_accounts SET balance = balance + ?, updated_at = ? WHERE user_id = ?" ).run(delta, Date.now(), userId); } function isFrozenUser(userId) { try { return Boolean(global.lumiModeration?.isFrozen?.(userId)); } catch { return false; } } function applyTransaction(db, payload) { const amount = Math.abs(Number(payload.amount)); if (!Number.isFinite(amount) || amount <= 0) { throw new Error("Invalid amount."); } const id = payload.id || crypto.randomUUID(); const now = Date.now(); const fromUserId = payload.fromUserId || null; const toUserId = payload.toUserId || null; const note = payload.note || null; const meta = payload.meta ? JSON.stringify(payload.meta) : null; if (!payload.allowFrozen) { if (fromUserId && isFrozenUser(fromUserId)) { throw new Error("Account is frozen."); } if (toUserId && isFrozenUser(toUserId)) { throw new Error("Account is frozen."); } } db.transaction(() => { if (fromUserId) { ensureAccount(db, fromUserId); const current = getBalance(db, fromUserId); if (!payload.allowNegative && current < amount) { throw new Error("Insufficient balance."); } updateBalance(db, fromUserId, -amount); } if (toUserId) { ensureAccount(db, toUserId); updateBalance(db, toUserId, amount); } db.prepare( "INSERT INTO echonomy_transactions (id, type, amount, from_user_id, to_user_id, note, meta, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" ).run( id, payload.type || "transaction", amount, fromUserId, toUserId, note, meta, now ); })(); emitter.emit("transaction", { id, type: payload.type, amount, fromUserId, toUserId, note, meta: payload.meta || null, createdAt: now }); return id; } function transferBalance(db, { fromUserId, toUserId, amount, note, meta, allowFrozen }) { try { applyTransaction(db, { type: "transfer", amount, fromUserId, toUserId, note, meta, allowNegative: false, allowFrozen: Boolean(allowFrozen) }); return { ok: true }; } catch (error) { return { ok: false, message: error.message || "Transfer failed." }; } } function grantBalance(db, { userId, amount, note, meta, allowFrozen }) { return applyTransaction(db, { type: "earn", amount, fromUserId: null, toUserId: userId, note, meta, allowNegative: true, allowFrozen: Boolean(allowFrozen) }); } function spendBalance(db, { userId, amount, note, meta, allowFrozen }) { try { applyTransaction(db, { type: "spend", amount, fromUserId: userId, toUserId: null, note, meta, allowNegative: false, allowFrozen: Boolean(allowFrozen) }); return { ok: true }; } catch (error) { return { ok: false, message: error.message || "Spend failed." }; } } function adjustBalance(db, { userId, amount, note, meta }) { if (amount === 0) { return; } if (amount > 0) { applyTransaction(db, { type: "adjust", amount, fromUserId: null, toUserId: userId, note, meta, allowNegative: true }); return; } applyTransaction(db, { type: "adjust", amount: Math.abs(amount), fromUserId: userId, toUserId: null, note, meta, allowNegative: true }); } function listTransactions(db, { userId, limit }) { const params = []; let where = ""; if (userId) { where = "WHERE t.from_user_id = ? OR t.to_user_id = ?"; params.push(userId, userId); } params.push(limit || 100); return db .prepare( "SELECT t.*, fromUser.internal_username AS from_name, toUser.internal_username AS to_name " + "FROM echonomy_transactions t " + "LEFT JOIN user_profiles AS fromUser ON fromUser.id = t.from_user_id " + "LEFT JOIN user_profiles AS toUser ON toUser.id = t.to_user_id " + `${where} ORDER BY t.created_at DESC LIMIT ?` ) .all(...params) .map((row) => normalizeTransactionRow(row)); } function normalizeTransactionRow(row) { const tx = { ...row }; const note = (row.note || "").toString(); const meta = parseTransactionMeta(row.meta); tx.meta_object = meta; tx.note_display = note || "-"; tx.note_search = note || ""; tx.activity_reward = null; if (meta?.source === "activity_reward") { const rewards = Array.isArray(meta.rewards) ? meta.rewards .map((entry) => ({ source: (entry?.source || "").toString(), label: ACTIVITY_REWARD_SOURCES[(entry?.source || "").toString()] || (entry?.label || entry?.source || "Activity"), amount: Number(entry?.amount || 0), hits: Number(entry?.hits || 0), minutes: Number(entry?.minutes || 0) })) .filter((entry) => entry.amount > 0) : []; tx.activity_reward = { hourStart: Number(meta.hourStart || 0), hourEnd: Number(meta.hourEnd || 0), rewards }; tx.note_display = ACTIVITY_REWARD_NOTE; tx.note_search = [ ACTIVITY_REWARD_NOTE, ...rewards.map((entry) => `${entry.label} ${entry.amount} ${entry.hits} ${entry.minutes}`) ].join(" "); } return tx; } function parseTransactionMeta(rawMeta) { if (!rawMeta) { return null; } if (typeof rawMeta === "object") { return rawMeta; } try { return JSON.parse(rawMeta); } catch { return null; } } function buildGlobalStats(db) { const totalBalance = db .prepare("SELECT COALESCE(SUM(balance), 0) AS total FROM echonomy_accounts") .get(); const totalSpent = db .prepare( "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + "WHERE from_user_id IS NOT NULL AND (to_user_id IS NULL OR to_user_id = '')" ) .get(); const totalTransactions = db .prepare("SELECT COUNT(*) AS count FROM echonomy_transactions") .get(); return { totalBalance: totalBalance?.total || 0, totalSpent: totalSpent?.total || 0, totalTransactions: totalTransactions?.count || 0 }; } function buildUserStats(db, userId) { if (!userId) { return { balance: 0, totalEarned: 0, totalSpent: 0, totalReceived: 0, totalSent: 0 }; } const balance = getBalance(db, userId); const totalEarned = db .prepare( "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + "WHERE to_user_id = ? AND (from_user_id IS NULL OR from_user_id = '')" ) .get(userId); const totalSpent = db .prepare( "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + "WHERE from_user_id = ? AND (to_user_id IS NULL OR to_user_id = '')" ) .get(userId); const totalReceived = db .prepare( "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + "WHERE to_user_id = ? AND from_user_id IS NOT NULL AND from_user_id != ''" ) .get(userId); const totalSent = db .prepare( "SELECT COALESCE(SUM(amount), 0) AS total FROM echonomy_transactions " + "WHERE from_user_id = ? AND to_user_id IS NOT NULL AND to_user_id != ''" ) .get(userId); return { balance, totalEarned: totalEarned?.total || 0, totalSpent: totalSpent?.total || 0, totalReceived: totalReceived?.total || 0, totalSent: totalSent?.total || 0 }; } function listTopBalances(db, limit) { return db .prepare( "SELECT user_profiles.internal_username AS username, echonomy_accounts.balance AS balance " + "FROM echonomy_accounts " + "JOIN user_profiles ON user_profiles.id = echonomy_accounts.user_id " + "ORDER BY echonomy_accounts.balance DESC LIMIT ?" ) .all(limit); } function listFunds(db) { return db .prepare("SELECT * FROM echonomy_pots WHERE status != 'archived' ORDER BY name") .all(); } function formatProviderLabel(provider) { const normalized = (provider || "").toLowerCase(); const map = { discord: "Discord", twitch: "Twitch", twitch_login: "Twitch", youtube: "YouTube", youtube_name: "YouTube", echonomy_name: "Internal" }; if (map[normalized]) { return map[normalized]; } if (!normalized) { return "Account"; } return normalized.charAt(0).toUpperCase() + normalized.slice(1); } function listUserDirectory(db) { const rows = db .prepare( "SELECT user_profiles.id AS user_id, user_profiles.internal_username AS internal_username, " + "user_identities.provider AS provider, user_identities.display_name AS display_name, " + "user_identities.provider_user_id AS provider_user_id " + "FROM user_profiles " + "LEFT JOIN user_identities ON user_identities.user_id = user_profiles.id " + "ORDER BY user_profiles.internal_username" ) .all(); const map = new Map(); rows.forEach((row) => { if (!map.has(row.user_id)) { map.set(row.user_id, { id: row.user_id, internal: row.internal_username || "", identities: [] }); } if (row.provider) { const display = row.display_name || row.provider_user_id || ""; map.get(row.user_id).identities.push({ provider: row.provider, label: formatProviderLabel(row.provider), display }); } }); return Array.from(map.values()); } function findFund(db, name) { return db .prepare("SELECT * FROM echonomy_pots WHERE lower(name) = lower(?)") .get(name); } function createFund(db, { name, description, targetAmount }) { const now = Date.now(); db.prepare( "INSERT INTO echonomy_pots (id, name, description, target_amount, current_amount, status, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 'active', ?, ?)" ).run(crypto.randomUUID(), name, description || "", targetAmount || 0, now, now); } function updateFund(db, { id, name, description, targetAmount, status }) { db.prepare( "UPDATE echonomy_pots SET name = ?, description = ?, target_amount = ?, status = ?, updated_at = ? WHERE id = ?" ).run( name, description || "", Number.isFinite(targetAmount) ? targetAmount : 0, status || "active", Date.now(), id ); } function addFundContribution(db, fundId, userId, amount) { const now = Date.now(); db.transaction(() => { db.prepare( "INSERT INTO echonomy_pot_contributions (id, pot_id, user_id, amount, created_at) VALUES (?, ?, ?, ?, ?)" ).run(crypto.randomUUID(), fundId, userId, amount, now); db.prepare( "UPDATE echonomy_pots SET current_amount = current_amount + ?, updated_at = ? WHERE id = ?" ).run(amount, now, fundId); })(); } function attachDiscordListeners({ db, settings, discordClient }) { if (!discordClient) { return; } discordClient.on("messageCreate", (message) => { if (!message || message.author?.bot) { return; } const config = getConfig(db); if (!config.platforms.discord || !config.earn.discordMessage.enabled) { return; } const userId = message.author.id; const key = `discord:${userId}`; const last = messageCooldowns.get(key) || 0; const now = Date.now(); if (now - last < config.earn.discordMessage.cooldown * 1000) { return; } const displayName = message.author.globalName || message.author.username || message.author.tag; const profile = ensureUserForIdentity({ provider: "discord", providerUserId: userId, displayName, avatar: message.author.avatar ? `https://cdn.discordapp.com/avatars/${userId}/${message.author.avatar}.png?size=128` : null }); const multiplier = getDiscordTierMultiplier(message, config); const reward = Math.max( 0, Math.floor(config.earn.discordMessage.amount * multiplier) ); if (reward > 0) { queueActivityReward(db, { userId: profile.id, source: "discord_message", amount: reward, hits: 1 }); messageCooldowns.set(key, now); } }); discordClient.on("voiceStateUpdate", (_oldState, newState) => { if (!newState?.member || newState.member.user?.bot) { return; } const userId = newState.member.id; const joined = Boolean(newState.channelId); if (!joined) { voiceStates.delete(userId); return; } if (!voiceStates.has(userId)) { voiceStates.set(userId, { member: newState.member, lastAwardAt: Date.now() }); } }); if (!voiceTimer) { voiceTimer = setInterval(() => { const config = getConfig(db); if (!config.platforms.discord || !config.earn.discordVoice.enabled) { return; } const tickMs = Math.max(1, config.earn.discordVoice.tickMinutes) * 60 * 1000; const rewardBase = config.earn.discordVoice.amountPerMin; const now = Date.now(); for (const [userId, state] of voiceStates.entries()) { const elapsed = now - state.lastAwardAt; if (elapsed < tickMs) { continue; } const minutes = Math.floor(elapsed / tickMs); const multiplier = getDiscordVoiceMultiplier(state.member, config); const reward = Math.max(0, Math.floor(rewardBase * minutes * multiplier)); if (reward > 0) { const profile = ensureUserForIdentity({ provider: "discord", providerUserId: userId, displayName: state.member.user.globalName || state.member.user.username || state.member.user.tag }); queueActivityReward(db, { userId: profile.id, source: "discord_voice", amount: reward, hits: 1, minutes: minutes * Math.max(1, config.earn.discordVoice.tickMinutes) }); } state.lastAwardAt = now; } }, 30000); } } function getDiscordTierMultiplier(message, config) { const boosterRoleId = message.guild?.premiumSubscriberRole?.id; if (!boosterRoleId) { return 1; } const hasBooster = message.member?.roles?.cache?.has(boosterRoleId); return hasBooster ? config.tiers.discordBooster : 1; } function getDiscordVoiceMultiplier(member, config) { const boosterRoleId = member?.guild?.premiumSubscriberRole?.id; if (!boosterRoleId) { return 1; } const hasBooster = member.roles?.cache?.has(boosterRoleId); return hasBooster ? config.tiers.discordBooster : 1; } function attachTwitchListeners({ db, settings, twitchClient }) { if (!twitchClient) { return; } twitchClient.on("message", (_channel, tags, _message, self) => { if (self) { return; } const config = getConfig(db); if (!config.platforms.twitch || !config.earn.twitchMessage.enabled) { return; } const userId = tags["user-id"]; if (!userId) { return; } const key = `twitch:${userId}`; const last = messageCooldowns.get(key) || 0; const now = Date.now(); if (now - last < config.earn.twitchMessage.cooldown * 1000) { return; } const displayName = tags["display-name"] || tags.username; const profile = ensureUserForIdentity({ provider: "twitch", providerUserId: userId, displayName }); const multiplier = getTwitchTierMultiplier(tags, config); const reward = Math.max(0, Math.floor(config.earn.twitchMessage.amount * multiplier)); if (reward > 0) { queueActivityReward(db, { userId: profile.id, source: "twitch_message", amount: reward, hits: 1 }); messageCooldowns.set(key, now); } }); } function getTwitchTierMultiplier(tags, config) { const badges = tags.badges || {}; if (badges.broadcaster) { return config.tiers.twitchBroadcaster; } if (badges.moderator || tags.mod) { return config.tiers.twitchMod; } if (badges.vip) { return config.tiers.twitchVip; } if (tags.subscriber) { return config.tiers.twitchSub; } return 1; }