Lumi/plugins/echonomy-framework/index.js
2026-05-30 20:37:42 +02:00

2363 lines
68 KiB
JavaScript

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 <user> <amount> [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 <user> <amount> [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 <user> <amount> [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 <fund> <amount>`
});
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 <event> <user>`
});
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 <user> <amount> | ` +
`${prefix}${root} top | ${prefix}${root} stats | ${prefix}${root} funds | ` +
`${prefix}${root} donate <fund> <amount>`
);
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 = '<div class="profile-actions">';
if (html.includes(marker)) {
html = html.replace(
marker,
`${marker}\n <a class="button" href="/profile/banking">${label}</a>`
);
}
}
} 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;
}