Add command policies and stream controls

This commit is contained in:
Franz Rolfsvaag 2026-07-19 15:33:20 +02:00
parent 4bd337d5e3
commit 7b7e49507c
30 changed files with 1765 additions and 52 deletions

View File

@ -1,5 +1,12 @@
# Lumi changelog
## 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 conditional keyword custom replies with optional conservative typo matching and shared placeholder handling.
- Added core `!clip [label]` orchestration for platform clips plus OBS replay saves through local WebSocket or the Browser Bridge, and Twitch `!raid <channel>` / `!raid cancel`.
- Added an admin command-policy page, command-list access summaries, focused verification, and operator documentation.
## 0.2.17
- Added a signed standalone OBS Custom Browser Dock page for every native Lumi chat source, with admin-only copy/open controls.

19
TODO.md
View File

@ -2,6 +2,25 @@
This file tracks larger Lumi work that cannot safely be completed in one pass. Keep pending work under the relevant category and move completed items to the Done section with a short note.
## Command policy and stream controls
Implemented on 2026-07-19: centralized command groups and per-command overrides
for every routed custom/core/plugin command, with global/per-user time windows,
persistent crash-tolerant per-stream limits, platform role requirements,
subscriber tiers, and Economy Framework costs/refunds in normal banking history.
Added conditional keyword custom replies with conservative fuzzy matching, plus
core `!clip` platform/OBS replay orchestration and Twitch `!raid`/`!raid cancel`.
OBS replay saving works through local WebSocket and the credential-free Browser
Bridge BASIC permission. Admin UI and focused verification cover the shared
storage and enforcement path.
Remaining work:
- Add platform action providers when YouTube or future chat integrations expose
supported clip/raid APIs.
- Replace heuristic Discord subscriber/VIP role-name matching with explicit
per-server role mapping if communities need stricter Discord entitlements.
## Current Local State / Source of Truth
### P0 OBS Overlay System

78
docs/commands.md Normal file
View File

@ -0,0 +1,78 @@
# Commands, access, and limits
Admins configure shared command rules at **Admin → Command access**. Moderators
continue to create and edit custom replies at **Custom commands**.
## Built-in stream commands
- `!clip [optional label]` requests a clip on the platform that delivered the
command and saves the replay buffer through the first operational OBS
connector. Twitch supports clip creation; platforms without a clip API report
that part as unavailable without preventing the OBS replay save.
- `!raid <channel>` starts a supported platform raid. `!raid cancel` cancels a
pending raid. Twitch shows its normal confirmation/countdown after Lumi starts
the raid.
Twitch clip creation needs `clips:edit`; raids need
`channel:manage:raids`. Follower, subscriber-tier, and editor requirements need
`moderator:read:followers`, `channel:read:subscriptions`, and
`channel:read:editors` respectively. The configured user token must represent
the broadcaster, or an eligible moderator where Twitch permits it. Lumi reports
a verification problem instead of silently allowing a user when a required
scope is missing.
Twitch does not expose a clip-title field in Create Clip, and OBS replay-save
bindings do not accept a filename. Lumi therefore records and repeats the
optional label for operator context but does not claim that either provider used
it as the saved title.
The local OBS WebSocket connector calls `SaveReplayBuffer`. The OBS Browser
Bridge calls OBS Browser Source's `window.obsstudio.saveReplayBuffer()` and
requires **Basic access to OBS** (level 3) or higher. The replay buffer must
already be running. A platform or OBS failure is reported independently, so one
failure does not hide a successful action from the other system.
## Conditional custom replies
Choose **Conditional Reply** for a custom command, then add keyword/reply pairs.
For example, a `rules` command can answer `!rules backseating` and
`!rules spoilers` differently. The optional main response is shown for `!rules`
without an argument; if it is empty, Lumi lists the available keywords.
Exact keyword matches always win. Optional fuzzy matching accepts only a clear,
close typo and refuses ambiguous or unrelated text. Reply templates use the
same shared placeholder validation and rendering as static custom commands.
## Groups and command overrides
A command can belong to several groups. Every assigned group rate limit and
role requirement applies. The highest group currency cost is used, so assigning
two priced groups does not charge twice. Group rate limits form a shared pool
across commands in that group.
Each command category has three choices:
- **Use group/default** inherits groups, or the command's conservative built-in
default when no group supplies that category.
- **No limit / Everyone / Free** explicitly disables inherited values for that
category.
- **Set** replaces inherited values for that category on this command.
Time-window limits support a shared global pool or one pool per Lumi user.
Per-stream limits also support global or per-user counting. Stream identities
are persisted, and a new provider stream that appears within two hours reuses
the previous session so a crash and troubleshooting restart cannot reset the
allowance.
Role requirements are evaluated from live platform roles first. Twitch performs
a scoped API check when follower status, subscriber tier, or editor status is
not available in chat metadata. YouTube uses owner, moderator, and member data
provided with live chat. Discord uses server ownership, permissions, and common
VIP/subscriber role names. A requirement that cannot be reliably verified is
denied with an actionable message.
Currency costs reuse the Economy Framework. A successful admission creates a
normal `spend` transaction with command metadata. If the command handler declines
or fails, Lumi releases the rate-limit reservation and writes a matching refund
transaction. If Economy is disabled, a priced command is denied rather than run
for free.

View File

