Lumi/src/services/command-policies.js
2026-07-19 15:33:20 +02:00

332 lines
14 KiB
JavaScript

const crypto = require("crypto");
const { db } = require("./db");
const { checkCommandRequirement, labelRequirement } = require("./command-entitlements");
const { getProviderStreamIdentity } = require("./command-platform");
const { createLogger } = require("./logger");
const policyLog = createLogger("core:command-policies", { category: "command" });
const STREAM_RESTART_TOLERANCE_MS = 2 * 60 * 60 * 1000;
function commandKey(pluginId, commandId) {
return `premade:${String(pluginId || "core")}:${String(commandId || "command")}`;
}
function customCommandKey(id) {
return `custom:${String(id)}`;
}
function listGroups() {
return db.prepare("SELECT * FROM command_groups ORDER BY name COLLATE NOCASE").all().map((row) => ({
...row,
policy: parsePolicy(row.policy_json)
}));
}
function createGroup({ name, description = "", policy = {} }) {
const id = crypto.randomUUID();
const now = Date.now();
db.prepare("INSERT INTO command_groups (id, name, description, policy_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
.run(id, cleanName(name), String(description || "").trim().slice(0, 500), JSON.stringify(normalizeStoredPolicy(policy)), now, now);
return id;
}
function updateGroup(id, { name, description = "", policy = {} }) {
db.prepare("UPDATE command_groups SET name = ?, description = ?, policy_json = ?, updated_at = ? WHERE id = ?")
.run(cleanName(name), String(description || "").trim().slice(0, 500), JSON.stringify(normalizeStoredPolicy(policy)), Date.now(), id);
}
function deleteGroup(id) {
db.transaction(() => {
db.prepare("DELETE FROM command_group_members WHERE group_id = ?").run(id);
db.prepare("DELETE FROM command_groups WHERE id = ?").run(id);
})();
}
function getCommandPolicy(commandKeyValue) {
const row = db.prepare("SELECT policy_json FROM command_policies WHERE command_key = ?").get(commandKeyValue);
const groups = db.prepare(
"SELECT g.* FROM command_groups g JOIN command_group_members m ON m.group_id = g.id WHERE m.command_key = ? ORDER BY g.name COLLATE NOCASE"
).all(commandKeyValue).map((group) => ({ ...group, policy: parsePolicy(group.policy_json) }));
return { policy: row ? parsePolicy(row.policy_json) : {}, groups };
}
function saveCommandPolicy(commandKeyValue, policy, groupIds = []) {
const normalized = normalizeStoredPolicy(policy);
const now = Date.now();
db.transaction(() => {
if (Object.keys(normalized).length) {
db.prepare(
"INSERT INTO command_policies (command_key, policy_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(command_key) DO UPDATE SET policy_json = excluded.policy_json, updated_at = excluded.updated_at"
).run(commandKeyValue, JSON.stringify(normalized), now);
} else {
db.prepare("DELETE FROM command_policies WHERE command_key = ?").run(commandKeyValue);
}
db.prepare("DELETE FROM command_group_members WHERE command_key = ?").run(commandKeyValue);
const insert = db.prepare("INSERT OR IGNORE INTO command_group_members (group_id, command_key, created_at) VALUES (?, ?, ?)");
for (const groupId of [...new Set(groupIds.map(String))]) insert.run(groupId, commandKeyValue, now);
})();
}
function resolvePolicy(commandKeyValue, defaultPolicy = {}) {
const stored = getCommandPolicy(commandKeyValue);
const direct = stored.policy;
const groupPolicies = stored.groups.map((group) => ({ id: group.id, name: group.name, policy: group.policy }));
const defaults = normalizeStoredPolicy(defaultPolicy);
return {
windows: resolveRules(commandKeyValue, "window", direct, groupPolicies, defaults),
perStreams: resolveRules(commandKeyValue, "perStream", direct, groupPolicies, defaults),
requirements: resolveRequirements(direct, groupPolicies, defaults),
cost: resolveCost(direct, groupPolicies, defaults),
direct,
groups: stored.groups
};
}
async function beginCommandUse({ commandKey: key, ctx, defaultPolicy = {} }) {
const policy = resolvePolicy(key, defaultPolicy);
for (const requirement of policy.requirements) {
const checked = await checkCommandRequirement(ctx, requirement.value);
if (!checked.allowed) {
policyLog.info("Command requirement denied", {
command_key: key,
user_id: ctx.user?.id,
platform: ctx.platform,
requirement: requirement.value?.role,
source: requirement.source,
verification_error: checked.verificationError || null
}, { event: "command_requirement_denied" });
return { allowed: false, message: checked.reason };
}
}
let streamSessionId = null;
if (policy.perStreams.length) {
streamSessionId = await resolveStreamSession(ctx);
if (!streamSessionId) {
return { allowed: false, message: "Lumi cannot identify the current stream, so this stream-limited command cannot be used right now." };
}
}
const usageId = crypto.randomUUID();
const now = Date.now();
const userId = String(ctx.user?.id || "");
let limitMessage = null;
db.transaction(() => {
db.prepare("DELETE FROM command_policy_uses WHERE used_at < ?").run(now - 45 * 24 * 60 * 60 * 1000);
for (const rule of policy.windows) {
const scopedUser = rule.value.scope === "global" ? null : userId;
const count = db.prepare(
"SELECT COUNT(*) AS count FROM command_policy_uses WHERE rule_key = ? AND user_id IS ? AND used_at >= ?"
).get(rule.key, scopedUser, now - rule.value.seconds * 1000)?.count || 0;
if (count >= rule.value.uses) {
limitMessage = rule.value.scope === "global"
? `That command has reached its shared limit. Try again in up to ${formatDuration(rule.value.seconds)}.`
: `You have reached this command's limit. Try again in up to ${formatDuration(rule.value.seconds)}.`;
return;
}
}
for (const rule of policy.perStreams) {
const scopedUser = rule.value.scope === "global" ? null : userId;
const count = db.prepare(
"SELECT COUNT(*) AS count FROM command_policy_uses WHERE rule_key = ? AND stream_session_id = ? AND user_id IS ?"
).get(rule.key, streamSessionId, scopedUser)?.count || 0;
if (count >= rule.value.uses) {
limitMessage = rule.value.scope === "global"
? "That command has reached its limit for this stream."
: "You have reached this command's limit for this stream.";
return;
}
}
const insert = db.prepare(
"INSERT INTO command_policy_uses (id, command_key, rule_key, user_id, stream_session_id, used_at) VALUES (?, ?, ?, ?, ?, ?)"
);
for (const rule of policy.windows) {
insert.run(usageId, key, rule.key, rule.value.scope === "global" ? null : userId, null, now);
}
for (const rule of policy.perStreams) {
insert.run(usageId, key, rule.key, rule.value.scope === "global" ? null : userId, streamSessionId, now);
}
})();
if (limitMessage) return { allowed: false, message: limitMessage };
let charged = false;
if (policy.cost > 0) {
const economy = global.lumiFrameworks?.economy;
if (!economy?.removeBalance) {
releaseUsage(usageId);
return { allowed: false, message: "This command has a currency cost, but the Economy Framework is not available." };
}
let result;
try {
result = economy.removeBalance({
userId,
amount: policy.cost,
note: `Command cost: ${ctx.raw || key}`,
meta: { source: "command_cost", commandKey: key, trigger: ctx.trigger, platform: ctx.platform }
});
} catch (error) {
releaseUsage(usageId);
policyLog.error("Command cost charge failed", { command_key: key, user_id: userId, amount: policy.cost, error }, { event: "command_cost_failed" });
return { allowed: false, message: "The command cost could not be charged." };
}
if (!result?.ok) {
releaseUsage(usageId);
return { allowed: false, message: result?.message === "Insufficient balance."
? `You need ${policy.cost} currency to use this command.`
: (result?.message || "The command cost could not be charged.") };
}
charged = true;
}
let finished = false;
return {
allowed: true,
async commit() { finished = true; },
async rollback(reason = "Command did not complete") {
if (finished) return;
finished = true;
releaseUsage(usageId);
if (charged) refundCommandCost({ userId, amount: policy.cost, key, ctx, reason });
}
};
}
function releaseUsage(usageId) {
db.prepare("DELETE FROM command_policy_uses WHERE id = ?").run(usageId);
}
function refundCommandCost({ userId, amount, key, ctx, reason }) {
try {
global.lumiFrameworks?.economy?.addBalance?.({
userId,
amount,
note: `Command cost refund: ${ctx.raw || key}`,
meta: { source: "command_cost_refund", commandKey: key, reason }
});
} catch (error) {
policyLog.error("Command cost refund failed", { command_key: key, user_id: userId, amount, error }, { event: "command_cost_refund_failed" });
}
}
async function resolveStreamSession(ctx) {
const identity = await getProviderStreamIdentity(ctx);
if (!identity?.streamKey) return null;
const now = Date.now();
const existing = db.prepare("SELECT * FROM command_stream_sessions WHERE stream_key = ?").get(identity.streamKey);
const sameProviderStream = existing && identity.providerStreamId && existing.provider_stream_id === identity.providerStreamId;
const withinRestartTolerance = existing && now - existing.last_seen_at <= STREAM_RESTART_TOLERANCE_MS;
const sessionId = sameProviderStream || withinRestartTolerance ? existing.session_id : crypto.randomUUID();
const startedAt = sessionId === existing?.session_id ? existing.started_at : now;
db.prepare(
"INSERT INTO command_stream_sessions (stream_key, session_id, provider_stream_id, started_at, last_seen_at) VALUES (?, ?, ?, ?, ?) " +
"ON CONFLICT(stream_key) DO UPDATE SET session_id = excluded.session_id, provider_stream_id = excluded.provider_stream_id, started_at = excluded.started_at, last_seen_at = excluded.last_seen_at"
).run(identity.streamKey, sessionId, identity.providerStreamId || existing?.provider_stream_id || null, startedAt, now);
return sessionId;
}
function resolveRules(commandKeyValue, category, direct, groups, defaults) {
if (Object.prototype.hasOwnProperty.call(direct, category)) {
return direct[category] === false ? [] : [{ key: `${commandKeyValue}:${category}`, source: "command", value: direct[category] }];
}
const rules = groups.flatMap((group) => {
const value = group.policy[category];
return value && value !== false ? [{ key: `group:${group.id}:${category}`, source: group.name, value }] : [];
});
if (rules.length) return rules;
const fallback = defaults[category];
return fallback && fallback !== false ? [{ key: `${commandKeyValue}:default:${category}`, source: "default", value: fallback }] : [];
}
function resolveRequirements(direct, groups, defaults) {
if (Object.prototype.hasOwnProperty.call(direct, "requirement")) {
return direct.requirement === false ? [] : [{ source: "command", value: direct.requirement }];
}
const values = groups.flatMap((group) => group.policy.requirement && group.policy.requirement !== false
? [{ source: group.name, value: group.policy.requirement }]
: []);
if (values.length) return values;
return defaults.requirement && defaults.requirement !== false ? [{ source: "default", value: defaults.requirement }] : [];
}
function resolveCost(direct, groups, defaults) {
if (Object.prototype.hasOwnProperty.call(direct, "cost")) return direct.cost === false ? 0 : Number(direct.cost?.amount || 0);
const costs = groups.map((group) => Number(group.policy.cost?.amount || 0)).filter((amount) => amount > 0);
if (costs.length) return Math.max(...costs);
return Number(defaults.cost?.amount || 0);
}
function normalizeStoredPolicy(value) {
const input = value && typeof value === "object" ? value : {};
const output = {};
if (Object.prototype.hasOwnProperty.call(input, "window")) output.window = normalizeWindow(input.window);
if (Object.prototype.hasOwnProperty.call(input, "perStream")) output.perStream = normalizePerStream(input.perStream);
if (Object.prototype.hasOwnProperty.call(input, "requirement")) output.requirement = normalizeRequirement(input.requirement);
if (Object.prototype.hasOwnProperty.call(input, "cost")) output.cost = normalizeCost(input.cost);
return Object.fromEntries(Object.entries(output).filter(([, item]) => item !== undefined));
}
function normalizeWindow(value) {
if (value === false || value?.enabled === false) return false;
if (!value || typeof value !== "object") return undefined;
return {
uses: clampInteger(value.uses, 1, 100000),
seconds: clampInteger(value.seconds, 1, 31_536_000),
scope: value.scope === "global" ? "global" : "user"
};
}
function normalizePerStream(value) {
if (value === false || value?.enabled === false) return false;
if (!value || typeof value !== "object") return undefined;
return { uses: clampInteger(value.uses, 1, 100000), scope: value.scope === "global" ? "global" : "user" };
}
function normalizeRequirement(value) {
if (value === false || value?.enabled === false || value?.role === "public") return false;
const role = ["follower", "subscriber", "vip", "mod", "editor", "streamer"].includes(value?.role) ? value.role : null;
if (!role) return undefined;
return { role, subscriberTier: role === "subscriber" ? clampInteger(value.subscriberTier, 1, 3) : 1 };
}
function normalizeCost(value) {
if (value === false || value?.enabled === false || Number(value?.amount) <= 0) return false;
if (!value || typeof value !== "object") return undefined;
return { amount: clampInteger(value.amount, 1, 1_000_000_000) };
}
function parsePolicy(value) {
try { return normalizeStoredPolicy(JSON.parse(value || "{}")); } catch { return {}; }
}
function cleanName(value) {
const name = String(value || "").trim().slice(0, 100);
if (!name) throw new Error("Group name is required.");
return name;
}
function clampInteger(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, Math.round(Number(value) || minimum)));
}
function formatDuration(seconds) {
if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
const minutes = Math.ceil(seconds / 60);
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}
module.exports = {
STREAM_RESTART_TOLERANCE_MS,
beginCommandUse,
commandKey,
createGroup,
customCommandKey,
deleteGroup,
getCommandPolicy,
labelRequirement,
listGroups,
normalizeStoredPolicy,
resolvePolicy,
saveCommandPolicy,
updateGroup
};