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 };