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