From 7b7e49507c8f3e1d80bf77ee19843f25958dfffb Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sun, 19 Jul 2026 15:33:20 +0200 Subject: [PATCH] Add command policies and stream controls --- CHANGELOG.md | 7 + TODO.md | 19 + docs/commands.md | 78 +++++ docs/obs-overlays.md | 10 + knowledge/core/lumi-core.md | 2 +- package-lock.json | 4 +- package.json | 2 +- release-index.json | 30 ++ scripts/verify-all.js | 1 + scripts/verify-command-policies.js | 95 +++++ scripts/verify-overlays.js | 4 + scripts/verify-release-metadata.js | 6 +- scripts/verify-update-system.js | 5 +- src/main.js | 1 + src/services/command-conditional.js | 111 ++++++ src/services/command-entitlements.js | 128 +++++++ src/services/command-platform.js | 144 ++++++++ src/services/command-policies.js | 331 ++++++++++++++++++ src/services/command-router.js | 116 ++++-- src/services/db.js | 54 +++ src/services/overlay-connectors.js | 45 +++ src/services/top.js | 76 ++++ src/services/youtube.js | 2 + src/web/public/lumi-components.css | 28 ++ src/web/public/overlay-runtime.js | 3 + src/web/server.js | 261 +++++++++++++- src/web/views/admin-command-policies.ejs | 87 +++++ src/web/views/admin-commands.ejs | 79 ++++- .../views/partials/command-policy-fields.ejs | 72 ++++ update-manifest.json | 16 +- 30 files changed, 1765 insertions(+), 52 deletions(-) create mode 100644 docs/commands.md create mode 100644 scripts/verify-command-policies.js create mode 100644 src/services/command-conditional.js create mode 100644 src/services/command-entitlements.js create mode 100644 src/services/command-platform.js create mode 100644 src/services/command-policies.js create mode 100644 src/web/views/admin-command-policies.ejs create mode 100644 src/web/views/partials/command-policy-fields.ejs diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6e1e5..cc0fc63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` / `!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. diff --git a/TODO.md b/TODO.md index 2d3c34b..81a2527 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..cce7716 --- /dev/null +++ b/docs/commands.md @@ -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 ` 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. diff --git a/docs/obs-overlays.md b/docs/obs-overlays.md index 227961d..06c27d0 100644 --- a/docs/obs-overlays.md +++ b/docs/obs-overlays.md @@ -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 diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index 36aa043..eb919d7 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -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 diff --git a/package-lock.json b/package-lock.json index a893310..85fba58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index cf69cbf..fc88d59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.17", + "version": "0.2.18", "private": true, "type": "commonjs", "scripts": { diff --git a/release-index.json b/release-index.json index 9703b7c..5abc803 100644 --- a/release-index.json +++ b/release-index.json @@ -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", diff --git a/scripts/verify-all.js b/scripts/verify-all.js index 1c0bbaa..4f016a9 100644 --- a/scripts/verify-all.js +++ b/scripts/verify-all.js @@ -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", diff --git a/scripts/verify-command-policies.js b/scripts/verify-command-policies.js new file mode 100644 index 0000000..85caa4f --- /dev/null +++ b/scripts/verify-command-policies.js @@ -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; +}); diff --git a/scripts/verify-overlays.js b/scripts/verify-overlays.js index e635131..e3edfcf 100644 --- a/scripts/verify-overlays.js +++ b/scripts/verify-overlays.js @@ -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/")); diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index a1e41e4..78e0a6f 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -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."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index f4abee4..9d4f6e2 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -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); diff --git a/src/main.js b/src/main.js index 88bbf4f..027644d 100644 --- a/src/main.js +++ b/src/main.js @@ -83,6 +83,7 @@ async function main() { const app = createWebServer({ discordClient, + commandRouter, loadPlugins: (appInstance, web, webhooks) => { if (safeModeRequested) return; loadEnabled({ diff --git a/src/services/command-conditional.js b/src/services/command-conditional.js new file mode 100644 index 0000000..6455b7e --- /dev/null +++ b/src/services/command-conditional.js @@ -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 +}; diff --git a/src/services/command-entitlements.js b/src/services/command-entitlements.js new file mode 100644 index 0000000..9207dfb --- /dev/null +++ b/src/services/command-entitlements.js @@ -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 +}; diff --git a/src/services/command-platform.js b/src/services/command-platform.js new file mode 100644 index 0000000..1c8c37a --- /dev/null +++ b/src/services/command-platform.js @@ -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 +}; diff --git a/src/services/command-policies.js b/src/services/command-policies.js new file mode 100644 index 0000000..ba828bd --- /dev/null +++ b/src/services/command-policies.js @@ -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 +}; diff --git a/src/services/command-router.js b/src/services/command-router.js index 761d4bb..a2dfeb6 100644 --- a/src/services/command-router.js +++ b/src/services/command-router.js @@ -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; diff --git a/src/services/db.js b/src/services/db.js index 3a3df9e..4b6a68b 100644 --- a/src/services/db.js +++ b/src/services/db.js @@ -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"); } diff --git a/src/services/overlay-connectors.js b/src/services/overlay-connectors.js index 8bcb6ab..25a1b3f 100644 --- a/src/services/overlay-connectors.js +++ b/src/services/overlay-connectors.js @@ -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) { diff --git a/src/services/top.js b/src/services/top.js index 0ee71a9..d132f3a 100644 --- a/src/services/top.js +++ b/src/services/top.js @@ -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 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(); diff --git a/src/services/youtube.js b/src/services/youtube.js index f26916d..cd602d7 100644 --- a/src/services/youtube.js +++ b/src/services/youtube.js @@ -167,6 +167,8 @@ async function handleChatItem(state, liveChatId, item) { }, meta: { liveChatId, + activeVideoId: state.activeVideoId, + broadcasterChannelId: state.channelId, messageId: item.id, snippet, author diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index 4eecc7a..76b65fc 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -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; diff --git a/src/web/public/overlay-runtime.js b/src/web/public/overlay-runtime.js index c961f1d..f457d27 100644 --- a/src/web/public/overlay-runtime.js +++ b/src/web/public/overlay-runtime.js @@ -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 {} }); diff --git a/src/web/server.js b/src/web/server.js index 44f0cf7..f9dc7da 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -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" ? " " : 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.prepare("DELETE FROM custom_commands WHERE id = ?").run(req.params.id); + 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"; diff --git a/src/web/views/admin-command-policies.ejs b/src/web/views/admin-command-policies.ejs new file mode 100644 index 0000000..ae1d4b3 --- /dev/null +++ b/src/web/views/admin-command-policies.ejs @@ -0,0 +1,87 @@ +<%- include("partials/layout-top", { title }) %> +
+ <%- 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: 'Custom commands' + }) %> +
+ How settings combine +

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.

+
+
+ +
+

Command groups

Create a reusable set of access, rate, stream, and currency rules.

+
+ Create command groupFor example: Public fun, Moderator tools, or Expensive commands +
+
+
+
<%- include("partials/command-policy-fields", { policy: {}, allowInherit: false }) %>
+
+
+
+ <% if (!groups.length) { %>

No command groups yet.

<% } %> + <% groups.forEach((group) => { %> +
+ <%= group.name %><%= group.description || "Reusable command rules" %> +
+
+
+
<%- include("partials/command-policy-fields", { policy: group.policy, allowInherit: false }) %>
+
+
+
+ +
+
+ <% }) %> +
+ +
+

Individual commands

Assign groups or override a category for one command.

+ <% if (!commands.length) { %>

No commands are currently registered.

<% } %> + <% commands.forEach((command) => { %> +
+ + <%= command.label %><%= command.origin %> · <%= command.description %> + <% if (command.groups.length) { %><%= command.groups.length %> group<%= command.groups.length === 1 ? "" : "s" %><% } %> + +
+ +
+ Groups +
+ <% groups.forEach((group) => { %> + + <% }) %> +
+ <% if (!groups.length) { %>

Create a group above first, or configure this command directly.

<% } %> +
+ <% if (Object.keys(command.defaultPolicy || {}).length) { %> +
Safe built-in defaults

This command has conservative built-in settings. Leave a category on “Use group/default” to keep them unless a group supplies that category.

+ <% } %> +
<%- include("partials/command-policy-fields", { policy: command.policy, allowInherit: true }) %>
+
+
+
+ <% }) %> +
+ +<%- include("partials/layout-bottom") %> diff --git a/src/web/views/admin-commands.ejs b/src/web/views/admin-commands.ejs index 44d0e49..abf14a6 100644 --- a/src/web/views/admin-commands.ejs +++ b/src/web/views/admin-commands.ejs @@ -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 ? 'Access, groups and limits' : '' }) %> <% const platformLabelMap = new Map((platforms || []).map((item) => [item.id, item.label])); %>
@@ -39,6 +40,7 @@ <% } %>
- + +

For conditional replies, this is shown when no argument is given. Leave it empty to list the available keywords.

+
+
+ +
+
+ + + +
+
+ + +

Example: !rules backseating. Exact matches are preferred; fuzzy matching only accepts a clear close match.

@@ -85,7 +101,7 @@ <% if (isAdmin) { %>

For Dynamic commands, paste a simple snippet that returns a reply, or provide an explicit run(ctx) or module export.

<% } else { %> -

Moderators can create Static and Random Reply commands. Dynamic code remains admin-only.

+

Moderators can create Static, Random Reply, and Conditional Reply commands. Dynamic code remains admin-only.

<% } %>
@@ -121,6 +137,9 @@ <% if (command.random.example) { %><%= command.random.example %><% } %> <% if (command.random.rng !== null) { %>Example roll: <%= command.random.rng %><% } %> <% if (command.random.error) { %><%= command.random.error %><% } %> + <% } else if (command.mode === "conditional") { %> + <%= command.conditional.replies.length %> keyword repl<%= command.conditional.replies.length === 1 ? "y" : "ies" %> + <%= command.conditional.replies.map((reply) => reply.keyword).join(", ") %> <% } else if (command.preview.status === "ready") { %> <% const previewId = `command-preview-${command.id}`; %> <% command.preview.parts.forEach((part) => { %><% if (part.dynamic) { %><%= part.text %><% } else { %><%= part.text %><% } %><% }) %> @@ -195,6 +214,7 @@ <% } %>
- + +

For conditional replies, this is shown when no argument is given. Leave it empty to list the available keywords.

+
+
+ +
+ <% (command.conditional.replies.length ? command.conditional.replies : [{ keyword: "", response: "" }]).forEach((reply) => { %> +
+ + + +
+ <% }) %> +
+ +
@@ -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(); }); }); diff --git a/src/web/views/partials/command-policy-fields.ejs b/src/web/views/partials/command-policy-fields.ejs new file mode 100644 index 0000000..74ccae8 --- /dev/null +++ b/src/web/views/partials/command-policy-fields.ejs @@ -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 : {}; +%> +
+
+ Rate limit + +
+ + + +
+
+
+ Per stream + +
+ + +
+

A restarted stream stays in the same session for two hours, so a crash does not reset this limit.

+
+
+ Who may use it + +
+ + +
+
+
+ Currency cost + +
+ +
+

The Economy Framework deducts and logs this like any other banking transaction.

+
+
diff --git a/update-manifest.json b/update-manifest.json index 977e188..8e6ff73 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -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." } ] }