@ -123,6 +123,16 @@ deleting or disabling the chat source makes its dock unavailable. Lumi also
revalidates credentials on open live-event streams and closes them after
revocation.
## OBS replay commands
The core `!clip [optional label]` command can save the active replay buffer
through either operational OBS connector while also requesting a clip from the
current streaming platform. Local OBS WebSocket uses `SaveReplayBuffer`. The
Browser Bridge uses OBS's native page binding and needs **Basic access to OBS**
or higher in Browser Source properties. The replay buffer must already be
running. Lumi selects one connected connector with an active replay buffer so a
setup with several overlays does not save duplicate replay files.
### Video and audio
Video sources support browser-playable media such as WebM and MP4; audio sources

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime.
## Runtime
Package: lumi-bot
Version: 0.2.17
Version: 0.2.18
## Routes
- GET /api/events
- POST /api/destructive-confirmations

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lumi-bot",
"version": "0.2.17",
"version": "0.2.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.2.17",
"version": "0.2.18",
"dependencies": {
"adm-zip": "^0.5.12",
"better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.2.17",
"version": "0.2.18",
"private": true,
"type": "commonjs",
"scripts": {

View File

@ -2,6 +2,36 @@
"schema_version": 1,
"channel": "stable",
"releases": [
{
"version": "0.2.18",
"ref": "refs/tags/v0.2.18",
"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": "Adds centralized command policies, conditional replies, platform clip and raid actions, and OBS replay saving. Existing commands, command 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.17",
"ref": "refs/tags/v0.2.17",

View File

@ -22,6 +22,7 @@ const checks = [
"plugins/lumi_ai_web_search/tests/verify.js",
"scripts/verify-assistant-panels.js",
"scripts/verify-command-preview-confirmations.js",
"scripts/verify-command-policies.js",
"scripts/verify-destructive-actions.js",
"scripts/verify-overlays.js",
"scripts/verify-overlay-web-documents.js",

View File

@ -0,0 +1,95 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const root = path.join(__dirname, "..");
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-command-policies-"));
const serviceDir = path.join(sandbox, "src", "services");
(async () => {
try {
fs.cpSync(path.join(root, "src", "services"), serviceDir, { recursive: true });
const database = require(path.join(serviceDir, "db.js"));
database.migrate();
const conditional = require(path.join(serviceDir, "command-conditional.js"));
const policies = require(path.join(serviceDir, "command-policies.js"));
const replies = conditional.normalizeConditionalReplies([
{ keyword: "backseating", response: "Please avoid backseating." },
{ keyword: "spoilers", response: "No spoilers." }
]);
assert.strictEqual(conditional.findConditionalReply(replies, "backseating").match.response, "Please avoid backseating.");
const fuzzy = conditional.findConditionalReply(replies, "backseatng");
assert.strictEqual(fuzzy.match.keyword, "backseating");
assert.strictEqual(fuzzy.fuzzy, true);
assert.strictEqual(conditional.findConditionalReply(replies, "something unrelated").match, null);
const groupId = policies.createGroup({
name: "Verification group",
policy: {
window: { uses: 3, seconds: 30, scope: "global" },
requirement: { role: "subscriber", subscriberTier: 2 },
cost: { amount: 5 }
}
});
const key = policies.commandKey("verification", "hello");
policies.saveCommandPolicy(key, {
window: { uses: 1, seconds: 60, scope: "user" },
requirement: false,
cost: { amount: 2 }
}, [groupId]);
const resolved = policies.resolvePolicy(key);
assert.strictEqual(resolved.windows.length, 1, "a direct rate limit must replace group limits");
assert.strictEqual(resolved.windows[0].value.uses, 1);
assert.strictEqual(resolved.requirements.length, 0, "a direct public setting must replace group roles");
assert.strictEqual(resolved.cost, 2, "a direct cost must replace the group cost");
const charges = [];
global.lumiFrameworks = {
economy: {
removeBalance(payload) { charges.push(["charge", payload]); return { ok: true }; },
addBalance(payload) { charges.push(["refund", payload]); return "refund-id"; }
}
};
const ctx = {
platform: "discord",
trigger: "hello",
raw: "!hello",
user: { id: "verification-user", platformId: "platform-user" },
platformUser: { id: "platform-user" },
meta: {}
};
const first = await policies.beginCommandUse({ commandKey: key, ctx });
assert.strictEqual(first.allowed, true);
assert.strictEqual(charges[0][0], "charge");
const limited = await policies.beginCommandUse({ commandKey: key, ctx });
assert.strictEqual(limited.allowed, false, "the second use inside the window must be denied");
await first.rollback("verification");
assert.strictEqual(charges[1][0], "refund", "failed commands must refund their logged currency cost");
const afterRollback = await policies.beginCommandUse({ commandKey: key, ctx });
assert.strictEqual(afterRollback.allowed, true, "rollback must release the rate-limit reservation");
await afterRollback.commit();
const dbColumns = database.db.prepare("PRAGMA table_info(custom_commands)").all().map((column) => column.name);
assert(dbColumns.includes("conditional_replies_json"));
assert(dbColumns.includes("conditional_fuzzy"));
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}`);
}
const server = fs.readFileSync(path.join(root, "src", "web", "server.js"), "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");
assert(server.includes('/admin/command-policies') && server.includes('commandPolicyFromBody'));
assert(view.includes("Command groups") && view.includes("Save command settings"));
assert(commandView.includes('value="conditional"') && commandView.includes("data-conditional-replies"));
database.db.close();
console.log("Command policies and conditional commands verified.");
} finally {
fs.rmSync(sandbox, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -11,6 +11,7 @@ fs.mkdirSync(serviceDir, { recursive: true });
for (const file of [
"config.js",
"db.js",
"logger.js",
"settings.js",
"web-events.js",
"overlay-secrets.js",
@ -332,6 +333,9 @@ try {
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
assert(runtimeScript.includes("window.obsstudio") && runtimeScript.includes("overlay:obs-browser-command"));
assert(runtimeScript.includes("saveReplayBuffer"), "Browser Bridge must support OBS replay saves");
assert(connectorScript.includes('this.client.call("SaveReplayBuffer")'), "local OBS connector must support replay saves");
assert(connectorScript.includes('action: "save_replay_buffer"'), "Browser Bridge connector must request replay saves");
assert(rendererScript.includes("buildMedia") && rendererScript.includes("buildWebsite"));
assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts"));
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));

View File

@ -4,8 +4,8 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.2.17";
const previousCoreVersion = "0.2.16";
const releaseVersion = "0.2.18";
const previousCoreVersion = "0.2.17";
const earliestCompatibleCoreVersion = "0.1.9";
const changedPlugins = {
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: core 0.2.17, Lumi AI 0.8.5, and synchronized package metadata.");
console.log("Release metadata verification passed: core 0.2.18, Lumi AI 0.8.5, and synchronized package metadata.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.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.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
available_versions: [
{ version: "0.2.18", ref: "refs/tags/v0.2.18", rollback_safe: true },
{ version: "0.2.17", ref: "refs/tags/v0.2.17", rollback_safe: true },
{ version: "0.2.16", ref: "refs/tags/v0.2.16", rollback_safe: true },
{ version: "0.2.15", ref: "refs/tags/v0.2.15", rollback_safe: true },
@ -77,7 +78,7 @@ const corrected = buildStatus({
channel: "stable"
});
assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.2.17");
assert.equal(corrected.safe_target_version, "0.2.18");
assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false);

View File

@ -83,6 +83,7 @@ async function main() {
const app = createWebServer({
discordClient,
commandRouter,
loadPlugins: (appInstance, web, webhooks) => {
if (safeModeRequested) return;
loadEnabled({

View File

@ -0,0 +1,111 @@
const MAX_CONDITIONAL_REPLIES = 100;
function normalizeConditionalKey(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function normalizeConditionalReplies(value) {
let input = value;
if (typeof input === "string") {
try { input = JSON.parse(input); } catch { input = []; }
}
if (!Array.isArray(input)) return [];
const seen = new Set();
return input.slice(0, MAX_CONDITIONAL_REPLIES).flatMap((entry) => {
const keyword = String(entry?.keyword || "").trim().slice(0, 100);
const response = String(entry?.response || "").trim().slice(0, 4000);
const normalized = normalizeConditionalKey(keyword);
if (!normalized || !response || seen.has(normalized)) return [];
seen.add(normalized);
return [{ keyword, normalized, response }];
});
}
function conditionalRepliesFromBody(body = {}) {
const keywords = asArray(body.conditional_keyword);
const responses = asArray(body.conditional_response);
const replies = [];
const errors = [];
const seen = new Set();
const count = Math.max(keywords.length, responses.length);
for (let index = 0; index < count; index += 1) {
const keyword = String(keywords[index] || "").trim();
const response = String(responses[index] || "").trim();
if (!keyword && !response) continue;
if (!keyword || !response) {
errors.push("Each conditional reply needs both a keyword and a response.");
continue;
}
const normalized = normalizeConditionalKey(keyword);
if (seen.has(normalized)) {
errors.push(`The keyword “${keyword}” is listed more than once.`);
continue;
}
seen.add(normalized);
replies.push({ keyword: keyword.slice(0, 100), response: response.slice(0, 4000) });
}
if (!replies.length) errors.push("Add at least one keyword reply.");
if (replies.length > MAX_CONDITIONAL_REPLIES) errors.push(`Use no more than ${MAX_CONDITIONAL_REPLIES} keyword replies.`);
return { ok: errors.length === 0, errors, replies: replies.slice(0, MAX_CONDITIONAL_REPLIES) };
}
function findConditionalReply(replies, argument, { fuzzy = true } = {}) {
const normalizedArgument = normalizeConditionalKey(argument);
if (!normalizedArgument) return { match: null, fuzzy: false };
const normalizedReplies = normalizeConditionalReplies(replies);
const exact = normalizedReplies.find((entry) => entry.normalized === normalizedArgument);
if (exact) return { match: exact, fuzzy: false };
if (!fuzzy) return { match: null, fuzzy: false };
const candidates = normalizedReplies
.map((entry) => ({ entry, score: similarity(normalizedArgument, entry.normalized) }))
.sort((left, right) => right.score - left.score);
const best = candidates[0];
const runnerUp = candidates[1];
const minimum = normalizedArgument.length <= 4 ? 0.82 : 0.68;
if (!best || best.score < minimum || (runnerUp && best.score - runnerUp.score < 0.08)) {
return { match: null, fuzzy: false };
}
return { match: best.entry, fuzzy: true };
}
function similarity(left, right) {
if (left === right) return 1;
const distance = levenshtein(left, right);
return 1 - distance / Math.max(left.length, right.length, 1);
}
function levenshtein(left, right) {
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
for (let i = 1; i <= left.length; i += 1) {
let diagonal = previous[0];
previous[0] = i;
for (let j = 1; j <= right.length; j += 1) {
const above = previous[j];
previous[j] = Math.min(
previous[j] + 1,
previous[j - 1] + 1,
diagonal + (left[i - 1] === right[j - 1] ? 0 : 1)
);
diagonal = above;
}
}
return previous[right.length];
}
function asArray(value) {
if (Array.isArray(value)) return value;
return value === undefined || value === null ? [] : [value];
}
module.exports = {
conditionalRepliesFromBody,
findConditionalReply,
normalizeConditionalKey,
normalizeConditionalReplies
};

View File

@ -0,0 +1,128 @@
const { getTwitchEntitlement } = require("./command-platform");
const REQUIREMENTS = ["public", "follower", "subscriber", "vip", "mod", "editor", "streamer"];
async function checkCommandRequirement(ctx, requirement = {}) {
const role = REQUIREMENTS.includes(requirement.role) ? requirement.role : "public";
if (role === "public") return { allowed: true };
const flags = localRoleFlags(ctx);
if (flags.streamer) return { allowed: true };
if (role === "streamer") return denied(role);
if (role === "editor") {
if (flags.editor) return { allowed: true };
return checkRemoteTwitch(ctx, role, requirement);
}
if (role === "mod") return flags.mod || flags.editor ? { allowed: true } : denied(role);
if (role === "vip") return flags.vip || flags.mod || flags.editor ? { allowed: true } : denied(role);
if (role === "subscriber") {
const minimumTier = Math.max(1, Math.min(3, Number(requirement.subscriberTier) || 1));
if ((flags.subscriberTier || 0) >= minimumTier || flags.vip || flags.mod || flags.editor) return { allowed: true };
return checkRemoteTwitch(ctx, role, { subscriberTier: minimumTier });
}
if (role === "follower") {
if (flags.follower || flags.subscriberTier || flags.vip || flags.mod || flags.editor) return { allowed: true };
return checkRemoteTwitch(ctx, role, requirement);
}
return denied(role);
}
function localRoleFlags(ctx) {
if (ctx.platform === "twitch") {
const tags = ctx.meta?.tags || {};
const badges = tags.badges || {};
const streamer = Boolean(badges.broadcaster) || sameTwitchChannel(ctx);
return {
streamer,
editor: false,
mod: streamer || Boolean(tags.mod || badges.moderator),
vip: Boolean(badges.vip),
subscriberTier: tags.subscriber || badges.subscriber ? 1 : 0,
follower: false
};
}
if (ctx.platform === "youtube") {
const author = ctx.meta?.author || {};
const streamer = Boolean(author.isChatOwner);
return {
streamer,
editor: streamer,
mod: streamer || Boolean(author.isChatModerator),
vip: false,
subscriberTier: author.isChatSponsor ? 1 : 0,
follower: false
};
}
if (ctx.platform === "discord") {
const message = ctx.meta?.message;
const member = message?.member;
const guild = message?.guild;
const roleNames = member?.roles?.cache
? Array.from(member.roles.cache.values()).map((role) => String(role.name || "").toLowerCase())
: [];
const permissions = member?.permissions;
const has = (permission) => {
try { return Boolean(permissions?.has?.(permission)); } catch { return false; }
};
const streamer = Boolean(guild?.ownerId && guild.ownerId === message?.author?.id);
const administrator = has("ADMINISTRATOR") || has(8n);
return {
streamer,
editor: streamer || administrator || has("MANAGE_GUILD"),
mod: streamer || administrator || has("MODERATE_MEMBERS") || has("MANAGE_MESSAGES"),
vip: roleNames.some((name) => name === "vip"),
subscriberTier: roleNames.some((name) => /subscriber|supporter|member/.test(name)) ? 1 : 0,
follower: false
};
}
return { streamer: false, editor: false, mod: false, vip: false, subscriberTier: 0, follower: false };
}
async function checkRemoteTwitch(ctx, role, requirement) {
if (ctx.platform !== "twitch") return denied(role, true);
try {
const allowed = await getTwitchEntitlement(ctx, role, requirement.subscriberTier);
return allowed ? { allowed: true } : denied(role);
} catch (error) {
return {
allowed: false,
reason: `Lumi could not verify the required ${labelRequirement(role, requirement.subscriberTier)} status. An admin may need to reconnect Twitch with the matching permission.`,
verificationError: error?.message || "Verification failed."
};
}
}
function denied(role, unsupported = false) {
const status = labelRequirement(role);
return {
allowed: false,
reason: unsupported
? `${status} access cannot be verified on this platform.`
: `This command is limited to ${status}.`
};
}
function labelRequirement(role, subscriberTier) {
if (role === "public") return "everyone";
if (role === "subscriber" && Number(subscriberTier) > 1) return `Tier ${subscriberTier}+ subscribers`;
return ({
follower: "followers",
subscriber: "subscribers or members",
vip: "VIPs",
mod: "moderators",
editor: "editors",
streamer: "the streamer"
})[role] || role;
}
function sameTwitchChannel(ctx) {
const channel = String(ctx.meta?.channel || "").replace(/^#/, "").toLowerCase();
const username = String(ctx.platformUser?.username || "").toLowerCase();
return Boolean(channel && username && channel === username);
}
module.exports = {
REQUIREMENTS,
checkCommandRequirement,
labelRequirement,
localRoleFlags
};

View File

@ -0,0 +1,144 @@
const { getSetting } = require("./settings");
const { createLogger } = require("./logger");
const platformLog = createLogger("core:command-platform", { category: "integration" });
const entitlementCache = new Map();
async function createPlatformClip(ctx) {
if (ctx.platform !== "twitch") {
return { ok: false, unsupported: true, message: `${platformLabel(ctx.platform)} does not provide clip creation through Lumi.` };
}
const broadcasterId = twitchBroadcasterId(ctx);
if (!broadcasterId) return { ok: false, message: "Twitch did not provide the current channel ID." };
const payload = await twitchRequest("POST", "/helix/clips", { broadcaster_id: broadcasterId });
const clip = payload?.data?.[0];
if (!clip?.id) return { ok: false, message: "Twitch accepted the request but did not return a clip." };
platformLog.info("Twitch clip requested", { broadcaster_id: broadcasterId, clip_id: clip.id }, { event: "clip_created" });
return {
ok: true,
id: clip.id,
url: `https://clips.twitch.tv/${encodeURIComponent(clip.id)}`,
editUrl: clip.edit_url || null
};
}
async function startPlatformRaid(ctx, targetChannel) {
if (ctx.platform !== "twitch") {
return { ok: false, unsupported: true, message: `${platformLabel(ctx.platform)} does not provide raids through Lumi.` };
}
const fromBroadcasterId = twitchBroadcasterId(ctx);
if (!fromBroadcasterId) return { ok: false, message: "Twitch did not provide the current channel ID." };
const login = String(targetChannel || "").trim().replace(/^[@#]/, "").toLowerCase();
if (!login) return { ok: false, message: "Choose a channel to raid." };
const users = await twitchRequest("GET", "/helix/users", { login });
const target = users?.data?.[0];
if (!target?.id) return { ok: false, message: `Twitch channel “${login}” was not found.` };
if (target.id === fromBroadcasterId) return { ok: false, message: "A channel cannot raid itself." };
const payload = await twitchRequest("POST", "/helix/raids", {
from_broadcaster_id: fromBroadcasterId,
to_broadcaster_id: target.id
});
platformLog.info("Twitch raid started", { from_broadcaster_id: fromBroadcasterId, to_broadcaster_id: target.id }, { event: "raid_started" });
return { ok: true, target: target.display_name || target.login || login, viewers: payload?.data?.[0]?.viewer_count ?? null };
}
async function cancelPlatformRaid(ctx) {
if (ctx.platform !== "twitch") {
return { ok: false, unsupported: true, message: `${platformLabel(ctx.platform)} does not provide raids through Lumi.` };
}
const broadcasterId = twitchBroadcasterId(ctx);
if (!broadcasterId) return { ok: false, message: "Twitch did not provide the current channel ID." };
await twitchRequest("DELETE", "/helix/raids", { broadcaster_id: broadcasterId });
platformLog.info("Twitch raid cancelled", { broadcaster_id: broadcasterId }, { event: "raid_cancelled" });
return { ok: true };
}
async function getProviderStreamIdentity(ctx) {
if (ctx.platform === "youtube") {
const id = String(ctx.meta?.activeVideoId || ctx.meta?.liveChatId || "");
const channelId = String(ctx.meta?.broadcasterChannelId || ctx.meta?.liveChatId || "");
return id && channelId ? { streamKey: `youtube:${channelId}`, providerStreamId: id } : null;
}
if (ctx.platform !== "twitch") return null;
const broadcasterId = twitchBroadcasterId(ctx);
if (!broadcasterId) return null;
try {
const payload = await twitchRequest("GET", "/helix/streams", { user_id: broadcasterId });
const stream = payload?.data?.[0];
return {
streamKey: `twitch:${broadcasterId}`,
providerStreamId: stream?.id || null,
live: Boolean(stream?.id)
};
} catch (error) {
platformLog.warn("Could not resolve Twitch stream identity", { error, broadcaster_id: broadcasterId }, { event: "stream_identity_failed" });
return { streamKey: `twitch:${broadcasterId}`, providerStreamId: null, live: null };
}
}
async function getTwitchEntitlement(ctx, role, minimumTier = 1) {
if (ctx.platform !== "twitch") return false;
const broadcasterId = twitchBroadcasterId(ctx);
const userId = String(ctx.platformUser?.id || ctx.user?.platformId || "");
if (!broadcasterId || !userId) return false;
const key = `${role}:${broadcasterId}:${userId}:${minimumTier}`;
const cached = entitlementCache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.value;
let value = false;
if (role === "follower") {
const result = await twitchRequest("GET", "/helix/channels/followers", { broadcaster_id: broadcasterId, user_id: userId });
value = Boolean(result?.data?.length);
} else if (role === "subscriber") {
const result = await twitchRequest("GET", "/helix/subscriptions", { broadcaster_id: broadcasterId, user_id: userId });
const tier = Number(result?.data?.[0]?.tier || 0) / 1000;
value = tier >= Math.max(1, Number(minimumTier) || 1);
} else if (role === "editor") {
const result = await twitchRequest("GET", "/helix/channels/editors", { broadcaster_id: broadcasterId, first: 100 });
value = Boolean(result?.data?.some((entry) => String(entry.user_id) === userId));
}
entitlementCache.set(key, { value, expiresAt: Date.now() + 60_000 });
return value;
}
async function twitchRequest(method, pathname, query = {}) {
const clientId = String(getSetting("twitch_client_id", "") || "").trim();
const token = normalizeOauthToken(getSetting("twitch_bot_oauth", ""));
if (!clientId || !token) throw new Error("Twitch API credentials are not configured.");
const url = new URL(`https://api.twitch.tv${pathname}`);
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
});
const response = await fetch(url, {
method,
headers: { "Client-Id": clientId, Authorization: `Bearer ${token}` }
});
const body = response.status === 204 ? null : await response.json().catch(() => null);
if (!response.ok) {
const detail = String(body?.message || body?.error || response.statusText || "Twitch request failed").slice(0, 300);
const error = new Error(detail);
error.status = response.status;
throw error;
}
return body;
}
function twitchBroadcasterId(ctx) {
return String(ctx.meta?.tags?.["room-id"] || "").trim() || null;
}
function normalizeOauthToken(value) {
return String(value || "").trim().replace(/^oauth:/i, "");
}
function platformLabel(platform) {
return ({ discord: "Discord", twitch: "Twitch", youtube: "YouTube" })[platform] || "This platform";
}
module.exports = {
cancelPlatformRaid,
createPlatformClip,
getProviderStreamIdentity,
getTwitchEntitlement,
startPlatformRaid,
twitchRequest
};

View File

@ -0,0 +1,331 @@
const crypto = require("crypto");
const { db } = require("./db");
const { checkCommandRequirement, labelRequirement } = require("./command-entitlements");
const { getProviderStreamIdentity } = require("./command-platform");
const { createLogger } = require("./logger");
const policyLog = createLogger("core:command-policies", { category: "command" });
const STREAM_RESTART_TOLERANCE_MS = 2 * 60 * 60 * 1000;
function commandKey(pluginId, commandId) {
return `premade:${String(pluginId || "core")}:${String(commandId || "command")}`;
}
function customCommandKey(id) {
return `custom:${String(id)}`;
}
function listGroups() {
return db.prepare("SELECT * FROM command_groups ORDER BY name COLLATE NOCASE").all().map((row) => ({
...row,
policy: parsePolicy(row.policy_json)
}));
}
function createGroup({ name, description = "", policy = {} }) {
const id = crypto.randomUUID();
const now = Date.now();
db.prepare("INSERT INTO command_groups (id, name, description, policy_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
.run(id, cleanName(name), String(description || "").trim().slice(0, 500), JSON.stringify(normalizeStoredPolicy(policy)), now, now);
return id;
}
function updateGroup(id, { name, description = "", policy = {} }) {
db.prepare("UPDATE command_groups SET name = ?, description = ?, policy_json = ?, updated_at = ? WHERE id = ?")
.run(cleanName(name), String(description || "").trim().slice(0, 500), JSON.stringify(normalizeStoredPolicy(policy)), Date.now(), id);
}
function deleteGroup(id) {
db.transaction(() => {
db.prepare("DELETE FROM command_group_members WHERE group_id = ?").run(id);
db.prepare("DELETE FROM command_groups WHERE id = ?").run(id);
})();
}
function getCommandPolicy(commandKeyValue) {
const row = db.prepare("SELECT policy_json FROM command_policies WHERE command_key = ?").get(commandKeyValue);
const groups = db.prepare(
"SELECT g.* FROM command_groups g JOIN command_group_members m ON m.group_id = g.id WHERE m.command_key = ? ORDER BY g.name COLLATE NOCASE"
).all(commandKeyValue).map((group) => ({ ...group, policy: parsePolicy(group.policy_json) }));
return { policy: row ? parsePolicy(row.policy_json) : {}, groups };
}
function saveCommandPolicy(commandKeyValue, policy, groupIds = []) {
const normalized = normalizeStoredPolicy(policy);
const now = Date.now();
db.transaction(() => {
if (Object.keys(normalized).length) {
db.prepare(
"INSERT INTO command_policies (command_key, policy_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(command_key) DO UPDATE SET policy_json = excluded.policy_json, updated_at = excluded.updated_at"
).run(commandKeyValue, JSON.stringify(normalized), now);
} else {
db.prepare("DELETE FROM command_policies WHERE command_key = ?").run(commandKeyValue);
}
db.prepare("DELETE FROM command_group_members WHERE command_key = ?").run(commandKeyValue);
const insert = db.prepare("INSERT OR IGNORE INTO command_group_members (group_id, command_key, created_at) VALUES (?, ?, ?)");
for (const groupId of [...new Set(groupIds.map(String))]) insert.run(groupId, commandKeyValue, now);
})();
}
function resolvePolicy(commandKeyValue, defaultPolicy = {}) {
const stored = getCommandPolicy(commandKeyValue);
const direct = stored.policy;
const groupPolicies = stored.groups.map((group) => ({ id: group.id, name: group.name, policy: group.policy }));
const defaults = normalizeStoredPolicy(defaultPolicy);
return {
windows: resolveRules(commandKeyValue, "window", direct, groupPolicies, defaults),
perStreams: resolveRules(commandKeyValue, "perStream", direct, groupPolicies, defaults),
requirements: resolveRequirements(direct, groupPolicies, defaults),
cost: resolveCost(direct, groupPolicies, defaults),
direct,
groups: stored.groups
};
}
async function beginCommandUse({ commandKey: key, ctx, defaultPolicy = {} }) {
const policy = resolvePolicy(key, defaultPolicy);
for (const requirement of policy.requirements) {
const checked = await checkCommandRequirement(ctx, requirement.value);
if (!checked.allowed) {
policyLog.info("Command requirement denied", {
command_key: key,
user_id: ctx.user?.id,
platform: ctx.platform,
requirement: requirement.value?.role,
source: requirement.source,
verification_error: checked.verificationError || null
}, { event: "command_requirement_denied" });
return { allowed: false, message: checked.reason };
}
}
let streamSessionId = null;
if (policy.perStreams.length) {
streamSessionId = await resolveStreamSession(ctx);
if (!streamSessionId) {
return { allowed: false, message: "Lumi cannot identify the current stream, so this stream-limited command cannot be used right now." };
}
}
const usageId = crypto.randomUUID();
const now = Date.now();
const userId = String(ctx.user?.id || "");
let limitMessage = null;
db.transaction(() => {
db.prepare("DELETE FROM command_policy_uses WHERE used_at < ?").run(now - 45 * 24 * 60 * 60 * 1000);
for (const rule of policy.windows) {
const scopedUser = rule.value.scope === "global" ? null : userId;
const count = db.prepare(
"SELECT COUNT(*) AS count FROM command_policy_uses WHERE rule_key = ? AND user_id IS ? AND used_at >= ?"
).get(rule.key, scopedUser, now - rule.value.seconds * 1000)?.count || 0;
if (count >= rule.value.uses) {
limitMessage = rule.value.scope === "global"
? `That command has reached its shared limit. Try again in up to ${formatDuration(rule.value.seconds)}.`
: `You have reached this command's limit. Try again in up to ${formatDuration(rule.value.seconds)}.`;
return;
}
}
for (const rule of policy.perStreams) {
const scopedUser = rule.value.scope === "global" ? null : userId;
const count = db.prepare(
"SELECT COUNT(*) AS count FROM command_policy_uses WHERE rule_key = ? AND stream_session_id = ? AND user_id IS ?"
).get(rule.key, streamSessionId, scopedUser)?.count || 0;
if (count >= rule.value.uses) {
limitMessage = rule.value.scope === "global"
? "That command has reached its limit for this stream."
: "You have reached this command's limit for this stream.";
return;
}
}
const insert = db.prepare(
"INSERT INTO command_policy_uses (id, command_key, rule_key, user_id, stream_session_id, used_at) VALUES (?, ?, ?, ?, ?, ?)"
);
for (const rule of policy.windows) {
insert.run(usageId, key, rule.key, rule.value.scope === "global" ? null : userId, null, now);
}
for (const rule of policy.perStreams) {
insert.run(usageId, key, rule.key, rule.value.scope === "global" ? null : userId, streamSessionId, now);
}
})();
if (limitMessage) return { allowed: false, message: limitMessage };
let charged = false;
if (policy.cost > 0) {
const economy = global.lumiFrameworks?.economy;
if (!economy?.removeBalance) {
releaseUsage(usageId);
return { allowed: false, message: "This command has a currency cost, but the Economy Framework is not available." };
}
let result;
try {
result = economy.removeBalance({
userId,
amount: policy.cost,
note: `Command cost: ${ctx.raw || key}`,
meta: { source: "command_cost", commandKey: key, trigger: ctx.trigger, platform: ctx.platform }
});
} catch (error) {
releaseUsage(usageId);
policyLog.error("Command cost charge failed", { command_key: key, user_id: userId, amount: policy.cost, error }, { event: "command_cost_failed" });
return { allowed: false, message: "The command cost could not be charged." };
}
if (!result?.ok) {
releaseUsage(usageId);
return { allowed: false, message: result?.message === "Insufficient balance."
? `You need ${policy.cost} currency to use this command.`
: (result?.message || "The command cost could not be charged.") };
}
charged = true;
}
let finished = false;
return {
allowed: true,
async commit() { finished = true; },
async rollback(reason = "Command did not complete") {
if (finished) return;
finished = true;
releaseUsage(usageId);
if (charged) refundCommandCost({ userId, amount: policy.cost, key, ctx, reason });
}
};
}
function releaseUsage(usageId) {
db.prepare("DELETE FROM command_policy_uses WHERE id = ?").run(usageId);
}
function refundCommandCost({ userId, amount, key, ctx, reason }) {
try {
global.lumiFrameworks?.economy?.addBalance?.({
userId,
amount,
note: `Command cost refund: ${ctx.raw || key}`,
meta: { source: "command_cost_refund", commandKey: key, reason }
});
} catch (error) {
policyLog.error("Command cost refund failed", { command_key: key, user_id: userId, amount, error }, { event: "command_cost_refund_failed" });
}
}
async function resolveStreamSession(ctx) {
const identity = await getProviderStreamIdentity(ctx);
if (!identity?.streamKey) return null;
const now = Date.now();
const existing = db.prepare("SELECT * FROM command_stream_sessions WHERE stream_key = ?").get(identity.streamKey);
const sameProviderStream = existing && identity.providerStreamId && existing.provider_stream_id === identity.providerStreamId;
const withinRestartTolerance = existing && now - existing.last_seen_at <= STREAM_RESTART_TOLERANCE_MS;
const sessionId = sameProviderStream || withinRestartTolerance ? existing.session_id : crypto.randomUUID();
const startedAt = sessionId === existing?.session_id ? existing.started_at : now;
db.prepare(
"INSERT INTO command_stream_sessions (stream_key, session_id, provider_stream_id, started_at, last_seen_at) VALUES (?, ?, ?, ?, ?) " +
"ON CONFLICT(stream_key) DO UPDATE SET session_id = excluded.session_id, provider_stream_id = excluded.provider_stream_id, started_at = excluded.started_at, last_seen_at = excluded.last_seen_at"
).run(identity.streamKey, sessionId, identity.providerStreamId || existing?.provider_stream_id || null, startedAt, now);
return sessionId;
}
function resolveRules(commandKeyValue, category, direct, groups, defaults) {
if (Object.prototype.hasOwnProperty.call(direct, category)) {
return direct[category] === false ? [] : [{ key: `${commandKeyValue}:${category}`, source: "command", value: direct[category] }];
}
const rules = groups.flatMap((group) => {
const value = group.policy[category];
return value && value !== false ? [{ key: `group:${group.id}:${category}`, source: group.name, value }] : [];
});
if (rules.length) return rules;
const fallback = defaults[category];
return fallback && fallback !== false ? [{ key: `${commandKeyValue}:default:${category}`, source: "default", value: fallback }] : [];
}
function resolveRequirements(direct, groups, defaults) {
if (Object.prototype.hasOwnProperty.call(direct, "requirement")) {
return direct.requirement === false ? [] : [{ source: "command", value: direct.requirement }];
}
const values = groups.flatMap((group) => group.policy.requirement && group.policy.requirement !== false
? [{ source: group.name, value: group.policy.requirement }]
: []);
if (values.length) return values;
return defaults.requirement && defaults.requirement !== false ? [{ source: "default", value: defaults.requirement }] : [];
}
function resolveCost(direct, groups, defaults) {
if (Object.prototype.hasOwnProperty.call(direct, "cost")) return direct.cost === false ? 0 : Number(direct.cost?.amount || 0);
const costs = groups.map((group) => Number(group.policy.cost?.amount || 0)).filter((amount) => amount > 0);
if (costs.length) return Math.max(...costs);
return Number(defaults.cost?.amount || 0);
}
function normalizeStoredPolicy(value) {
const input = value && typeof value === "object" ? value : {};
const output = {};
if (Object.prototype.hasOwnProperty.call(input, "window")) output.window = normalizeWindow(input.window);
if (Object.prototype.hasOwnProperty.call(input, "perStream")) output.perStream = normalizePerStream(input.perStream);
if (Object.prototype.hasOwnProperty.call(input, "requirement")) output.requirement = normalizeRequirement(input.requirement);
if (Object.prototype.hasOwnProperty.call(input, "cost")) output.cost = normalizeCost(input.cost);
return Object.fromEntries(Object.entries(output).filter(([, item]) => item !== undefined));
}
function normalizeWindow(value) {
if (value === false || value?.enabled === false) return false;
if (!value || typeof value !== "object") return undefined;
return {
uses: clampInteger(value.uses, 1, 100000),
seconds: clampInteger(value.seconds, 1, 31_536_000),
scope: value.scope === "global" ? "global" : "user"
};
}
function normalizePerStream(value) {
if (value === false || value?.enabled === false) return false;
if (!value || typeof value !== "object") return undefined;
return { uses: clampInteger(value.uses, 1, 100000), scope: value.scope === "global" ? "global" : "user" };
}
function normalizeRequirement(value) {
if (value === false || value?.enabled === false || value?.role === "public") return false;
const role = ["follower", "subscriber", "vip", "mod", "editor", "streamer"].includes(value?.role) ? value.role : null;
if (!role) return undefined;
return { role, subscriberTier: role === "subscriber" ? clampInteger(value.subscriberTier, 1, 3) : 1 };
}
function normalizeCost(value) {
if (value === false || value?.enabled === false || Number(value?.amount) <= 0) return false;
if (!value || typeof value !== "object") return undefined;
return { amount: clampInteger(value.amount, 1, 1_000_000_000) };
}
function parsePolicy(value) {
try { return normalizeStoredPolicy(JSON.parse(value || "{}")); } catch { return {}; }
}
function cleanName(value) {
const name = String(value || "").trim().slice(0, 100);
if (!name) throw new Error("Group name is required.");
return name;
}
function clampInteger(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, Math.round(Number(value) || minimum)));
}
function formatDuration(seconds) {
if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
const minutes = Math.ceil(seconds / 60);
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}
module.exports = {
STREAM_RESTART_TOLERANCE_MS,
beginCommandUse,
commandKey,
createGroup,
customCommandKey,
deleteGroup,
getCommandPolicy,
labelRequirement,
listGroups,
normalizeStoredPolicy,
resolvePolicy,
saveCommandPolicy,
updateGroup
};

View File

@ -6,6 +6,8 @@ const {
normalizeCommandResult
} = require("./commands");
const { normalizeRandomReplies, selectRandomReply } = require("./command-random");
const { findConditionalReply, normalizeConditionalReplies } = require("./command-conditional");
const { beginCommandUse, commandKey, customCommandKey } = require("./command-policies");
const { getEnabledPlatformIds, normalizePlatformSelection } = require("./platforms");
const placeholders = require("./placeholders");
const { createLogger } = require("./logger");
@ -15,6 +17,7 @@ const commandLog = createLogger("core:commands", { category: "command" });
function createCommandRouter({ settings }) {
const commandMap = new Map();
const pluginCommands = new Map();
const registry = new Map();
function clearCommands(pluginId) {
const existing = pluginCommands.get(pluginId) || [];
@ -28,6 +31,7 @@ function createCommandRouter({ settings }) {
}
}
pluginCommands.delete(pluginId);
registry.delete(pluginId);
}
function registerCommands(pluginId, commands = []) {
@ -36,34 +40,47 @@ function createCommandRouter({ settings }) {
}
clearCommands(pluginId);
const entries = [];
const registered = [];
for (const command of commands) {
const triggers = (command.triggers || [])
.map((trigger) => trigger.toLowerCase())
.filter(Boolean);
const handler = buildHandler(command);
const handler = buildHandler(pluginId, command, triggers);
for (const trigger of triggers) {
const list = commandMap.get(trigger) || [];
list.push(handler);
commandMap.set(trigger, list);
entries.push({ trigger, handler });
}
registered.push({
key: handler.commandKey,
pluginId,
id: handler.commandId,
triggers,
platforms: handler.platforms,
description: String(command.description || ""),
defaultPolicy: handler.defaultPolicy
});
}
pluginCommands.set(pluginId, entries);
registry.set(pluginId, registered);
}
function buildHandler(command) {
function buildHandler(pluginId, command, triggers) {
const handler = async (ctx) => {
if (command.platforms && command.platforms.length) {
if (!command.platforms.includes(ctx.platform)) {
return false;
}
}
return await command.handler(ctx);
};
handler.commandId = command.id || null;
handler.commandId = command.id || triggers[0] || null;
handler.commandKey = commandKey(pluginId, handler.commandId);
handler.platforms = Array.isArray(command.platforms) ? command.platforms : [];
handler.defaultPolicy = command.defaultPolicy || {};
return handler;
}
function listCommands() {
return Array.from(registry.values()).flat().map((entry) => ({ ...entry, triggers: [...entry.triggers], platforms: [...entry.platforms] }));
}
async function handleMessage({ platform, raw, user, platformUser, reply, meta }) {
const prefix = settings.getSetting("command_prefix", "!");
if (!raw.startsWith(prefix)) {
@ -114,10 +131,28 @@ function createCommandRouter({ settings }) {
const handlers = commandMap.get(trigger) || [];
for (const handler of handlers) {
if (handler.platforms.length && !handler.platforms.includes(ctx.platform)) continue;
let lease;
try {
lease = await beginCommandUse({
commandKey: handler.commandKey,
ctx,
defaultPolicy: handler.defaultPolicy
});
} catch (error) {
commandLog.error("Command policy check failed", { command_id: handler.commandId, trigger, platform, user_id: user.id, error }, { event: "command_policy_failed" });
await safeReply(reply, "This command's access settings could not be checked.");
return true;
}
if (!lease.allowed) {
await safeReply(reply, lease.message);
return true;
}
try {
const result = await handler(ctx);
if (typeof result === "string" && result) {
await safeReply(reply, result);
await lease.commit();
recordCommandUsage(handler.commandId);
incrementCommands(user.id);
commandLog.debug("Command completed", {
@ -129,6 +164,7 @@ function createCommandRouter({ settings }) {
return true;
}
if (result === true) {
await lease.commit();
recordCommandUsage(handler.commandId);
incrementCommands(user.id);
commandLog.debug("Command completed", {
@ -139,7 +175,9 @@ function createCommandRouter({ settings }) {
}, { event: "command_completed" });
return true;
}
await lease.rollback("Command handler did not accept the invocation.");
} catch (error) {
await lease.rollback(error?.message || "Command handler failed.");
commandLog.error("Command handler failed", {
command_id: handler.commandId,
trigger,
@ -158,14 +196,15 @@ function createCommandRouter({ settings }) {
return {
registerCommands,
clearCommands,
handleMessage
handleMessage,
listCommands
};
}
async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
const row = db
.prepare(
"SELECT response, mode, language, code, platform, random_replies_json, rng_enabled, rng_min, rng_max FROM custom_commands WHERE trigger = ? AND enabled = 1"
"SELECT id, response, mode, language, code, platform, random_replies_json, rng_enabled, rng_min, rng_max, conditional_replies_json, conditional_fuzzy FROM custom_commands WHERE trigger = ? AND enabled = 1"
)
.get(trigger);
if (!row) {
@ -176,6 +215,18 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
if (!allowedPlatforms.includes(platform)) {
return false;
}
let lease;
try {
lease = await beginCommandUse({ commandKey: customCommandKey(row.id), ctx });
} catch (error) {
commandLog.error("Custom command policy check failed", { trigger, platform, user_id: ctx.user.id, error }, { event: "command_policy_failed" });
await safeReply(reply, "This command's access settings could not be checked.");
return true;
}
if (!lease.allowed) {
await safeReply(reply, lease.message);
return true;
}
try {
if (row.mode === "advanced" && row.code) {
const messageInfo = buildMessageInfo(ctx, raw);
@ -222,24 +273,27 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
runtimeContext: { ctx, user: ctx.user, platform, runtime: true, command: { rng: selected.rng } }
});
await safeReply(reply, rendered.rendered);
} else {
const rendered = await placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",
template: row.response,
outputAudience: "user",
user: {
id: ctx.user.id,
username: ctx.user.username,
isAdmin: false,
isMod: false
},
runtimeContext: { ctx, user: ctx.user, platform, runtime: true }
} else if (row.mode === "conditional") {
const replies = normalizeConditionalReplies(row.conditional_replies_json);
const found = findConditionalReply(replies, ctx.argsText, { fuzzy: Boolean(row.conditional_fuzzy) });
let template = found.match?.response || "";
if (!ctx.argsText) {
template = row.response || `Available options: ${replies.map((entry) => entry.keyword).join(", ")}`;
} else if (!found.match) {
template = `Unknown option. Available options: ${replies.map((entry) => entry.keyword).join(", ")}`;
}
const rendered = await renderStaticResponse(template, ctx, platform, {
conditional: { requested: ctx.argsText, keyword: found.match?.keyword || null, fuzzy: found.fuzzy }
});
await safeReply(reply, rendered.rendered);
await safeReply(reply, rendered);
} else {
await safeReply(reply, await renderStaticResponse(row.response, ctx, platform));
}
recordCommandUsage(`custom:${trigger}`);
await lease.commit();
return true;
} catch (error) {
await lease.rollback(error?.message || "Custom command failed.");
commandLog.error("Custom command failed", {
trigger,
platform,
@ -251,6 +305,22 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
}
}
async function renderStaticResponse(template, ctx, platform, extraRuntime = {}) {
const rendered = await placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",
template,
outputAudience: "user",
user: {
id: ctx.user.id,
username: ctx.user.username,
isAdmin: false,
isMod: false
},
runtimeContext: { ctx, user: ctx.user, platform, runtime: true, ...extraRuntime }
});
return rendered.rendered;
}
function buildMessageInfo(ctx, raw) {
if (ctx.platform === "discord" && ctx.meta?.message) {
const message = ctx.meta.message;

View File

@ -131,6 +131,8 @@ function migrate() {
preview_generated_at INTEGER,
preview_dynamic_segments TEXT NOT NULL DEFAULT '[]',
random_replies_json TEXT NOT NULL DEFAULT '[]',
conditional_replies_json TEXT NOT NULL DEFAULT '[]',
conditional_fuzzy INTEGER NOT NULL DEFAULT 1,
rng_enabled INTEGER NOT NULL DEFAULT 0,
rng_min INTEGER NOT NULL DEFAULT 1,
rng_max INTEGER NOT NULL DEFAULT 100,
@ -145,6 +147,52 @@ function migrate() {
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS command_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
description TEXT NOT NULL DEFAULT '',
policy_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS command_group_members (
group_id TEXT NOT NULL,
command_key TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (group_id, command_key),
FOREIGN KEY (group_id) REFERENCES command_groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS command_policies (
command_key TEXT PRIMARY KEY,
policy_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS command_policy_uses (
id TEXT NOT NULL,
command_key TEXT NOT NULL,
rule_key TEXT NOT NULL,
user_id TEXT,
stream_session_id TEXT,
used_at INTEGER NOT NULL,
PRIMARY KEY (id, rule_key)
);
CREATE INDEX IF NOT EXISTS command_policy_uses_window_idx
ON command_policy_uses (rule_key, user_id, used_at);
CREATE INDEX IF NOT EXISTS command_policy_uses_stream_idx
ON command_policy_uses (rule_key, stream_session_id, user_id);
CREATE TABLE IF NOT EXISTS command_stream_sessions (
stream_key TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
provider_stream_id TEXT,
started_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
level TEXT NOT NULL,
@ -356,6 +404,12 @@ function migrate() {
if (!columns.includes("random_replies_json")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN random_replies_json TEXT NOT NULL DEFAULT '[]'");
}
if (!columns.includes("conditional_replies_json")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN conditional_replies_json TEXT NOT NULL DEFAULT '[]'");
}
if (!columns.includes("conditional_fuzzy")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN conditional_fuzzy INTEGER NOT NULL DEFAULT 1");
}
if (!columns.includes("rng_enabled")) {
db.exec("ALTER TABLE custom_commands ADD COLUMN rng_enabled INTEGER NOT NULL DEFAULT 0");
}

View File

@ -7,6 +7,7 @@ try {
obsWebSocketLoadError = error;
}
const { publishWebEvent } = require("./web-events");
const { createLogger } = require("./logger");
const {
getObsSettings,
getOverlay,
@ -17,6 +18,7 @@ const {
const providers = new Map();
const browserBridgeConnectors = new Map();
const connectorLog = createLogger("core:obs-connectors", { category: "integration" });
const OBS_BROWSER_CONTROL_LEVELS = [
"No OBS access",
@ -171,6 +173,13 @@ class LocalObsWebSocketConnector {
this.currentScene = sceneName;
}
async saveReplay() {
if (this.state !== "connected") throw new Error("OBS WebSocket is not connected.");
if (!this.outputs.replay_buffer) throw new Error("The OBS replay buffer is not running.");
await this.client.call("SaveReplayBuffer");
return { queued: true };
}
async refreshSetupState() {
const results = await Promise.allSettled([
this.listScenes(),
@ -353,6 +362,20 @@ class ObsBrowserBridgeConnector {
if (!delivered) throw new Error("The OBS Browser Bridge is not listening for commands.");
}
async saveReplay() {
if (this.state !== "connected") throw new Error("The OBS Browser Bridge is not connected.");
if (this.controlLevel < 3) throw new Error("Basic OBS page permission is required to save the replay buffer.");
if (!this.outputs.replay_buffer) throw new Error("The OBS replay buffer is not running.");
const commandId = `${Date.now()}:${Math.random().toString(16).slice(2)}`;
const delivered = publishWebEvent("overlay:obs-browser-command", {
command_id: commandId,
target_instance_id: this.instanceId,
action: "save_replay_buffer"
}, { scope: `overlay:${this.overlayId}` });
if (!delivered) throw new Error("The OBS Browser Bridge is not listening for commands.");
return { queued: true };
}
status() {
return {
state: this.state,
@ -553,6 +576,28 @@ class OverlayConnectorManager {
return this.status(overlayId, { includeScenes: true });
}
async saveReplay({ name = "" } = {}) {
const candidates = Array.from(this.connections.entries())
.filter(([, entry]) => entry?.connector?.status?.().state === "connected" && typeof entry.connector.saveReplay === "function")
.sort((left, right) => Number(Boolean(right[1].connector.status().outputs?.replay_buffer)) - Number(Boolean(left[1].connector.status().outputs?.replay_buffer)));
if (!candidates.length) throw new Error("No operational OBS connector is available.");
const failures = [];
for (const [overlayId, entry] of candidates) {
try {
await entry.connector.saveReplay({ name });
connectorLog.info("OBS replay save requested", {
overlay_id: overlayId,
provider: entry.settings.provider,
label: String(name || "").trim().slice(0, 120) || null
}, { event: "obs_replay_saved" });
return { ok: true, overlay_id: overlayId, provider: entry.settings.provider, name: String(name || "").trim() || null };
} catch (error) {
failures.push(error?.message || "OBS replay save failed.");
}
}
throw new Error(failures[0] || "OBS replay save failed.");
}
async status(overlayId, { includeScenes = false } = {}) {
const entry = this.connections.get(overlayId);
if (!entry) {

View File

@ -195,10 +195,86 @@ function registerTopCommand({ commandRouter, settings }) {
triggers: ["top"],
platforms,
handler: (ctx) => handleTopCommand({ ctx, settings })
},
{
id: "clip",
triggers: ["clip"],
platforms,
description: "Create a platform clip and save the current OBS replay buffer.",
defaultPolicy: {
requirement: { role: "mod" },
window: { uses: 1, seconds: 10, scope: "global" }
},
handler: handleClipCommand
},
{
id: "raid",
triggers: ["raid"],
platforms,
description: "Start or cancel a raid on the current streaming platform.",
defaultPolicy: {
requirement: { role: "streamer" },
window: { uses: 10, seconds: 600, scope: "global" }
},
handler: (ctx) => handleRaidCommand(ctx, settings)
}
]);
}
async function handleClipCommand(ctx) {
const { createPlatformClip } = require("./command-platform");
const { overlayConnectorManager } = require("./overlay-connectors");
const requestedName = String(ctx.argsText || "").trim().slice(0, 80);
const [platformResult, obsResult] = await Promise.allSettled([
createPlatformClip(ctx),
overlayConnectorManager.saveReplay({ name: requestedName })
]);
const parts = [];
const clip = platformResult.status === "fulfilled" ? platformResult.value : null;
if (clip?.ok) {
parts.push(`Platform clip created: ${clip.url}`);
} else {
parts.push(`Platform clip: ${cleanCommandError(clip?.message || platformResult.reason?.message)}`);
}
if (obsResult.status === "fulfilled") {
parts.push("OBS replay save requested.");
} else {
parts.push(`OBS replay: ${cleanCommandError(obsResult.reason?.message)}`);
}
if (requestedName) parts.push(`Label: ${requestedName}.`);
await ctx.reply(parts.join(" "));
return true;
}
async function handleRaidCommand(ctx, settings) {
const { cancelPlatformRaid, startPlatformRaid } = require("./command-platform");
const target = String(ctx.args?.[0] || "").trim();
if (!target) {
const prefix = settings?.getSetting?.("command_prefix", "!") || "!";
await ctx.reply(`Usage: ${prefix}raid <channel> or ${prefix}raid cancel`);
return true;
}
try {
if (target.toLowerCase() === "cancel") {
const result = await cancelPlatformRaid(ctx);
await ctx.reply(result.ok ? "The pending raid was cancelled." : cleanCommandError(result.message));
return true;
}
const result = await startPlatformRaid(ctx, target);
await ctx.reply(result.ok
? `Raid to ${result.target} started. Twitch will show its normal confirmation countdown.`
: cleanCommandError(result.message));
return true;
} catch (error) {
await ctx.reply(`Raid failed: ${cleanCommandError(error?.message)}`);
return true;
}
}
function cleanCommandError(value) {
return String(value || "not available").replace(/[\r\n]+/g, " ").slice(0, 140);
}
async function handleTopCommand({ ctx, settings }) {
const prefix = settings.getSetting("command_prefix", "!");
const rawId = (ctx.args[0] || "").trim().toLowerCase();

View File

@ -167,6 +167,8 @@ async function handleChatItem(state, liveChatId, item) {
},
meta: {
liveChatId,
activeVideoId: state.activeVideoId,
broadcasterChannelId: state.channelId,
messageId: item.id,
snippet,
author

View File

@ -1031,6 +1031,33 @@ input[type="color"] {
align-items: end;
}
.command-conditional-row {
grid-template-columns: minmax(10rem, 0.35fr) minmax(16rem, 1fr) auto;
}
.command-policy-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--lumi-space-3);
}
.command-policy-section {
display: grid;
align-content: start;
gap: var(--lumi-space-2);
margin: 0;
}
.command-policy-values {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: var(--lumi-space-2);
}
.command-policy-item > form {
margin-top: var(--lumi-space-3);
}
.command-rng-range {
display: grid;
grid-template-columns: repeat(2, minmax(8rem, 1fr));
@ -1817,6 +1844,7 @@ details > summary:focus-visible {
}
.command-random-row,
.command-policy-grid,
.command-rng-range,
.custom-placeholder-row {
grid-template-columns: 1fr;

View File

@ -238,6 +238,9 @@
if (payload.action === "set_current_scene" && typeof obsBridge.setCurrentScene === "function") {
obsBridge.setCurrentScene(String(payload.scene_name || ""));
scheduleObsBridgeReport(350);
} else if (payload.action === "save_replay_buffer" && typeof obsBridge.saveReplayBuffer === "function") {
obsBridge.saveReplayBuffer();
scheduleObsBridgeReport(350);
}
} catch {}
});

View File

@ -77,6 +77,21 @@ const {
} = require("../services/platforms");
const { getClient: getTwitchClient } = require("../services/twitch");
const { getClient: getYouTubeClient } = require("../services/youtube");
const {
conditionalRepliesFromBody,
normalizeConditionalReplies
} = require("../services/command-conditional");
const {
createGroup: createCommandGroup,
customCommandKey,
deleteGroup: deleteCommandGroup,
getCommandPolicy,
listGroups: listCommandGroups,
normalizeStoredPolicy,
resolvePolicy,
saveCommandPolicy,
updateGroup: updateCommandGroup
} = require("../services/command-policies");
const {
ensureUserForIdentity,
linkIdentityToUser,
@ -689,6 +704,11 @@ const TWITCH_SCOPE_DEFS = [
label: "Manage predictions",
description: "Create and manage predictions."
},
{
scope: "channel:manage:raids",
label: "Manage raids",
description: "Start and cancel channel raids from Lumi commands."
},
{
scope: "channel:manage:redemptions",
label: "Manage redemptions",
@ -1297,6 +1317,23 @@ function buildRandomCommandPresentation(command) {
}
}
function buildConditionalCommandPresentation(command) {
return { replies: normalizeConditionalReplies(command.conditional_replies_json) };
}
function validateConditionalReplyTemplates(config, user) {
for (const reply of config.replies) {
const validation = placeholders.validateTemplate({
fieldId: "core.custom_commands.static_response",
template: reply.response,
outputAudience: "user",
user
});
if (!validation.ok) return formatPlaceholderErrors(validation.errors);
}
return "";
}
function buildCustomCommandListPreview(command) {
if (command.mode === "advanced") {
return buildCommandPreviewPresentation(command);
@ -1313,6 +1350,18 @@ function buildCustomCommandListPreview(command) {
parts: previewParts(text, [])
};
}
if (command.mode === "conditional") {
const replies = normalizeConditionalReplies(command.conditional_replies_json);
const text = String(command.response || `Available options: ${replies.map((entry) => entry.keyword).join(", ")}`);
return {
text,
status: text ? "ready" : "unavailable",
error: "Preview unavailable.",
generatedAt: null,
isLong: text.length > 160 || text.includes("\n"),
parts: previewParts(text, [])
};
}
const text = String(command.response || "");
return {
text,
@ -1324,6 +1373,52 @@ function buildCustomCommandListPreview(command) {
};
}
function commandPolicyFromBody(body = {}, { allowInherit = true } = {}) {
const output = {};
const mode = (name) => String(body[`${name}_mode`] || (allowInherit ? "inherit" : "off"));
const assign = (name, value) => {
const selected = mode(name);
if (selected === "inherit" && allowInherit) return;
output[name] = selected === "off" ? false : value;
};
assign("window", {
uses: body.window_uses,
seconds: body.window_seconds,
scope: body.window_scope
});
assign("perStream", {
uses: body.perStream_uses,
scope: body.perStream_scope
});
const requirementMode = mode("requirement");
if (!(allowInherit && requirementMode === "inherit")) {
output.requirement = requirementMode === "off" ? false : {
role: body.requirement_role,
subscriberTier: body.requirement_subscriber_tier
};
}
assign("cost", { amount: body.cost_amount });
return normalizeStoredPolicy(output);
}
function asStringArray(value) {
if (Array.isArray(value)) return value.map(String);
return value === undefined || value === null || value === "" ? [] : [String(value)];
}
function commandPolicyPresentation(key, defaultPolicy = {}) {
const resolved = resolvePolicy(key, defaultPolicy);
const roles = [...new Set(resolved.requirements.map((item) => item.value?.role).filter(Boolean))];
const details = [];
if (resolved.windows.length) details.push("rate limited");
if (resolved.perStreams.length) details.push("limited per stream");
if (resolved.cost > 0) details.push(`costs ${resolved.cost} currency`);
return {
level: roles.length ? roles.map((role) => role === "subscriber" ? "subscriber/member" : role).join(" + ") : "public",
levelHelp: details.length ? `This command is ${details.join(", ")}.` : ""
};
}
function customPlaceholdersFromBody(body = {}) {
const array = (value) => Array.isArray(value) ? value : value === undefined ? [] : [value];
const names = array(body.custom_placeholder_name);
@ -2903,7 +2998,7 @@ async function verifyYouTubeSettings(settings) {
}
}
function createWebServer({ loadPlugins, discordClient }) {
function createWebServer({ loadPlugins, discordClient, commandRouter }) {
const app = express();
// Only trust forwarding headers from a reverse proxy on this machine. This
// lets the diagnostics endpoint recognize HTTPS without trusting arbitrary
@ -4661,7 +4756,7 @@ function createWebServer({ loadPlugins, discordClient }) {
const customCommands = db
.prepare(
"SELECT id, trigger, description, response, mode, language, platform, preview_text, preview_status, preview_error, preview_generated_at, preview_dynamic_segments, random_replies_json, rng_enabled, rng_min, rng_max FROM custom_commands WHERE enabled = 1 ORDER BY trigger"
"SELECT id, trigger, description, response, mode, language, platform, preview_text, preview_status, preview_error, preview_generated_at, preview_dynamic_segments, random_replies_json, rng_enabled, rng_min, rng_max, conditional_replies_json, conditional_fuzzy FROM custom_commands WHERE enabled = 1 ORDER BY trigger"
)
.all();
const supportedPlatforms = getPlatformStatus()
@ -4675,6 +4770,7 @@ function createWebServer({ loadPlugins, discordClient }) {
const platforms = normalizeCustomPlatforms(row.platform, supportedPlatforms);
const activePlatforms = platforms.filter((platform) => enabledPlatforms.includes(platform));
const description = truncateText(row.description || "", 500);
const policyPresentation = commandPolicyPresentation(customCommandKey(row.id));
addCommand({
id: `custom:${trigger}`,
trigger,
@ -4683,7 +4779,7 @@ function createWebServer({ loadPlugins, discordClient }) {
description,
preview: buildCustomCommandListPreview(row),
isCustom: true,
level: "public",
...policyPresentation,
origin: "Custom",
platforms,
activePlatforms,
@ -4733,6 +4829,25 @@ function createWebServer({ loadPlugins, discordClient }) {
}
}
for (const registered of (commandRouter?.listCommands?.() || []).filter((command) => command.pluginId === "core" && command.id !== "top")) {
const trigger = registered.triggers[0];
if (!trigger) continue;
const policyPresentation = commandPolicyPresentation(registered.key, registered.defaultPolicy);
addCommand({
id: registered.id,
trigger,
triggerDisplay: `${prefix}${trigger}${registered.id === "raid" ? " <channel|cancel>" : registered.id === "clip" ? " [name]" : ""}`,
name: toTitleCase(registered.id),
description: registered.description,
...policyPresentation,
origin: "Core",
platforms: registered.platforms,
activePlatforms: registered.platforms.filter((platform) => enabledPlatforms.includes(platform)),
platformLabels: buildPlatformLabels(registered.platforms),
conflictTriggers: registered.triggers
});
}
const plugins = getPlugins().filter((plugin) => plugin.enabled);
for (const plugin of plugins) {
const cmdsPath = path.join(plugin.path, "cmds.json");
@ -4793,6 +4908,12 @@ function createWebServer({ loadPlugins, discordClient }) {
.filter(Boolean)
: [];
const description = truncateText(command.description || "", 140);
const registered = (commandRouter?.listCommands?.() || []).find((item) =>
item.pluginId === plugin.id && item.triggers.includes(trigger)
);
const policyPresentation = registered
? commandPolicyPresentation(registered.key, registered.defaultPolicy)
: { level: command.level || "public", levelHelp: command.levelHelp || "" };
addCommand({
id: `${plugin.id}:${command.id || trigger}`,
trigger,
@ -4800,8 +4921,7 @@ function createWebServer({ loadPlugins, discordClient }) {
triggerDisplay: `${prefix}${usage}`,
name: command.name || toTitleCase(trigger) || trigger,
description,
level: command.level || "public",
levelHelp: command.levelHelp || "",
...policyPresentation,
origin: pluginName,
platforms,
platformLabels: buildPlatformLabels(platforms),
@ -6198,7 +6318,8 @@ function createWebServer({ loadPlugins, discordClient }) {
...command,
platforms: normalizeCustomPlatforms(command.platform, availablePlatforms),
preview: buildCommandPreviewPresentation(command),
random: buildRandomCommandPresentation(command)
random: buildRandomCommandPresentation(command),
conditional: buildConditionalCommandPresentation(command)
}));
res.render("admin-commands", {
title: "Custom commands",
@ -6212,6 +6333,85 @@ function createWebServer({ loadPlugins, discordClient }) {
});
});
app.get("/admin/command-policies", requireRole("admin"), (req, res) => {
const premade = (commandRouter?.listCommands?.() || []).map((command) => ({
key: command.key,
label: `!${command.triggers[0] || command.id}`,
description: command.description || `${command.pluginId} command`,
origin: command.pluginId === "core" ? "Core" : command.pluginId,
defaultPolicy: command.defaultPolicy || {},
platforms: command.platforms || []
}));
const custom = db.prepare("SELECT id, trigger, description, platform FROM custom_commands ORDER BY trigger").all().map((command) => ({
key: customCommandKey(command.id),
label: `!${command.trigger}`,
description: command.description || "Custom command",
origin: "Custom",
defaultPolicy: {},
platforms: normalizePlatformSelection(command.platform, getEnabledPlatformIds())
}));
const commands = [...premade, ...custom]
.filter((command, index, all) => all.findIndex((item) => item.key === command.key) === index)
.sort((left, right) => left.label.localeCompare(right.label))
.map((command) => ({ ...command, ...getCommandPolicy(command.key) }));
res.render("admin-command-policies", {
title: "Command access and limits",
commands,
groups: listCommandGroups()
});
});
app.post("/admin/command-policies/groups", requireRole("admin"), (req, res) => {
try {
createCommandGroup({
name: req.body.name,
description: req.body.description,
policy: commandPolicyFromBody(req.body, { allowInherit: false })
});
setFlash(req, "success", "Command group created.");
} catch (error) {
setFlash(req, "error", error?.message || "The command group could not be created.");
}
res.redirect("/admin/command-policies");
});
app.post("/admin/command-policies/groups/:id", requireRole("admin"), (req, res) => {
try {
updateCommandGroup(req.params.id, {
name: req.body.name,
description: req.body.description,
policy: commandPolicyFromBody(req.body, { allowInherit: false })
});
setFlash(req, "success", "Command group updated.");
} catch (error) {
setFlash(req, "error", error?.message || "The command group could not be updated.");
}
res.redirect("/admin/command-policies");
});
app.post("/admin/command-policies/groups/:id/delete", requireRole("admin"), (req, res) => {
deleteCommandGroup(req.params.id);
setFlash(req, "success", "Command group deleted. Its commands keep their direct settings.");
res.redirect("/admin/command-policies");
});
app.post("/admin/command-policies/command", requireRole("admin"), (req, res) => {
const key = String(req.body.command_key || "");
const knownKeys = new Set([
...(commandRouter?.listCommands?.() || []).map((command) => command.key),
...db.prepare("SELECT id FROM custom_commands").all().map((command) => customCommandKey(command.id))
]);
if (!knownKeys.has(key)) {
setFlash(req, "error", "That command is no longer available.");
return res.redirect("/admin/command-policies");
}
const validGroups = new Set(listCommandGroups().map((group) => group.id));
const groupIds = asStringArray(req.body.group_ids).filter((id) => validGroups.has(id));
saveCommandPolicy(key, commandPolicyFromBody(req.body), groupIds);
setFlash(req, "success", "Command settings saved.");
return res.redirect("/admin/command-policies");
});
app.post("/admin/commands", requireRole("mod"), async (req, res) => {
const isAdmin = Boolean(req.session.user?.isAdmin);
const availablePlatforms = getPlatformStatus()
@ -6220,11 +6420,12 @@ function createWebServer({ loadPlugins, discordClient }) {
const trigger = (req.body.trigger || "").trim().toLowerCase();
const description = String(req.body.description || "").trim().slice(0, 500);
const requestedMode = (req.body.mode || "plain").trim();
const mode = ["plain", "random", "advanced"].includes(requestedMode) ? requestedMode : "plain";
const mode = ["plain", "random", "conditional", "advanced"].includes(requestedMode) ? requestedMode : "plain";
const language = req.body.language === "python" ? "python" : "js";
const response = (req.body.response || "").trim();
const code = (req.body.code || "").trim();
const randomValidation = randomCommandConfigFromBody(req.body);
const conditionalValidation = conditionalRepliesFromBody(req.body);
const selectedPlatforms = parsePlatformSelectionFromBody(
req.body,
availablePlatforms
@ -6252,6 +6453,9 @@ function createWebServer({ loadPlugins, discordClient }) {
} else if (mode === "random" && !randomValidation.ok) {
setFlash(req, "error", randomValidation.errors[0]);
return res.redirect("/admin/commands");
} else if (mode === "conditional" && !conditionalValidation.ok) {
setFlash(req, "error", conditionalValidation.errors[0]);
return res.redirect("/admin/commands");
}
if (mode === "plain") {
const placeholderValidation = placeholders.validateTemplate({
@ -6270,6 +6474,12 @@ function createWebServer({ loadPlugins, discordClient }) {
setFlash(req, "error", templateError);
return res.redirect("/admin/commands");
}
} else if (mode === "conditional") {
const templateError = validateConditionalReplyTemplates(conditionalValidation, req.session.user);
if (templateError) {
setFlash(req, "error", templateError);
return res.redirect("/admin/commands");
}
}
const now = Date.now();
const preview = isAdmin && mode === "advanced"
@ -6277,12 +6487,12 @@ function createWebServer({ loadPlugins, discordClient }) {
: emptyCommandPreview();
try {
db.prepare(
"INSERT INTO custom_commands (trigger, description, response, mode, language, code, platform, preview_text, preview_status, preview_error, preview_generated_at, preview_dynamic_segments, random_replies_json, rng_enabled, rng_min, rng_max, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)"
"INSERT INTO custom_commands (trigger, description, response, mode, language, code, platform, preview_text, preview_status, preview_error, preview_generated_at, preview_dynamic_segments, random_replies_json, rng_enabled, rng_min, rng_max, conditional_replies_json, conditional_fuzzy, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)"
).run(
trigger,
description,
response || "",
isAdmin || mode === "random" ? mode : "plain",
isAdmin || mode !== "advanced" ? mode : "plain",
isAdmin ? language : "js",
isAdmin && mode === "advanced" ? code : null,
serializePlatformSelection(selectedPlatforms),
@ -6295,6 +6505,8 @@ function createWebServer({ loadPlugins, discordClient }) {
mode === "random" && randomValidation.config.rngEnabled ? 1 : 0,
randomValidation.config.rngMin,
randomValidation.config.rngMax,
mode === "conditional" ? JSON.stringify(conditionalValidation.replies) : "[]",
mode === "conditional" && req.body.conditional_fuzzy ? 1 : 0,
now,
now
);
@ -6319,7 +6531,11 @@ function createWebServer({ loadPlugins, discordClient }) {
});
app.post("/admin/commands/:id/delete", requireRole("mod"), (req, res) => {
db.transaction(() => {
db.prepare("DELETE FROM command_policies WHERE command_key = ?").run(customCommandKey(req.params.id));
db.prepare("DELETE FROM command_group_members WHERE command_key = ?").run(customCommandKey(req.params.id));
db.prepare("DELETE FROM custom_commands WHERE id = ?").run(req.params.id);
})();
setFlash(req, "success", "Command deleted.");
res.redirect("/admin/commands");
});
@ -6339,11 +6555,12 @@ function createWebServer({ loadPlugins, discordClient }) {
const trigger = (req.body.trigger || "").trim().toLowerCase();
const description = String(req.body.description || "").trim().slice(0, 500);
const requestedMode = (req.body.mode || "plain").trim();
const mode = ["plain", "random", "advanced"].includes(requestedMode) ? requestedMode : "plain";
const mode = ["plain", "random", "conditional", "advanced"].includes(requestedMode) ? requestedMode : "plain";
const language = req.body.language === "python" ? "python" : "js";
const response = (req.body.response || "").trim();
const code = (req.body.code || "").trim();
const randomValidation = randomCommandConfigFromBody(req.body);
const conditionalValidation = conditionalRepliesFromBody(req.body);
const selectedPlatforms = parsePlatformSelectionFromBody(
req.body,
availablePlatforms
@ -6371,6 +6588,9 @@ function createWebServer({ loadPlugins, discordClient }) {
} else if (mode === "random" && !randomValidation.ok) {
setFlash(req, "error", randomValidation.errors[0]);
return res.redirect("/admin/commands");
} else if (mode === "conditional" && !conditionalValidation.ok) {
setFlash(req, "error", conditionalValidation.errors[0]);
return res.redirect("/admin/commands");
}
if (mode === "plain") {
const placeholderValidation = placeholders.validateTemplate({
@ -6389,18 +6609,24 @@ function createWebServer({ loadPlugins, discordClient }) {
setFlash(req, "error", templateError);
return res.redirect("/admin/commands");
}
} else if (mode === "conditional") {
const templateError = validateConditionalReplyTemplates(conditionalValidation, req.session.user);
if (templateError) {
setFlash(req, "error", templateError);
return res.redirect("/admin/commands");
}
}
const preview = isAdmin && mode === "advanced"
? await generateCommandPreview({ code, language })
: emptyCommandPreview();
try {
db.prepare(
"UPDATE custom_commands SET trigger = ?, description = ?, response = ?, mode = ?, language = ?, code = ?, platform = ?, preview_text = ?, preview_status = ?, preview_error = ?, preview_generated_at = ?, preview_dynamic_segments = ?, random_replies_json = ?, rng_enabled = ?, rng_min = ?, rng_max = ?, updated_at = ? WHERE id = ?"
"UPDATE custom_commands SET trigger = ?, description = ?, response = ?, mode = ?, language = ?, code = ?, platform = ?, preview_text = ?, preview_status = ?, preview_error = ?, preview_generated_at = ?, preview_dynamic_segments = ?, random_replies_json = ?, rng_enabled = ?, rng_min = ?, rng_max = ?, conditional_replies_json = ?, conditional_fuzzy = ?, updated_at = ? WHERE id = ?"
).run(
trigger,
description,
response || "",
isAdmin || mode === "random" ? mode : "plain",
isAdmin || mode !== "advanced" ? mode : "plain",
isAdmin ? language : "js",
isAdmin && mode === "advanced" ? code : null,
serializePlatformSelection(selectedPlatforms),
@ -6413,6 +6639,8 @@ function createWebServer({ loadPlugins, discordClient }) {
mode === "random" && randomValidation.config.rngEnabled ? 1 : 0,
randomValidation.config.rngMin,
randomValidation.config.rngMax,
mode === "conditional" ? JSON.stringify(conditionalValidation.replies) : "[]",
mode === "conditional" && req.body.conditional_fuzzy ? 1 : 0,
Date.now(),
req.params.id
);
@ -7223,6 +7451,12 @@ function collectNavItems(user, pluginNav, currentPath) {
role: "mod",
section: "moderation"
},
{
label: "Command access",
path: "/admin/command-policies",
role: "admin",
section: "admin"
},
{ label: "Pages", path: "/admin/pages", role: "admin", section: "admin" },
{ label: "Users", path: "/admin/users", role: "mod", section: "moderation" },
{ label: "Plugins", path: "/admin/plugins", role: "admin", section: "admin" }
@ -7479,6 +7713,7 @@ function getDefaultNavIcon(item) {
if (pathName === "/admin/logs") return "logs";
if (pathName === "/admin/updates") return "updates";
if (pathName === "/admin/commands") return "commands";
if (pathName === "/admin/command-policies") return "commands";
if (pathName === "/admin/pages") return "pages";
if (pathName === "/admin/users") return "users";
if (pathName === "/admin/plugins") return "plugins";

View File

@ -0,0 +1,87 @@
<%- include("partials/layout-top", { title }) %>
<section class="card">
<%- include("partials/page-header", {
eyebrow: "Chat tools",
pageTitle: "Command access and limits",
description: "Keep common rules in groups, then override only the settings that are different for an individual command.",
actions: '<a class="button subtle" href="/admin/commands">Custom commands</a>'
}) %>
<div class="callout info">
<strong>How settings combine</strong>
<p>Commands inherit their groups. If several groups are assigned, every group limit and role requirement applies; the highest group currency cost is used. Choosing a setting directly on a command replaces all group and built-in values for that one category.</p>
</div>
</section>
<section class="card">
<div class="section-header"><div><h2>Command groups</h2><p class="hint">Create a reusable set of access, rate, stream, and currency rules.</p></div></div>
<details class="lumi-expandable-settings">
<summary><span><strong>Create command group</strong><span class="hint">For example: Public fun, Moderator tools, or Expensive commands</span></span></summary>
<form method="post" action="/admin/command-policies/groups" class="form-grid command-policy-form">
<div class="field"><label>Name<input name="name" maxlength="100" required /></label></div>
<div class="field"><label>Description<input name="description" maxlength="500" /></label></div>
<div class="field full"><%- include("partials/command-policy-fields", { policy: {}, allowInherit: false }) %></div>
<div class="form-actions"><button class="button" type="submit">Create group</button></div>
</form>
</details>
<% if (!groups.length) { %><p class="hint">No command groups yet.</p><% } %>
<% groups.forEach((group) => { %>
<details class="lumi-expandable-settings command-policy-item">
<summary><span><strong><%= group.name %></strong><span class="hint"><%= group.description || "Reusable command rules" %></span></span></summary>
<form method="post" action="/admin/command-policies/groups/<%= group.id %>" class="form-grid command-policy-form">
<div class="field"><label>Name<input name="name" maxlength="100" required value="<%= group.name %>" /></label></div>
<div class="field"><label>Description<input name="description" maxlength="500" value="<%= group.description %>" /></label></div>
<div class="field full"><%- include("partials/command-policy-fields", { policy: group.policy, allowInherit: false }) %></div>
<div class="form-actions"><button class="button" type="submit">Save group</button></div>
</form>
<form method="post" action="/admin/command-policies/groups/<%= group.id %>/delete" class="form-actions" data-confirm-mode="modal" data-confirm-title="Delete command group" data-confirm-text="Delete the group <%= group.name %>? Commands keep their direct settings." data-confirm-label="Delete group">
<button class="button danger" type="submit">Delete group</button>
</form>
</details>
<% }) %>
</section>
<section class="card">
<div class="section-header"><div><h2>Individual commands</h2><p class="hint">Assign groups or override a category for one command.</p></div></div>
<% if (!commands.length) { %><p>No commands are currently registered.</p><% } %>
<% commands.forEach((command) => { %>
<details class="lumi-expandable-settings command-policy-item">
<summary>
<span><strong><%= command.label %></strong><span class="hint"><%= command.origin %> · <%= command.description %></span></span>
<% if (command.groups.length) { %><span class="badge"><%= command.groups.length %> group<%= command.groups.length === 1 ? "" : "s" %></span><% } %>
</summary>
<form method="post" action="/admin/command-policies/command" class="form-grid command-policy-form">
<input type="hidden" name="command_key" value="<%= command.key %>" />
<fieldset class="subsection field full">
<legend>Groups</legend>
<div class="platform-checkboxes">
<% groups.forEach((group) => { %>
<label class="platform-check"><input type="checkbox" name="group_ids" value="<%= group.id %>" <%= command.groups.some((item) => item.id === group.id) ? "checked" : "" %> /><span><%= group.name %></span></label>
<% }) %>
</div>
<% if (!groups.length) { %><p class="hint">Create a group above first, or configure this command directly.</p><% } %>
</fieldset>
<% if (Object.keys(command.defaultPolicy || {}).length) { %>
<div class="callout info field full"><strong>Safe built-in defaults</strong><p>This command has conservative built-in settings. Leave a category on “Use group/default” to keep them unless a group supplies that category.</p></div>
<% } %>
<div class="field full"><%- include("partials/command-policy-fields", { policy: command.policy, allowInherit: true }) %></div>
<div class="form-actions"><button class="button" type="submit">Save command settings</button></div>
</form>
</details>
<% }) %>
</section>
<script>
const updatePolicyCategory = (category) => {
const custom = category.querySelector("[data-policy-mode]")?.value === "custom";
const values = category.querySelector("[data-policy-values]");
if (values) values.hidden = !custom;
const role = category.querySelector("[data-requirement-role]");
const tier = category.querySelector("[data-subscriber-tier]");
if (tier) tier.hidden = !custom || role?.value !== "subscriber";
};
document.querySelectorAll("[data-policy-category]").forEach((category) => {
updatePolicyCategory(category);
category.querySelector("[data-policy-mode]")?.addEventListener("change", () => updatePolicyCategory(category));
category.querySelector("[data-requirement-role]")?.addEventListener("change", () => updatePolicyCategory(category));
});
</script>
<%- include("partials/layout-bottom") %>

View File

@ -3,7 +3,8 @@
<%- include("partials/page-header", {
eyebrow: "Chat tools",
pageTitle: "Custom commands",
description: "Create a command, choose where it works, and control the reply it sends."
description: "Create a command, choose where it works, and control the reply it sends.",
actions: isAdmin ? '<a class="button subtle" href="/admin/command-policies">Access, groups and limits</a>' : ''
}) %>
<% const platformLabelMap = new Map((platforms || []).map((item) => [item.id, item.label])); %>
<form method="post" action="/admin/commands" class="form-grid command-form">
@ -39,6 +40,7 @@
<select name="mode" class="js-command-mode">
<option value="plain" selected>Static</option>
<option value="random">Random Reply</option>
<option value="conditional">Conditional Reply</option>
<% if (isAdmin) { %>
<option value="advanced">Dynamic</option>
<% } %>
@ -54,8 +56,22 @@
</div>
<% } else { %><input type="hidden" name="language" value="js" /><% } %>
<div class="field">
<label>Response</label>
<label>Response / help text</label>
<input name="response" placeholder="Hello there!" class="js-field-response" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
<p class="hint">For conditional replies, this is shown when no argument is given. Leave it empty to list the available keywords.</p>
</div>
<div class="field full js-field-conditional">
<label>Keyword replies</label>
<div class="command-random-replies" data-conditional-replies>
<div class="command-random-row command-conditional-row" data-conditional-reply-row>
<input name="conditional_keyword" placeholder="backseating" aria-label="Keyword" />
<input name="conditional_response" placeholder="The reply for this keyword" aria-label="Reply" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
<button type="button" class="button subtle" data-conditional-remove>Remove</button>
</div>
</div>
<button type="button" class="button subtle" data-conditional-add>Add keyword</button>
<label class="switch"><input type="checkbox" class="switch-input" name="conditional_fuzzy" checked /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Suggest a close keyword when there is a small typo</span></label>
<p class="hint">Example: <code>!rules backseating</code>. Exact matches are preferred; fuzzy matching only accepts a clear close match.</p>
</div>
<div class="field full js-field-random">
<label>Random replies</label>
@ -85,7 +101,7 @@
<% if (isAdmin) { %>
<p class="hint">For Dynamic commands, paste a simple snippet that returns a reply, or provide an explicit <code>run(ctx)</code> or module export.</p>
<% } else { %>
<p class="hint">Moderators can create Static and Random Reply commands. Dynamic code remains admin-only.</p>
<p class="hint">Moderators can create Static, Random Reply, and Conditional Reply commands. Dynamic code remains admin-only.</p>
<% } %>
</section>
<section class="card">
@ -121,6 +137,9 @@
<% if (command.random.example) { %><span class="command-preview" title="Example using a repeatable preview roll"><%= command.random.example %></span><% } %>
<% if (command.random.rng !== null) { %><span class="hint">Example roll: <%= command.random.rng %></span><% } %>
<% if (command.random.error) { %><span class="command-preview-unavailable"><%= command.random.error %></span><% } %>
<% } else if (command.mode === "conditional") { %>
<span><strong><%= command.conditional.replies.length %> keyword repl<%= command.conditional.replies.length === 1 ? "y" : "ies" %></strong></span>
<span class="hint"><%= command.conditional.replies.map((reply) => reply.keyword).join(", ") %></span>
<% } else if (command.preview.status === "ready") { %>
<% const previewId = `command-preview-${command.id}`; %>
<span id="<%= previewId %>" class="command-preview <%= command.preview.isLong ? 'is-collapsed' : '' %>" title="Sandboxed example output"><% command.preview.parts.forEach((part) => { %><% if (part.dynamic) { %><mark class="preview-dynamic" title="Dynamic <%= part.type %>"><%= part.text %></mark><% } else { %><%= part.text %><% } %><% }) %></span>
@ -195,6 +214,7 @@
<select name="mode" class="js-command-mode">
<option value="plain" <%= command.mode === 'plain' ? 'selected' : '' %>>Static</option>
<option value="random" <%= command.mode === 'random' ? 'selected' : '' %>>Random Reply</option>
<option value="conditional" <%= command.mode === 'conditional' ? 'selected' : '' %>>Conditional Reply</option>
<% if (isAdmin) { %>
<option value="advanced" <%= command.mode === 'advanced' ? 'selected' : '' %>>Dynamic</option>
<% } %>
@ -210,8 +230,23 @@
</div>
<% } else { %><input type="hidden" name="language" value="js" /><% } %>
<div class="field">
<label>Response</label>
<label>Response / help text</label>
<input name="response" value="<%= command.response %>" class="js-field-response" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
<p class="hint">For conditional replies, this is shown when no argument is given. Leave it empty to list the available keywords.</p>
</div>
<div class="field full js-field-conditional">
<label>Keyword replies</label>
<div class="command-random-replies" data-conditional-replies>
<% (command.conditional.replies.length ? command.conditional.replies : [{ keyword: "", response: "" }]).forEach((reply) => { %>
<div class="command-random-row command-conditional-row" data-conditional-reply-row>
<input name="conditional_keyword" value="<%= reply.keyword %>" placeholder="backseating" aria-label="Keyword" />
<input name="conditional_response" value="<%= reply.response %>" placeholder="The reply for this keyword" aria-label="Reply" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
<button type="button" class="button subtle" data-conditional-remove>Remove</button>
</div>
<% }) %>
</div>
<button type="button" class="button subtle" data-conditional-add>Add keyword</button>
<label class="switch"><input type="checkbox" class="switch-input" name="conditional_fuzzy" <%= command.conditional_fuzzy ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Suggest a close keyword when there is a small typo</span></label>
</div>
<div class="field full js-field-random">
<label>Random replies</label>
@ -258,11 +293,38 @@
const code = form.querySelector(".js-field-code");
const language = form.querySelector(".js-field-language");
const random = form.querySelector(".js-field-random");
const conditional = form.querySelector(".js-field-conditional");
const isAdvanced = mode === "advanced";
if (response) response.style.display = mode === "plain" ? "" : "none";
if (response) response.style.display = ["plain", "conditional"].includes(mode) ? "" : "none";
if (code) code.style.display = isAdvanced ? "" : "none";
if (language) language.style.display = isAdvanced ? "" : "none";
if (random) random.style.display = mode === "random" ? "" : "none";
if (conditional) conditional.style.display = mode === "conditional" ? "" : "none";
};
const addConditionalReply = (form) => {
const list = form.querySelector("[data-conditional-replies]");
if (!list) return;
const row = document.createElement("div");
row.className = "command-random-row command-conditional-row";
row.dataset.conditionalReplyRow = "";
const keyword = document.createElement("input");
keyword.name = "conditional_keyword";
keyword.placeholder = "keyword";
keyword.setAttribute("aria-label", "Keyword");
const response = document.createElement("input");
response.name = "conditional_response";
response.placeholder = "The reply for this keyword";
response.setAttribute("aria-label", "Reply");
response.dataset.placeholderField = "core.custom_commands.static_response";
response.dataset.placeholderOutputAudience = "user";
const remove = document.createElement("button");
remove.type = "button";
remove.className = "button subtle";
remove.dataset.conditionalRemove = "";
remove.textContent = "Remove";
row.append(keyword, response, remove);
list.append(row);
keyword.focus();
};
const toggleRngFields = (form) => {
const enabled = form.querySelector(".js-rng-enabled")?.checked === true;
@ -309,10 +371,17 @@
});
form.querySelector(".js-rng-enabled")?.addEventListener("change", () => toggleRngFields(form));
form.querySelector("[data-random-add]")?.addEventListener("click", () => addRandomReply(form));
form.querySelector("[data-conditional-add]")?.addEventListener("click", () => addConditionalReply(form));
form.addEventListener("click", (event) => {
const remove = event.target.closest("[data-random-remove]");
if (!remove || !form.contains(remove)) return;
remove.closest("[data-random-reply-row]")?.remove();
return;
});
form.addEventListener("click", (event) => {
const remove = event.target.closest("[data-conditional-remove]");
if (!remove || !form.contains(remove)) return;
remove.closest("[data-conditional-reply-row]")?.remove();
});
});
</script>

View File

@ -0,0 +1,72 @@
<%
const hasOwn = (name) => Object.prototype.hasOwnProperty.call(policy || {}, name);
const policyMode = (name) => !hasOwn(name) ? (allowInherit ? "inherit" : "off") : policy[name] === false ? "off" : "custom";
const windowValue = policy?.window && policy.window !== false ? policy.window : {};
const streamValue = policy?.perStream && policy.perStream !== false ? policy.perStream : {};
const requirementValue = policy?.requirement && policy.requirement !== false ? policy.requirement : {};
const costValue = policy?.cost && policy.cost !== false ? policy.cost : {};
%>
<div class="command-policy-grid">
<fieldset class="subsection command-policy-section" data-policy-category>
<legend>Rate limit</legend>
<label>Setting
<select name="window_mode" data-policy-mode>
<% if (allowInherit) { %><option value="inherit" <%= policyMode("window") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
<option value="off" <%= policyMode("window") === "off" ? "selected" : "" %>>No time-window limit</option>
<option value="custom" <%= policyMode("window") === "custom" ? "selected" : "" %>>Set a limit</option>
</select>
</label>
<div class="command-policy-values" data-policy-values>
<label>Uses<input name="window_uses" type="number" min="1" step="1" value="<%= windowValue.uses || 1 %>" /></label>
<label>Within seconds<input name="window_seconds" type="number" min="1" step="1" value="<%= windowValue.seconds || 30 %>" /></label>
<label>Count<select name="window_scope"><option value="user" <%= windowValue.scope !== "global" ? "selected" : "" %>>Per user</option><option value="global" <%= windowValue.scope === "global" ? "selected" : "" %>>Everyone together</option></select></label>
</div>
</fieldset>
<fieldset class="subsection command-policy-section" data-policy-category>
<legend>Per stream</legend>
<label>Setting
<select name="perStream_mode" data-policy-mode>
<% if (allowInherit) { %><option value="inherit" <%= policyMode("perStream") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
<option value="off" <%= policyMode("perStream") === "off" ? "selected" : "" %>>No stream limit</option>
<option value="custom" <%= policyMode("perStream") === "custom" ? "selected" : "" %>>Set a limit</option>
</select>
</label>
<div class="command-policy-values" data-policy-values>
<label>Uses per stream<input name="perStream_uses" type="number" min="1" step="1" value="<%= streamValue.uses || 1 %>" /></label>
<label>Count<select name="perStream_scope"><option value="user" <%= streamValue.scope !== "global" ? "selected" : "" %>>Per user</option><option value="global" <%= streamValue.scope === "global" ? "selected" : "" %>>Everyone together</option></select></label>
</div>
<p class="hint">A restarted stream stays in the same session for two hours, so a crash does not reset this limit.</p>
</fieldset>
<fieldset class="subsection command-policy-section" data-policy-category>
<legend>Who may use it</legend>
<label>Setting
<select name="requirement_mode" data-policy-mode>
<% if (allowInherit) { %><option value="inherit" <%= policyMode("requirement") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
<option value="off" <%= policyMode("requirement") === "off" ? "selected" : "" %>>Everyone</option>
<option value="custom" <%= policyMode("requirement") === "custom" ? "selected" : "" %>>Require a role</option>
</select>
</label>
<div class="command-policy-values" data-policy-values>
<label>Minimum access<select name="requirement_role" data-requirement-role>
<% [["follower","Follower"],["subscriber","Subscriber / member"],["vip","VIP"],["mod","Moderator"],["editor","Editor"],["streamer","Streamer only"]].forEach(([value,label]) => { %>
<option value="<%= value %>" <%= requirementValue.role === value ? "selected" : "" %>><%= label %></option>
<% }) %>
</select></label>
<label data-subscriber-tier>Subscriber tier<select name="requirement_subscriber_tier"><option value="1" <%= Number(requirementValue.subscriberTier || 1) === 1 ? "selected" : "" %>>Any tier</option><option value="2" <%= Number(requirementValue.subscriberTier) === 2 ? "selected" : "" %>>Tier 2+</option><option value="3" <%= Number(requirementValue.subscriberTier) === 3 ? "selected" : "" %>>Tier 3</option></select></label>
</div>
</fieldset>
<fieldset class="subsection command-policy-section" data-policy-category>
<legend>Currency cost</legend>
<label>Setting
<select name="cost_mode" data-policy-mode>
<% if (allowInherit) { %><option value="inherit" <%= policyMode("cost") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
<option value="off" <%= policyMode("cost") === "off" ? "selected" : "" %>>Free</option>
<option value="custom" <%= policyMode("cost") === "custom" ? "selected" : "" %>>Charge currency</option>
</select>
</label>
<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>
</div>
<p class="hint">The Economy Framework deducts and logs this like any other banking transaction.</p>
</fieldset>
</div>

View File

@ -1,6 +1,6 @@
{
"name": "Lumi Core",
"version": "0.2.17",
"version": "0.2.18",
"channel": "stable",
"released_at": "2026-07-19",
"compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [
"1.2.0"
],
"migration_notes": "Adds responsive standalone OBS chat docks backed by existing native chat settings and revocable credentials. Existing 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": "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.",
"rollback_safe": true,
"requirements": [
"Node.js 18 or newer"
@ -229,6 +229,18 @@
],
"rollback_safe": true,
"migration_notes": "Adds responsive standalone OBS chat docks backed by existing native chat settings and revocable credentials; existing overlay records, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved."
},
{
"version": "0.2.18",
"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": "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."
}
]
}