388 lines
18 KiB
JavaScript
388 lines
18 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;
|
|
const DEFAULT_GROUP_ID = "core-default";
|
|
let defaultGroupEnsured = false;
|
|
|
|
function commandKey(pluginId, commandId) {
|
|
return `premade:${String(pluginId || "core")}:${String(commandId || "command")}`;
|
|
}
|
|
|
|
function customCommandKey(id) {
|
|
return `custom:${String(id)}`;
|
|
}
|
|
|
|
function listGroups() {
|
|
ensureDefaultGroup();
|
|
return db.prepare("SELECT * FROM command_groups ORDER BY name COLLATE NOCASE").all().map((row) => ({
|
|
...row,
|
|
is_default: Boolean(row.is_default),
|
|
policy: parsePolicy(row.policy_json)
|
|
}));
|
|
}
|
|
|
|
function ensureDefaultGroup() {
|
|
if (defaultGroupEnsured) return;
|
|
const now = Date.now();
|
|
db.prepare(
|
|
"INSERT OR IGNORE INTO command_groups (id, name, description, policy_json, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)"
|
|
).run(
|
|
DEFAULT_GROUP_ID,
|
|
"All commands (defaults)",
|
|
"Baseline settings inherited by every command unless that command overrides a category.",
|
|
"{}",
|
|
now,
|
|
now
|
|
);
|
|
db.prepare("UPDATE command_groups SET is_default = 1 WHERE id = ?").run(DEFAULT_GROUP_ID);
|
|
defaultGroupEnsured = true;
|
|
}
|
|
|
|
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) {
|
|
ensureDefaultGroup();
|
|
const group = db.prepare("SELECT is_default FROM command_groups WHERE id = ?").get(id);
|
|
if (group?.is_default) throw new Error("The default command group cannot be deleted.");
|
|
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) {
|
|
ensureDefaultGroup();
|
|
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 = ? AND g.is_default = 0 ORDER BY g.name COLLATE NOCASE"
|
|
).all(commandKeyValue).map((group) => ({ ...group, is_default: false, policy: parsePolicy(group.policy_json) }));
|
|
return { policy: row ? parsePolicy(row.policy_json) : {}, groups };
|
|
}
|
|
|
|
function saveCommandPolicy(commandKeyValue, policy, groupIds = []) {
|
|
saveCommandPolicies([{ commandKey: commandKeyValue, policy, groupIds }]);
|
|
}
|
|
|
|
function saveCommandPolicies(updates = []) {
|
|
const now = Date.now();
|
|
db.transaction(() => {
|
|
const upsertPolicy = 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"
|
|
);
|
|
const deletePolicy = db.prepare("DELETE FROM command_policies WHERE command_key = ?");
|
|
const deleteMembers = db.prepare("DELETE FROM command_group_members WHERE command_key = ?");
|
|
const insertMember = db.prepare("INSERT OR IGNORE INTO command_group_members (group_id, command_key, created_at) VALUES (?, ?, ?)");
|
|
for (const update of updates) {
|
|
const commandKeyValue = String(update?.commandKey || "");
|
|
if (!commandKeyValue) throw new Error("Command key is required.");
|
|
const normalized = normalizeStoredPolicy(update.policy);
|
|
if (Object.keys(normalized).length) upsertPolicy.run(commandKeyValue, JSON.stringify(normalized), now);
|
|
else deletePolicy.run(commandKeyValue);
|
|
deleteMembers.run(commandKeyValue);
|
|
for (const groupId of [...new Set((update.groupIds || []).map(String))]) {
|
|
if (groupId !== DEFAULT_GROUP_ID) insertMember.run(groupId, commandKeyValue, now);
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
|
|
function resolvePolicy(commandKeyValue, defaultPolicy = {}, { ignoreDirect = [] } = {}) {
|
|
const stored = getCommandPolicy(commandKeyValue);
|
|
const ignored = new Set(ignoreDirect);
|
|
const direct = Object.fromEntries(Object.entries(stored.policy).filter(([name]) => !ignored.has(name)));
|
|
const groupPolicies = stored.groups.map((group) => ({ id: group.id, name: group.name, policy: group.policy }));
|
|
const defaultRow = db.prepare("SELECT * FROM command_groups WHERE id = ?").get(DEFAULT_GROUP_ID);
|
|
const globalDefault = defaultRow ? { id: defaultRow.id, name: defaultRow.name, policy: parsePolicy(defaultRow.policy_json) } : null;
|
|
const defaults = normalizeStoredPolicy(defaultPolicy);
|
|
return {
|
|
windows: resolveRules(commandKeyValue, "window", direct, groupPolicies, globalDefault, defaults),
|
|
perStreams: resolveRules(commandKeyValue, "perStream", direct, groupPolicies, globalDefault, defaults),
|
|
requirements: resolveRequirements(direct, groupPolicies, globalDefault, defaults),
|
|
cost: resolveCost(direct, groupPolicies, globalDefault, defaults),
|
|
direct,
|
|
groups: stored.groups,
|
|
defaultGroup: globalDefault
|
|
};
|
|
}
|
|
|
|
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, globalDefault, 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 }] : [];
|
|
});
|
|
const defaultHasCategory = Boolean(globalDefault && Object.prototype.hasOwnProperty.call(globalDefault.policy, category));
|
|
const defaultValue = defaultHasCategory ? globalDefault.policy[category] : undefined;
|
|
if (defaultValue && defaultValue !== false) {
|
|
rules.push({ key: `group:${globalDefault.id}:${category}`, source: globalDefault.name, value: defaultValue });
|
|
}
|
|
if (rules.length) return rules;
|
|
if (defaultHasCategory && defaultValue === false) return [];
|
|
const fallback = defaults[category];
|
|
return fallback && fallback !== false ? [{ key: `${commandKeyValue}:default:${category}`, source: "default", value: fallback }] : [];
|
|
}
|
|
|
|
function resolveRequirements(direct, groups, globalDefault, 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 }]
|
|
: []);
|
|
const defaultHasRequirement = Boolean(globalDefault && Object.prototype.hasOwnProperty.call(globalDefault.policy, "requirement"));
|
|
const defaultRequirement = defaultHasRequirement ? globalDefault.policy.requirement : undefined;
|
|
if (defaultRequirement && defaultRequirement !== false) values.push({ source: globalDefault.name, value: defaultRequirement });
|
|
if (values.length) return values;
|
|
if (defaultHasRequirement && defaultRequirement === false) return [];
|
|
return defaults.requirement && defaults.requirement !== false ? [{ source: "default", value: defaults.requirement }] : [];
|
|
}
|
|
|
|
function resolveCost(direct, groups, globalDefault, 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);
|
|
const defaultHasCost = Boolean(globalDefault && Object.prototype.hasOwnProperty.call(globalDefault.policy, "cost"));
|
|
const defaultCost = defaultHasCost ? Number(globalDefault.policy.cost?.amount || 0) : 0;
|
|
if (defaultCost > 0) costs.push(defaultCost);
|
|
if (costs.length) return Math.max(...costs);
|
|
if (defaultHasCost && globalDefault.policy.cost === false) return 0;
|
|
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 = {
|
|
DEFAULT_GROUP_ID,
|
|
STREAM_RESTART_TOLERANCE_MS,
|
|
beginCommandUse,
|
|
commandKey,
|
|
createGroup,
|
|
customCommandKey,
|
|
deleteGroup,
|
|
ensureDefaultGroup,
|
|
getCommandPolicy,
|
|
labelRequirement,
|
|
listGroups,
|
|
normalizeStoredPolicy,
|
|
resolvePolicy,
|
|
saveCommandPolicy,
|
|
saveCommandPolicies,
|
|
updateGroup
|
|
};
|