2363 lines
68 KiB
JavaScript
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, "&")
|
|
.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 = '<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;
|
|
}
|