Improve command policy administration
This commit is contained in:
parent
7b7e49507c
commit
4853092956
@ -1,5 +1,12 @@
|
|||||||
# Lumi changelog
|
# 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
|
## 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.
|
- 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
|
Bridge BASIC permission. Admin UI and focused verification cover the shared
|
||||||
storage and enforcement path.
|
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:
|
Remaining work:
|
||||||
|
|
||||||
- Add platform action providers when YouTube or future chat integrations expose
|
- 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
|
Admins configure shared command rules at **Admin → Command access**. Moderators
|
||||||
continue to create and edit custom replies at **Custom commands**.
|
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
|
## Built-in stream commands
|
||||||
|
|
||||||
- `!clip [optional label]` requests a clip on the platform that delivered the
|
- `!clip [optional label]` requests a clip on the platform that delivered the
|
||||||
@ -58,6 +63,17 @@ Each command category has three choices:
|
|||||||
category.
|
category.
|
||||||
- **Set** replaces inherited values for that category on this command.
|
- **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.
|
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
|
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
|
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.
|
Lumi is the core web UI and bot runtime.
|
||||||
## Runtime
|
## Runtime
|
||||||
Package: lumi-bot
|
Package: lumi-bot
|
||||||
Version: 0.2.18
|
Version: 0.2.19
|
||||||
## Routes
|
## Routes
|
||||||
- GET /api/events
|
- GET /api/events
|
||||||
- POST /api/destructive-confirmations
|
- POST /api/destructive-confirmations
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.18",
|
"version": "0.2.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.18",
|
"version": "0.2.19",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.12",
|
"adm-zip": "^0.5.12",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.18",
|
"version": "0.2.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -2,6 +2,36 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"releases": [
|
"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",
|
"version": "0.2.18",
|
||||||
"ref": "refs/tags/v0.2.18",
|
"ref": "refs/tags/v0.2.18",
|
||||||
|
|||||||
@ -5,14 +5,18 @@ const path = require("path");
|
|||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-command-policies-"));
|
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-command-policies-"));
|
||||||
const serviceDir = path.join(sandbox, "src", "services");
|
const serviceDir = path.join(sandbox, "src", "services");
|
||||||
|
let database = null;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
fs.cpSync(path.join(root, "src", "services"), serviceDir, { recursive: true });
|
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();
|
database.migrate();
|
||||||
const conditional = require(path.join(serviceDir, "command-conditional.js"));
|
const conditional = require(path.join(serviceDir, "command-conditional.js"));
|
||||||
const policies = require(path.join(serviceDir, "command-policies.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([
|
const replies = conditional.normalizeConditionalReplies([
|
||||||
{ keyword: "backseating", response: "Please avoid backseating." },
|
{ keyword: "backseating", response: "Please avoid backseating." },
|
||||||
@ -43,6 +47,18 @@ try {
|
|||||||
assert.strictEqual(resolved.windows[0].value.uses, 1);
|
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.requirements.length, 0, "a direct public setting must replace group roles");
|
||||||
assert.strictEqual(resolved.cost, 2, "a direct cost must replace the group cost");
|
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 = [];
|
const charges = [];
|
||||||
global.lumiFrameworks = {
|
global.lumiFrameworks = {
|
||||||
@ -73,6 +89,7 @@ try {
|
|||||||
const dbColumns = database.db.prepare("PRAGMA table_info(custom_commands)").all().map((column) => column.name);
|
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_replies_json"));
|
||||||
assert(dbColumns.includes("conditional_fuzzy"));
|
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"]) {
|
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}`);
|
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 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");
|
const commandView = fs.readFileSync(path.join(root, "src", "web", "views", "admin-commands.ejs"), "utf8");
|
||||||
assert(server.includes('/admin/command-policies') && server.includes('commandPolicyFromBody'));
|
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"));
|
assert(commandView.includes('value="conditional"') && commandView.includes("data-conditional-replies"));
|
||||||
|
|
||||||
database.db.close();
|
database.db.close();
|
||||||
|
database = null;
|
||||||
console.log("Command policies and conditional commands verified.");
|
console.log("Command policies and conditional commands verified.");
|
||||||
} finally {
|
} 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) => {
|
})().catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@ -4,8 +4,8 @@ const path = require("path");
|
|||||||
const { findSafeTarget } = require("../src/services/versioning");
|
const { findSafeTarget } = require("../src/services/versioning");
|
||||||
|
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const releaseVersion = "0.2.18";
|
const releaseVersion = "0.2.19";
|
||||||
const previousCoreVersion = "0.2.17";
|
const previousCoreVersion = "0.2.18";
|
||||||
const earliestCompatibleCoreVersion = "0.1.9";
|
const earliestCompatibleCoreVersion = "0.1.9";
|
||||||
const changedPlugins = {
|
const changedPlugins = {
|
||||||
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
|
"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(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
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 releaseIndex = readJson("release-index.json");
|
||||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
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");
|
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||||
for (const release of releaseIndex.releases) {
|
for (const release of releaseIndex.releases) {
|
||||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||||
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
|
|||||||
const baseTarget = {
|
const baseTarget = {
|
||||||
current_version: "0.2.4",
|
current_version: "0.2.4",
|
||||||
available_versions: [
|
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.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.17", ref: "refs/tags/v0.2.17", rollback_safe: true },
|
||||||
{ version: "0.2.16", ref: "refs/tags/v0.2.16", 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"
|
channel: "stable"
|
||||||
});
|
});
|
||||||
assert.equal(corrected.version_correction, true);
|
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.update_available, true);
|
||||||
assert.equal(corrected.blocked, false);
|
assert.equal(corrected.blocked, false);
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,8 @@ const { createLogger } = require("./logger");
|
|||||||
|
|
||||||
const policyLog = createLogger("core:command-policies", { category: "command" });
|
const policyLog = createLogger("core:command-policies", { category: "command" });
|
||||||
const STREAM_RESTART_TOLERANCE_MS = 2 * 60 * 60 * 1000;
|
const STREAM_RESTART_TOLERANCE_MS = 2 * 60 * 60 * 1000;
|
||||||
|
const DEFAULT_GROUP_ID = "core-default";
|
||||||
|
let defaultGroupEnsured = false;
|
||||||
|
|
||||||
function commandKey(pluginId, commandId) {
|
function commandKey(pluginId, commandId) {
|
||||||
return `premade:${String(pluginId || "core")}:${String(commandId || "command")}`;
|
return `premade:${String(pluginId || "core")}:${String(commandId || "command")}`;
|
||||||
@ -16,12 +18,31 @@ function customCommandKey(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function listGroups() {
|
function listGroups() {
|
||||||
|
ensureDefaultGroup();
|
||||||
return db.prepare("SELECT * FROM command_groups ORDER BY name COLLATE NOCASE").all().map((row) => ({
|
return db.prepare("SELECT * FROM command_groups ORDER BY name COLLATE NOCASE").all().map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
|
is_default: Boolean(row.is_default),
|
||||||
policy: parsePolicy(row.policy_json)
|
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 = {} }) {
|
function createGroup({ name, description = "", policy = {} }) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -36,6 +57,9 @@ function updateGroup(id, { name, description = "", policy = {} }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deleteGroup(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.transaction(() => {
|
||||||
db.prepare("DELETE FROM command_group_members WHERE group_id = ?").run(id);
|
db.prepare("DELETE FROM command_group_members WHERE group_id = ?").run(id);
|
||||||
db.prepare("DELETE FROM command_groups WHERE id = ?").run(id);
|
db.prepare("DELETE FROM command_groups WHERE id = ?").run(id);
|
||||||
@ -43,42 +67,57 @@ function deleteGroup(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCommandPolicy(commandKeyValue) {
|
function getCommandPolicy(commandKeyValue) {
|
||||||
|
ensureDefaultGroup();
|
||||||
const row = db.prepare("SELECT policy_json FROM command_policies WHERE command_key = ?").get(commandKeyValue);
|
const row = db.prepare("SELECT policy_json FROM command_policies WHERE command_key = ?").get(commandKeyValue);
|
||||||
const groups = db.prepare(
|
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"
|
"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, policy: parsePolicy(group.policy_json) }));
|
).all(commandKeyValue).map((group) => ({ ...group, is_default: false, policy: parsePolicy(group.policy_json) }));
|
||||||
return { policy: row ? parsePolicy(row.policy_json) : {}, groups };
|
return { policy: row ? parsePolicy(row.policy_json) : {}, groups };
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveCommandPolicy(commandKeyValue, policy, groupIds = []) {
|
function saveCommandPolicy(commandKeyValue, policy, groupIds = []) {
|
||||||
const normalized = normalizeStoredPolicy(policy);
|
saveCommandPolicies([{ commandKey: commandKeyValue, policy, groupIds }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCommandPolicies(updates = []) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
if (Object.keys(normalized).length) {
|
const upsertPolicy = db.prepare(
|
||||||
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"
|
||||||
"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);
|
const deletePolicy = db.prepare("DELETE FROM command_policies WHERE command_key = ?");
|
||||||
} else {
|
const deleteMembers = db.prepare("DELETE FROM command_group_members WHERE command_key = ?");
|
||||||
db.prepare("DELETE FROM command_policies WHERE command_key = ?").run(commandKeyValue);
|
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 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 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);
|
const defaults = normalizeStoredPolicy(defaultPolicy);
|
||||||
return {
|
return {
|
||||||
windows: resolveRules(commandKeyValue, "window", direct, groupPolicies, defaults),
|
windows: resolveRules(commandKeyValue, "window", direct, groupPolicies, globalDefault, defaults),
|
||||||
perStreams: resolveRules(commandKeyValue, "perStream", direct, groupPolicies, defaults),
|
perStreams: resolveRules(commandKeyValue, "perStream", direct, groupPolicies, globalDefault, defaults),
|
||||||
requirements: resolveRequirements(direct, groupPolicies, defaults),
|
requirements: resolveRequirements(direct, groupPolicies, globalDefault, defaults),
|
||||||
cost: resolveCost(direct, groupPolicies, defaults),
|
cost: resolveCost(direct, groupPolicies, globalDefault, defaults),
|
||||||
direct,
|
direct,
|
||||||
groups: stored.groups
|
groups: stored.groups,
|
||||||
|
defaultGroup: globalDefault
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,7 +263,7 @@ async function resolveStreamSession(ctx) {
|
|||||||
return sessionId;
|
return sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRules(commandKeyValue, category, direct, groups, defaults) {
|
function resolveRules(commandKeyValue, category, direct, groups, globalDefault, defaults) {
|
||||||
if (Object.prototype.hasOwnProperty.call(direct, category)) {
|
if (Object.prototype.hasOwnProperty.call(direct, category)) {
|
||||||
return direct[category] === false ? [] : [{ key: `${commandKeyValue}:${category}`, source: "command", value: 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];
|
const value = group.policy[category];
|
||||||
return value && value !== false ? [{ key: `group:${group.id}:${category}`, source: group.name, value }] : [];
|
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 (rules.length) return rules;
|
||||||
|
if (defaultHasCategory && defaultValue === false) return [];
|
||||||
const fallback = defaults[category];
|
const fallback = defaults[category];
|
||||||
return fallback && fallback !== false ? [{ key: `${commandKeyValue}:default:${category}`, source: "default", value: fallback }] : [];
|
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")) {
|
if (Object.prototype.hasOwnProperty.call(direct, "requirement")) {
|
||||||
return direct.requirement === false ? [] : [{ source: "command", value: direct.requirement }];
|
return direct.requirement === false ? [] : [{ source: "command", value: direct.requirement }];
|
||||||
}
|
}
|
||||||
const values = groups.flatMap((group) => group.policy.requirement && group.policy.requirement !== false
|
const values = groups.flatMap((group) => group.policy.requirement && group.policy.requirement !== false
|
||||||
? [{ source: group.name, value: group.policy.requirement }]
|
? [{ 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 (values.length) return values;
|
||||||
|
if (defaultHasRequirement && defaultRequirement === false) return [];
|
||||||
return defaults.requirement && defaults.requirement !== false ? [{ source: "default", value: defaults.requirement }] : [];
|
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);
|
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 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 (costs.length) return Math.max(...costs);
|
||||||
|
if (defaultHasCost && globalDefault.policy.cost === false) return 0;
|
||||||
return Number(defaults.cost?.amount || 0);
|
return Number(defaults.cost?.amount || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,17 +368,20 @@ function formatDuration(seconds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
DEFAULT_GROUP_ID,
|
||||||
STREAM_RESTART_TOLERANCE_MS,
|
STREAM_RESTART_TOLERANCE_MS,
|
||||||
beginCommandUse,
|
beginCommandUse,
|
||||||
commandKey,
|
commandKey,
|
||||||
createGroup,
|
createGroup,
|
||||||
customCommandKey,
|
customCommandKey,
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
|
ensureDefaultGroup,
|
||||||
getCommandPolicy,
|
getCommandPolicy,
|
||||||
labelRequirement,
|
labelRequirement,
|
||||||
listGroups,
|
listGroups,
|
||||||
normalizeStoredPolicy,
|
normalizeStoredPolicy,
|
||||||
resolvePolicy,
|
resolvePolicy,
|
||||||
saveCommandPolicy,
|
saveCommandPolicy,
|
||||||
|
saveCommandPolicies,
|
||||||
updateGroup
|
updateGroup
|
||||||
};
|
};
|
||||||
|
|||||||
@ -152,6 +152,7 @@ function migrate() {
|
|||||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
policy_json TEXT NOT NULL DEFAULT '{}',
|
policy_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
@ -410,6 +411,14 @@ function migrate() {
|
|||||||
if (!columns.includes("conditional_fuzzy")) {
|
if (!columns.includes("conditional_fuzzy")) {
|
||||||
db.exec("ALTER TABLE custom_commands ADD COLUMN conditional_fuzzy INTEGER NOT NULL DEFAULT 1");
|
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")) {
|
if (!columns.includes("rng_enabled")) {
|
||||||
db.exec("ALTER TABLE custom_commands ADD COLUMN rng_enabled INTEGER NOT NULL DEFAULT 0");
|
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 {
|
.command-policy-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--lumi-space-3);
|
gap: var(--lumi-space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-policy-section {
|
.command-policy-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: var(--lumi-space-2);
|
gap: var(--lumi-space-3);
|
||||||
margin: 0;
|
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 {
|
.command-policy-values {
|
||||||
@ -1054,10 +1074,148 @@ input[type="color"] {
|
|||||||
gap: var(--lumi-space-2);
|
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);
|
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 {
|
.command-rng-range {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(8rem, 1fr));
|
grid-template-columns: repeat(2, minmax(8rem, 1fr));
|
||||||
@ -1845,11 +2003,23 @@ details > summary:focus-visible {
|
|||||||
|
|
||||||
.command-random-row,
|
.command-random-row,
|
||||||
.command-policy-grid,
|
.command-policy-grid,
|
||||||
|
.command-policy-toolbar,
|
||||||
|
.command-policy-command-toolbar,
|
||||||
.command-rng-range,
|
.command-rng-range,
|
||||||
.custom-placeholder-row {
|
.custom-placeholder-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.command-policy-savebar {
|
||||||
|
position: static;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-policy-savebar .button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.homepage-hero-media {
|
.homepage-hero-media {
|
||||||
min-height: 12rem;
|
min-height: 12rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,8 @@
|
|||||||
--lumi-surface-subtle: var(--surface-2, #f4f7f8);
|
--lumi-surface-subtle: var(--surface-2, #f4f7f8);
|
||||||
--lumi-surface-raised: var(--surface-3, #edf2f3);
|
--lumi-surface-raised: var(--surface-3, #edf2f3);
|
||||||
--lumi-border: var(--border, #d8e0e3);
|
--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-bg: var(--lumi-surface);
|
||||||
--lumi-input-border: var(--lumi-border);
|
--lumi-input-border: var(--lumi-border);
|
||||||
--lumi-input-text: var(--lumi-text);
|
--lumi-input-text: var(--lumi-text);
|
||||||
|
|||||||
@ -86,10 +86,12 @@ const {
|
|||||||
customCommandKey,
|
customCommandKey,
|
||||||
deleteGroup: deleteCommandGroup,
|
deleteGroup: deleteCommandGroup,
|
||||||
getCommandPolicy,
|
getCommandPolicy,
|
||||||
|
labelRequirement,
|
||||||
listGroups: listCommandGroups,
|
listGroups: listCommandGroups,
|
||||||
normalizeStoredPolicy,
|
normalizeStoredPolicy,
|
||||||
resolvePolicy,
|
resolvePolicy,
|
||||||
saveCommandPolicy,
|
saveCommandPolicy,
|
||||||
|
saveCommandPolicies,
|
||||||
updateGroup: updateCommandGroup
|
updateGroup: updateCommandGroup
|
||||||
} = require("../services/command-policies");
|
} = require("../services/command-policies");
|
||||||
const {
|
const {
|
||||||
@ -1373,31 +1375,32 @@ function buildCustomCommandListPreview(command) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function commandPolicyFromBody(body = {}, { allowInherit = true } = {}) {
|
function commandPolicyFromBody(body = {}, { allowInherit = true, prefix = "" } = {}) {
|
||||||
const output = {};
|
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 assign = (name, value) => {
|
||||||
const selected = mode(name);
|
const selected = mode(name);
|
||||||
if (selected === "inherit" && allowInherit) return;
|
if (selected === "inherit" && allowInherit) return;
|
||||||
output[name] = selected === "off" ? false : value;
|
output[name] = selected === "off" ? false : value;
|
||||||
};
|
};
|
||||||
assign("window", {
|
assign("window", {
|
||||||
uses: body.window_uses,
|
uses: field("window_uses"),
|
||||||
seconds: body.window_seconds,
|
seconds: field("window_seconds"),
|
||||||
scope: body.window_scope
|
scope: field("window_scope")
|
||||||
});
|
});
|
||||||
assign("perStream", {
|
assign("perStream", {
|
||||||
uses: body.perStream_uses,
|
uses: field("perStream_uses"),
|
||||||
scope: body.perStream_scope
|
scope: field("perStream_scope")
|
||||||
});
|
});
|
||||||
const requirementMode = mode("requirement");
|
const requirementMode = mode("requirement");
|
||||||
if (!(allowInherit && requirementMode === "inherit")) {
|
if (!(allowInherit && requirementMode === "inherit")) {
|
||||||
output.requirement = requirementMode === "off" ? false : {
|
output.requirement = requirementMode === "off" ? false : {
|
||||||
role: body.requirement_role,
|
role: field("requirement_role"),
|
||||||
subscriberTier: body.requirement_subscriber_tier
|
subscriberTier: field("requirement_subscriber_tier")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
assign("cost", { amount: body.cost_amount });
|
assign("cost", { amount: field("cost_amount") });
|
||||||
return normalizeStoredPolicy(output);
|
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 = {}) {
|
function customPlaceholdersFromBody(body = {}) {
|
||||||
const array = (value) => Array.isArray(value) ? value : value === undefined ? [] : [value];
|
const array = (value) => Array.isArray(value) ? value : value === undefined ? [] : [value];
|
||||||
const names = array(body.custom_placeholder_name);
|
const names = array(body.custom_placeholder_name);
|
||||||
@ -3089,7 +3120,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
store: sessionStore
|
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(express.json({ limit: "1mb" }));
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
@ -6334,30 +6365,52 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get("/admin/command-policies", requireRole("admin"), (req, res) => {
|
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) => ({
|
const premade = (commandRouter?.listCommands?.() || []).map((command) => ({
|
||||||
key: command.key,
|
key: command.key,
|
||||||
label: `!${command.triggers[0] || command.id}`,
|
label: `!${command.triggers[0] || command.id}`,
|
||||||
description: command.description || `${command.pluginId} command`,
|
description: command.description || "No description provided.",
|
||||||
origin: command.pluginId === "core" ? "Core" : command.pluginId,
|
origin: command.pluginId === "core" ? "Built-in command" : `${pluginNames.get(command.pluginId) || command.pluginId} plugin`,
|
||||||
defaultPolicy: command.defaultPolicy || {},
|
defaultPolicy: command.defaultPolicy || {},
|
||||||
platforms: command.platforms || []
|
platforms: command.platforms || []
|
||||||
}));
|
}));
|
||||||
const custom = db.prepare("SELECT id, trigger, description, platform FROM custom_commands ORDER BY trigger").all().map((command) => ({
|
const custom = db.prepare("SELECT id, trigger, description, platform FROM custom_commands ORDER BY trigger").all().map((command) => ({
|
||||||
key: customCommandKey(command.id),
|
key: customCommandKey(command.id),
|
||||||
label: `!${command.trigger}`,
|
label: `!${command.trigger}`,
|
||||||
description: command.description || "Custom command",
|
description: command.description || "No description provided.",
|
||||||
origin: "Custom",
|
origin: "Custom command",
|
||||||
defaultPolicy: {},
|
defaultPolicy: {},
|
||||||
platforms: normalizePlatformSelection(command.platform, getEnabledPlatformIds())
|
platforms: normalizePlatformSelection(command.platform, getEnabledPlatformIds())
|
||||||
}));
|
}));
|
||||||
const commands = [...premade, ...custom]
|
const commands = [...premade, ...custom]
|
||||||
.filter((command, index, all) => all.findIndex((item) => item.key === command.key) === index)
|
.filter((command, index, all) => all.findIndex((item) => item.key === command.key) === index)
|
||||||
.sort((left, right) => left.label.localeCompare(right.label))
|
.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", {
|
res.render("admin-command-policies", {
|
||||||
title: "Command access and limits",
|
title: "Command access and limits",
|
||||||
commands,
|
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) => {
|
app.post("/admin/command-policies/groups/:id", requireRole("admin"), (req, res) => {
|
||||||
try {
|
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, {
|
updateCommandGroup(req.params.id, {
|
||||||
name: req.body.name,
|
name: req.body.name,
|
||||||
description: req.body.description,
|
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.");
|
setFlash(req, "success", "Command group updated.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -6390,11 +6445,46 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/command-policies/groups/:id/delete", requireRole("admin"), (req, res) => {
|
app.post("/admin/command-policies/groups/:id/delete", requireRole("admin"), (req, res) => {
|
||||||
deleteCommandGroup(req.params.id);
|
try {
|
||||||
setFlash(req, "success", "Command group deleted. Its commands keep their direct settings.");
|
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");
|
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) => {
|
app.post("/admin/command-policies/command", requireRole("admin"), (req, res) => {
|
||||||
const key = String(req.body.command_key || "");
|
const key = String(req.body.command_key || "");
|
||||||
const knownKeys = new Set([
|
const knownKeys = new Set([
|
||||||
|
|||||||
@ -1,36 +1,83 @@
|
|||||||
<%- include("partials/layout-top", { title }) %>
|
<%- 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">
|
<section class="card">
|
||||||
<%- include("partials/page-header", {
|
<%- include("partials/page-header", {
|
||||||
eyebrow: "Chat tools",
|
eyebrow: "Chat tools",
|
||||||
pageTitle: "Command access and limits",
|
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>'
|
actions: '<a class="button subtle" href="/admin/commands">Custom commands</a>'
|
||||||
}) %>
|
}) %>
|
||||||
<div class="callout info">
|
<div class="callout info">
|
||||||
<strong>How settings combine</strong>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card policy-collection" data-policy-search-scope="groups">
|
||||||
<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>
|
<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>
|
||||||
<details class="lumi-expandable-settings">
|
<div class="command-policy-toolbar">
|
||||||
<summary><span><strong>Create command group</strong><span class="hint">For example: Public fun, Moderator tools, or Expensive commands</span></span></summary>
|
<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">
|
<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>Name<input name="name" maxlength="100" required /></label></div>
|
||||||
<div class="field"><label>Description<input name="description" maxlength="500" /></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>
|
<div class="form-actions"><button class="button" type="submit">Create group</button></div>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
<% if (!groups.length) { %><p class="hint">No command groups yet.</p><% } %>
|
|
||||||
<% groups.forEach((group) => { %>
|
<% groups.forEach((group) => { %>
|
||||||
<details class="lumi-expandable-settings command-policy-item">
|
<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><strong><%= group.name %></strong><span class="hint"><%= group.description || "Reusable command rules" %></span></span></summary>
|
<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">
|
<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>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"><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>
|
<div class="form-actions"><button class="button" type="submit">Save group</button></div>
|
||||||
</form>
|
</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">
|
<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>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
<p class="command-policy-no-results" data-policy-no-results hidden>No command groups match that search and filter.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card policy-collection" data-policy-search-scope="commands">
|
||||||
<div class="section-header"><div><h2>Individual commands</h2><p class="hint">Assign groups or override a category for one command.</p></div></div>
|
<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><% } %>
|
<% if (!commands.length) { %><p>No commands are currently registered.</p><% } %>
|
||||||
<% commands.forEach((command) => { %>
|
<% if (commands.length) { %>
|
||||||
<details class="lumi-expandable-settings command-policy-item">
|
<form method="post" action="/admin/command-policies/commands" class="command-policy-batch-form" data-command-policy-batch>
|
||||||
<summary>
|
<input type="hidden" name="command_count" value="<%= commands.length %>" />
|
||||||
<span><strong><%= command.label %></strong><span class="hint"><%= command.origin %> · <%= command.description %></span></span>
|
<div class="command-policy-savebar">
|
||||||
<% if (command.groups.length) { %><span class="badge"><%= command.groups.length %> group<%= command.groups.length === 1 ? "" : "s" %></span><% } %>
|
<span><strong>Command settings</strong><span class="hint" data-command-save-state>All displayed values are saved.</span></span>
|
||||||
</summary>
|
<button class="button" type="submit">Save all command settings</button>
|
||||||
<form method="post" action="/admin/command-policies/command" class="form-grid command-policy-form">
|
</div>
|
||||||
<input type="hidden" name="command_key" value="<%= command.key %>" />
|
<% commands.forEach((command, index) => { %>
|
||||||
<fieldset class="subsection field full">
|
<% const prefix = `command_${index}_`; %>
|
||||||
<legend>Groups</legend>
|
<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() %>">
|
||||||
<div class="platform-checkboxes">
|
<summary>
|
||||||
<% groups.forEach((group) => { %>
|
<span class="command-policy-summary-copy">
|
||||||
<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>
|
<span class="command-policy-title"><strong><%= command.label %></strong><span class="badge"><%= command.origin %></span></span>
|
||||||
<% }) %>
|
<span class="hint"><%= command.description %></span>
|
||||||
</div>
|
</span>
|
||||||
<% if (!groups.length) { %><p class="hint">Create a group above first, or configure this command directly.</p><% } %>
|
<% if (command.groups.length) { %><span class="badge"><%= command.groups.length %> assigned group<%= command.groups.length === 1 ? "" : "s" %></span><% } %>
|
||||||
</fieldset>
|
</summary>
|
||||||
<% if (Object.keys(command.defaultPolicy || {}).length) { %>
|
<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 || {}) %>">
|
||||||
<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>
|
<input type="hidden" name="<%= prefix %>key" value="<%= command.key %>" />
|
||||||
<% } %>
|
<fieldset class="subsection field full command-policy-groups">
|
||||||
<div class="field full"><%- include("partials/command-policy-fields", { policy: command.policy, allowInherit: true }) %></div>
|
<legend>Assigned groups</legend>
|
||||||
<div class="form-actions"><button class="button" type="submit">Save command settings</button></div>
|
<div class="platform-checkboxes">
|
||||||
</form>
|
<% groups.forEach((group) => { %>
|
||||||
</details>
|
<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>
|
</section>
|
||||||
<script>
|
<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 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 values = category.querySelector("[data-policy-values]");
|
||||||
|
const inheritedSummary = category.querySelector("[data-policy-inherited]");
|
||||||
if (values) values.hidden = !custom;
|
if (values) values.hidden = !custom;
|
||||||
|
if (inheritedSummary) inheritedSummary.hidden = !inherited;
|
||||||
const role = category.querySelector("[data-requirement-role]");
|
const role = category.querySelector("[data-requirement-role]");
|
||||||
const tier = category.querySelector("[data-subscriber-tier]");
|
const tier = category.querySelector("[data-subscriber-tier]");
|
||||||
if (tier) tier.hidden = !custom || role?.value !== "subscriber";
|
if (tier) tier.hidden = !custom || role?.value !== "subscriber";
|
||||||
@ -83,5 +301,10 @@
|
|||||||
category.querySelector("[data-policy-mode]")?.addEventListener("change", () => updatePolicyCategory(category));
|
category.querySelector("[data-policy-mode]")?.addEventListener("change", () => updatePolicyCategory(category));
|
||||||
category.querySelector("[data-requirement-role]")?.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>
|
</script>
|
||||||
<%- include("partials/layout-bottom") %>
|
<%- include("partials/layout-bottom") %>
|
||||||
|
|||||||
@ -5,67 +5,76 @@
|
|||||||
const streamValue = policy?.perStream && policy.perStream !== false ? policy.perStream : {};
|
const streamValue = policy?.perStream && policy.perStream !== false ? policy.perStream : {};
|
||||||
const requirementValue = policy?.requirement && policy.requirement !== false ? policy.requirement : {};
|
const requirementValue = policy?.requirement && policy.requirement !== false ? policy.requirement : {};
|
||||||
const costValue = policy?.cost && policy.cost !== false ? policy.cost : {};
|
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">
|
<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>
|
<legend>Rate limit</legend>
|
||||||
<label>Setting
|
<label>Setting
|
||||||
<select name="window_mode" data-policy-mode>
|
<select name="<%= fieldName('window_mode') %>" data-policy-mode>
|
||||||
<% if (allowInherit) { %><option value="inherit" <%= policyMode("window") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
|
<% 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="off" <%= policyMode("window") === "off" ? "selected" : "" %>>No time-window limit</option>
|
||||||
<option value="custom" <%= policyMode("window") === "custom" ? "selected" : "" %>>Set a limit</option>
|
<option value="custom" <%= policyMode("window") === "custom" ? "selected" : "" %>>Set a limit</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<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>Uses<input name="<%= fieldName('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>Within seconds<input name="<%= fieldName('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>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>
|
</div>
|
||||||
</fieldset>
|
</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>
|
<legend>Per stream</legend>
|
||||||
<label>Setting
|
<label>Setting
|
||||||
<select name="perStream_mode" data-policy-mode>
|
<select name="<%= fieldName('perStream_mode') %>" data-policy-mode>
|
||||||
<% if (allowInherit) { %><option value="inherit" <%= policyMode("perStream") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
|
<% if (allowInherit) { %><option value="inherit" <%= policyMode("perStream") === "inherit" ? "selected" : "" %>><%= inheritOptionLabel %></option><% } %>
|
||||||
<option value="off" <%= policyMode("perStream") === "off" ? "selected" : "" %>>No stream limit</option>
|
<option value="off" <%= policyMode("perStream") === "off" ? "selected" : "" %>>No stream limit</option>
|
||||||
<option value="custom" <%= policyMode("perStream") === "custom" ? "selected" : "" %>>Set a limit</option>
|
<option value="custom" <%= policyMode("perStream") === "custom" ? "selected" : "" %>>Set a limit</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<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>Uses per stream<input name="<%= fieldName('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>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>
|
</div>
|
||||||
<p class="hint">A restarted stream stays in the same session for two hours, so a crash does not reset this limit.</p>
|
<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>
|
||||||
<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>
|
<legend>Who may use it</legend>
|
||||||
<label>Setting
|
<label>Setting
|
||||||
<select name="requirement_mode" data-policy-mode>
|
<select name="<%= fieldName('requirement_mode') %>" data-policy-mode>
|
||||||
<% if (allowInherit) { %><option value="inherit" <%= policyMode("requirement") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
|
<% if (allowInherit) { %><option value="inherit" <%= policyMode("requirement") === "inherit" ? "selected" : "" %>><%= inheritOptionLabel %></option><% } %>
|
||||||
<option value="off" <%= policyMode("requirement") === "off" ? "selected" : "" %>>Everyone</option>
|
<option value="off" <%= policyMode("requirement") === "off" ? "selected" : "" %>>Everyone</option>
|
||||||
<option value="custom" <%= policyMode("requirement") === "custom" ? "selected" : "" %>>Require a role</option>
|
<option value="custom" <%= policyMode("requirement") === "custom" ? "selected" : "" %>>Require a role</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<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]) => { %>
|
<% [["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>
|
<option value="<%= value %>" <%= requirementValue.role === value ? "selected" : "" %>><%= label %></option>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</select></label>
|
</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>
|
</div>
|
||||||
</fieldset>
|
</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>
|
<legend>Currency cost</legend>
|
||||||
<label>Setting
|
<label>Setting
|
||||||
<select name="cost_mode" data-policy-mode>
|
<select name="<%= fieldName('cost_mode') %>" data-policy-mode>
|
||||||
<% if (allowInherit) { %><option value="inherit" <%= policyMode("cost") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
|
<% if (allowInherit) { %><option value="inherit" <%= policyMode("cost") === "inherit" ? "selected" : "" %>><%= inheritOptionLabel %></option><% } %>
|
||||||
<option value="off" <%= policyMode("cost") === "off" ? "selected" : "" %>>Free</option>
|
<option value="off" <%= policyMode("cost") === "off" ? "selected" : "" %>>Free</option>
|
||||||
<option value="custom" <%= policyMode("cost") === "custom" ? "selected" : "" %>>Charge currency</option>
|
<option value="custom" <%= policyMode("cost") === "custom" ? "selected" : "" %>>Charge currency</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<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>
|
</div>
|
||||||
<p class="hint">The Economy Framework deducts and logs this like any other banking transaction.</p>
|
<p class="hint">The Economy Framework deducts and logs this like any other banking transaction.</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Lumi Core",
|
"name": "Lumi Core",
|
||||||
"version": "0.2.18",
|
"version": "0.2.19",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"released_at": "2026-07-19",
|
"released_at": "2026-07-19",
|
||||||
"compatible_from": "0.1.9",
|
"compatible_from": "0.1.9",
|
||||||
@ -8,7 +8,7 @@
|
|||||||
"replaces_versions": [
|
"replaces_versions": [
|
||||||
"1.2.0"
|
"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,
|
"rollback_safe": true,
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"Node.js 18 or newer"
|
"Node.js 18 or newer"
|
||||||
@ -241,6 +241,18 @@
|
|||||||
],
|
],
|
||||||
"rollback_safe": true,
|
"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."
|
"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