Improve command policy administration
This commit is contained in:
parent
7b7e49507c
commit
4853092956
@ -1,5 +1,12 @@
|
||||
# Lumi changelog
|
||||
|
||||
## 0.2.19
|
||||
|
||||
- Added a protected, implicitly inherited default command group that can be edited but not deleted.
|
||||
- Replaced inherited policy inputs with accurate read-only effective summaries and made group changes recalculate those summaries live.
|
||||
- Added clearer policy-section hierarchy, friendly command-source badges, fuzzy group/command suggestions, and positive-only setting/source filters.
|
||||
- Replaced per-command submission with one atomic page-wide save so edits to multiple collapsed or filtered commands are retained together.
|
||||
|
||||
## 0.2.18
|
||||
|
||||
- Added centralized command groups and per-command overrides for time-window limits, crash-tolerant per-stream limits, platform roles/subscriber tiers, and Economy Framework costs with logged refunds.
|
||||
|
||||
7
TODO.md
7
TODO.md
@ -14,6 +14,13 @@ OBS replay saving works through local WebSocket and the credential-free Browser
|
||||
Bridge BASIC permission. Admin UI and focused verification cover the shared
|
||||
storage and enforcement path.
|
||||
|
||||
Follow-up completed on 2026-07-19: added a protected, implicitly inherited
|
||||
default group; accurate read-only inherited-value summaries; conditional child
|
||||
fields; clearer nested visual hierarchy; friendly command-source labels; fuzzy
|
||||
search suggestions with positive-only live setting/source filters; and atomic
|
||||
page-wide saving for every command so collapsed or filtered drafts survive the
|
||||
save.
|
||||
|
||||
Remaining work:
|
||||
|
||||
- Add platform action providers when YouTube or future chat integrations expose
|
||||
|
||||
@ -3,6 +3,11 @@
|
||||
Admins configure shared command rules at **Admin → Command access**. Moderators
|
||||
continue to create and edit custom replies at **Custom commands**.
|
||||
|
||||
The protected **All commands (defaults)** group applies implicitly to every
|
||||
command and cannot be deleted. Administrators can leave each category on
|
||||
**Keep command built-ins**, explicitly remove that category globally, or define
|
||||
a new global value. Additional groups remain assignable to selected commands.
|
||||
|
||||
## Built-in stream commands
|
||||
|
||||
- `!clip [optional label]` requests a clip on the platform that delivered the
|
||||
@ -58,6 +63,17 @@ Each command category has three choices:
|
||||
category.
|
||||
- **Set** replaces inherited values for that category on this command.
|
||||
|
||||
When **Use groups/default** is selected, the editable child fields are hidden and
|
||||
the effective inherited value is shown read-only. Changing assigned groups
|
||||
updates that summary immediately. Command settings use one page-wide save action,
|
||||
so edits to collapsed or filtered commands are submitted atomically instead of
|
||||
being lost when another command is saved.
|
||||
|
||||
Group and command lists provide fuzzy search suggestions and live setting
|
||||
filters. Non-matches are hidden while searching. Command sources use friendly
|
||||
labels such as **Built-in command**, **Custom command**, or the plugin's display
|
||||
name rather than internal registry identifiers.
|
||||
|
||||
Time-window limits support a shared global pool or one pool per Lumi user.
|
||||
Per-stream limits also support global or per-user counting. Stream identities
|
||||
are persisted, and a new provider stream that appears within two hours reuses
|
||||
|
||||
@ -14,7 +14,7 @@ editable: false
|
||||
Lumi is the core web UI and bot runtime.
|
||||
## Runtime
|
||||
Package: lumi-bot
|
||||
Version: 0.2.18
|
||||
Version: 0.2.19
|
||||
## Routes
|
||||
- GET /api/events
|
||||
- POST /api/destructive-confirmations
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.18",
|
||||
"version": "0.2.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.18",
|
||||
"version": "0.2.19",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.12",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.18",
|
||||
"version": "0.2.19",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@ -2,6 +2,36 @@
|
||||
"schema_version": 1,
|
||||
"channel": "stable",
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.2.19",
|
||||
"ref": "refs/tags/v0.2.19",
|
||||
"released_at": "2026-07-19",
|
||||
"installable": true,
|
||||
"rollback_safe": true,
|
||||
"replaces_versions": [
|
||||
"1.2.0"
|
||||
],
|
||||
"data_policy": "preserve",
|
||||
"dependency_policy": "sync_on_restart",
|
||||
"migration_notes": "Improves command-policy administration with a protected inherited default group, conditional effective-value summaries, fuzzy searching and filters, clearer command origins, and atomic multi-command saves. Existing commands, policies, groups, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets are preserved.",
|
||||
"plugins": {
|
||||
"auto-vc": "0.1.6",
|
||||
"birthday": "0.1.3",
|
||||
"economy-framework": "0.2.10",
|
||||
"economy-games": "0.1.7",
|
||||
"expression-interaction": "0.2.1",
|
||||
"lumi_ai": "0.8.5",
|
||||
"moderation": "0.1.5",
|
||||
"okf": "0.1.1",
|
||||
"quotes": "0.1.2",
|
||||
"sample-plugin": "0.1.0",
|
||||
"throne_wishlist": "0.1.2",
|
||||
"welcome_messages": "0.1.1"
|
||||
},
|
||||
"tools": {
|
||||
"lumi_ai_web_search": "0.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.2.18",
|
||||
"ref": "refs/tags/v0.2.18",
|
||||
|
||||
@ -5,14 +5,18 @@ const path = require("path");
|
||||
const root = path.join(__dirname, "..");
|
||||
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-command-policies-"));
|
||||
const serviceDir = path.join(sandbox, "src", "services");
|
||||
let database = null;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
fs.cpSync(path.join(root, "src", "services"), serviceDir, { recursive: true });
|
||||
const database = require(path.join(serviceDir, "db.js"));
|
||||
database = require(path.join(serviceDir, "db.js"));
|
||||
database.migrate();
|
||||
const conditional = require(path.join(serviceDir, "command-conditional.js"));
|
||||
const policies = require(path.join(serviceDir, "command-policies.js"));
|
||||
const defaultGroup = policies.listGroups().find((group) => group.is_default);
|
||||
assert(defaultGroup, "a protected default command group must be created automatically");
|
||||
assert.throws(() => policies.deleteGroup(defaultGroup.id), /cannot be deleted/);
|
||||
|
||||
const replies = conditional.normalizeConditionalReplies([
|
||||
{ keyword: "backseating", response: "Please avoid backseating." },
|
||||
@ -43,6 +47,18 @@ try {
|
||||
assert.strictEqual(resolved.windows[0].value.uses, 1);
|
||||
assert.strictEqual(resolved.requirements.length, 0, "a direct public setting must replace group roles");
|
||||
assert.strictEqual(resolved.cost, 2, "a direct cost must replace the group cost");
|
||||
policies.updateGroup(defaultGroup.id, {
|
||||
name: defaultGroup.name,
|
||||
description: defaultGroup.description,
|
||||
policy: { window: { uses: 4, seconds: 90, scope: "global" } }
|
||||
});
|
||||
const inheritedKey = policies.commandKey("verification", "inherited");
|
||||
assert.strictEqual(policies.resolvePolicy(inheritedKey).windows[0].value.uses, 4, "the default group must apply without explicit membership");
|
||||
policies.saveCommandPolicies([
|
||||
{ commandKey: inheritedKey, policy: { cost: { amount: 3 } }, groupIds: [] },
|
||||
{ commandKey: policies.commandKey("verification", "second"), policy: { requirement: false }, groupIds: [groupId] }
|
||||
]);
|
||||
assert.strictEqual(policies.resolvePolicy(inheritedKey).cost, 3, "batch saves must persist every submitted command");
|
||||
|
||||
const charges = [];
|
||||
global.lumiFrameworks = {
|
||||
@ -73,6 +89,7 @@ try {
|
||||
const dbColumns = database.db.prepare("PRAGMA table_info(custom_commands)").all().map((column) => column.name);
|
||||
assert(dbColumns.includes("conditional_replies_json"));
|
||||
assert(dbColumns.includes("conditional_fuzzy"));
|
||||
assert(database.db.prepare("PRAGMA table_info(command_groups)").all().some((column) => column.name === "is_default"));
|
||||
for (const table of ["command_groups", "command_group_members", "command_policies", "command_policy_uses", "command_stream_sessions"]) {
|
||||
assert(database.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table), `missing ${table}`);
|
||||
}
|
||||
@ -81,13 +98,17 @@ try {
|
||||
const view = fs.readFileSync(path.join(root, "src", "web", "views", "admin-command-policies.ejs"), "utf8");
|
||||
const commandView = fs.readFileSync(path.join(root, "src", "web", "views", "admin-commands.ejs"), "utf8");
|
||||
assert(server.includes('/admin/command-policies') && server.includes('commandPolicyFromBody'));
|
||||
assert(view.includes("Command groups") && view.includes("Save command settings"));
|
||||
assert(view.includes("Command groups") && view.includes("Save all command settings"));
|
||||
assert(view.includes("data-policy-search") && view.includes("data-policy-setting-filter"));
|
||||
assert(view.includes("Protected default") && view.includes("data-command-policy-batch"));
|
||||
assert(commandView.includes('value="conditional"') && commandView.includes("data-conditional-replies"));
|
||||
|
||||
database.db.close();
|
||||
database = null;
|
||||
console.log("Command policies and conditional commands verified.");
|
||||
} finally {
|
||||
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||
try { database?.db?.close?.(); } catch {}
|
||||
fs.rmSync(sandbox, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
@ -4,8 +4,8 @@ const path = require("path");
|
||||
const { findSafeTarget } = require("../src/services/versioning");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const releaseVersion = "0.2.18";
|
||||
const previousCoreVersion = "0.2.17";
|
||||
const releaseVersion = "0.2.19";
|
||||
const previousCoreVersion = "0.2.18";
|
||||
const earliestCompatibleCoreVersion = "0.1.9";
|
||||
const changedPlugins = {
|
||||
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
|
||||
@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
|
||||
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
||||
|
||||
console.log("Release metadata verification passed: core 0.2.18, Lumi AI 0.8.5, and synchronized package metadata.");
|
||||
console.log("Release metadata verification passed: core 0.2.19, Lumi AI 0.8.5, and synchronized package metadata.");
|
||||
|
||||
@ -16,7 +16,7 @@ function readJson(relativePath) {
|
||||
|
||||
const releaseIndex = readJson("release-index.json");
|
||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
||||
assert.deepEqual(releaseVersions, ["0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
|
||||
assert.deepEqual(releaseVersions, ["0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
|
||||
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||
for (const release of releaseIndex.releases) {
|
||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
|
||||
const baseTarget = {
|
||||
current_version: "0.2.4",
|
||||
available_versions: [
|
||||
{ version: "0.2.19", ref: "refs/tags/v0.2.19", rollback_safe: true },
|
||||
{ version: "0.2.18", ref: "refs/tags/v0.2.18", rollback_safe: true },
|
||||
{ version: "0.2.17", ref: "refs/tags/v0.2.17", rollback_safe: true },
|
||||
{ version: "0.2.16", ref: "refs/tags/v0.2.16", rollback_safe: true },
|
||||
@ -78,7 +79,7 @@ const corrected = buildStatus({
|
||||
channel: "stable"
|
||||
});
|
||||
assert.equal(corrected.version_correction, true);
|
||||
assert.equal(corrected.safe_target_version, "0.2.18");
|
||||
assert.equal(corrected.safe_target_version, "0.2.19");
|
||||
assert.equal(corrected.update_available, true);
|
||||
assert.equal(corrected.blocked, false);
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@ 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")}`;
|
||||
@ -16,12 +18,31 @@ function customCommandKey(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();
|
||||
@ -36,6 +57,9 @@ function updateGroup(id, { name, description = "", policy = {} }) {
|
||||
}
|
||||
|
||||
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);
|
||||
@ -43,42 +67,57 @@ function deleteGroup(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 = ? ORDER BY g.name COLLATE NOCASE"
|
||||
).all(commandKeyValue).map((group) => ({ ...group, policy: parsePolicy(group.policy_json) }));
|
||||
"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 = []) {
|
||||
const normalized = normalizeStoredPolicy(policy);
|
||||
saveCommandPolicies([{ commandKey: commandKeyValue, policy, groupIds }]);
|
||||
}
|
||||
|
||||
function saveCommandPolicies(updates = []) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 = {}) {
|
||||
function resolvePolicy(commandKeyValue, defaultPolicy = {}, { ignoreDirect = [] } = {}) {
|
||||
const stored = getCommandPolicy(commandKeyValue);
|
||||
const direct = stored.policy;
|
||||
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, defaults),
|
||||
perStreams: resolveRules(commandKeyValue, "perStream", direct, groupPolicies, defaults),
|
||||
requirements: resolveRequirements(direct, groupPolicies, defaults),
|
||||
cost: resolveCost(direct, groupPolicies, defaults),
|
||||
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
|
||||
groups: stored.groups,
|
||||
defaultGroup: globalDefault
|
||||
};
|
||||
}
|
||||
|
||||
@ -224,7 +263,7 @@ async function resolveStreamSession(ctx) {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function resolveRules(commandKeyValue, category, direct, groups, defaults) {
|
||||
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] }];
|
||||
}
|
||||
@ -232,26 +271,40 @@ function resolveRules(commandKeyValue, category, direct, groups, defaults) {
|
||||
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, defaults) {
|
||||
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, defaults) {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -315,17 +368,20 @@ function formatDuration(seconds) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_GROUP_ID,
|
||||
STREAM_RESTART_TOLERANCE_MS,
|
||||
beginCommandUse,
|
||||
commandKey,
|
||||
createGroup,
|
||||
customCommandKey,
|
||||
deleteGroup,
|
||||
ensureDefaultGroup,
|
||||
getCommandPolicy,
|
||||
labelRequirement,
|
||||
listGroups,
|
||||
normalizeStoredPolicy,
|
||||
resolvePolicy,
|
||||
saveCommandPolicy,
|
||||
saveCommandPolicies,
|
||||
updateGroup
|
||||
};
|
||||
|
||||
@ -152,6 +152,7 @@ function migrate() {
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
policy_json TEXT NOT NULL DEFAULT '{}',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@ -410,6 +411,14 @@ function migrate() {
|
||||
if (!columns.includes("conditional_fuzzy")) {
|
||||
db.exec("ALTER TABLE custom_commands ADD COLUMN conditional_fuzzy INTEGER NOT NULL DEFAULT 1");
|
||||
}
|
||||
|
||||
const commandGroupColumns = db
|
||||
.prepare("PRAGMA table_info(command_groups)")
|
||||
.all()
|
||||
.map((column) => column.name);
|
||||
if (!commandGroupColumns.includes("is_default")) {
|
||||
db.exec("ALTER TABLE command_groups ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
if (!columns.includes("rng_enabled")) {
|
||||
db.exec("ALTER TABLE custom_commands ADD COLUMN rng_enabled INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
|
||||
@ -1038,14 +1038,34 @@ input[type="color"] {
|
||||
.command-policy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--lumi-space-3);
|
||||
gap: var(--lumi-space-4);
|
||||
}
|
||||
|
||||
.command-policy-section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--lumi-space-2);
|
||||
gap: var(--lumi-space-3);
|
||||
margin: 0;
|
||||
padding: var(--lumi-space-4);
|
||||
border: 1px solid var(--lumi-border-strong);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
box-shadow: inset 0 3px 0 color-mix(in srgb, var(--lumi-accent) 32%, transparent);
|
||||
}
|
||||
|
||||
.command-policy-section legend {
|
||||
padding: 0 var(--lumi-space-2);
|
||||
color: var(--lumi-text);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.command-policy-values[hidden],
|
||||
.command-policy-inherited[hidden],
|
||||
.command-policy-no-results[hidden],
|
||||
.command-policy-suggestions[hidden],
|
||||
[data-policy-item][hidden],
|
||||
[data-subscriber-tier][hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.command-policy-values {
|
||||
@ -1054,10 +1074,148 @@ input[type="color"] {
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.command-policy-item > form {
|
||||
.command-policy-inherited {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
padding: var(--lumi-space-3);
|
||||
border-left: 3px solid var(--lumi-accent);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.command-policy-inherited span {
|
||||
color: var(--lumi-text-muted);
|
||||
font-size: var(--lumi-font-size-sm);
|
||||
}
|
||||
|
||||
.command-policy-item > form,
|
||||
.command-policy-item > .command-policy-editor {
|
||||
margin-top: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.command-policy-item[open] {
|
||||
border-color: var(--lumi-border-strong);
|
||||
background: var(--lumi-surface);
|
||||
}
|
||||
|
||||
.command-policy-item[open] > summary {
|
||||
border-bottom: 1px solid var(--lumi-border);
|
||||
}
|
||||
|
||||
.command-policy-item.is-default-group {
|
||||
border-color: color-mix(in srgb, var(--lumi-accent) 58%, var(--lumi-border));
|
||||
}
|
||||
|
||||
.command-policy-summary-copy {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.command-policy-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.command-policy-toolbar {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(15rem, 2fr) minmax(12rem, 1fr) auto;
|
||||
gap: var(--lumi-space-3);
|
||||
align-items: end;
|
||||
margin-bottom: var(--lumi-space-4);
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.command-policy-command-toolbar {
|
||||
grid-template-columns: minmax(16rem, 2fr) repeat(2, minmax(11rem, 1fr)) auto;
|
||||
}
|
||||
|
||||
.command-policy-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.command-policy-suggestions {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + var(--lumi-space-1));
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--lumi-border-strong);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: var(--lumi-surface-raised);
|
||||
box-shadow: var(--lumi-shadow-lg);
|
||||
}
|
||||
|
||||
.command-policy-suggestions button {
|
||||
width: 100%;
|
||||
padding: var(--lumi-space-2) var(--lumi-space-3);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--lumi-border);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--lumi-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.command-policy-suggestions button:hover,
|
||||
.command-policy-suggestions button:focus-visible {
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.command-policy-result-count {
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-policy-savebar {
|
||||
position: sticky;
|
||||
z-index: 8;
|
||||
top: var(--lumi-space-2);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-3);
|
||||
margin-bottom: var(--lumi-space-3);
|
||||
padding: var(--lumi-space-3);
|
||||
border: 1px solid var(--lumi-border-strong);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-surface-raised);
|
||||
box-shadow: var(--lumi-shadow-md);
|
||||
}
|
||||
|
||||
.command-policy-savebar > span:first-child {
|
||||
display: grid;
|
||||
gap: var(--lumi-space-1);
|
||||
}
|
||||
|
||||
.command-policy-savebar.is-bottom {
|
||||
position: static;
|
||||
margin-top: var(--lumi-space-4);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.command-policy-groups {
|
||||
padding: var(--lumi-space-4);
|
||||
border-color: var(--lumi-border-strong);
|
||||
background: var(--lumi-surface-subtle);
|
||||
}
|
||||
|
||||
.command-policy-no-results {
|
||||
padding: var(--lumi-space-4);
|
||||
border: 1px dashed var(--lumi-border-strong);
|
||||
border-radius: var(--lumi-radius-md);
|
||||
color: var(--lumi-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.command-rng-range {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(8rem, 1fr));
|
||||
@ -1845,11 +2003,23 @@ details > summary:focus-visible {
|
||||
|
||||
.command-random-row,
|
||||
.command-policy-grid,
|
||||
.command-policy-toolbar,
|
||||
.command-policy-command-toolbar,
|
||||
.command-rng-range,
|
||||
.custom-placeholder-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.command-policy-savebar {
|
||||
position: static;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.command-policy-savebar .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.homepage-hero-media {
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@
|
||||
--lumi-surface-subtle: var(--surface-2, #f4f7f8);
|
||||
--lumi-surface-raised: var(--surface-3, #edf2f3);
|
||||
--lumi-border: var(--border, #d8e0e3);
|
||||
--lumi-border-strong: color-mix(in srgb, var(--lumi-border) 72%, var(--lumi-text));
|
||||
--lumi-font-size-sm: 0.875rem;
|
||||
--lumi-input-bg: var(--lumi-surface);
|
||||
--lumi-input-border: var(--lumi-border);
|
||||
--lumi-input-text: var(--lumi-text);
|
||||
|
||||
@ -86,10 +86,12 @@ const {
|
||||
customCommandKey,
|
||||
deleteGroup: deleteCommandGroup,
|
||||
getCommandPolicy,
|
||||
labelRequirement,
|
||||
listGroups: listCommandGroups,
|
||||
normalizeStoredPolicy,
|
||||
resolvePolicy,
|
||||
saveCommandPolicy,
|
||||
saveCommandPolicies,
|
||||
updateGroup: updateCommandGroup
|
||||
} = require("../services/command-policies");
|
||||
const {
|
||||
@ -1373,31 +1375,32 @@ function buildCustomCommandListPreview(command) {
|
||||
};
|
||||
}
|
||||
|
||||
function commandPolicyFromBody(body = {}, { allowInherit = true } = {}) {
|
||||
function commandPolicyFromBody(body = {}, { allowInherit = true, prefix = "" } = {}) {
|
||||
const output = {};
|
||||
const mode = (name) => String(body[`${name}_mode`] || (allowInherit ? "inherit" : "off"));
|
||||
const field = (name) => body[`${prefix}${name}`];
|
||||
const mode = (name) => String(field(`${name}_mode`) || (allowInherit ? "inherit" : "off"));
|
||||
const assign = (name, value) => {
|
||||
const selected = mode(name);
|
||||
if (selected === "inherit" && allowInherit) return;
|
||||
output[name] = selected === "off" ? false : value;
|
||||
};
|
||||
assign("window", {
|
||||
uses: body.window_uses,
|
||||
seconds: body.window_seconds,
|
||||
scope: body.window_scope
|
||||
uses: field("window_uses"),
|
||||
seconds: field("window_seconds"),
|
||||
scope: field("window_scope")
|
||||
});
|
||||
assign("perStream", {
|
||||
uses: body.perStream_uses,
|
||||
scope: body.perStream_scope
|
||||
uses: field("perStream_uses"),
|
||||
scope: field("perStream_scope")
|
||||
});
|
||||
const requirementMode = mode("requirement");
|
||||
if (!(allowInherit && requirementMode === "inherit")) {
|
||||
output.requirement = requirementMode === "off" ? false : {
|
||||
role: body.requirement_role,
|
||||
subscriberTier: body.requirement_subscriber_tier
|
||||
role: field("requirement_role"),
|
||||
subscriberTier: field("requirement_subscriber_tier")
|
||||
};
|
||||
}
|
||||
assign("cost", { amount: body.cost_amount });
|
||||
assign("cost", { amount: field("cost_amount") });
|
||||
return normalizeStoredPolicy(output);
|
||||
}
|
||||
|
||||
@ -1419,6 +1422,34 @@ function commandPolicyPresentation(key, defaultPolicy = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function commandPolicyEffectivePresentation(key, defaultPolicy = {}) {
|
||||
const resolved = resolvePolicy(key, defaultPolicy);
|
||||
const inheritedWindow = resolvePolicy(key, defaultPolicy, { ignoreDirect: ["window"] });
|
||||
const inheritedStream = resolvePolicy(key, defaultPolicy, { ignoreDirect: ["perStream"] });
|
||||
const inheritedRequirement = resolvePolicy(key, defaultPolicy, { ignoreDirect: ["requirement"] });
|
||||
const inheritedCost = resolvePolicy(key, defaultPolicy, { ignoreDirect: ["cost"] });
|
||||
const formatScope = (scope) => scope === "global" ? "shared by everyone" : "per user";
|
||||
const joinRules = (rules, formatter, empty) => rules.length
|
||||
? rules.map((rule) => `${rule.source}: ${formatter(rule.value)}`).join(" · ")
|
||||
: empty;
|
||||
const assignedGroups = resolved.groups || [];
|
||||
return {
|
||||
window: joinRules(inheritedWindow.windows, (value) => `${value.uses} use${value.uses === 1 ? "" : "s"} per ${value.seconds} seconds, ${formatScope(value.scope)}`, "No time-window limit."),
|
||||
perStream: joinRules(inheritedStream.perStreams, (value) => `${value.uses} use${value.uses === 1 ? "" : "s"} per stream, ${formatScope(value.scope)}`, "No per-stream limit."),
|
||||
requirement: inheritedRequirement.requirements.length
|
||||
? inheritedRequirement.requirements.map((item) => `${item.source}: ${labelRequirement(item.value.role, item.value.subscriberTier)}`).join(" · ")
|
||||
: "Everyone may use it.",
|
||||
cost: inheritedCost.cost > 0 ? `${inheritedCost.cost} currency per successful run.` : "Free to use.",
|
||||
flags: {
|
||||
rate: resolved.windows.length > 0,
|
||||
stream: resolved.perStreams.length > 0,
|
||||
access: resolved.requirements.length > 0,
|
||||
cost: resolved.cost > 0,
|
||||
groups: assignedGroups.length > 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function customPlaceholdersFromBody(body = {}) {
|
||||
const array = (value) => Array.isArray(value) ? value : value === undefined ? [] : [value];
|
||||
const names = array(body.custom_placeholder_name);
|
||||
@ -3089,7 +3120,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
store: sessionStore
|
||||
})
|
||||
);
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(express.urlencoded({ extended: false, limit: "1mb", parameterLimit: 20000 }));
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use((req, res, next) => {
|
||||
const requestId = crypto.randomUUID();
|
||||
@ -6334,30 +6365,52 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
});
|
||||
|
||||
app.get("/admin/command-policies", requireRole("admin"), (req, res) => {
|
||||
const pluginNames = new Map(getPlugins().map((plugin) => [plugin.id, plugin.name || plugin.id]));
|
||||
const allGroups = listCommandGroups();
|
||||
const defaultGroup = allGroups.find((group) => group.is_default) || null;
|
||||
const groups = allGroups.filter((group) => !group.is_default);
|
||||
const premade = (commandRouter?.listCommands?.() || []).map((command) => ({
|
||||
key: command.key,
|
||||
label: `!${command.triggers[0] || command.id}`,
|
||||
description: command.description || `${command.pluginId} command`,
|
||||
origin: command.pluginId === "core" ? "Core" : command.pluginId,
|
||||
description: command.description || "No description provided.",
|
||||
origin: command.pluginId === "core" ? "Built-in command" : `${pluginNames.get(command.pluginId) || command.pluginId} plugin`,
|
||||
defaultPolicy: command.defaultPolicy || {},
|
||||
platforms: command.platforms || []
|
||||
}));
|
||||
const custom = db.prepare("SELECT id, trigger, description, platform FROM custom_commands ORDER BY trigger").all().map((command) => ({
|
||||
key: customCommandKey(command.id),
|
||||
label: `!${command.trigger}`,
|
||||
description: command.description || "Custom command",
|
||||
origin: "Custom",
|
||||
description: command.description || "No description provided.",
|
||||
origin: "Custom command",
|
||||
defaultPolicy: {},
|
||||
platforms: normalizePlatformSelection(command.platform, getEnabledPlatformIds())
|
||||
}));
|
||||
const commands = [...premade, ...custom]
|
||||
.filter((command, index, all) => all.findIndex((item) => item.key === command.key) === index)
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
.map((command) => ({ ...command, ...getCommandPolicy(command.key) }));
|
||||
.map((command) => {
|
||||
const stored = getCommandPolicy(command.key);
|
||||
const effective = commandPolicyEffectivePresentation(command.key, command.defaultPolicy);
|
||||
return {
|
||||
...command,
|
||||
...stored,
|
||||
effective,
|
||||
searchText: [command.label, command.origin, command.description, ...stored.groups.map((group) => group.name)].join(" ").toLowerCase(),
|
||||
settingTokens: [
|
||||
effective.flags.rate && "rate",
|
||||
effective.flags.stream && "stream",
|
||||
effective.flags.access && "access",
|
||||
effective.flags.cost && "cost",
|
||||
effective.flags.groups && "groups",
|
||||
Object.keys(stored.policy).length && "overrides"
|
||||
].filter(Boolean)
|
||||
};
|
||||
});
|
||||
res.render("admin-command-policies", {
|
||||
title: "Command access and limits",
|
||||
commands,
|
||||
groups: listCommandGroups()
|
||||
groups,
|
||||
defaultGroup
|
||||
});
|
||||
});
|
||||
|
||||
@ -6377,10 +6430,12 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
|
||||
app.post("/admin/command-policies/groups/:id", requireRole("admin"), (req, res) => {
|
||||
try {
|
||||
const existing = listCommandGroups().find((group) => group.id === req.params.id);
|
||||
if (!existing) throw new Error("That command group no longer exists.");
|
||||
updateCommandGroup(req.params.id, {
|
||||
name: req.body.name,
|
||||
description: req.body.description,
|
||||
policy: commandPolicyFromBody(req.body, { allowInherit: false })
|
||||
policy: commandPolicyFromBody(req.body, { allowInherit: Boolean(existing.is_default) })
|
||||
});
|
||||
setFlash(req, "success", "Command group updated.");
|
||||
} catch (error) {
|
||||
@ -6390,11 +6445,46 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||
});
|
||||
|
||||
app.post("/admin/command-policies/groups/:id/delete", requireRole("admin"), (req, res) => {
|
||||
deleteCommandGroup(req.params.id);
|
||||
setFlash(req, "success", "Command group deleted. Its commands keep their direct settings.");
|
||||
try {
|
||||
deleteCommandGroup(req.params.id);
|
||||
setFlash(req, "success", "Command group deleted. Its commands keep their direct settings.");
|
||||
} catch (error) {
|
||||
setFlash(req, "error", error?.message || "The command group could not be deleted.");
|
||||
}
|
||||
res.redirect("/admin/command-policies");
|
||||
});
|
||||
|
||||
app.post("/admin/command-policies/commands", requireRole("admin"), (req, res) => {
|
||||
const knownKeys = new Set([
|
||||
...(commandRouter?.listCommands?.() || []).map((command) => command.key),
|
||||
...db.prepare("SELECT id FROM custom_commands").all().map((command) => customCommandKey(command.id))
|
||||
]);
|
||||
const validGroups = new Set(listCommandGroups().filter((group) => !group.is_default).map((group) => group.id));
|
||||
const count = Math.min(2000, Math.max(0, Number(req.body.command_count) || 0));
|
||||
const updates = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const prefix = `command_${index}_`;
|
||||
const key = String(req.body[`${prefix}key`] || "");
|
||||
if (!knownKeys.has(key)) continue;
|
||||
updates.push({
|
||||
commandKey: key,
|
||||
policy: commandPolicyFromBody(req.body, { prefix }),
|
||||
groupIds: asStringArray(req.body[`${prefix}group_ids`]).filter((id) => validGroups.has(id))
|
||||
});
|
||||
}
|
||||
if (!updates.length) {
|
||||
setFlash(req, "error", "No available command settings were submitted.");
|
||||
return res.redirect("/admin/command-policies");
|
||||
}
|
||||
try {
|
||||
saveCommandPolicies(updates);
|
||||
setFlash(req, "success", `${updates.length} command${updates.length === 1 ? "" : "s"} saved.`);
|
||||
} catch (error) {
|
||||
setFlash(req, "error", error?.message || "Command settings could not be saved.");
|
||||
}
|
||||
return res.redirect("/admin/command-policies");
|
||||
});
|
||||
|
||||
app.post("/admin/command-policies/command", requireRole("admin"), (req, res) => {
|
||||
const key = String(req.body.command_key || "");
|
||||
const knownKeys = new Set([
|
||||
|
||||
@ -1,36 +1,83 @@
|
||||
<%- include("partials/layout-top", { title }) %>
|
||||
<%
|
||||
const hasSetting = (policy, name) => Object.prototype.hasOwnProperty.call(policy || {}, name);
|
||||
const settingTokens = (policy, extra = []) => [
|
||||
hasSetting(policy, "window") && "rate",
|
||||
hasSetting(policy, "perStream") && "stream",
|
||||
hasSetting(policy, "requirement") && "access",
|
||||
hasSetting(policy, "cost") && "cost",
|
||||
...extra
|
||||
].filter(Boolean).join(" ");
|
||||
const commandOrigins = [...new Set(commands.map((command) => command.origin))].sort((a, b) => a.localeCompare(b));
|
||||
%>
|
||||
<section class="card">
|
||||
<%- include("partials/page-header", {
|
||||
eyebrow: "Chat tools",
|
||||
pageTitle: "Command access and limits",
|
||||
description: "Keep common rules in groups, then override only the settings that are different for an individual command.",
|
||||
description: "Set a baseline once, reuse groups for related commands, and override only genuine exceptions.",
|
||||
actions: '<a class="button subtle" href="/admin/commands">Custom commands</a>'
|
||||
}) %>
|
||||
<div class="callout info">
|
||||
<strong>How settings combine</strong>
|
||||
<p>Commands inherit their groups. If several groups are assigned, every group limit and role requirement applies; the highest group currency cost is used. Choosing a setting directly on a command replaces all group and built-in values for that one category.</p>
|
||||
<p>The protected default group applies to every command. Other assigned groups add their restrictions. A setting chosen directly on a command replaces every group and built-in value for that category.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-header"><div><h2>Command groups</h2><p class="hint">Create a reusable set of access, rate, stream, and currency rules.</p></div></div>
|
||||
<details class="lumi-expandable-settings">
|
||||
<summary><span><strong>Create command group</strong><span class="hint">For example: Public fun, Moderator tools, or Expensive commands</span></span></summary>
|
||||
<section class="card policy-collection" data-policy-search-scope="groups">
|
||||
<div class="section-header"><div><h2>Command groups</h2><p class="hint">Manage the global baseline and reusable rules for related commands.</p></div></div>
|
||||
<div class="command-policy-toolbar">
|
||||
<label class="command-policy-search">Search groups
|
||||
<input type="search" placeholder="Try moderator or currency" autocomplete="off" data-policy-search list="command-group-search-options" />
|
||||
<datalist id="command-group-search-options">
|
||||
<% if (defaultGroup) { %><option value="<%= defaultGroup.name %>"></option><% } %>
|
||||
<% groups.forEach((group) => { %><option value="<%= group.name %>"></option><% }) %>
|
||||
</datalist>
|
||||
<div class="command-policy-suggestions" data-policy-suggestions hidden></div>
|
||||
</label>
|
||||
<label>Filter by setting
|
||||
<select data-policy-setting-filter>
|
||||
<option value="">Any settings</option>
|
||||
<option value="rate">Rate-limit setting</option>
|
||||
<option value="stream">Per-stream setting</option>
|
||||
<option value="access">Access setting</option>
|
||||
<option value="cost">Currency-cost setting</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="hint command-policy-result-count" data-policy-result-count></span>
|
||||
</div>
|
||||
|
||||
<% if (defaultGroup) { %>
|
||||
<details class="lumi-expandable-settings command-policy-item is-default-group" data-policy-item data-search-name="<%= defaultGroup.name %>" data-search="<%= `${defaultGroup.name} ${defaultGroup.description}`.toLowerCase() %>" data-settings="<%= settingTokens(defaultGroup.policy) %>">
|
||||
<summary>
|
||||
<span class="command-policy-summary-copy"><span class="command-policy-title"><strong><%= defaultGroup.name %></strong><span class="badge">Protected default</span></span><span class="hint"><%= defaultGroup.description %></span></span>
|
||||
</summary>
|
||||
<form method="post" action="/admin/command-policies/groups/<%= defaultGroup.id %>" class="form-grid command-policy-form">
|
||||
<div class="field"><label>Name<input name="name" maxlength="100" required value="<%= defaultGroup.name %>" /></label></div>
|
||||
<div class="field"><label>Description<input name="description" maxlength="500" value="<%= defaultGroup.description %>" /></label></div>
|
||||
<div class="callout info field full"><strong>Inherited by every command</strong><p>“Keep command built-ins” leaves that category untouched. Choose an explicit value to replace built-in defaults globally.</p></div>
|
||||
<div class="field full"><%- include("partials/command-policy-fields", { policy: defaultGroup.policy, allowInherit: true, namePrefix: "", effective: null, inheritLabel: "Keep command built-ins" }) %></div>
|
||||
<div class="form-actions"><button class="button" type="submit">Save default settings</button></div>
|
||||
</form>
|
||||
</details>
|
||||
<% } %>
|
||||
|
||||
<details class="lumi-expandable-settings command-policy-item" data-policy-create-group>
|
||||
<summary><span class="command-policy-summary-copy"><strong>Create command group</strong><span class="hint">For example: Public fun, Moderator tools, or Expensive commands</span></span></summary>
|
||||
<form method="post" action="/admin/command-policies/groups" class="form-grid command-policy-form">
|
||||
<div class="field"><label>Name<input name="name" maxlength="100" required /></label></div>
|
||||
<div class="field"><label>Description<input name="description" maxlength="500" /></label></div>
|
||||
<div class="field full"><%- include("partials/command-policy-fields", { policy: {}, allowInherit: false }) %></div>
|
||||
<div class="field full"><%- include("partials/command-policy-fields", { policy: {}, allowInherit: false, namePrefix: "", effective: null }) %></div>
|
||||
<div class="form-actions"><button class="button" type="submit">Create group</button></div>
|
||||
</form>
|
||||
</details>
|
||||
<% if (!groups.length) { %><p class="hint">No command groups yet.</p><% } %>
|
||||
|
||||
<% groups.forEach((group) => { %>
|
||||
<details class="lumi-expandable-settings command-policy-item">
|
||||
<summary><span><strong><%= group.name %></strong><span class="hint"><%= group.description || "Reusable command rules" %></span></span></summary>
|
||||
<details class="lumi-expandable-settings command-policy-item" data-policy-item data-search-name="<%= group.name %>" data-search="<%= `${group.name} ${group.description}`.toLowerCase() %>" data-settings="<%= settingTokens(group.policy) %>">
|
||||
<summary><span class="command-policy-summary-copy"><strong><%= group.name %></strong><span class="hint"><%= group.description || "Reusable command rules" %></span></span></summary>
|
||||
<form method="post" action="/admin/command-policies/groups/<%= group.id %>" class="form-grid command-policy-form">
|
||||
<div class="field"><label>Name<input name="name" maxlength="100" required value="<%= group.name %>" /></label></div>
|
||||
<div class="field"><label>Description<input name="description" maxlength="500" value="<%= group.description %>" /></label></div>
|
||||
<div class="field full"><%- include("partials/command-policy-fields", { policy: group.policy, allowInherit: false }) %></div>
|
||||
<div class="field full"><%- include("partials/command-policy-fields", { policy: group.policy, allowInherit: false, namePrefix: "", effective: null }) %></div>
|
||||
<div class="form-actions"><button class="button" type="submit">Save group</button></div>
|
||||
</form>
|
||||
<form method="post" action="/admin/command-policies/groups/<%= group.id %>/delete" class="form-actions" data-confirm-mode="modal" data-confirm-title="Delete command group" data-confirm-text="Delete the group <%= group.name %>? Commands keep their direct settings." data-confirm-label="Delete group">
|
||||
@ -38,42 +85,213 @@
|
||||
</form>
|
||||
</details>
|
||||
<% }) %>
|
||||
<p class="command-policy-no-results" data-policy-no-results hidden>No command groups match that search and filter.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-header"><div><h2>Individual commands</h2><p class="hint">Assign groups or override a category for one command.</p></div></div>
|
||||
<section class="card policy-collection" data-policy-search-scope="commands">
|
||||
<div class="section-header"><div><h2>Individual commands</h2><p class="hint">Search, compare effective values, and save every command edit together.</p></div></div>
|
||||
<div class="command-policy-toolbar command-policy-command-toolbar">
|
||||
<label class="command-policy-search">Search commands
|
||||
<input type="search" placeholder="Command, description, group, or source" autocomplete="off" data-policy-search list="command-search-options" />
|
||||
<datalist id="command-search-options"><% commands.forEach((command) => { %><option value="<%= command.label %>"></option><% }) %></datalist>
|
||||
<div class="command-policy-suggestions" data-policy-suggestions hidden></div>
|
||||
</label>
|
||||
<label>Filter by setting
|
||||
<select data-policy-setting-filter>
|
||||
<option value="">Any settings</option>
|
||||
<option value="rate">Has a rate limit</option>
|
||||
<option value="stream">Has a per-stream limit</option>
|
||||
<option value="access">Has an access requirement</option>
|
||||
<option value="cost">Has a currency cost</option>
|
||||
<option value="groups">Uses an assigned group</option>
|
||||
<option value="overrides">Has direct overrides</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Command source
|
||||
<select data-policy-origin-filter><option value="">Any source</option><% commandOrigins.forEach((origin) => { %><option value="<%= origin.toLowerCase() %>"><%= origin %></option><% }) %></select>
|
||||
</label>
|
||||
<span class="hint command-policy-result-count" data-policy-result-count></span>
|
||||
</div>
|
||||
|
||||
<% if (!commands.length) { %><p>No commands are currently registered.</p><% } %>
|
||||
<% commands.forEach((command) => { %>
|
||||
<details class="lumi-expandable-settings command-policy-item">
|
||||
<summary>
|
||||
<span><strong><%= command.label %></strong><span class="hint"><%= command.origin %> · <%= command.description %></span></span>
|
||||
<% if (command.groups.length) { %><span class="badge"><%= command.groups.length %> group<%= command.groups.length === 1 ? "" : "s" %></span><% } %>
|
||||
</summary>
|
||||
<form method="post" action="/admin/command-policies/command" class="form-grid command-policy-form">
|
||||
<input type="hidden" name="command_key" value="<%= command.key %>" />
|
||||
<fieldset class="subsection field full">
|
||||
<legend>Groups</legend>
|
||||
<div class="platform-checkboxes">
|
||||
<% groups.forEach((group) => { %>
|
||||
<label class="platform-check"><input type="checkbox" name="group_ids" value="<%= group.id %>" <%= command.groups.some((item) => item.id === group.id) ? "checked" : "" %> /><span><%= group.name %></span></label>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% if (!groups.length) { %><p class="hint">Create a group above first, or configure this command directly.</p><% } %>
|
||||
</fieldset>
|
||||
<% if (Object.keys(command.defaultPolicy || {}).length) { %>
|
||||
<div class="callout info field full"><strong>Safe built-in defaults</strong><p>This command has conservative built-in settings. Leave a category on “Use group/default” to keep them unless a group supplies that category.</p></div>
|
||||
<% } %>
|
||||
<div class="field full"><%- include("partials/command-policy-fields", { policy: command.policy, allowInherit: true }) %></div>
|
||||
<div class="form-actions"><button class="button" type="submit">Save command settings</button></div>
|
||||
</form>
|
||||
</details>
|
||||
<% }) %>
|
||||
<% if (commands.length) { %>
|
||||
<form method="post" action="/admin/command-policies/commands" class="command-policy-batch-form" data-command-policy-batch>
|
||||
<input type="hidden" name="command_count" value="<%= commands.length %>" />
|
||||
<div class="command-policy-savebar">
|
||||
<span><strong>Command settings</strong><span class="hint" data-command-save-state>All displayed values are saved.</span></span>
|
||||
<button class="button" type="submit">Save all command settings</button>
|
||||
</div>
|
||||
<% commands.forEach((command, index) => { %>
|
||||
<% const prefix = `command_${index}_`; %>
|
||||
<details class="lumi-expandable-settings command-policy-item" data-policy-item data-search-name="<%= command.label %>" data-search="<%= command.searchText %>" data-settings="<%= command.settingTokens.join(' ') %>" data-origin="<%= command.origin.toLowerCase() %>">
|
||||
<summary>
|
||||
<span class="command-policy-summary-copy">
|
||||
<span class="command-policy-title"><strong><%= command.label %></strong><span class="badge"><%= command.origin %></span></span>
|
||||
<span class="hint"><%= command.description %></span>
|
||||
</span>
|
||||
<% if (command.groups.length) { %><span class="badge"><%= command.groups.length %> assigned group<%= command.groups.length === 1 ? "" : "s" %></span><% } %>
|
||||
</summary>
|
||||
<div class="form-grid command-policy-form command-policy-editor" data-command-policy-editor data-built-in-policy="<%= JSON.stringify(command.defaultPolicy || {}) %>" data-default-group-policy="<%= JSON.stringify(defaultGroup?.policy || {}) %>">
|
||||
<input type="hidden" name="<%= prefix %>key" value="<%= command.key %>" />
|
||||
<fieldset class="subsection field full command-policy-groups">
|
||||
<legend>Assigned groups</legend>
|
||||
<div class="platform-checkboxes">
|
||||
<% groups.forEach((group) => { %>
|
||||
<label class="platform-check"><input type="checkbox" name="<%= prefix %>group_ids" value="<%= group.id %>" data-group-name="<%= group.name %>" data-group-policy="<%= JSON.stringify(group.policy || {}) %>" <%= command.groups.some((item) => item.id === group.id) ? "checked" : "" %> /><span><%= group.name %></span></label>
|
||||
<% }) %>
|
||||
</div>
|
||||
<% if (!groups.length) { %><p class="hint">Create a reusable group above, or configure this command directly.</p><% } %>
|
||||
<p class="hint">The protected default group already applies automatically and does not need to be assigned.</p>
|
||||
</fieldset>
|
||||
<div class="field full"><%- include("partials/command-policy-fields", { policy: command.policy, allowInherit: true, namePrefix: prefix, effective: command.effective, inheritLabel: "Use groups/default" }) %></div>
|
||||
</div>
|
||||
</details>
|
||||
<% }) %>
|
||||
<p class="command-policy-no-results" data-policy-no-results hidden>No commands match that search and filter.</p>
|
||||
<div class="command-policy-savebar is-bottom">
|
||||
<span class="hint">Every command on this page is submitted together, including collapsed commands.</span>
|
||||
<button class="button" type="submit">Save all command settings</button>
|
||||
</div>
|
||||
</form>
|
||||
<% } %>
|
||||
</section>
|
||||
<script>
|
||||
const normalizeSearch = (value) => String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||
const editDistance = (left, right) => {
|
||||
const row = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||
for (let i = 1; i <= left.length; i += 1) {
|
||||
let diagonal = row[0]; row[0] = i;
|
||||
for (let j = 1; j <= right.length; j += 1) {
|
||||
const above = row[j];
|
||||
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (left[i - 1] === right[j - 1] ? 0 : 1));
|
||||
diagonal = above;
|
||||
}
|
||||
}
|
||||
return row[right.length];
|
||||
};
|
||||
const searchScore = (query, text) => {
|
||||
const desired = normalizeSearch(query);
|
||||
const candidate = normalizeSearch(text);
|
||||
if (!desired) return 1;
|
||||
if (candidate.includes(desired)) return 1;
|
||||
const words = candidate.split(" ").filter(Boolean);
|
||||
if (words.some((word) => word.startsWith(desired))) return 0.92;
|
||||
return words.reduce((best, word) => Math.max(best, 1 - editDistance(desired, word) / Math.max(desired.length, word.length, 1)), 0);
|
||||
};
|
||||
const refreshSearchScope = (scope) => {
|
||||
const query = scope.querySelector("[data-policy-search]")?.value || "";
|
||||
const setting = scope.querySelector("[data-policy-setting-filter]")?.value || "";
|
||||
const origin = scope.querySelector("[data-policy-origin-filter]")?.value || "";
|
||||
const items = [...scope.querySelectorAll("[data-policy-item]")];
|
||||
let shown = 0;
|
||||
const ranked = [];
|
||||
items.forEach((item) => {
|
||||
const score = searchScore(query, item.dataset.search || "");
|
||||
const threshold = normalizeSearch(query).length <= 3 ? 0.82 : 0.62;
|
||||
const matchesSearch = !normalizeSearch(query) || score >= threshold;
|
||||
const matchesSetting = !setting || (item.dataset.settings || "").split(" ").includes(setting);
|
||||
const matchesOrigin = !origin || item.dataset.origin === origin;
|
||||
item.hidden = !(matchesSearch && matchesSetting && matchesOrigin);
|
||||
if (!item.hidden) shown += 1;
|
||||
if (matchesSetting && matchesOrigin && score >= threshold) ranked.push({ item, score });
|
||||
});
|
||||
const count = scope.querySelector("[data-policy-result-count]");
|
||||
if (count) count.textContent = `Showing ${shown} of ${items.length}`;
|
||||
const noResults = scope.querySelector("[data-policy-no-results]");
|
||||
if (noResults) noResults.hidden = shown !== 0;
|
||||
const suggestions = scope.querySelector("[data-policy-suggestions]");
|
||||
if (suggestions) {
|
||||
suggestions.replaceChildren();
|
||||
if (normalizeSearch(query)) {
|
||||
ranked.sort((left, right) => right.score - left.score).slice(0, 5).forEach(({ item }) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = item.dataset.searchName || "Match";
|
||||
button.addEventListener("click", () => {
|
||||
const input = scope.querySelector("[data-policy-search]");
|
||||
input.value = item.dataset.searchName || "";
|
||||
suggestions.hidden = true;
|
||||
refreshSearchScope(scope);
|
||||
item.open = true;
|
||||
item.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
});
|
||||
suggestions.append(button);
|
||||
});
|
||||
}
|
||||
suggestions.hidden = !suggestions.children.length;
|
||||
}
|
||||
};
|
||||
document.querySelectorAll("[data-policy-search-scope]").forEach((scope) => {
|
||||
scope.querySelectorAll("[data-policy-search], [data-policy-setting-filter], [data-policy-origin-filter]").forEach((control) => {
|
||||
control.addEventListener(control.matches("input") ? "input" : "change", () => refreshSearchScope(scope));
|
||||
});
|
||||
scope.querySelector("[data-policy-search]")?.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") scope.querySelector("[data-policy-suggestions]").hidden = true;
|
||||
});
|
||||
refreshSearchScope(scope);
|
||||
});
|
||||
|
||||
const parsePolicyData = (value) => { try { return JSON.parse(value || "{}"); } catch { return {}; } };
|
||||
const hasPolicyCategory = (policy, name) => Object.prototype.hasOwnProperty.call(policy || {}, name);
|
||||
const roleLabel = (value = {}) => {
|
||||
if (value.role === "subscriber") return Number(value.subscriberTier) > 1 ? `Tier ${value.subscriberTier}+ subscribers` : "Subscribers / members";
|
||||
return ({ follower: "Followers", vip: "VIPs", mod: "Moderators", editor: "Editors", streamer: "Streamer only" })[value.role] || "Everyone";
|
||||
};
|
||||
const ruleText = (name, value) => {
|
||||
if (name === "window") return `${value.uses} use${Number(value.uses) === 1 ? "" : "s"} per ${value.seconds} seconds, ${value.scope === "global" ? "shared by everyone" : "per user"}`;
|
||||
if (name === "perStream") return `${value.uses} use${Number(value.uses) === 1 ? "" : "s"} per stream, ${value.scope === "global" ? "shared by everyone" : "per user"}`;
|
||||
if (name === "requirement") return roleLabel(value);
|
||||
if (name === "cost") return `${value.amount} currency per successful run.`;
|
||||
return "";
|
||||
};
|
||||
const emptyRuleText = (name) => ({
|
||||
window: "No time-window limit.",
|
||||
perStream: "No per-stream limit.",
|
||||
requirement: "Everyone may use it.",
|
||||
cost: "Free to use."
|
||||
})[name];
|
||||
const refreshInheritedSummaries = (editor) => {
|
||||
const builtIn = parsePolicyData(editor.dataset.builtInPolicy);
|
||||
const globalDefault = parsePolicyData(editor.dataset.defaultGroupPolicy);
|
||||
const selected = [...editor.querySelectorAll("input[data-group-policy]:checked")].map((input) => ({
|
||||
name: input.dataset.groupName || "Group",
|
||||
policy: parsePolicyData(input.dataset.groupPolicy)
|
||||
}));
|
||||
for (const name of ["window", "perStream", "requirement", "cost"]) {
|
||||
const summary = editor.querySelector(`[data-policy-inherited="${name}"] strong`);
|
||||
if (!summary) continue;
|
||||
const positive = selected
|
||||
.filter((item) => hasPolicyCategory(item.policy, name) && item.policy[name] && item.policy[name] !== false)
|
||||
.map((item) => ({ source: item.name, value: item.policy[name] }));
|
||||
const defaultHas = hasPolicyCategory(globalDefault, name);
|
||||
if (defaultHas && globalDefault[name] && globalDefault[name] !== false) positive.push({ source: "Default group", value: globalDefault[name] });
|
||||
if (name === "cost" && positive.length) {
|
||||
const highest = positive.reduce((best, item) => Number(item.value.amount) > Number(best.value.amount) ? item : best);
|
||||
summary.textContent = `${highest.source}: ${ruleText(name, highest.value)}`;
|
||||
} else if (positive.length) {
|
||||
summary.textContent = positive.map((item) => `${item.source}: ${ruleText(name, item.value)}`).join(" · ");
|
||||
} else if (defaultHas && globalDefault[name] === false) {
|
||||
summary.textContent = emptyRuleText(name);
|
||||
} else if (builtIn[name] && builtIn[name] !== false) {
|
||||
summary.textContent = `Built-in: ${ruleText(name, builtIn[name])}`;
|
||||
} else {
|
||||
summary.textContent = emptyRuleText(name);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.querySelectorAll("[data-command-policy-editor]").forEach((editor) => {
|
||||
refreshInheritedSummaries(editor);
|
||||
editor.querySelectorAll("input[data-group-policy]").forEach((input) => input.addEventListener("change", () => refreshInheritedSummaries(editor)));
|
||||
});
|
||||
|
||||
const updatePolicyCategory = (category) => {
|
||||
const custom = category.querySelector("[data-policy-mode]")?.value === "custom";
|
||||
const mode = category.querySelector("[data-policy-mode]")?.value || "off";
|
||||
const custom = mode === "custom";
|
||||
const inherited = mode === "inherit";
|
||||
const values = category.querySelector("[data-policy-values]");
|
||||
const inheritedSummary = category.querySelector("[data-policy-inherited]");
|
||||
if (values) values.hidden = !custom;
|
||||
if (inheritedSummary) inheritedSummary.hidden = !inherited;
|
||||
const role = category.querySelector("[data-requirement-role]");
|
||||
const tier = category.querySelector("[data-subscriber-tier]");
|
||||
if (tier) tier.hidden = !custom || role?.value !== "subscriber";
|
||||
@ -83,5 +301,10 @@
|
||||
category.querySelector("[data-policy-mode]")?.addEventListener("change", () => updatePolicyCategory(category));
|
||||
category.querySelector("[data-requirement-role]")?.addEventListener("change", () => updatePolicyCategory(category));
|
||||
});
|
||||
|
||||
const batch = document.querySelector("[data-command-policy-batch]");
|
||||
const saveState = document.querySelector("[data-command-save-state]");
|
||||
batch?.addEventListener("change", () => { if (saveState) saveState.textContent = "Unsaved command changes."; });
|
||||
batch?.addEventListener("input", () => { if (saveState) saveState.textContent = "Unsaved command changes."; });
|
||||
</script>
|
||||
<%- include("partials/layout-bottom") %>
|
||||
|
||||
@ -5,67 +5,76 @@
|
||||
const streamValue = policy?.perStream && policy.perStream !== false ? policy.perStream : {};
|
||||
const requirementValue = policy?.requirement && policy.requirement !== false ? policy.requirement : {};
|
||||
const costValue = policy?.cost && policy.cost !== false ? policy.cost : {};
|
||||
const fieldPrefix = typeof namePrefix === "string" ? namePrefix : "";
|
||||
const fieldName = (name) => `${fieldPrefix}${name}`;
|
||||
const effectiveValues = typeof effective === "object" && effective ? effective : {};
|
||||
const inheritOptionLabel = typeof inheritLabel === "string" && inheritLabel ? inheritLabel : "Use group/default";
|
||||
const inheritedText = (name, fallback) => effectiveValues[name] || fallback;
|
||||
%>
|
||||
<div class="command-policy-grid">
|
||||
<fieldset class="subsection command-policy-section" data-policy-category>
|
||||
<fieldset class="subsection command-policy-section" data-policy-category data-policy-name="window">
|
||||
<legend>Rate limit</legend>
|
||||
<label>Setting
|
||||
<select name="window_mode" data-policy-mode>
|
||||
<% if (allowInherit) { %><option value="inherit" <%= policyMode("window") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
|
||||
<select name="<%= fieldName('window_mode') %>" data-policy-mode>
|
||||
<% if (allowInherit) { %><option value="inherit" <%= policyMode("window") === "inherit" ? "selected" : "" %>><%= inheritOptionLabel %></option><% } %>
|
||||
<option value="off" <%= policyMode("window") === "off" ? "selected" : "" %>>No time-window limit</option>
|
||||
<option value="custom" <%= policyMode("window") === "custom" ? "selected" : "" %>>Set a limit</option>
|
||||
</select>
|
||||
</label>
|
||||
<% if (allowInherit) { %><div class="command-policy-inherited" data-policy-inherited="window"><span>Currently applies</span><strong><%= inheritedText("window", "This category keeps each command's built-in value.") %></strong></div><% } %>
|
||||
<div class="command-policy-values" data-policy-values>
|
||||
<label>Uses<input name="window_uses" type="number" min="1" step="1" value="<%= windowValue.uses || 1 %>" /></label>
|
||||
<label>Within seconds<input name="window_seconds" type="number" min="1" step="1" value="<%= windowValue.seconds || 30 %>" /></label>
|
||||
<label>Count<select name="window_scope"><option value="user" <%= windowValue.scope !== "global" ? "selected" : "" %>>Per user</option><option value="global" <%= windowValue.scope === "global" ? "selected" : "" %>>Everyone together</option></select></label>
|
||||
<label>Uses<input name="<%= fieldName('window_uses') %>" type="number" min="1" step="1" value="<%= windowValue.uses || 1 %>" /></label>
|
||||
<label>Within seconds<input name="<%= fieldName('window_seconds') %>" type="number" min="1" step="1" value="<%= windowValue.seconds || 30 %>" /></label>
|
||||
<label>Count<select name="<%= fieldName('window_scope') %>"><option value="user" <%= windowValue.scope !== "global" ? "selected" : "" %>>Per user</option><option value="global" <%= windowValue.scope === "global" ? "selected" : "" %>>Everyone together</option></select></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="subsection command-policy-section" data-policy-category>
|
||||
<fieldset class="subsection command-policy-section" data-policy-category data-policy-name="perStream">
|
||||
<legend>Per stream</legend>
|
||||
<label>Setting
|
||||
<select name="perStream_mode" data-policy-mode>
|
||||
<% if (allowInherit) { %><option value="inherit" <%= policyMode("perStream") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
|
||||
<select name="<%= fieldName('perStream_mode') %>" data-policy-mode>
|
||||
<% if (allowInherit) { %><option value="inherit" <%= policyMode("perStream") === "inherit" ? "selected" : "" %>><%= inheritOptionLabel %></option><% } %>
|
||||
<option value="off" <%= policyMode("perStream") === "off" ? "selected" : "" %>>No stream limit</option>
|
||||
<option value="custom" <%= policyMode("perStream") === "custom" ? "selected" : "" %>>Set a limit</option>
|
||||
</select>
|
||||
</label>
|
||||
<% if (allowInherit) { %><div class="command-policy-inherited" data-policy-inherited="perStream"><span>Currently applies</span><strong><%= inheritedText("perStream", "This category keeps each command's built-in value.") %></strong></div><% } %>
|
||||
<div class="command-policy-values" data-policy-values>
|
||||
<label>Uses per stream<input name="perStream_uses" type="number" min="1" step="1" value="<%= streamValue.uses || 1 %>" /></label>
|
||||
<label>Count<select name="perStream_scope"><option value="user" <%= streamValue.scope !== "global" ? "selected" : "" %>>Per user</option><option value="global" <%= streamValue.scope === "global" ? "selected" : "" %>>Everyone together</option></select></label>
|
||||
<label>Uses per stream<input name="<%= fieldName('perStream_uses') %>" type="number" min="1" step="1" value="<%= streamValue.uses || 1 %>" /></label>
|
||||
<label>Count<select name="<%= fieldName('perStream_scope') %>"><option value="user" <%= streamValue.scope !== "global" ? "selected" : "" %>>Per user</option><option value="global" <%= streamValue.scope === "global" ? "selected" : "" %>>Everyone together</option></select></label>
|
||||
</div>
|
||||
<p class="hint">A restarted stream stays in the same session for two hours, so a crash does not reset this limit.</p>
|
||||
</fieldset>
|
||||
<fieldset class="subsection command-policy-section" data-policy-category>
|
||||
<fieldset class="subsection command-policy-section" data-policy-category data-policy-name="requirement">
|
||||
<legend>Who may use it</legend>
|
||||
<label>Setting
|
||||
<select name="requirement_mode" data-policy-mode>
|
||||
<% if (allowInherit) { %><option value="inherit" <%= policyMode("requirement") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
|
||||
<select name="<%= fieldName('requirement_mode') %>" data-policy-mode>
|
||||
<% if (allowInherit) { %><option value="inherit" <%= policyMode("requirement") === "inherit" ? "selected" : "" %>><%= inheritOptionLabel %></option><% } %>
|
||||
<option value="off" <%= policyMode("requirement") === "off" ? "selected" : "" %>>Everyone</option>
|
||||
<option value="custom" <%= policyMode("requirement") === "custom" ? "selected" : "" %>>Require a role</option>
|
||||
</select>
|
||||
</label>
|
||||
<% if (allowInherit) { %><div class="command-policy-inherited" data-policy-inherited="requirement"><span>Currently applies</span><strong><%= inheritedText("requirement", "This category keeps each command's built-in value.") %></strong></div><% } %>
|
||||
<div class="command-policy-values" data-policy-values>
|
||||
<label>Minimum access<select name="requirement_role" data-requirement-role>
|
||||
<label>Minimum access<select name="<%= fieldName('requirement_role') %>" data-requirement-role>
|
||||
<% [["follower","Follower"],["subscriber","Subscriber / member"],["vip","VIP"],["mod","Moderator"],["editor","Editor"],["streamer","Streamer only"]].forEach(([value,label]) => { %>
|
||||
<option value="<%= value %>" <%= requirementValue.role === value ? "selected" : "" %>><%= label %></option>
|
||||
<% }) %>
|
||||
</select></label>
|
||||
<label data-subscriber-tier>Subscriber tier<select name="requirement_subscriber_tier"><option value="1" <%= Number(requirementValue.subscriberTier || 1) === 1 ? "selected" : "" %>>Any tier</option><option value="2" <%= Number(requirementValue.subscriberTier) === 2 ? "selected" : "" %>>Tier 2+</option><option value="3" <%= Number(requirementValue.subscriberTier) === 3 ? "selected" : "" %>>Tier 3</option></select></label>
|
||||
<label data-subscriber-tier>Subscriber tier<select name="<%= fieldName('requirement_subscriber_tier') %>"><option value="1" <%= Number(requirementValue.subscriberTier || 1) === 1 ? "selected" : "" %>>Any tier</option><option value="2" <%= Number(requirementValue.subscriberTier) === 2 ? "selected" : "" %>>Tier 2+</option><option value="3" <%= Number(requirementValue.subscriberTier) === 3 ? "selected" : "" %>>Tier 3</option></select></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="subsection command-policy-section" data-policy-category>
|
||||
<fieldset class="subsection command-policy-section" data-policy-category data-policy-name="cost">
|
||||
<legend>Currency cost</legend>
|
||||
<label>Setting
|
||||
<select name="cost_mode" data-policy-mode>
|
||||
<% if (allowInherit) { %><option value="inherit" <%= policyMode("cost") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
|
||||
<select name="<%= fieldName('cost_mode') %>" data-policy-mode>
|
||||
<% if (allowInherit) { %><option value="inherit" <%= policyMode("cost") === "inherit" ? "selected" : "" %>><%= inheritOptionLabel %></option><% } %>
|
||||
<option value="off" <%= policyMode("cost") === "off" ? "selected" : "" %>>Free</option>
|
||||
<option value="custom" <%= policyMode("cost") === "custom" ? "selected" : "" %>>Charge currency</option>
|
||||
</select>
|
||||
</label>
|
||||
<% if (allowInherit) { %><div class="command-policy-inherited" data-policy-inherited="cost"><span>Currently applies</span><strong><%= inheritedText("cost", "This category keeps each command's built-in value.") %></strong></div><% } %>
|
||||
<div class="command-policy-values" data-policy-values>
|
||||
<label>Cost<input name="cost_amount" type="number" min="1" step="1" value="<%= costValue.amount || 1 %>" /></label>
|
||||
<label>Cost<input name="<%= fieldName('cost_amount') %>" type="number" min="1" step="1" value="<%= costValue.amount || 1 %>" /></label>
|
||||
</div>
|
||||
<p class="hint">The Economy Framework deducts and logs this like any other banking transaction.</p>
|
||||
</fieldset>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Lumi Core",
|
||||
"version": "0.2.18",
|
||||
"version": "0.2.19",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-07-19",
|
||||
"compatible_from": "0.1.9",
|
||||
@ -8,7 +8,7 @@
|
||||
"replaces_versions": [
|
||||
"1.2.0"
|
||||
],
|
||||
"migration_notes": "Adds centralized command policies, conditional replies, platform clip and raid actions, and OBS replay saving. Existing commands, usage totals, overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.",
|
||||
"migration_notes": "Improves command-policy administration with a protected inherited default group, conditional effective-value summaries, fuzzy searching and filters, clearer command origins, and atomic multi-command saves. Existing commands, policies, groups, usage totals, overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.",
|
||||
"rollback_safe": true,
|
||||
"requirements": [
|
||||
"Node.js 18 or newer"
|
||||
@ -241,6 +241,18 @@
|
||||
],
|
||||
"rollback_safe": true,
|
||||
"migration_notes": "Adds centralized command policies, conditional replies, platform clip and raid actions, and OBS replay saving; existing commands, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
|
||||
},
|
||||
{
|
||||
"version": "0.2.19",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-07-19",
|
||||
"compatible_from": "0.1.9",
|
||||
"migration_kind": "patch",
|
||||
"replaces_versions": [
|
||||
"1.2.0"
|
||||
],
|
||||
"rollback_safe": true,
|
||||
"migration_notes": "Improves command-policy administration with a protected inherited default group, conditional effective-value summaries, fuzzy searching and filters, clearer command origins, and atomic multi-command saves; existing commands, policies, groups, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user