Add command policies and stream controls
This commit is contained in:
parent
4bd337d5e3
commit
7b7e49507c
@ -1,5 +1,12 @@
|
|||||||
# Lumi changelog
|
# Lumi changelog
|
||||||
|
|
||||||
|
## 0.2.18
|
||||||
|
|
||||||
|
- Added centralized command groups and per-command overrides for time-window limits, crash-tolerant per-stream limits, platform roles/subscriber tiers, and Economy Framework costs with logged refunds.
|
||||||
|
- Added conditional keyword custom replies with optional conservative typo matching and shared placeholder handling.
|
||||||
|
- Added core `!clip [label]` orchestration for platform clips plus OBS replay saves through local WebSocket or the Browser Bridge, and Twitch `!raid <channel>` / `!raid cancel`.
|
||||||
|
- Added an admin command-policy page, command-list access summaries, focused verification, and operator documentation.
|
||||||
|
|
||||||
## 0.2.17
|
## 0.2.17
|
||||||
|
|
||||||
- Added a signed standalone OBS Custom Browser Dock page for every native Lumi chat source, with admin-only copy/open controls.
|
- Added a signed standalone OBS Custom Browser Dock page for every native Lumi chat source, with admin-only copy/open controls.
|
||||||
|
|||||||
19
TODO.md
19
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.
|
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
|
## Current Local State / Source of Truth
|
||||||
|
|
||||||
### P0 OBS Overlay System
|
### P0 OBS Overlay System
|
||||||
|
|||||||
78
docs/commands.md
Normal file
78
docs/commands.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# Commands, access, and limits
|
||||||
|
|
||||||
|
Admins configure shared command rules at **Admin → Command access**. Moderators
|
||||||
|
continue to create and edit custom replies at **Custom commands**.
|
||||||
|
|
||||||
|
## Built-in stream commands
|
||||||
|
|
||||||
|
- `!clip [optional label]` requests a clip on the platform that delivered the
|
||||||
|
command and saves the replay buffer through the first operational OBS
|
||||||
|
connector. Twitch supports clip creation; platforms without a clip API report
|
||||||
|
that part as unavailable without preventing the OBS replay save.
|
||||||
|
- `!raid <channel>` starts a supported platform raid. `!raid cancel` cancels a
|
||||||
|
pending raid. Twitch shows its normal confirmation/countdown after Lumi starts
|
||||||
|
the raid.
|
||||||
|
|
||||||
|
Twitch clip creation needs `clips:edit`; raids need
|
||||||
|
`channel:manage:raids`. Follower, subscriber-tier, and editor requirements need
|
||||||
|
`moderator:read:followers`, `channel:read:subscriptions`, and
|
||||||
|
`channel:read:editors` respectively. The configured user token must represent
|
||||||
|
the broadcaster, or an eligible moderator where Twitch permits it. Lumi reports
|
||||||
|
a verification problem instead of silently allowing a user when a required
|
||||||
|
scope is missing.
|
||||||
|
|
||||||
|
Twitch does not expose a clip-title field in Create Clip, and OBS replay-save
|
||||||
|
bindings do not accept a filename. Lumi therefore records and repeats the
|
||||||
|
optional label for operator context but does not claim that either provider used
|
||||||
|
it as the saved title.
|
||||||
|
|
||||||
|
The local OBS WebSocket connector calls `SaveReplayBuffer`. The OBS Browser
|
||||||
|
Bridge calls OBS Browser Source's `window.obsstudio.saveReplayBuffer()` and
|
||||||
|
requires **Basic access to OBS** (level 3) or higher. The replay buffer must
|
||||||
|
already be running. A platform or OBS failure is reported independently, so one
|
||||||
|
failure does not hide a successful action from the other system.
|
||||||
|
|
||||||
|
## Conditional custom replies
|
||||||
|
|
||||||
|
Choose **Conditional Reply** for a custom command, then add keyword/reply pairs.
|
||||||
|
For example, a `rules` command can answer `!rules backseating` and
|
||||||
|
`!rules spoilers` differently. The optional main response is shown for `!rules`
|
||||||
|
without an argument; if it is empty, Lumi lists the available keywords.
|
||||||
|
|
||||||
|
Exact keyword matches always win. Optional fuzzy matching accepts only a clear,
|
||||||
|
close typo and refuses ambiguous or unrelated text. Reply templates use the
|
||||||
|
same shared placeholder validation and rendering as static custom commands.
|
||||||
|
|
||||||
|
## Groups and command overrides
|
||||||
|
|
||||||
|
A command can belong to several groups. Every assigned group rate limit and
|
||||||
|
role requirement applies. The highest group currency cost is used, so assigning
|
||||||
|
two priced groups does not charge twice. Group rate limits form a shared pool
|
||||||
|
across commands in that group.
|
||||||
|
|
||||||
|
Each command category has three choices:
|
||||||
|
|
||||||
|
- **Use group/default** inherits groups, or the command's conservative built-in
|
||||||
|
default when no group supplies that category.
|
||||||
|
- **No limit / Everyone / Free** explicitly disables inherited values for that
|
||||||
|
category.
|
||||||
|
- **Set** replaces inherited values for that category on this command.
|
||||||
|
|
||||||
|
Time-window limits support a shared global pool or one pool per Lumi user.
|
||||||
|
Per-stream limits also support global or per-user counting. Stream identities
|
||||||
|
are persisted, and a new provider stream that appears within two hours reuses
|
||||||
|
the previous session so a crash and troubleshooting restart cannot reset the
|
||||||
|
allowance.
|
||||||
|
|
||||||
|
Role requirements are evaluated from live platform roles first. Twitch performs
|
||||||
|
a scoped API check when follower status, subscriber tier, or editor status is
|
||||||
|
not available in chat metadata. YouTube uses owner, moderator, and member data
|
||||||
|
provided with live chat. Discord uses server ownership, permissions, and common
|
||||||
|
VIP/subscriber role names. A requirement that cannot be reliably verified is
|
||||||
|
denied with an actionable message.
|
||||||
|
|
||||||
|
Currency costs reuse the Economy Framework. A successful admission creates a
|
||||||
|
normal `spend` transaction with command metadata. If the command handler declines
|
||||||
|
or fails, Lumi releases the rate-limit reservation and writes a matching refund
|
||||||
|
transaction. If Economy is disabled, a priced command is denied rather than run
|
||||||
|
for free.
|
||||||
@ -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
|
revalidates credentials on open live-event streams and closes them after
|
||||||
revocation.
|
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 and audio
|
||||||
|
|
||||||
Video sources support browser-playable media such as WebM and MP4; audio sources
|
Video sources support browser-playable media such as WebM and MP4; audio sources
|
||||||
|
|||||||
@ -14,7 +14,7 @@ editable: false
|
|||||||
Lumi is the core web UI and bot runtime.
|
Lumi is the core web UI and bot runtime.
|
||||||
## Runtime
|
## Runtime
|
||||||
Package: lumi-bot
|
Package: lumi-bot
|
||||||
Version: 0.2.17
|
Version: 0.2.18
|
||||||
## Routes
|
## Routes
|
||||||
- GET /api/events
|
- GET /api/events
|
||||||
- POST /api/destructive-confirmations
|
- POST /api/destructive-confirmations
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.17",
|
"version": "0.2.18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.17",
|
"version": "0.2.18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.12",
|
"adm-zip": "^0.5.12",
|
||||||
"better-sqlite3": "^11.5.0",
|
"better-sqlite3": "^11.5.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lumi-bot",
|
"name": "lumi-bot",
|
||||||
"version": "0.2.17",
|
"version": "0.2.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -2,6 +2,36 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"releases": [
|
"releases": [
|
||||||
|
{
|
||||||
|
"version": "0.2.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",
|
"version": "0.2.17",
|
||||||
"ref": "refs/tags/v0.2.17",
|
"ref": "refs/tags/v0.2.17",
|
||||||
|
|||||||
@ -22,6 +22,7 @@ const checks = [
|
|||||||
"plugins/lumi_ai_web_search/tests/verify.js",
|
"plugins/lumi_ai_web_search/tests/verify.js",
|
||||||
"scripts/verify-assistant-panels.js",
|
"scripts/verify-assistant-panels.js",
|
||||||
"scripts/verify-command-preview-confirmations.js",
|
"scripts/verify-command-preview-confirmations.js",
|
||||||
|
"scripts/verify-command-policies.js",
|
||||||
"scripts/verify-destructive-actions.js",
|
"scripts/verify-destructive-actions.js",
|
||||||
"scripts/verify-overlays.js",
|
"scripts/verify-overlays.js",
|
||||||
"scripts/verify-overlay-web-documents.js",
|
"scripts/verify-overlay-web-documents.js",
|
||||||
|
|||||||
95
scripts/verify-command-policies.js
Normal file
95
scripts/verify-command-policies.js
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
const assert = require("assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const root = path.join(__dirname, "..");
|
||||||
|
const sandbox = fs.mkdtempSync(path.join(root, ".tmp-lumi-command-policies-"));
|
||||||
|
const serviceDir = path.join(sandbox, "src", "services");
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
fs.cpSync(path.join(root, "src", "services"), serviceDir, { recursive: true });
|
||||||
|
const database = require(path.join(serviceDir, "db.js"));
|
||||||
|
database.migrate();
|
||||||
|
const conditional = require(path.join(serviceDir, "command-conditional.js"));
|
||||||
|
const policies = require(path.join(serviceDir, "command-policies.js"));
|
||||||
|
|
||||||
|
const replies = conditional.normalizeConditionalReplies([
|
||||||
|
{ keyword: "backseating", response: "Please avoid backseating." },
|
||||||
|
{ keyword: "spoilers", response: "No spoilers." }
|
||||||
|
]);
|
||||||
|
assert.strictEqual(conditional.findConditionalReply(replies, "backseating").match.response, "Please avoid backseating.");
|
||||||
|
const fuzzy = conditional.findConditionalReply(replies, "backseatng");
|
||||||
|
assert.strictEqual(fuzzy.match.keyword, "backseating");
|
||||||
|
assert.strictEqual(fuzzy.fuzzy, true);
|
||||||
|
assert.strictEqual(conditional.findConditionalReply(replies, "something unrelated").match, null);
|
||||||
|
|
||||||
|
const groupId = policies.createGroup({
|
||||||
|
name: "Verification group",
|
||||||
|
policy: {
|
||||||
|
window: { uses: 3, seconds: 30, scope: "global" },
|
||||||
|
requirement: { role: "subscriber", subscriberTier: 2 },
|
||||||
|
cost: { amount: 5 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const key = policies.commandKey("verification", "hello");
|
||||||
|
policies.saveCommandPolicy(key, {
|
||||||
|
window: { uses: 1, seconds: 60, scope: "user" },
|
||||||
|
requirement: false,
|
||||||
|
cost: { amount: 2 }
|
||||||
|
}, [groupId]);
|
||||||
|
const resolved = policies.resolvePolicy(key);
|
||||||
|
assert.strictEqual(resolved.windows.length, 1, "a direct rate limit must replace group limits");
|
||||||
|
assert.strictEqual(resolved.windows[0].value.uses, 1);
|
||||||
|
assert.strictEqual(resolved.requirements.length, 0, "a direct public setting must replace group roles");
|
||||||
|
assert.strictEqual(resolved.cost, 2, "a direct cost must replace the group cost");
|
||||||
|
|
||||||
|
const charges = [];
|
||||||
|
global.lumiFrameworks = {
|
||||||
|
economy: {
|
||||||
|
removeBalance(payload) { charges.push(["charge", payload]); return { ok: true }; },
|
||||||
|
addBalance(payload) { charges.push(["refund", payload]); return "refund-id"; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
platform: "discord",
|
||||||
|
trigger: "hello",
|
||||||
|
raw: "!hello",
|
||||||
|
user: { id: "verification-user", platformId: "platform-user" },
|
||||||
|
platformUser: { id: "platform-user" },
|
||||||
|
meta: {}
|
||||||
|
};
|
||||||
|
const first = await policies.beginCommandUse({ commandKey: key, ctx });
|
||||||
|
assert.strictEqual(first.allowed, true);
|
||||||
|
assert.strictEqual(charges[0][0], "charge");
|
||||||
|
const limited = await policies.beginCommandUse({ commandKey: key, ctx });
|
||||||
|
assert.strictEqual(limited.allowed, false, "the second use inside the window must be denied");
|
||||||
|
await first.rollback("verification");
|
||||||
|
assert.strictEqual(charges[1][0], "refund", "failed commands must refund their logged currency cost");
|
||||||
|
const afterRollback = await policies.beginCommandUse({ commandKey: key, ctx });
|
||||||
|
assert.strictEqual(afterRollback.allowed, true, "rollback must release the rate-limit reservation");
|
||||||
|
await afterRollback.commit();
|
||||||
|
|
||||||
|
const dbColumns = database.db.prepare("PRAGMA table_info(custom_commands)").all().map((column) => column.name);
|
||||||
|
assert(dbColumns.includes("conditional_replies_json"));
|
||||||
|
assert(dbColumns.includes("conditional_fuzzy"));
|
||||||
|
for (const table of ["command_groups", "command_group_members", "command_policies", "command_policy_uses", "command_stream_sessions"]) {
|
||||||
|
assert(database.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table), `missing ${table}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = fs.readFileSync(path.join(root, "src", "web", "server.js"), "utf8");
|
||||||
|
const view = fs.readFileSync(path.join(root, "src", "web", "views", "admin-command-policies.ejs"), "utf8");
|
||||||
|
const commandView = fs.readFileSync(path.join(root, "src", "web", "views", "admin-commands.ejs"), "utf8");
|
||||||
|
assert(server.includes('/admin/command-policies') && server.includes('commandPolicyFromBody'));
|
||||||
|
assert(view.includes("Command groups") && view.includes("Save command settings"));
|
||||||
|
assert(commandView.includes('value="conditional"') && commandView.includes("data-conditional-replies"));
|
||||||
|
|
||||||
|
database.db.close();
|
||||||
|
console.log("Command policies and conditional commands verified.");
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(sandbox, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@ -11,6 +11,7 @@ fs.mkdirSync(serviceDir, { recursive: true });
|
|||||||
for (const file of [
|
for (const file of [
|
||||||
"config.js",
|
"config.js",
|
||||||
"db.js",
|
"db.js",
|
||||||
|
"logger.js",
|
||||||
"settings.js",
|
"settings.js",
|
||||||
"web-events.js",
|
"web-events.js",
|
||||||
"overlay-secrets.js",
|
"overlay-secrets.js",
|
||||||
@ -332,6 +333,9 @@ try {
|
|||||||
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
|
assert(runtimeScript.includes("overlay:module-refresh") && runtimeScript.includes("checkWebsiteSource"));
|
||||||
assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
|
assert(runtimeScript.includes("overlay:chat-message") && runtimeScript.includes("handleChatMessage"));
|
||||||
assert(runtimeScript.includes("window.obsstudio") && runtimeScript.includes("overlay:obs-browser-command"));
|
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("buildMedia") && rendererScript.includes("buildWebsite"));
|
||||||
assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts"));
|
assert(rendererScript.includes("buildChat") && rendererScript.includes("chatAccepts"));
|
||||||
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));
|
assert(rendererScript.includes("chatUserBlocked") && rendererScript.includes("/icons/platforms/"));
|
||||||
|
|||||||
@ -4,8 +4,8 @@ const path = require("path");
|
|||||||
const { findSafeTarget } = require("../src/services/versioning");
|
const { findSafeTarget } = require("../src/services/versioning");
|
||||||
|
|
||||||
const root = path.join(__dirname, "..");
|
const root = path.join(__dirname, "..");
|
||||||
const releaseVersion = "0.2.17";
|
const releaseVersion = "0.2.18";
|
||||||
const previousCoreVersion = "0.2.16";
|
const previousCoreVersion = "0.2.17";
|
||||||
const earliestCompatibleCoreVersion = "0.1.9";
|
const earliestCompatibleCoreVersion = "0.1.9";
|
||||||
const changedPlugins = {
|
const changedPlugins = {
|
||||||
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
|
"auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" },
|
||||||
@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
|
|||||||
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
|
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
|
||||||
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
|
||||||
|
|
||||||
console.log("Release metadata verification passed: core 0.2.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.");
|
||||||
|
|||||||
@ -16,7 +16,7 @@ function readJson(relativePath) {
|
|||||||
|
|
||||||
const releaseIndex = readJson("release-index.json");
|
const releaseIndex = readJson("release-index.json");
|
||||||
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
const releaseVersions = releaseIndex.releases.map((release) => release.version);
|
||||||
assert.deepEqual(releaseVersions, ["0.2.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");
|
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
|
||||||
for (const release of releaseIndex.releases) {
|
for (const release of releaseIndex.releases) {
|
||||||
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
|
||||||
@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
|
|||||||
const baseTarget = {
|
const baseTarget = {
|
||||||
current_version: "0.2.4",
|
current_version: "0.2.4",
|
||||||
available_versions: [
|
available_versions: [
|
||||||
|
{ version: "0.2.18", ref: "refs/tags/v0.2.18", rollback_safe: true },
|
||||||
{ version: "0.2.17", ref: "refs/tags/v0.2.17", rollback_safe: true },
|
{ version: "0.2.17", ref: "refs/tags/v0.2.17", rollback_safe: true },
|
||||||
{ version: "0.2.16", ref: "refs/tags/v0.2.16", rollback_safe: true },
|
{ version: "0.2.16", ref: "refs/tags/v0.2.16", rollback_safe: true },
|
||||||
{ version: "0.2.15", ref: "refs/tags/v0.2.15", 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"
|
channel: "stable"
|
||||||
});
|
});
|
||||||
assert.equal(corrected.version_correction, true);
|
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.update_available, true);
|
||||||
assert.equal(corrected.blocked, false);
|
assert.equal(corrected.blocked, false);
|
||||||
|
|
||||||
|
|||||||
@ -83,6 +83,7 @@ async function main() {
|
|||||||
|
|
||||||
const app = createWebServer({
|
const app = createWebServer({
|
||||||
discordClient,
|
discordClient,
|
||||||
|
commandRouter,
|
||||||
loadPlugins: (appInstance, web, webhooks) => {
|
loadPlugins: (appInstance, web, webhooks) => {
|
||||||
if (safeModeRequested) return;
|
if (safeModeRequested) return;
|
||||||
loadEnabled({
|
loadEnabled({
|
||||||
|
|||||||
111
src/services/command-conditional.js
Normal file
111
src/services/command-conditional.js
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
const MAX_CONDITIONAL_REPLIES = 100;
|
||||||
|
|
||||||
|
function normalizeConditionalKey(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConditionalReplies(value) {
|
||||||
|
let input = value;
|
||||||
|
if (typeof input === "string") {
|
||||||
|
try { input = JSON.parse(input); } catch { input = []; }
|
||||||
|
}
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
const seen = new Set();
|
||||||
|
return input.slice(0, MAX_CONDITIONAL_REPLIES).flatMap((entry) => {
|
||||||
|
const keyword = String(entry?.keyword || "").trim().slice(0, 100);
|
||||||
|
const response = String(entry?.response || "").trim().slice(0, 4000);
|
||||||
|
const normalized = normalizeConditionalKey(keyword);
|
||||||
|
if (!normalized || !response || seen.has(normalized)) return [];
|
||||||
|
seen.add(normalized);
|
||||||
|
return [{ keyword, normalized, response }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function conditionalRepliesFromBody(body = {}) {
|
||||||
|
const keywords = asArray(body.conditional_keyword);
|
||||||
|
const responses = asArray(body.conditional_response);
|
||||||
|
const replies = [];
|
||||||
|
const errors = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const count = Math.max(keywords.length, responses.length);
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const keyword = String(keywords[index] || "").trim();
|
||||||
|
const response = String(responses[index] || "").trim();
|
||||||
|
if (!keyword && !response) continue;
|
||||||
|
if (!keyword || !response) {
|
||||||
|
errors.push("Each conditional reply needs both a keyword and a response.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalized = normalizeConditionalKey(keyword);
|
||||||
|
if (seen.has(normalized)) {
|
||||||
|
errors.push(`The keyword “${keyword}” is listed more than once.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(normalized);
|
||||||
|
replies.push({ keyword: keyword.slice(0, 100), response: response.slice(0, 4000) });
|
||||||
|
}
|
||||||
|
if (!replies.length) errors.push("Add at least one keyword reply.");
|
||||||
|
if (replies.length > MAX_CONDITIONAL_REPLIES) errors.push(`Use no more than ${MAX_CONDITIONAL_REPLIES} keyword replies.`);
|
||||||
|
return { ok: errors.length === 0, errors, replies: replies.slice(0, MAX_CONDITIONAL_REPLIES) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findConditionalReply(replies, argument, { fuzzy = true } = {}) {
|
||||||
|
const normalizedArgument = normalizeConditionalKey(argument);
|
||||||
|
if (!normalizedArgument) return { match: null, fuzzy: false };
|
||||||
|
const normalizedReplies = normalizeConditionalReplies(replies);
|
||||||
|
const exact = normalizedReplies.find((entry) => entry.normalized === normalizedArgument);
|
||||||
|
if (exact) return { match: exact, fuzzy: false };
|
||||||
|
if (!fuzzy) return { match: null, fuzzy: false };
|
||||||
|
|
||||||
|
const candidates = normalizedReplies
|
||||||
|
.map((entry) => ({ entry, score: similarity(normalizedArgument, entry.normalized) }))
|
||||||
|
.sort((left, right) => right.score - left.score);
|
||||||
|
const best = candidates[0];
|
||||||
|
const runnerUp = candidates[1];
|
||||||
|
const minimum = normalizedArgument.length <= 4 ? 0.82 : 0.68;
|
||||||
|
if (!best || best.score < minimum || (runnerUp && best.score - runnerUp.score < 0.08)) {
|
||||||
|
return { match: null, fuzzy: false };
|
||||||
|
}
|
||||||
|
return { match: best.entry, fuzzy: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function similarity(left, right) {
|
||||||
|
if (left === right) return 1;
|
||||||
|
const distance = levenshtein(left, right);
|
||||||
|
return 1 - distance / Math.max(left.length, right.length, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function levenshtein(left, right) {
|
||||||
|
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||||
|
for (let i = 1; i <= left.length; i += 1) {
|
||||||
|
let diagonal = previous[0];
|
||||||
|
previous[0] = i;
|
||||||
|
for (let j = 1; j <= right.length; j += 1) {
|
||||||
|
const above = previous[j];
|
||||||
|
previous[j] = Math.min(
|
||||||
|
previous[j] + 1,
|
||||||
|
previous[j - 1] + 1,
|
||||||
|
diagonal + (left[i - 1] === right[j - 1] ? 0 : 1)
|
||||||
|
);
|
||||||
|
diagonal = above;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return previous[right.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function asArray(value) {
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
return value === undefined || value === null ? [] : [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
conditionalRepliesFromBody,
|
||||||
|
findConditionalReply,
|
||||||
|
normalizeConditionalKey,
|
||||||
|
normalizeConditionalReplies
|
||||||
|
};
|
||||||
128
src/services/command-entitlements.js
Normal file
128
src/services/command-entitlements.js
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
const { getTwitchEntitlement } = require("./command-platform");
|
||||||
|
|
||||||
|
const REQUIREMENTS = ["public", "follower", "subscriber", "vip", "mod", "editor", "streamer"];
|
||||||
|
|
||||||
|
async function checkCommandRequirement(ctx, requirement = {}) {
|
||||||
|
const role = REQUIREMENTS.includes(requirement.role) ? requirement.role : "public";
|
||||||
|
if (role === "public") return { allowed: true };
|
||||||
|
const flags = localRoleFlags(ctx);
|
||||||
|
if (flags.streamer) return { allowed: true };
|
||||||
|
if (role === "streamer") return denied(role);
|
||||||
|
if (role === "editor") {
|
||||||
|
if (flags.editor) return { allowed: true };
|
||||||
|
return checkRemoteTwitch(ctx, role, requirement);
|
||||||
|
}
|
||||||
|
if (role === "mod") return flags.mod || flags.editor ? { allowed: true } : denied(role);
|
||||||
|
if (role === "vip") return flags.vip || flags.mod || flags.editor ? { allowed: true } : denied(role);
|
||||||
|
if (role === "subscriber") {
|
||||||
|
const minimumTier = Math.max(1, Math.min(3, Number(requirement.subscriberTier) || 1));
|
||||||
|
if ((flags.subscriberTier || 0) >= minimumTier || flags.vip || flags.mod || flags.editor) return { allowed: true };
|
||||||
|
return checkRemoteTwitch(ctx, role, { subscriberTier: minimumTier });
|
||||||
|
}
|
||||||
|
if (role === "follower") {
|
||||||
|
if (flags.follower || flags.subscriberTier || flags.vip || flags.mod || flags.editor) return { allowed: true };
|
||||||
|
return checkRemoteTwitch(ctx, role, requirement);
|
||||||
|
}
|
||||||
|
return denied(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localRoleFlags(ctx) {
|
||||||
|
if (ctx.platform === "twitch") {
|
||||||
|
const tags = ctx.meta?.tags || {};
|
||||||
|
const badges = tags.badges || {};
|
||||||
|
const streamer = Boolean(badges.broadcaster) || sameTwitchChannel(ctx);
|
||||||
|
return {
|
||||||
|
streamer,
|
||||||
|
editor: false,
|
||||||
|
mod: streamer || Boolean(tags.mod || badges.moderator),
|
||||||
|
vip: Boolean(badges.vip),
|
||||||
|
subscriberTier: tags.subscriber || badges.subscriber ? 1 : 0,
|
||||||
|
follower: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ctx.platform === "youtube") {
|
||||||
|
const author = ctx.meta?.author || {};
|
||||||
|
const streamer = Boolean(author.isChatOwner);
|
||||||
|
return {
|
||||||
|
streamer,
|
||||||
|
editor: streamer,
|
||||||
|
mod: streamer || Boolean(author.isChatModerator),
|
||||||
|
vip: false,
|
||||||
|
subscriberTier: author.isChatSponsor ? 1 : 0,
|
||||||
|
follower: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ctx.platform === "discord") {
|
||||||
|
const message = ctx.meta?.message;
|
||||||
|
const member = message?.member;
|
||||||
|
const guild = message?.guild;
|
||||||
|
const roleNames = member?.roles?.cache
|
||||||
|
? Array.from(member.roles.cache.values()).map((role) => String(role.name || "").toLowerCase())
|
||||||
|
: [];
|
||||||
|
const permissions = member?.permissions;
|
||||||
|
const has = (permission) => {
|
||||||
|
try { return Boolean(permissions?.has?.(permission)); } catch { return false; }
|
||||||
|
};
|
||||||
|
const streamer = Boolean(guild?.ownerId && guild.ownerId === message?.author?.id);
|
||||||
|
const administrator = has("ADMINISTRATOR") || has(8n);
|
||||||
|
return {
|
||||||
|
streamer,
|
||||||
|
editor: streamer || administrator || has("MANAGE_GUILD"),
|
||||||
|
mod: streamer || administrator || has("MODERATE_MEMBERS") || has("MANAGE_MESSAGES"),
|
||||||
|
vip: roleNames.some((name) => name === "vip"),
|
||||||
|
subscriberTier: roleNames.some((name) => /subscriber|supporter|member/.test(name)) ? 1 : 0,
|
||||||
|
follower: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { streamer: false, editor: false, mod: false, vip: false, subscriberTier: 0, follower: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRemoteTwitch(ctx, role, requirement) {
|
||||||
|
if (ctx.platform !== "twitch") return denied(role, true);
|
||||||
|
try {
|
||||||
|
const allowed = await getTwitchEntitlement(ctx, role, requirement.subscriberTier);
|
||||||
|
return allowed ? { allowed: true } : denied(role);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `Lumi could not verify the required ${labelRequirement(role, requirement.subscriberTier)} status. An admin may need to reconnect Twitch with the matching permission.`,
|
||||||
|
verificationError: error?.message || "Verification failed."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function denied(role, unsupported = false) {
|
||||||
|
const status = labelRequirement(role);
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: unsupported
|
||||||
|
? `${status} access cannot be verified on this platform.`
|
||||||
|
: `This command is limited to ${status}.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelRequirement(role, subscriberTier) {
|
||||||
|
if (role === "public") return "everyone";
|
||||||
|
if (role === "subscriber" && Number(subscriberTier) > 1) return `Tier ${subscriberTier}+ subscribers`;
|
||||||
|
return ({
|
||||||
|
follower: "followers",
|
||||||
|
subscriber: "subscribers or members",
|
||||||
|
vip: "VIPs",
|
||||||
|
mod: "moderators",
|
||||||
|
editor: "editors",
|
||||||
|
streamer: "the streamer"
|
||||||
|
})[role] || role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameTwitchChannel(ctx) {
|
||||||
|
const channel = String(ctx.meta?.channel || "").replace(/^#/, "").toLowerCase();
|
||||||
|
const username = String(ctx.platformUser?.username || "").toLowerCase();
|
||||||
|
return Boolean(channel && username && channel === username);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
REQUIREMENTS,
|
||||||
|
checkCommandRequirement,
|
||||||
|
labelRequirement,
|
||||||
|
localRoleFlags
|
||||||
|
};
|
||||||
144
src/services/command-platform.js
Normal file
144
src/services/command-platform.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
const { getSetting } = require("./settings");
|
||||||
|
const { createLogger } = require("./logger");
|
||||||
|
|
||||||
|
const platformLog = createLogger("core:command-platform", { category: "integration" });
|
||||||
|
const entitlementCache = new Map();
|
||||||
|
|
||||||
|
async function createPlatformClip(ctx) {
|
||||||
|
if (ctx.platform !== "twitch") {
|
||||||
|
return { ok: false, unsupported: true, message: `${platformLabel(ctx.platform)} does not provide clip creation through Lumi.` };
|
||||||
|
}
|
||||||
|
const broadcasterId = twitchBroadcasterId(ctx);
|
||||||
|
if (!broadcasterId) return { ok: false, message: "Twitch did not provide the current channel ID." };
|
||||||
|
const payload = await twitchRequest("POST", "/helix/clips", { broadcaster_id: broadcasterId });
|
||||||
|
const clip = payload?.data?.[0];
|
||||||
|
if (!clip?.id) return { ok: false, message: "Twitch accepted the request but did not return a clip." };
|
||||||
|
platformLog.info("Twitch clip requested", { broadcaster_id: broadcasterId, clip_id: clip.id }, { event: "clip_created" });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
id: clip.id,
|
||||||
|
url: `https://clips.twitch.tv/${encodeURIComponent(clip.id)}`,
|
||||||
|
editUrl: clip.edit_url || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPlatformRaid(ctx, targetChannel) {
|
||||||
|
if (ctx.platform !== "twitch") {
|
||||||
|
return { ok: false, unsupported: true, message: `${platformLabel(ctx.platform)} does not provide raids through Lumi.` };
|
||||||
|
}
|
||||||
|
const fromBroadcasterId = twitchBroadcasterId(ctx);
|
||||||
|
if (!fromBroadcasterId) return { ok: false, message: "Twitch did not provide the current channel ID." };
|
||||||
|
const login = String(targetChannel || "").trim().replace(/^[@#]/, "").toLowerCase();
|
||||||
|
if (!login) return { ok: false, message: "Choose a channel to raid." };
|
||||||
|
const users = await twitchRequest("GET", "/helix/users", { login });
|
||||||
|
const target = users?.data?.[0];
|
||||||
|
if (!target?.id) return { ok: false, message: `Twitch channel “${login}” was not found.` };
|
||||||
|
if (target.id === fromBroadcasterId) return { ok: false, message: "A channel cannot raid itself." };
|
||||||
|
const payload = await twitchRequest("POST", "/helix/raids", {
|
||||||
|
from_broadcaster_id: fromBroadcasterId,
|
||||||
|
to_broadcaster_id: target.id
|
||||||
|
});
|
||||||
|
platformLog.info("Twitch raid started", { from_broadcaster_id: fromBroadcasterId, to_broadcaster_id: target.id }, { event: "raid_started" });
|
||||||
|
return { ok: true, target: target.display_name || target.login || login, viewers: payload?.data?.[0]?.viewer_count ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelPlatformRaid(ctx) {
|
||||||
|
if (ctx.platform !== "twitch") {
|
||||||
|
return { ok: false, unsupported: true, message: `${platformLabel(ctx.platform)} does not provide raids through Lumi.` };
|
||||||
|
}
|
||||||
|
const broadcasterId = twitchBroadcasterId(ctx);
|
||||||
|
if (!broadcasterId) return { ok: false, message: "Twitch did not provide the current channel ID." };
|
||||||
|
await twitchRequest("DELETE", "/helix/raids", { broadcaster_id: broadcasterId });
|
||||||
|
platformLog.info("Twitch raid cancelled", { broadcaster_id: broadcasterId }, { event: "raid_cancelled" });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProviderStreamIdentity(ctx) {
|
||||||
|
if (ctx.platform === "youtube") {
|
||||||
|
const id = String(ctx.meta?.activeVideoId || ctx.meta?.liveChatId || "");
|
||||||
|
const channelId = String(ctx.meta?.broadcasterChannelId || ctx.meta?.liveChatId || "");
|
||||||
|
return id && channelId ? { streamKey: `youtube:${channelId}`, providerStreamId: id } : null;
|
||||||
|
}
|
||||||
|
if (ctx.platform !== "twitch") return null;
|
||||||
|
const broadcasterId = twitchBroadcasterId(ctx);
|
||||||
|
if (!broadcasterId) return null;
|
||||||
|
try {
|
||||||
|
const payload = await twitchRequest("GET", "/helix/streams", { user_id: broadcasterId });
|
||||||
|
const stream = payload?.data?.[0];
|
||||||
|
return {
|
||||||
|
streamKey: `twitch:${broadcasterId}`,
|
||||||
|
providerStreamId: stream?.id || null,
|
||||||
|
live: Boolean(stream?.id)
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
platformLog.warn("Could not resolve Twitch stream identity", { error, broadcaster_id: broadcasterId }, { event: "stream_identity_failed" });
|
||||||
|
return { streamKey: `twitch:${broadcasterId}`, providerStreamId: null, live: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTwitchEntitlement(ctx, role, minimumTier = 1) {
|
||||||
|
if (ctx.platform !== "twitch") return false;
|
||||||
|
const broadcasterId = twitchBroadcasterId(ctx);
|
||||||
|
const userId = String(ctx.platformUser?.id || ctx.user?.platformId || "");
|
||||||
|
if (!broadcasterId || !userId) return false;
|
||||||
|
const key = `${role}:${broadcasterId}:${userId}:${minimumTier}`;
|
||||||
|
const cached = entitlementCache.get(key);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||||
|
let value = false;
|
||||||
|
if (role === "follower") {
|
||||||
|
const result = await twitchRequest("GET", "/helix/channels/followers", { broadcaster_id: broadcasterId, user_id: userId });
|
||||||
|
value = Boolean(result?.data?.length);
|
||||||
|
} else if (role === "subscriber") {
|
||||||
|
const result = await twitchRequest("GET", "/helix/subscriptions", { broadcaster_id: broadcasterId, user_id: userId });
|
||||||
|
const tier = Number(result?.data?.[0]?.tier || 0) / 1000;
|
||||||
|
value = tier >= Math.max(1, Number(minimumTier) || 1);
|
||||||
|
} else if (role === "editor") {
|
||||||
|
const result = await twitchRequest("GET", "/helix/channels/editors", { broadcaster_id: broadcasterId, first: 100 });
|
||||||
|
value = Boolean(result?.data?.some((entry) => String(entry.user_id) === userId));
|
||||||
|
}
|
||||||
|
entitlementCache.set(key, { value, expiresAt: Date.now() + 60_000 });
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function twitchRequest(method, pathname, query = {}) {
|
||||||
|
const clientId = String(getSetting("twitch_client_id", "") || "").trim();
|
||||||
|
const token = normalizeOauthToken(getSetting("twitch_bot_oauth", ""));
|
||||||
|
if (!clientId || !token) throw new Error("Twitch API credentials are not configured.");
|
||||||
|
const url = new URL(`https://api.twitch.tv${pathname}`);
|
||||||
|
Object.entries(query).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { "Client-Id": clientId, Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const body = response.status === 204 ? null : await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = String(body?.message || body?.error || response.statusText || "Twitch request failed").slice(0, 300);
|
||||||
|
const error = new Error(detail);
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function twitchBroadcasterId(ctx) {
|
||||||
|
return String(ctx.meta?.tags?.["room-id"] || "").trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOauthToken(value) {
|
||||||
|
return String(value || "").trim().replace(/^oauth:/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformLabel(platform) {
|
||||||
|
return ({ discord: "Discord", twitch: "Twitch", youtube: "YouTube" })[platform] || "This platform";
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
cancelPlatformRaid,
|
||||||
|
createPlatformClip,
|
||||||
|
getProviderStreamIdentity,
|
||||||
|
getTwitchEntitlement,
|
||||||
|
startPlatformRaid,
|
||||||
|
twitchRequest
|
||||||
|
};
|
||||||
331
src/services/command-policies.js
Normal file
331
src/services/command-policies.js
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
|
const { db } = require("./db");
|
||||||
|
const { checkCommandRequirement, labelRequirement } = require("./command-entitlements");
|
||||||
|
const { getProviderStreamIdentity } = require("./command-platform");
|
||||||
|
const { createLogger } = require("./logger");
|
||||||
|
|
||||||
|
const policyLog = createLogger("core:command-policies", { category: "command" });
|
||||||
|
const STREAM_RESTART_TOLERANCE_MS = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function commandKey(pluginId, commandId) {
|
||||||
|
return `premade:${String(pluginId || "core")}:${String(commandId || "command")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function customCommandKey(id) {
|
||||||
|
return `custom:${String(id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listGroups() {
|
||||||
|
return db.prepare("SELECT * FROM command_groups ORDER BY name COLLATE NOCASE").all().map((row) => ({
|
||||||
|
...row,
|
||||||
|
policy: parsePolicy(row.policy_json)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGroup({ name, description = "", policy = {} }) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
db.prepare("INSERT INTO command_groups (id, name, description, policy_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||||
|
.run(id, cleanName(name), String(description || "").trim().slice(0, 500), JSON.stringify(normalizeStoredPolicy(policy)), now, now);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGroup(id, { name, description = "", policy = {} }) {
|
||||||
|
db.prepare("UPDATE command_groups SET name = ?, description = ?, policy_json = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.run(cleanName(name), String(description || "").trim().slice(0, 500), JSON.stringify(normalizeStoredPolicy(policy)), Date.now(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteGroup(id) {
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare("DELETE FROM command_group_members WHERE group_id = ?").run(id);
|
||||||
|
db.prepare("DELETE FROM command_groups WHERE id = ?").run(id);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommandPolicy(commandKeyValue) {
|
||||||
|
const row = db.prepare("SELECT policy_json FROM command_policies WHERE command_key = ?").get(commandKeyValue);
|
||||||
|
const groups = db.prepare(
|
||||||
|
"SELECT g.* FROM command_groups g JOIN command_group_members m ON m.group_id = g.id WHERE m.command_key = ? ORDER BY g.name COLLATE NOCASE"
|
||||||
|
).all(commandKeyValue).map((group) => ({ ...group, policy: parsePolicy(group.policy_json) }));
|
||||||
|
return { policy: row ? parsePolicy(row.policy_json) : {}, groups };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCommandPolicy(commandKeyValue, policy, groupIds = []) {
|
||||||
|
const normalized = normalizeStoredPolicy(policy);
|
||||||
|
const now = Date.now();
|
||||||
|
db.transaction(() => {
|
||||||
|
if (Object.keys(normalized).length) {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO command_policies (command_key, policy_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(command_key) DO UPDATE SET policy_json = excluded.policy_json, updated_at = excluded.updated_at"
|
||||||
|
).run(commandKeyValue, JSON.stringify(normalized), now);
|
||||||
|
} else {
|
||||||
|
db.prepare("DELETE FROM command_policies WHERE command_key = ?").run(commandKeyValue);
|
||||||
|
}
|
||||||
|
db.prepare("DELETE FROM command_group_members WHERE command_key = ?").run(commandKeyValue);
|
||||||
|
const insert = db.prepare("INSERT OR IGNORE INTO command_group_members (group_id, command_key, created_at) VALUES (?, ?, ?)");
|
||||||
|
for (const groupId of [...new Set(groupIds.map(String))]) insert.run(groupId, commandKeyValue, now);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePolicy(commandKeyValue, defaultPolicy = {}) {
|
||||||
|
const stored = getCommandPolicy(commandKeyValue);
|
||||||
|
const direct = stored.policy;
|
||||||
|
const groupPolicies = stored.groups.map((group) => ({ id: group.id, name: group.name, policy: group.policy }));
|
||||||
|
const defaults = normalizeStoredPolicy(defaultPolicy);
|
||||||
|
return {
|
||||||
|
windows: resolveRules(commandKeyValue, "window", direct, groupPolicies, defaults),
|
||||||
|
perStreams: resolveRules(commandKeyValue, "perStream", direct, groupPolicies, defaults),
|
||||||
|
requirements: resolveRequirements(direct, groupPolicies, defaults),
|
||||||
|
cost: resolveCost(direct, groupPolicies, defaults),
|
||||||
|
direct,
|
||||||
|
groups: stored.groups
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function beginCommandUse({ commandKey: key, ctx, defaultPolicy = {} }) {
|
||||||
|
const policy = resolvePolicy(key, defaultPolicy);
|
||||||
|
for (const requirement of policy.requirements) {
|
||||||
|
const checked = await checkCommandRequirement(ctx, requirement.value);
|
||||||
|
if (!checked.allowed) {
|
||||||
|
policyLog.info("Command requirement denied", {
|
||||||
|
command_key: key,
|
||||||
|
user_id: ctx.user?.id,
|
||||||
|
platform: ctx.platform,
|
||||||
|
requirement: requirement.value?.role,
|
||||||
|
source: requirement.source,
|
||||||
|
verification_error: checked.verificationError || null
|
||||||
|
}, { event: "command_requirement_denied" });
|
||||||
|
return { allowed: false, message: checked.reason };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let streamSessionId = null;
|
||||||
|
if (policy.perStreams.length) {
|
||||||
|
streamSessionId = await resolveStreamSession(ctx);
|
||||||
|
if (!streamSessionId) {
|
||||||
|
return { allowed: false, message: "Lumi cannot identify the current stream, so this stream-limited command cannot be used right now." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageId = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
const userId = String(ctx.user?.id || "");
|
||||||
|
let limitMessage = null;
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare("DELETE FROM command_policy_uses WHERE used_at < ?").run(now - 45 * 24 * 60 * 60 * 1000);
|
||||||
|
for (const rule of policy.windows) {
|
||||||
|
const scopedUser = rule.value.scope === "global" ? null : userId;
|
||||||
|
const count = db.prepare(
|
||||||
|
"SELECT COUNT(*) AS count FROM command_policy_uses WHERE rule_key = ? AND user_id IS ? AND used_at >= ?"
|
||||||
|
).get(rule.key, scopedUser, now - rule.value.seconds * 1000)?.count || 0;
|
||||||
|
if (count >= rule.value.uses) {
|
||||||
|
limitMessage = rule.value.scope === "global"
|
||||||
|
? `That command has reached its shared limit. Try again in up to ${formatDuration(rule.value.seconds)}.`
|
||||||
|
: `You have reached this command's limit. Try again in up to ${formatDuration(rule.value.seconds)}.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const rule of policy.perStreams) {
|
||||||
|
const scopedUser = rule.value.scope === "global" ? null : userId;
|
||||||
|
const count = db.prepare(
|
||||||
|
"SELECT COUNT(*) AS count FROM command_policy_uses WHERE rule_key = ? AND stream_session_id = ? AND user_id IS ?"
|
||||||
|
).get(rule.key, streamSessionId, scopedUser)?.count || 0;
|
||||||
|
if (count >= rule.value.uses) {
|
||||||
|
limitMessage = rule.value.scope === "global"
|
||||||
|
? "That command has reached its limit for this stream."
|
||||||
|
: "You have reached this command's limit for this stream.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const insert = db.prepare(
|
||||||
|
"INSERT INTO command_policy_uses (id, command_key, rule_key, user_id, stream_session_id, used_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
|
for (const rule of policy.windows) {
|
||||||
|
insert.run(usageId, key, rule.key, rule.value.scope === "global" ? null : userId, null, now);
|
||||||
|
}
|
||||||
|
for (const rule of policy.perStreams) {
|
||||||
|
insert.run(usageId, key, rule.key, rule.value.scope === "global" ? null : userId, streamSessionId, now);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (limitMessage) return { allowed: false, message: limitMessage };
|
||||||
|
|
||||||
|
let charged = false;
|
||||||
|
if (policy.cost > 0) {
|
||||||
|
const economy = global.lumiFrameworks?.economy;
|
||||||
|
if (!economy?.removeBalance) {
|
||||||
|
releaseUsage(usageId);
|
||||||
|
return { allowed: false, message: "This command has a currency cost, but the Economy Framework is not available." };
|
||||||
|
}
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = economy.removeBalance({
|
||||||
|
userId,
|
||||||
|
amount: policy.cost,
|
||||||
|
note: `Command cost: ${ctx.raw || key}`,
|
||||||
|
meta: { source: "command_cost", commandKey: key, trigger: ctx.trigger, platform: ctx.platform }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
releaseUsage(usageId);
|
||||||
|
policyLog.error("Command cost charge failed", { command_key: key, user_id: userId, amount: policy.cost, error }, { event: "command_cost_failed" });
|
||||||
|
return { allowed: false, message: "The command cost could not be charged." };
|
||||||
|
}
|
||||||
|
if (!result?.ok) {
|
||||||
|
releaseUsage(usageId);
|
||||||
|
return { allowed: false, message: result?.message === "Insufficient balance."
|
||||||
|
? `You need ${policy.cost} currency to use this command.`
|
||||||
|
: (result?.message || "The command cost could not be charged.") };
|
||||||
|
}
|
||||||
|
charged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finished = false;
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
async commit() { finished = true; },
|
||||||
|
async rollback(reason = "Command did not complete") {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
releaseUsage(usageId);
|
||||||
|
if (charged) refundCommandCost({ userId, amount: policy.cost, key, ctx, reason });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseUsage(usageId) {
|
||||||
|
db.prepare("DELETE FROM command_policy_uses WHERE id = ?").run(usageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refundCommandCost({ userId, amount, key, ctx, reason }) {
|
||||||
|
try {
|
||||||
|
global.lumiFrameworks?.economy?.addBalance?.({
|
||||||
|
userId,
|
||||||
|
amount,
|
||||||
|
note: `Command cost refund: ${ctx.raw || key}`,
|
||||||
|
meta: { source: "command_cost_refund", commandKey: key, reason }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
policyLog.error("Command cost refund failed", { command_key: key, user_id: userId, amount, error }, { event: "command_cost_refund_failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveStreamSession(ctx) {
|
||||||
|
const identity = await getProviderStreamIdentity(ctx);
|
||||||
|
if (!identity?.streamKey) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = db.prepare("SELECT * FROM command_stream_sessions WHERE stream_key = ?").get(identity.streamKey);
|
||||||
|
const sameProviderStream = existing && identity.providerStreamId && existing.provider_stream_id === identity.providerStreamId;
|
||||||
|
const withinRestartTolerance = existing && now - existing.last_seen_at <= STREAM_RESTART_TOLERANCE_MS;
|
||||||
|
const sessionId = sameProviderStream || withinRestartTolerance ? existing.session_id : crypto.randomUUID();
|
||||||
|
const startedAt = sessionId === existing?.session_id ? existing.started_at : now;
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO command_stream_sessions (stream_key, session_id, provider_stream_id, started_at, last_seen_at) VALUES (?, ?, ?, ?, ?) " +
|
||||||
|
"ON CONFLICT(stream_key) DO UPDATE SET session_id = excluded.session_id, provider_stream_id = excluded.provider_stream_id, started_at = excluded.started_at, last_seen_at = excluded.last_seen_at"
|
||||||
|
).run(identity.streamKey, sessionId, identity.providerStreamId || existing?.provider_stream_id || null, startedAt, now);
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRules(commandKeyValue, category, direct, groups, defaults) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(direct, category)) {
|
||||||
|
return direct[category] === false ? [] : [{ key: `${commandKeyValue}:${category}`, source: "command", value: direct[category] }];
|
||||||
|
}
|
||||||
|
const rules = groups.flatMap((group) => {
|
||||||
|
const value = group.policy[category];
|
||||||
|
return value && value !== false ? [{ key: `group:${group.id}:${category}`, source: group.name, value }] : [];
|
||||||
|
});
|
||||||
|
if (rules.length) return rules;
|
||||||
|
const fallback = defaults[category];
|
||||||
|
return fallback && fallback !== false ? [{ key: `${commandKeyValue}:default:${category}`, source: "default", value: fallback }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequirements(direct, groups, defaults) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(direct, "requirement")) {
|
||||||
|
return direct.requirement === false ? [] : [{ source: "command", value: direct.requirement }];
|
||||||
|
}
|
||||||
|
const values = groups.flatMap((group) => group.policy.requirement && group.policy.requirement !== false
|
||||||
|
? [{ source: group.name, value: group.policy.requirement }]
|
||||||
|
: []);
|
||||||
|
if (values.length) return values;
|
||||||
|
return defaults.requirement && defaults.requirement !== false ? [{ source: "default", value: defaults.requirement }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCost(direct, groups, defaults) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(direct, "cost")) return direct.cost === false ? 0 : Number(direct.cost?.amount || 0);
|
||||||
|
const costs = groups.map((group) => Number(group.policy.cost?.amount || 0)).filter((amount) => amount > 0);
|
||||||
|
if (costs.length) return Math.max(...costs);
|
||||||
|
return Number(defaults.cost?.amount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStoredPolicy(value) {
|
||||||
|
const input = value && typeof value === "object" ? value : {};
|
||||||
|
const output = {};
|
||||||
|
if (Object.prototype.hasOwnProperty.call(input, "window")) output.window = normalizeWindow(input.window);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(input, "perStream")) output.perStream = normalizePerStream(input.perStream);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(input, "requirement")) output.requirement = normalizeRequirement(input.requirement);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(input, "cost")) output.cost = normalizeCost(input.cost);
|
||||||
|
return Object.fromEntries(Object.entries(output).filter(([, item]) => item !== undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWindow(value) {
|
||||||
|
if (value === false || value?.enabled === false) return false;
|
||||||
|
if (!value || typeof value !== "object") return undefined;
|
||||||
|
return {
|
||||||
|
uses: clampInteger(value.uses, 1, 100000),
|
||||||
|
seconds: clampInteger(value.seconds, 1, 31_536_000),
|
||||||
|
scope: value.scope === "global" ? "global" : "user"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePerStream(value) {
|
||||||
|
if (value === false || value?.enabled === false) return false;
|
||||||
|
if (!value || typeof value !== "object") return undefined;
|
||||||
|
return { uses: clampInteger(value.uses, 1, 100000), scope: value.scope === "global" ? "global" : "user" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequirement(value) {
|
||||||
|
if (value === false || value?.enabled === false || value?.role === "public") return false;
|
||||||
|
const role = ["follower", "subscriber", "vip", "mod", "editor", "streamer"].includes(value?.role) ? value.role : null;
|
||||||
|
if (!role) return undefined;
|
||||||
|
return { role, subscriberTier: role === "subscriber" ? clampInteger(value.subscriberTier, 1, 3) : 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCost(value) {
|
||||||
|
if (value === false || value?.enabled === false || Number(value?.amount) <= 0) return false;
|
||||||
|
if (!value || typeof value !== "object") return undefined;
|
||||||
|
return { amount: clampInteger(value.amount, 1, 1_000_000_000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePolicy(value) {
|
||||||
|
try { return normalizeStoredPolicy(JSON.parse(value || "{}")); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanName(value) {
|
||||||
|
const name = String(value || "").trim().slice(0, 100);
|
||||||
|
if (!name) throw new Error("Group name is required.");
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInteger(value, minimum, maximum) {
|
||||||
|
return Math.min(maximum, Math.max(minimum, Math.round(Number(value) || minimum)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
|
||||||
|
const minutes = Math.ceil(seconds / 60);
|
||||||
|
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
STREAM_RESTART_TOLERANCE_MS,
|
||||||
|
beginCommandUse,
|
||||||
|
commandKey,
|
||||||
|
createGroup,
|
||||||
|
customCommandKey,
|
||||||
|
deleteGroup,
|
||||||
|
getCommandPolicy,
|
||||||
|
labelRequirement,
|
||||||
|
listGroups,
|
||||||
|
normalizeStoredPolicy,
|
||||||
|
resolvePolicy,
|
||||||
|
saveCommandPolicy,
|
||||||
|
updateGroup
|
||||||
|
};
|
||||||
@ -6,6 +6,8 @@ const {
|
|||||||
normalizeCommandResult
|
normalizeCommandResult
|
||||||
} = require("./commands");
|
} = require("./commands");
|
||||||
const { normalizeRandomReplies, selectRandomReply } = require("./command-random");
|
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 { getEnabledPlatformIds, normalizePlatformSelection } = require("./platforms");
|
||||||
const placeholders = require("./placeholders");
|
const placeholders = require("./placeholders");
|
||||||
const { createLogger } = require("./logger");
|
const { createLogger } = require("./logger");
|
||||||
@ -15,6 +17,7 @@ const commandLog = createLogger("core:commands", { category: "command" });
|
|||||||
function createCommandRouter({ settings }) {
|
function createCommandRouter({ settings }) {
|
||||||
const commandMap = new Map();
|
const commandMap = new Map();
|
||||||
const pluginCommands = new Map();
|
const pluginCommands = new Map();
|
||||||
|
const registry = new Map();
|
||||||
|
|
||||||
function clearCommands(pluginId) {
|
function clearCommands(pluginId) {
|
||||||
const existing = pluginCommands.get(pluginId) || [];
|
const existing = pluginCommands.get(pluginId) || [];
|
||||||
@ -28,6 +31,7 @@ function createCommandRouter({ settings }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pluginCommands.delete(pluginId);
|
pluginCommands.delete(pluginId);
|
||||||
|
registry.delete(pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerCommands(pluginId, commands = []) {
|
function registerCommands(pluginId, commands = []) {
|
||||||
@ -36,34 +40,47 @@ function createCommandRouter({ settings }) {
|
|||||||
}
|
}
|
||||||
clearCommands(pluginId);
|
clearCommands(pluginId);
|
||||||
const entries = [];
|
const entries = [];
|
||||||
|
const registered = [];
|
||||||
for (const command of commands) {
|
for (const command of commands) {
|
||||||
const triggers = (command.triggers || [])
|
const triggers = (command.triggers || [])
|
||||||
.map((trigger) => trigger.toLowerCase())
|
.map((trigger) => trigger.toLowerCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const handler = buildHandler(command);
|
const handler = buildHandler(pluginId, command, triggers);
|
||||||
for (const trigger of triggers) {
|
for (const trigger of triggers) {
|
||||||
const list = commandMap.get(trigger) || [];
|
const list = commandMap.get(trigger) || [];
|
||||||
list.push(handler);
|
list.push(handler);
|
||||||
commandMap.set(trigger, list);
|
commandMap.set(trigger, list);
|
||||||
entries.push({ trigger, handler });
|
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);
|
pluginCommands.set(pluginId, entries);
|
||||||
|
registry.set(pluginId, registered);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHandler(command) {
|
function buildHandler(pluginId, command, triggers) {
|
||||||
const handler = async (ctx) => {
|
const handler = async (ctx) => {
|
||||||
if (command.platforms && command.platforms.length) {
|
|
||||||
if (!command.platforms.includes(ctx.platform)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return await command.handler(ctx);
|
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;
|
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 }) {
|
async function handleMessage({ platform, raw, user, platformUser, reply, meta }) {
|
||||||
const prefix = settings.getSetting("command_prefix", "!");
|
const prefix = settings.getSetting("command_prefix", "!");
|
||||||
if (!raw.startsWith(prefix)) {
|
if (!raw.startsWith(prefix)) {
|
||||||
@ -114,10 +131,28 @@ function createCommandRouter({ settings }) {
|
|||||||
|
|
||||||
const handlers = commandMap.get(trigger) || [];
|
const handlers = commandMap.get(trigger) || [];
|
||||||
for (const handler of handlers) {
|
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 {
|
try {
|
||||||
const result = await handler(ctx);
|
const result = await handler(ctx);
|
||||||
if (typeof result === "string" && result) {
|
if (typeof result === "string" && result) {
|
||||||
await safeReply(reply, result);
|
await safeReply(reply, result);
|
||||||
|
await lease.commit();
|
||||||
recordCommandUsage(handler.commandId);
|
recordCommandUsage(handler.commandId);
|
||||||
incrementCommands(user.id);
|
incrementCommands(user.id);
|
||||||
commandLog.debug("Command completed", {
|
commandLog.debug("Command completed", {
|
||||||
@ -129,6 +164,7 @@ function createCommandRouter({ settings }) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (result === true) {
|
if (result === true) {
|
||||||
|
await lease.commit();
|
||||||
recordCommandUsage(handler.commandId);
|
recordCommandUsage(handler.commandId);
|
||||||
incrementCommands(user.id);
|
incrementCommands(user.id);
|
||||||
commandLog.debug("Command completed", {
|
commandLog.debug("Command completed", {
|
||||||
@ -139,7 +175,9 @@ function createCommandRouter({ settings }) {
|
|||||||
}, { event: "command_completed" });
|
}, { event: "command_completed" });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
await lease.rollback("Command handler did not accept the invocation.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await lease.rollback(error?.message || "Command handler failed.");
|
||||||
commandLog.error("Command handler failed", {
|
commandLog.error("Command handler failed", {
|
||||||
command_id: handler.commandId,
|
command_id: handler.commandId,
|
||||||
trigger,
|
trigger,
|
||||||
@ -158,14 +196,15 @@ function createCommandRouter({ settings }) {
|
|||||||
return {
|
return {
|
||||||
registerCommands,
|
registerCommands,
|
||||||
clearCommands,
|
clearCommands,
|
||||||
handleMessage
|
handleMessage,
|
||||||
|
listCommands
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
|
async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.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);
|
.get(trigger);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@ -176,6 +215,18 @@ async function handleCustomCommand({ trigger, platform, ctx, raw, reply }) {
|
|||||||
if (!allowedPlatforms.includes(platform)) {
|
if (!allowedPlatforms.includes(platform)) {
|
||||||
return false;
|
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 {
|
try {
|
||||||
if (row.mode === "advanced" && row.code) {
|
if (row.mode === "advanced" && row.code) {
|
||||||
const messageInfo = buildMessageInfo(ctx, raw);
|
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 } }
|
runtimeContext: { ctx, user: ctx.user, platform, runtime: true, command: { rng: selected.rng } }
|
||||||
});
|
});
|
||||||
await safeReply(reply, rendered.rendered);
|
await safeReply(reply, rendered.rendered);
|
||||||
} else {
|
} else if (row.mode === "conditional") {
|
||||||
const rendered = await placeholders.renderTemplate({
|
const replies = normalizeConditionalReplies(row.conditional_replies_json);
|
||||||
fieldId: "core.custom_commands.static_response",
|
const found = findConditionalReply(replies, ctx.argsText, { fuzzy: Boolean(row.conditional_fuzzy) });
|
||||||
template: row.response,
|
let template = found.match?.response || "";
|
||||||
outputAudience: "user",
|
if (!ctx.argsText) {
|
||||||
user: {
|
template = row.response || `Available options: ${replies.map((entry) => entry.keyword).join(", ")}`;
|
||||||
id: ctx.user.id,
|
} else if (!found.match) {
|
||||||
username: ctx.user.username,
|
template = `Unknown option. Available options: ${replies.map((entry) => entry.keyword).join(", ")}`;
|
||||||
isAdmin: false,
|
}
|
||||||
isMod: false
|
const rendered = await renderStaticResponse(template, ctx, platform, {
|
||||||
},
|
conditional: { requested: ctx.argsText, keyword: found.match?.keyword || null, fuzzy: found.fuzzy }
|
||||||
runtimeContext: { ctx, user: ctx.user, platform, runtime: true }
|
|
||||||
});
|
});
|
||||||
await safeReply(reply, rendered.rendered);
|
await safeReply(reply, rendered);
|
||||||
|
} else {
|
||||||
|
await safeReply(reply, await renderStaticResponse(row.response, ctx, platform));
|
||||||
}
|
}
|
||||||
recordCommandUsage(`custom:${trigger}`);
|
recordCommandUsage(`custom:${trigger}`);
|
||||||
|
await lease.commit();
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await lease.rollback(error?.message || "Custom command failed.");
|
||||||
commandLog.error("Custom command failed", {
|
commandLog.error("Custom command failed", {
|
||||||
trigger,
|
trigger,
|
||||||
platform,
|
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) {
|
function buildMessageInfo(ctx, raw) {
|
||||||
if (ctx.platform === "discord" && ctx.meta?.message) {
|
if (ctx.platform === "discord" && ctx.meta?.message) {
|
||||||
const message = ctx.meta.message;
|
const message = ctx.meta.message;
|
||||||
|
|||||||
@ -131,6 +131,8 @@ function migrate() {
|
|||||||
preview_generated_at INTEGER,
|
preview_generated_at INTEGER,
|
||||||
preview_dynamic_segments TEXT NOT NULL DEFAULT '[]',
|
preview_dynamic_segments TEXT NOT NULL DEFAULT '[]',
|
||||||
random_replies_json 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_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
rng_min INTEGER NOT NULL DEFAULT 1,
|
rng_min INTEGER NOT NULL DEFAULT 1,
|
||||||
rng_max INTEGER NOT NULL DEFAULT 100,
|
rng_max INTEGER NOT NULL DEFAULT 100,
|
||||||
@ -145,6 +147,52 @@ function migrate() {
|
|||||||
updated_at INTEGER NOT NULL
|
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 (
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
level TEXT NOT NULL,
|
level TEXT NOT NULL,
|
||||||
@ -356,6 +404,12 @@ function migrate() {
|
|||||||
if (!columns.includes("random_replies_json")) {
|
if (!columns.includes("random_replies_json")) {
|
||||||
db.exec("ALTER TABLE custom_commands ADD COLUMN random_replies_json TEXT NOT NULL DEFAULT '[]'");
|
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")) {
|
if (!columns.includes("rng_enabled")) {
|
||||||
db.exec("ALTER TABLE custom_commands ADD COLUMN rng_enabled INTEGER NOT NULL DEFAULT 0");
|
db.exec("ALTER TABLE custom_commands ADD COLUMN rng_enabled INTEGER NOT NULL DEFAULT 0");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ try {
|
|||||||
obsWebSocketLoadError = error;
|
obsWebSocketLoadError = error;
|
||||||
}
|
}
|
||||||
const { publishWebEvent } = require("./web-events");
|
const { publishWebEvent } = require("./web-events");
|
||||||
|
const { createLogger } = require("./logger");
|
||||||
const {
|
const {
|
||||||
getObsSettings,
|
getObsSettings,
|
||||||
getOverlay,
|
getOverlay,
|
||||||
@ -17,6 +18,7 @@ const {
|
|||||||
|
|
||||||
const providers = new Map();
|
const providers = new Map();
|
||||||
const browserBridgeConnectors = new Map();
|
const browserBridgeConnectors = new Map();
|
||||||
|
const connectorLog = createLogger("core:obs-connectors", { category: "integration" });
|
||||||
|
|
||||||
const OBS_BROWSER_CONTROL_LEVELS = [
|
const OBS_BROWSER_CONTROL_LEVELS = [
|
||||||
"No OBS access",
|
"No OBS access",
|
||||||
@ -171,6 +173,13 @@ class LocalObsWebSocketConnector {
|
|||||||
this.currentScene = sceneName;
|
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() {
|
async refreshSetupState() {
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
this.listScenes(),
|
this.listScenes(),
|
||||||
@ -353,6 +362,20 @@ class ObsBrowserBridgeConnector {
|
|||||||
if (!delivered) throw new Error("The OBS Browser Bridge is not listening for commands.");
|
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() {
|
status() {
|
||||||
return {
|
return {
|
||||||
state: this.state,
|
state: this.state,
|
||||||
@ -553,6 +576,28 @@ class OverlayConnectorManager {
|
|||||||
return this.status(overlayId, { includeScenes: true });
|
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 } = {}) {
|
async status(overlayId, { includeScenes = false } = {}) {
|
||||||
const entry = this.connections.get(overlayId);
|
const entry = this.connections.get(overlayId);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
|
|||||||
@ -195,10 +195,86 @@ function registerTopCommand({ commandRouter, settings }) {
|
|||||||
triggers: ["top"],
|
triggers: ["top"],
|
||||||
platforms,
|
platforms,
|
||||||
handler: (ctx) => handleTopCommand({ ctx, settings })
|
handler: (ctx) => handleTopCommand({ ctx, settings })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "clip",
|
||||||
|
triggers: ["clip"],
|
||||||
|
platforms,
|
||||||
|
description: "Create a platform clip and save the current OBS replay buffer.",
|
||||||
|
defaultPolicy: {
|
||||||
|
requirement: { role: "mod" },
|
||||||
|
window: { uses: 1, seconds: 10, scope: "global" }
|
||||||
|
},
|
||||||
|
handler: handleClipCommand
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "raid",
|
||||||
|
triggers: ["raid"],
|
||||||
|
platforms,
|
||||||
|
description: "Start or cancel a raid on the current streaming platform.",
|
||||||
|
defaultPolicy: {
|
||||||
|
requirement: { role: "streamer" },
|
||||||
|
window: { uses: 10, seconds: 600, scope: "global" }
|
||||||
|
},
|
||||||
|
handler: (ctx) => handleRaidCommand(ctx, settings)
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleClipCommand(ctx) {
|
||||||
|
const { createPlatformClip } = require("./command-platform");
|
||||||
|
const { overlayConnectorManager } = require("./overlay-connectors");
|
||||||
|
const requestedName = String(ctx.argsText || "").trim().slice(0, 80);
|
||||||
|
const [platformResult, obsResult] = await Promise.allSettled([
|
||||||
|
createPlatformClip(ctx),
|
||||||
|
overlayConnectorManager.saveReplay({ name: requestedName })
|
||||||
|
]);
|
||||||
|
const parts = [];
|
||||||
|
const clip = platformResult.status === "fulfilled" ? platformResult.value : null;
|
||||||
|
if (clip?.ok) {
|
||||||
|
parts.push(`Platform clip created: ${clip.url}`);
|
||||||
|
} else {
|
||||||
|
parts.push(`Platform clip: ${cleanCommandError(clip?.message || platformResult.reason?.message)}`);
|
||||||
|
}
|
||||||
|
if (obsResult.status === "fulfilled") {
|
||||||
|
parts.push("OBS replay save requested.");
|
||||||
|
} else {
|
||||||
|
parts.push(`OBS replay: ${cleanCommandError(obsResult.reason?.message)}`);
|
||||||
|
}
|
||||||
|
if (requestedName) parts.push(`Label: ${requestedName}.`);
|
||||||
|
await ctx.reply(parts.join(" "));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRaidCommand(ctx, settings) {
|
||||||
|
const { cancelPlatformRaid, startPlatformRaid } = require("./command-platform");
|
||||||
|
const target = String(ctx.args?.[0] || "").trim();
|
||||||
|
if (!target) {
|
||||||
|
const prefix = settings?.getSetting?.("command_prefix", "!") || "!";
|
||||||
|
await ctx.reply(`Usage: ${prefix}raid <channel> or ${prefix}raid cancel`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (target.toLowerCase() === "cancel") {
|
||||||
|
const result = await cancelPlatformRaid(ctx);
|
||||||
|
await ctx.reply(result.ok ? "The pending raid was cancelled." : cleanCommandError(result.message));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const result = await startPlatformRaid(ctx, target);
|
||||||
|
await ctx.reply(result.ok
|
||||||
|
? `Raid to ${result.target} started. Twitch will show its normal confirmation countdown.`
|
||||||
|
: cleanCommandError(result.message));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
await ctx.reply(`Raid failed: ${cleanCommandError(error?.message)}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanCommandError(value) {
|
||||||
|
return String(value || "not available").replace(/[\r\n]+/g, " ").slice(0, 140);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTopCommand({ ctx, settings }) {
|
async function handleTopCommand({ ctx, settings }) {
|
||||||
const prefix = settings.getSetting("command_prefix", "!");
|
const prefix = settings.getSetting("command_prefix", "!");
|
||||||
const rawId = (ctx.args[0] || "").trim().toLowerCase();
|
const rawId = (ctx.args[0] || "").trim().toLowerCase();
|
||||||
|
|||||||
@ -167,6 +167,8 @@ async function handleChatItem(state, liveChatId, item) {
|
|||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
liveChatId,
|
liveChatId,
|
||||||
|
activeVideoId: state.activeVideoId,
|
||||||
|
broadcasterChannelId: state.channelId,
|
||||||
messageId: item.id,
|
messageId: item.id,
|
||||||
snippet,
|
snippet,
|
||||||
author
|
author
|
||||||
|
|||||||
@ -1031,6 +1031,33 @@ input[type="color"] {
|
|||||||
align-items: end;
|
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 {
|
.command-rng-range {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(8rem, 1fr));
|
grid-template-columns: repeat(2, minmax(8rem, 1fr));
|
||||||
@ -1817,6 +1844,7 @@ details > summary:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.command-random-row,
|
.command-random-row,
|
||||||
|
.command-policy-grid,
|
||||||
.command-rng-range,
|
.command-rng-range,
|
||||||
.custom-placeholder-row {
|
.custom-placeholder-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@ -238,6 +238,9 @@
|
|||||||
if (payload.action === "set_current_scene" && typeof obsBridge.setCurrentScene === "function") {
|
if (payload.action === "set_current_scene" && typeof obsBridge.setCurrentScene === "function") {
|
||||||
obsBridge.setCurrentScene(String(payload.scene_name || ""));
|
obsBridge.setCurrentScene(String(payload.scene_name || ""));
|
||||||
scheduleObsBridgeReport(350);
|
scheduleObsBridgeReport(350);
|
||||||
|
} else if (payload.action === "save_replay_buffer" && typeof obsBridge.saveReplayBuffer === "function") {
|
||||||
|
obsBridge.saveReplayBuffer();
|
||||||
|
scheduleObsBridgeReport(350);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -77,6 +77,21 @@ const {
|
|||||||
} = require("../services/platforms");
|
} = require("../services/platforms");
|
||||||
const { getClient: getTwitchClient } = require("../services/twitch");
|
const { getClient: getTwitchClient } = require("../services/twitch");
|
||||||
const { getClient: getYouTubeClient } = require("../services/youtube");
|
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 {
|
const {
|
||||||
ensureUserForIdentity,
|
ensureUserForIdentity,
|
||||||
linkIdentityToUser,
|
linkIdentityToUser,
|
||||||
@ -689,6 +704,11 @@ const TWITCH_SCOPE_DEFS = [
|
|||||||
label: "Manage predictions",
|
label: "Manage predictions",
|
||||||
description: "Create and 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",
|
scope: "channel:manage:redemptions",
|
||||||
label: "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) {
|
function buildCustomCommandListPreview(command) {
|
||||||
if (command.mode === "advanced") {
|
if (command.mode === "advanced") {
|
||||||
return buildCommandPreviewPresentation(command);
|
return buildCommandPreviewPresentation(command);
|
||||||
@ -1313,6 +1350,18 @@ function buildCustomCommandListPreview(command) {
|
|||||||
parts: previewParts(text, [])
|
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 || "");
|
const text = String(command.response || "");
|
||||||
return {
|
return {
|
||||||
text,
|
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 = {}) {
|
function customPlaceholdersFromBody(body = {}) {
|
||||||
const array = (value) => Array.isArray(value) ? value : value === undefined ? [] : [value];
|
const array = (value) => Array.isArray(value) ? value : value === undefined ? [] : [value];
|
||||||
const names = array(body.custom_placeholder_name);
|
const names = array(body.custom_placeholder_name);
|
||||||
@ -2903,7 +2998,7 @@ async function verifyYouTubeSettings(settings) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWebServer({ loadPlugins, discordClient }) {
|
function createWebServer({ loadPlugins, discordClient, commandRouter }) {
|
||||||
const app = express();
|
const app = express();
|
||||||
// Only trust forwarding headers from a reverse proxy on this machine. This
|
// Only trust forwarding headers from a reverse proxy on this machine. This
|
||||||
// lets the diagnostics endpoint recognize HTTPS without trusting arbitrary
|
// lets the diagnostics endpoint recognize HTTPS without trusting arbitrary
|
||||||
@ -4661,7 +4756,7 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
|
|
||||||
const customCommands = db
|
const customCommands = db
|
||||||
.prepare(
|
.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();
|
.all();
|
||||||
const supportedPlatforms = getPlatformStatus()
|
const supportedPlatforms = getPlatformStatus()
|
||||||
@ -4675,6 +4770,7 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
const platforms = normalizeCustomPlatforms(row.platform, supportedPlatforms);
|
const platforms = normalizeCustomPlatforms(row.platform, supportedPlatforms);
|
||||||
const activePlatforms = platforms.filter((platform) => enabledPlatforms.includes(platform));
|
const activePlatforms = platforms.filter((platform) => enabledPlatforms.includes(platform));
|
||||||
const description = truncateText(row.description || "", 500);
|
const description = truncateText(row.description || "", 500);
|
||||||
|
const policyPresentation = commandPolicyPresentation(customCommandKey(row.id));
|
||||||
addCommand({
|
addCommand({
|
||||||
id: `custom:${trigger}`,
|
id: `custom:${trigger}`,
|
||||||
trigger,
|
trigger,
|
||||||
@ -4683,7 +4779,7 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
description,
|
description,
|
||||||
preview: buildCustomCommandListPreview(row),
|
preview: buildCustomCommandListPreview(row),
|
||||||
isCustom: true,
|
isCustom: true,
|
||||||
level: "public",
|
...policyPresentation,
|
||||||
origin: "Custom",
|
origin: "Custom",
|
||||||
platforms,
|
platforms,
|
||||||
activePlatforms,
|
activePlatforms,
|
||||||
@ -4733,6 +4829,25 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const registered of (commandRouter?.listCommands?.() || []).filter((command) => command.pluginId === "core" && command.id !== "top")) {
|
||||||
|
const trigger = registered.triggers[0];
|
||||||
|
if (!trigger) continue;
|
||||||
|
const policyPresentation = commandPolicyPresentation(registered.key, registered.defaultPolicy);
|
||||||
|
addCommand({
|
||||||
|
id: registered.id,
|
||||||
|
trigger,
|
||||||
|
triggerDisplay: `${prefix}${trigger}${registered.id === "raid" ? " <channel|cancel>" : registered.id === "clip" ? " [name]" : ""}`,
|
||||||
|
name: toTitleCase(registered.id),
|
||||||
|
description: registered.description,
|
||||||
|
...policyPresentation,
|
||||||
|
origin: "Core",
|
||||||
|
platforms: registered.platforms,
|
||||||
|
activePlatforms: registered.platforms.filter((platform) => enabledPlatforms.includes(platform)),
|
||||||
|
platformLabels: buildPlatformLabels(registered.platforms),
|
||||||
|
conflictTriggers: registered.triggers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const plugins = getPlugins().filter((plugin) => plugin.enabled);
|
const plugins = getPlugins().filter((plugin) => plugin.enabled);
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
const cmdsPath = path.join(plugin.path, "cmds.json");
|
const cmdsPath = path.join(plugin.path, "cmds.json");
|
||||||
@ -4793,6 +4908,12 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
const description = truncateText(command.description || "", 140);
|
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({
|
addCommand({
|
||||||
id: `${plugin.id}:${command.id || trigger}`,
|
id: `${plugin.id}:${command.id || trigger}`,
|
||||||
trigger,
|
trigger,
|
||||||
@ -4800,8 +4921,7 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
triggerDisplay: `${prefix}${usage}`,
|
triggerDisplay: `${prefix}${usage}`,
|
||||||
name: command.name || toTitleCase(trigger) || trigger,
|
name: command.name || toTitleCase(trigger) || trigger,
|
||||||
description,
|
description,
|
||||||
level: command.level || "public",
|
...policyPresentation,
|
||||||
levelHelp: command.levelHelp || "",
|
|
||||||
origin: pluginName,
|
origin: pluginName,
|
||||||
platforms,
|
platforms,
|
||||||
platformLabels: buildPlatformLabels(platforms),
|
platformLabels: buildPlatformLabels(platforms),
|
||||||
@ -6198,7 +6318,8 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
...command,
|
...command,
|
||||||
platforms: normalizeCustomPlatforms(command.platform, availablePlatforms),
|
platforms: normalizeCustomPlatforms(command.platform, availablePlatforms),
|
||||||
preview: buildCommandPreviewPresentation(command),
|
preview: buildCommandPreviewPresentation(command),
|
||||||
random: buildRandomCommandPresentation(command)
|
random: buildRandomCommandPresentation(command),
|
||||||
|
conditional: buildConditionalCommandPresentation(command)
|
||||||
}));
|
}));
|
||||||
res.render("admin-commands", {
|
res.render("admin-commands", {
|
||||||
title: "Custom 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) => {
|
app.post("/admin/commands", requireRole("mod"), async (req, res) => {
|
||||||
const isAdmin = Boolean(req.session.user?.isAdmin);
|
const isAdmin = Boolean(req.session.user?.isAdmin);
|
||||||
const availablePlatforms = getPlatformStatus()
|
const availablePlatforms = getPlatformStatus()
|
||||||
@ -6220,11 +6420,12 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
const trigger = (req.body.trigger || "").trim().toLowerCase();
|
const trigger = (req.body.trigger || "").trim().toLowerCase();
|
||||||
const description = String(req.body.description || "").trim().slice(0, 500);
|
const description = String(req.body.description || "").trim().slice(0, 500);
|
||||||
const requestedMode = (req.body.mode || "plain").trim();
|
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 language = req.body.language === "python" ? "python" : "js";
|
||||||
const response = (req.body.response || "").trim();
|
const response = (req.body.response || "").trim();
|
||||||
const code = (req.body.code || "").trim();
|
const code = (req.body.code || "").trim();
|
||||||
const randomValidation = randomCommandConfigFromBody(req.body);
|
const randomValidation = randomCommandConfigFromBody(req.body);
|
||||||
|
const conditionalValidation = conditionalRepliesFromBody(req.body);
|
||||||
const selectedPlatforms = parsePlatformSelectionFromBody(
|
const selectedPlatforms = parsePlatformSelectionFromBody(
|
||||||
req.body,
|
req.body,
|
||||||
availablePlatforms
|
availablePlatforms
|
||||||
@ -6252,6 +6453,9 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
} else if (mode === "random" && !randomValidation.ok) {
|
} else if (mode === "random" && !randomValidation.ok) {
|
||||||
setFlash(req, "error", randomValidation.errors[0]);
|
setFlash(req, "error", randomValidation.errors[0]);
|
||||||
return res.redirect("/admin/commands");
|
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") {
|
if (mode === "plain") {
|
||||||
const placeholderValidation = placeholders.validateTemplate({
|
const placeholderValidation = placeholders.validateTemplate({
|
||||||
@ -6270,6 +6474,12 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
setFlash(req, "error", templateError);
|
setFlash(req, "error", templateError);
|
||||||
return res.redirect("/admin/commands");
|
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 now = Date.now();
|
||||||
const preview = isAdmin && mode === "advanced"
|
const preview = isAdmin && mode === "advanced"
|
||||||
@ -6277,12 +6487,12 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
: emptyCommandPreview();
|
: emptyCommandPreview();
|
||||||
try {
|
try {
|
||||||
db.prepare(
|
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(
|
).run(
|
||||||
trigger,
|
trigger,
|
||||||
description,
|
description,
|
||||||
response || "",
|
response || "",
|
||||||
isAdmin || mode === "random" ? mode : "plain",
|
isAdmin || mode !== "advanced" ? mode : "plain",
|
||||||
isAdmin ? language : "js",
|
isAdmin ? language : "js",
|
||||||
isAdmin && mode === "advanced" ? code : null,
|
isAdmin && mode === "advanced" ? code : null,
|
||||||
serializePlatformSelection(selectedPlatforms),
|
serializePlatformSelection(selectedPlatforms),
|
||||||
@ -6295,6 +6505,8 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
mode === "random" && randomValidation.config.rngEnabled ? 1 : 0,
|
mode === "random" && randomValidation.config.rngEnabled ? 1 : 0,
|
||||||
randomValidation.config.rngMin,
|
randomValidation.config.rngMin,
|
||||||
randomValidation.config.rngMax,
|
randomValidation.config.rngMax,
|
||||||
|
mode === "conditional" ? JSON.stringify(conditionalValidation.replies) : "[]",
|
||||||
|
mode === "conditional" && req.body.conditional_fuzzy ? 1 : 0,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
@ -6319,7 +6531,11 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/commands/:id/delete", requireRole("mod"), (req, res) => {
|
app.post("/admin/commands/:id/delete", requireRole("mod"), (req, res) => {
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare("DELETE FROM command_policies WHERE command_key = ?").run(customCommandKey(req.params.id));
|
||||||
|
db.prepare("DELETE FROM command_group_members WHERE command_key = ?").run(customCommandKey(req.params.id));
|
||||||
db.prepare("DELETE FROM custom_commands WHERE id = ?").run(req.params.id);
|
db.prepare("DELETE FROM custom_commands WHERE id = ?").run(req.params.id);
|
||||||
|
})();
|
||||||
setFlash(req, "success", "Command deleted.");
|
setFlash(req, "success", "Command deleted.");
|
||||||
res.redirect("/admin/commands");
|
res.redirect("/admin/commands");
|
||||||
});
|
});
|
||||||
@ -6339,11 +6555,12 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
const trigger = (req.body.trigger || "").trim().toLowerCase();
|
const trigger = (req.body.trigger || "").trim().toLowerCase();
|
||||||
const description = String(req.body.description || "").trim().slice(0, 500);
|
const description = String(req.body.description || "").trim().slice(0, 500);
|
||||||
const requestedMode = (req.body.mode || "plain").trim();
|
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 language = req.body.language === "python" ? "python" : "js";
|
||||||
const response = (req.body.response || "").trim();
|
const response = (req.body.response || "").trim();
|
||||||
const code = (req.body.code || "").trim();
|
const code = (req.body.code || "").trim();
|
||||||
const randomValidation = randomCommandConfigFromBody(req.body);
|
const randomValidation = randomCommandConfigFromBody(req.body);
|
||||||
|
const conditionalValidation = conditionalRepliesFromBody(req.body);
|
||||||
const selectedPlatforms = parsePlatformSelectionFromBody(
|
const selectedPlatforms = parsePlatformSelectionFromBody(
|
||||||
req.body,
|
req.body,
|
||||||
availablePlatforms
|
availablePlatforms
|
||||||
@ -6371,6 +6588,9 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
} else if (mode === "random" && !randomValidation.ok) {
|
} else if (mode === "random" && !randomValidation.ok) {
|
||||||
setFlash(req, "error", randomValidation.errors[0]);
|
setFlash(req, "error", randomValidation.errors[0]);
|
||||||
return res.redirect("/admin/commands");
|
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") {
|
if (mode === "plain") {
|
||||||
const placeholderValidation = placeholders.validateTemplate({
|
const placeholderValidation = placeholders.validateTemplate({
|
||||||
@ -6389,18 +6609,24 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
setFlash(req, "error", templateError);
|
setFlash(req, "error", templateError);
|
||||||
return res.redirect("/admin/commands");
|
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"
|
const preview = isAdmin && mode === "advanced"
|
||||||
? await generateCommandPreview({ code, language })
|
? await generateCommandPreview({ code, language })
|
||||||
: emptyCommandPreview();
|
: emptyCommandPreview();
|
||||||
try {
|
try {
|
||||||
db.prepare(
|
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(
|
).run(
|
||||||
trigger,
|
trigger,
|
||||||
description,
|
description,
|
||||||
response || "",
|
response || "",
|
||||||
isAdmin || mode === "random" ? mode : "plain",
|
isAdmin || mode !== "advanced" ? mode : "plain",
|
||||||
isAdmin ? language : "js",
|
isAdmin ? language : "js",
|
||||||
isAdmin && mode === "advanced" ? code : null,
|
isAdmin && mode === "advanced" ? code : null,
|
||||||
serializePlatformSelection(selectedPlatforms),
|
serializePlatformSelection(selectedPlatforms),
|
||||||
@ -6413,6 +6639,8 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
mode === "random" && randomValidation.config.rngEnabled ? 1 : 0,
|
mode === "random" && randomValidation.config.rngEnabled ? 1 : 0,
|
||||||
randomValidation.config.rngMin,
|
randomValidation.config.rngMin,
|
||||||
randomValidation.config.rngMax,
|
randomValidation.config.rngMax,
|
||||||
|
mode === "conditional" ? JSON.stringify(conditionalValidation.replies) : "[]",
|
||||||
|
mode === "conditional" && req.body.conditional_fuzzy ? 1 : 0,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
req.params.id
|
req.params.id
|
||||||
);
|
);
|
||||||
@ -7223,6 +7451,12 @@ function collectNavItems(user, pluginNav, currentPath) {
|
|||||||
role: "mod",
|
role: "mod",
|
||||||
section: "moderation"
|
section: "moderation"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Command access",
|
||||||
|
path: "/admin/command-policies",
|
||||||
|
role: "admin",
|
||||||
|
section: "admin"
|
||||||
|
},
|
||||||
{ label: "Pages", path: "/admin/pages", role: "admin", section: "admin" },
|
{ label: "Pages", path: "/admin/pages", role: "admin", section: "admin" },
|
||||||
{ label: "Users", path: "/admin/users", role: "mod", section: "moderation" },
|
{ label: "Users", path: "/admin/users", role: "mod", section: "moderation" },
|
||||||
{ label: "Plugins", path: "/admin/plugins", role: "admin", section: "admin" }
|
{ 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/logs") return "logs";
|
||||||
if (pathName === "/admin/updates") return "updates";
|
if (pathName === "/admin/updates") return "updates";
|
||||||
if (pathName === "/admin/commands") return "commands";
|
if (pathName === "/admin/commands") return "commands";
|
||||||
|
if (pathName === "/admin/command-policies") return "commands";
|
||||||
if (pathName === "/admin/pages") return "pages";
|
if (pathName === "/admin/pages") return "pages";
|
||||||
if (pathName === "/admin/users") return "users";
|
if (pathName === "/admin/users") return "users";
|
||||||
if (pathName === "/admin/plugins") return "plugins";
|
if (pathName === "/admin/plugins") return "plugins";
|
||||||
|
|||||||
87
src/web/views/admin-command-policies.ejs
Normal file
87
src/web/views/admin-command-policies.ejs
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<%- include("partials/layout-top", { title }) %>
|
||||||
|
<section class="card">
|
||||||
|
<%- include("partials/page-header", {
|
||||||
|
eyebrow: "Chat tools",
|
||||||
|
pageTitle: "Command access and limits",
|
||||||
|
description: "Keep common rules in groups, then override only the settings that are different for an individual command.",
|
||||||
|
actions: '<a class="button subtle" href="/admin/commands">Custom commands</a>'
|
||||||
|
}) %>
|
||||||
|
<div class="callout info">
|
||||||
|
<strong>How settings combine</strong>
|
||||||
|
<p>Commands inherit their groups. If several groups are assigned, every group limit and role requirement applies; the highest group currency cost is used. Choosing a setting directly on a command replaces all group and built-in values for that one category.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header"><div><h2>Command groups</h2><p class="hint">Create a reusable set of access, rate, stream, and currency rules.</p></div></div>
|
||||||
|
<details class="lumi-expandable-settings">
|
||||||
|
<summary><span><strong>Create command group</strong><span class="hint">For example: Public fun, Moderator tools, or Expensive commands</span></span></summary>
|
||||||
|
<form method="post" action="/admin/command-policies/groups" class="form-grid command-policy-form">
|
||||||
|
<div class="field"><label>Name<input name="name" maxlength="100" required /></label></div>
|
||||||
|
<div class="field"><label>Description<input name="description" maxlength="500" /></label></div>
|
||||||
|
<div class="field full"><%- include("partials/command-policy-fields", { policy: {}, allowInherit: false }) %></div>
|
||||||
|
<div class="form-actions"><button class="button" type="submit">Create group</button></div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<% if (!groups.length) { %><p class="hint">No command groups yet.</p><% } %>
|
||||||
|
<% groups.forEach((group) => { %>
|
||||||
|
<details class="lumi-expandable-settings command-policy-item">
|
||||||
|
<summary><span><strong><%= group.name %></strong><span class="hint"><%= group.description || "Reusable command rules" %></span></span></summary>
|
||||||
|
<form method="post" action="/admin/command-policies/groups/<%= group.id %>" class="form-grid command-policy-form">
|
||||||
|
<div class="field"><label>Name<input name="name" maxlength="100" required value="<%= group.name %>" /></label></div>
|
||||||
|
<div class="field"><label>Description<input name="description" maxlength="500" value="<%= group.description %>" /></label></div>
|
||||||
|
<div class="field full"><%- include("partials/command-policy-fields", { policy: group.policy, allowInherit: false }) %></div>
|
||||||
|
<div class="form-actions"><button class="button" type="submit">Save group</button></div>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/command-policies/groups/<%= group.id %>/delete" class="form-actions" data-confirm-mode="modal" data-confirm-title="Delete command group" data-confirm-text="Delete the group <%= group.name %>? Commands keep their direct settings." data-confirm-label="Delete group">
|
||||||
|
<button class="button danger" type="submit">Delete group</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="section-header"><div><h2>Individual commands</h2><p class="hint">Assign groups or override a category for one command.</p></div></div>
|
||||||
|
<% if (!commands.length) { %><p>No commands are currently registered.</p><% } %>
|
||||||
|
<% commands.forEach((command) => { %>
|
||||||
|
<details class="lumi-expandable-settings command-policy-item">
|
||||||
|
<summary>
|
||||||
|
<span><strong><%= command.label %></strong><span class="hint"><%= command.origin %> · <%= command.description %></span></span>
|
||||||
|
<% if (command.groups.length) { %><span class="badge"><%= command.groups.length %> group<%= command.groups.length === 1 ? "" : "s" %></span><% } %>
|
||||||
|
</summary>
|
||||||
|
<form method="post" action="/admin/command-policies/command" class="form-grid command-policy-form">
|
||||||
|
<input type="hidden" name="command_key" value="<%= command.key %>" />
|
||||||
|
<fieldset class="subsection field full">
|
||||||
|
<legend>Groups</legend>
|
||||||
|
<div class="platform-checkboxes">
|
||||||
|
<% groups.forEach((group) => { %>
|
||||||
|
<label class="platform-check"><input type="checkbox" name="group_ids" value="<%= group.id %>" <%= command.groups.some((item) => item.id === group.id) ? "checked" : "" %> /><span><%= group.name %></span></label>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% if (!groups.length) { %><p class="hint">Create a group above first, or configure this command directly.</p><% } %>
|
||||||
|
</fieldset>
|
||||||
|
<% if (Object.keys(command.defaultPolicy || {}).length) { %>
|
||||||
|
<div class="callout info field full"><strong>Safe built-in defaults</strong><p>This command has conservative built-in settings. Leave a category on “Use group/default” to keep them unless a group supplies that category.</p></div>
|
||||||
|
<% } %>
|
||||||
|
<div class="field full"><%- include("partials/command-policy-fields", { policy: command.policy, allowInherit: true }) %></div>
|
||||||
|
<div class="form-actions"><button class="button" type="submit">Save command settings</button></div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
const updatePolicyCategory = (category) => {
|
||||||
|
const custom = category.querySelector("[data-policy-mode]")?.value === "custom";
|
||||||
|
const values = category.querySelector("[data-policy-values]");
|
||||||
|
if (values) values.hidden = !custom;
|
||||||
|
const role = category.querySelector("[data-requirement-role]");
|
||||||
|
const tier = category.querySelector("[data-subscriber-tier]");
|
||||||
|
if (tier) tier.hidden = !custom || role?.value !== "subscriber";
|
||||||
|
};
|
||||||
|
document.querySelectorAll("[data-policy-category]").forEach((category) => {
|
||||||
|
updatePolicyCategory(category);
|
||||||
|
category.querySelector("[data-policy-mode]")?.addEventListener("change", () => updatePolicyCategory(category));
|
||||||
|
category.querySelector("[data-requirement-role]")?.addEventListener("change", () => updatePolicyCategory(category));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<%- include("partials/layout-bottom") %>
|
||||||
@ -3,7 +3,8 @@
|
|||||||
<%- include("partials/page-header", {
|
<%- include("partials/page-header", {
|
||||||
eyebrow: "Chat tools",
|
eyebrow: "Chat tools",
|
||||||
pageTitle: "Custom commands",
|
pageTitle: "Custom commands",
|
||||||
description: "Create a command, choose where it works, and control the reply it sends."
|
description: "Create a command, choose where it works, and control the reply it sends.",
|
||||||
|
actions: isAdmin ? '<a class="button subtle" href="/admin/command-policies">Access, groups and limits</a>' : ''
|
||||||
}) %>
|
}) %>
|
||||||
<% const platformLabelMap = new Map((platforms || []).map((item) => [item.id, item.label])); %>
|
<% const platformLabelMap = new Map((platforms || []).map((item) => [item.id, item.label])); %>
|
||||||
<form method="post" action="/admin/commands" class="form-grid command-form">
|
<form method="post" action="/admin/commands" class="form-grid command-form">
|
||||||
@ -39,6 +40,7 @@
|
|||||||
<select name="mode" class="js-command-mode">
|
<select name="mode" class="js-command-mode">
|
||||||
<option value="plain" selected>Static</option>
|
<option value="plain" selected>Static</option>
|
||||||
<option value="random">Random Reply</option>
|
<option value="random">Random Reply</option>
|
||||||
|
<option value="conditional">Conditional Reply</option>
|
||||||
<% if (isAdmin) { %>
|
<% if (isAdmin) { %>
|
||||||
<option value="advanced">Dynamic</option>
|
<option value="advanced">Dynamic</option>
|
||||||
<% } %>
|
<% } %>
|
||||||
@ -54,8 +56,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<% } else { %><input type="hidden" name="language" value="js" /><% } %>
|
<% } else { %><input type="hidden" name="language" value="js" /><% } %>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Response</label>
|
<label>Response / help text</label>
|
||||||
<input name="response" placeholder="Hello there!" class="js-field-response" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
|
<input name="response" placeholder="Hello there!" class="js-field-response" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
|
||||||
|
<p class="hint">For conditional replies, this is shown when no argument is given. Leave it empty to list the available keywords.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field full js-field-conditional">
|
||||||
|
<label>Keyword replies</label>
|
||||||
|
<div class="command-random-replies" data-conditional-replies>
|
||||||
|
<div class="command-random-row command-conditional-row" data-conditional-reply-row>
|
||||||
|
<input name="conditional_keyword" placeholder="backseating" aria-label="Keyword" />
|
||||||
|
<input name="conditional_response" placeholder="The reply for this keyword" aria-label="Reply" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
|
||||||
|
<button type="button" class="button subtle" data-conditional-remove>Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="button subtle" data-conditional-add>Add keyword</button>
|
||||||
|
<label class="switch"><input type="checkbox" class="switch-input" name="conditional_fuzzy" checked /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Suggest a close keyword when there is a small typo</span></label>
|
||||||
|
<p class="hint">Example: <code>!rules backseating</code>. Exact matches are preferred; fuzzy matching only accepts a clear close match.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field full js-field-random">
|
<div class="field full js-field-random">
|
||||||
<label>Random replies</label>
|
<label>Random replies</label>
|
||||||
@ -85,7 +101,7 @@
|
|||||||
<% if (isAdmin) { %>
|
<% if (isAdmin) { %>
|
||||||
<p class="hint">For Dynamic commands, paste a simple snippet that returns a reply, or provide an explicit <code>run(ctx)</code> or module export.</p>
|
<p class="hint">For Dynamic commands, paste a simple snippet that returns a reply, or provide an explicit <code>run(ctx)</code> or module export.</p>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<p class="hint">Moderators can create Static and Random Reply commands. Dynamic code remains admin-only.</p>
|
<p class="hint">Moderators can create Static, Random Reply, and Conditional Reply commands. Dynamic code remains admin-only.</p>
|
||||||
<% } %>
|
<% } %>
|
||||||
</section>
|
</section>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@ -121,6 +137,9 @@
|
|||||||
<% if (command.random.example) { %><span class="command-preview" title="Example using a repeatable preview roll"><%= command.random.example %></span><% } %>
|
<% if (command.random.example) { %><span class="command-preview" title="Example using a repeatable preview roll"><%= command.random.example %></span><% } %>
|
||||||
<% if (command.random.rng !== null) { %><span class="hint">Example roll: <%= command.random.rng %></span><% } %>
|
<% if (command.random.rng !== null) { %><span class="hint">Example roll: <%= command.random.rng %></span><% } %>
|
||||||
<% if (command.random.error) { %><span class="command-preview-unavailable"><%= command.random.error %></span><% } %>
|
<% if (command.random.error) { %><span class="command-preview-unavailable"><%= command.random.error %></span><% } %>
|
||||||
|
<% } else if (command.mode === "conditional") { %>
|
||||||
|
<span><strong><%= command.conditional.replies.length %> keyword repl<%= command.conditional.replies.length === 1 ? "y" : "ies" %></strong></span>
|
||||||
|
<span class="hint"><%= command.conditional.replies.map((reply) => reply.keyword).join(", ") %></span>
|
||||||
<% } else if (command.preview.status === "ready") { %>
|
<% } else if (command.preview.status === "ready") { %>
|
||||||
<% const previewId = `command-preview-${command.id}`; %>
|
<% const previewId = `command-preview-${command.id}`; %>
|
||||||
<span id="<%= previewId %>" class="command-preview <%= command.preview.isLong ? 'is-collapsed' : '' %>" title="Sandboxed example output"><% command.preview.parts.forEach((part) => { %><% if (part.dynamic) { %><mark class="preview-dynamic" title="Dynamic <%= part.type %>"><%= part.text %></mark><% } else { %><%= part.text %><% } %><% }) %></span>
|
<span id="<%= previewId %>" class="command-preview <%= command.preview.isLong ? 'is-collapsed' : '' %>" title="Sandboxed example output"><% command.preview.parts.forEach((part) => { %><% if (part.dynamic) { %><mark class="preview-dynamic" title="Dynamic <%= part.type %>"><%= part.text %></mark><% } else { %><%= part.text %><% } %><% }) %></span>
|
||||||
@ -195,6 +214,7 @@
|
|||||||
<select name="mode" class="js-command-mode">
|
<select name="mode" class="js-command-mode">
|
||||||
<option value="plain" <%= command.mode === 'plain' ? 'selected' : '' %>>Static</option>
|
<option value="plain" <%= command.mode === 'plain' ? 'selected' : '' %>>Static</option>
|
||||||
<option value="random" <%= command.mode === 'random' ? 'selected' : '' %>>Random Reply</option>
|
<option value="random" <%= command.mode === 'random' ? 'selected' : '' %>>Random Reply</option>
|
||||||
|
<option value="conditional" <%= command.mode === 'conditional' ? 'selected' : '' %>>Conditional Reply</option>
|
||||||
<% if (isAdmin) { %>
|
<% if (isAdmin) { %>
|
||||||
<option value="advanced" <%= command.mode === 'advanced' ? 'selected' : '' %>>Dynamic</option>
|
<option value="advanced" <%= command.mode === 'advanced' ? 'selected' : '' %>>Dynamic</option>
|
||||||
<% } %>
|
<% } %>
|
||||||
@ -210,8 +230,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<% } else { %><input type="hidden" name="language" value="js" /><% } %>
|
<% } else { %><input type="hidden" name="language" value="js" /><% } %>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Response</label>
|
<label>Response / help text</label>
|
||||||
<input name="response" value="<%= command.response %>" class="js-field-response" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
|
<input name="response" value="<%= command.response %>" class="js-field-response" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
|
||||||
|
<p class="hint">For conditional replies, this is shown when no argument is given. Leave it empty to list the available keywords.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field full js-field-conditional">
|
||||||
|
<label>Keyword replies</label>
|
||||||
|
<div class="command-random-replies" data-conditional-replies>
|
||||||
|
<% (command.conditional.replies.length ? command.conditional.replies : [{ keyword: "", response: "" }]).forEach((reply) => { %>
|
||||||
|
<div class="command-random-row command-conditional-row" data-conditional-reply-row>
|
||||||
|
<input name="conditional_keyword" value="<%= reply.keyword %>" placeholder="backseating" aria-label="Keyword" />
|
||||||
|
<input name="conditional_response" value="<%= reply.response %>" placeholder="The reply for this keyword" aria-label="Reply" data-placeholder-field="core.custom_commands.static_response" data-placeholder-output-audience="user" />
|
||||||
|
<button type="button" class="button subtle" data-conditional-remove>Remove</button>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="button subtle" data-conditional-add>Add keyword</button>
|
||||||
|
<label class="switch"><input type="checkbox" class="switch-input" name="conditional_fuzzy" <%= command.conditional_fuzzy ? "checked" : "" %> /><span class="switch-track" aria-hidden="true"></span><span class="switch-text">Suggest a close keyword when there is a small typo</span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field full js-field-random">
|
<div class="field full js-field-random">
|
||||||
<label>Random replies</label>
|
<label>Random replies</label>
|
||||||
@ -258,11 +293,38 @@
|
|||||||
const code = form.querySelector(".js-field-code");
|
const code = form.querySelector(".js-field-code");
|
||||||
const language = form.querySelector(".js-field-language");
|
const language = form.querySelector(".js-field-language");
|
||||||
const random = form.querySelector(".js-field-random");
|
const random = form.querySelector(".js-field-random");
|
||||||
|
const conditional = form.querySelector(".js-field-conditional");
|
||||||
const isAdvanced = mode === "advanced";
|
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 (code) code.style.display = isAdvanced ? "" : "none";
|
||||||
if (language) language.style.display = isAdvanced ? "" : "none";
|
if (language) language.style.display = isAdvanced ? "" : "none";
|
||||||
if (random) random.style.display = mode === "random" ? "" : "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 toggleRngFields = (form) => {
|
||||||
const enabled = form.querySelector(".js-rng-enabled")?.checked === true;
|
const enabled = form.querySelector(".js-rng-enabled")?.checked === true;
|
||||||
@ -309,10 +371,17 @@
|
|||||||
});
|
});
|
||||||
form.querySelector(".js-rng-enabled")?.addEventListener("change", () => toggleRngFields(form));
|
form.querySelector(".js-rng-enabled")?.addEventListener("change", () => toggleRngFields(form));
|
||||||
form.querySelector("[data-random-add]")?.addEventListener("click", () => addRandomReply(form));
|
form.querySelector("[data-random-add]")?.addEventListener("click", () => addRandomReply(form));
|
||||||
|
form.querySelector("[data-conditional-add]")?.addEventListener("click", () => addConditionalReply(form));
|
||||||
form.addEventListener("click", (event) => {
|
form.addEventListener("click", (event) => {
|
||||||
const remove = event.target.closest("[data-random-remove]");
|
const remove = event.target.closest("[data-random-remove]");
|
||||||
if (!remove || !form.contains(remove)) return;
|
if (!remove || !form.contains(remove)) return;
|
||||||
remove.closest("[data-random-reply-row]")?.remove();
|
remove.closest("[data-random-reply-row]")?.remove();
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
form.addEventListener("click", (event) => {
|
||||||
|
const remove = event.target.closest("[data-conditional-remove]");
|
||||||
|
if (!remove || !form.contains(remove)) return;
|
||||||
|
remove.closest("[data-conditional-reply-row]")?.remove();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
72
src/web/views/partials/command-policy-fields.ejs
Normal file
72
src/web/views/partials/command-policy-fields.ejs
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<%
|
||||||
|
const hasOwn = (name) => Object.prototype.hasOwnProperty.call(policy || {}, name);
|
||||||
|
const policyMode = (name) => !hasOwn(name) ? (allowInherit ? "inherit" : "off") : policy[name] === false ? "off" : "custom";
|
||||||
|
const windowValue = policy?.window && policy.window !== false ? policy.window : {};
|
||||||
|
const streamValue = policy?.perStream && policy.perStream !== false ? policy.perStream : {};
|
||||||
|
const requirementValue = policy?.requirement && policy.requirement !== false ? policy.requirement : {};
|
||||||
|
const costValue = policy?.cost && policy.cost !== false ? policy.cost : {};
|
||||||
|
%>
|
||||||
|
<div class="command-policy-grid">
|
||||||
|
<fieldset class="subsection command-policy-section" data-policy-category>
|
||||||
|
<legend>Rate limit</legend>
|
||||||
|
<label>Setting
|
||||||
|
<select name="window_mode" data-policy-mode>
|
||||||
|
<% if (allowInherit) { %><option value="inherit" <%= policyMode("window") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
|
||||||
|
<option value="off" <%= policyMode("window") === "off" ? "selected" : "" %>>No time-window limit</option>
|
||||||
|
<option value="custom" <%= policyMode("window") === "custom" ? "selected" : "" %>>Set a limit</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="command-policy-values" data-policy-values>
|
||||||
|
<label>Uses<input name="window_uses" type="number" min="1" step="1" value="<%= windowValue.uses || 1 %>" /></label>
|
||||||
|
<label>Within seconds<input name="window_seconds" type="number" min="1" step="1" value="<%= windowValue.seconds || 30 %>" /></label>
|
||||||
|
<label>Count<select name="window_scope"><option value="user" <%= windowValue.scope !== "global" ? "selected" : "" %>>Per user</option><option value="global" <%= windowValue.scope === "global" ? "selected" : "" %>>Everyone together</option></select></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="subsection command-policy-section" data-policy-category>
|
||||||
|
<legend>Per stream</legend>
|
||||||
|
<label>Setting
|
||||||
|
<select name="perStream_mode" data-policy-mode>
|
||||||
|
<% if (allowInherit) { %><option value="inherit" <%= policyMode("perStream") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
|
||||||
|
<option value="off" <%= policyMode("perStream") === "off" ? "selected" : "" %>>No stream limit</option>
|
||||||
|
<option value="custom" <%= policyMode("perStream") === "custom" ? "selected" : "" %>>Set a limit</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="command-policy-values" data-policy-values>
|
||||||
|
<label>Uses per stream<input name="perStream_uses" type="number" min="1" step="1" value="<%= streamValue.uses || 1 %>" /></label>
|
||||||
|
<label>Count<select name="perStream_scope"><option value="user" <%= streamValue.scope !== "global" ? "selected" : "" %>>Per user</option><option value="global" <%= streamValue.scope === "global" ? "selected" : "" %>>Everyone together</option></select></label>
|
||||||
|
</div>
|
||||||
|
<p class="hint">A restarted stream stays in the same session for two hours, so a crash does not reset this limit.</p>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="subsection command-policy-section" data-policy-category>
|
||||||
|
<legend>Who may use it</legend>
|
||||||
|
<label>Setting
|
||||||
|
<select name="requirement_mode" data-policy-mode>
|
||||||
|
<% if (allowInherit) { %><option value="inherit" <%= policyMode("requirement") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
|
||||||
|
<option value="off" <%= policyMode("requirement") === "off" ? "selected" : "" %>>Everyone</option>
|
||||||
|
<option value="custom" <%= policyMode("requirement") === "custom" ? "selected" : "" %>>Require a role</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="command-policy-values" data-policy-values>
|
||||||
|
<label>Minimum access<select name="requirement_role" data-requirement-role>
|
||||||
|
<% [["follower","Follower"],["subscriber","Subscriber / member"],["vip","VIP"],["mod","Moderator"],["editor","Editor"],["streamer","Streamer only"]].forEach(([value,label]) => { %>
|
||||||
|
<option value="<%= value %>" <%= requirementValue.role === value ? "selected" : "" %>><%= label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select></label>
|
||||||
|
<label data-subscriber-tier>Subscriber tier<select name="requirement_subscriber_tier"><option value="1" <%= Number(requirementValue.subscriberTier || 1) === 1 ? "selected" : "" %>>Any tier</option><option value="2" <%= Number(requirementValue.subscriberTier) === 2 ? "selected" : "" %>>Tier 2+</option><option value="3" <%= Number(requirementValue.subscriberTier) === 3 ? "selected" : "" %>>Tier 3</option></select></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="subsection command-policy-section" data-policy-category>
|
||||||
|
<legend>Currency cost</legend>
|
||||||
|
<label>Setting
|
||||||
|
<select name="cost_mode" data-policy-mode>
|
||||||
|
<% if (allowInherit) { %><option value="inherit" <%= policyMode("cost") === "inherit" ? "selected" : "" %>>Use group/default</option><% } %>
|
||||||
|
<option value="off" <%= policyMode("cost") === "off" ? "selected" : "" %>>Free</option>
|
||||||
|
<option value="custom" <%= policyMode("cost") === "custom" ? "selected" : "" %>>Charge currency</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="command-policy-values" data-policy-values>
|
||||||
|
<label>Cost<input name="cost_amount" type="number" min="1" step="1" value="<%= costValue.amount || 1 %>" /></label>
|
||||||
|
</div>
|
||||||
|
<p class="hint">The Economy Framework deducts and logs this like any other banking transaction.</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Lumi Core",
|
"name": "Lumi Core",
|
||||||
"version": "0.2.17",
|
"version": "0.2.18",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"released_at": "2026-07-19",
|
"released_at": "2026-07-19",
|
||||||
"compatible_from": "0.1.9",
|
"compatible_from": "0.1.9",
|
||||||
@ -8,7 +8,7 @@
|
|||||||
"replaces_versions": [
|
"replaces_versions": [
|
||||||
"1.2.0"
|
"1.2.0"
|
||||||
],
|
],
|
||||||
"migration_notes": "Adds 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,
|
"rollback_safe": true,
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"Node.js 18 or newer"
|
"Node.js 18 or newer"
|
||||||
@ -229,6 +229,18 @@
|
|||||||
],
|
],
|
||||||
"rollback_safe": true,
|
"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."
|
"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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user