From 66d0b6307798d7bb16e443f9abe70f30a6f158b7 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sun, 19 Jul 2026 20:34:37 +0200 Subject: [PATCH] Support conditional command keyword aliases --- CHANGELOG.md | 4 ++ TODO.md | 3 ++ docs/commands.md | 3 ++ knowledge/core/lumi-core.md | 2 +- package-lock.json | 4 +- package.json | 2 +- release-index.json | 30 ++++++++++++++ scripts/verify-command-policies.js | 17 ++++++++ scripts/verify-release-metadata.js | 6 +-- scripts/verify-update-system.js | 5 ++- src/services/command-conditional.js | 63 +++++++++++++++++++++-------- src/web/server.js | 5 ++- src/web/views/admin-commands.ejs | 13 +++--- update-manifest.json | 16 +++++++- 14 files changed, 137 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c5f2d..e41c73d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Lumi changelog +## 0.2.20 + +- Added comma-separated aliases to conditional command reply rules, allowing several keywords to return one shared response while retaining exact and fuzzy matching. + ## 0.2.19 - Added a protected, implicitly inherited default command group that can be edited but not deleted. diff --git a/TODO.md b/TODO.md index 5a11778..b31ba66 100644 --- a/TODO.md +++ b/TODO.md @@ -21,6 +21,9 @@ search suggestions with positive-only live setting/source filters; and atomic page-wide saving for every command so collapsed or filtered drafts survive the save. +Patch completed on 2026-07-19: conditional reply rules now accept +comma-separated keyword aliases that share one response. + Remaining work: - Add platform action providers when YouTube or future chat integrations expose diff --git a/docs/commands.md b/docs/commands.md index fe9dbb9..efd8875 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -44,6 +44,9 @@ 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. +Separate keywords with commas when several aliases should return the same +reply. For example, `backseat, backseating, hints` can share one rules response. + 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. diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index a004255..a350488 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,7 +14,7 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.2.19 +Version: 0.2.20 ## Routes - GET /api/events - POST /api/destructive-confirmations diff --git a/package-lock.json b/package-lock.json index 87016f5..8919064 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.2.19", + "version": "0.2.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.2.19", + "version": "0.2.20", "dependencies": { "adm-zip": "^0.5.12", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index 530c5e1..141c6a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.19", + "version": "0.2.20", "private": true, "type": "commonjs", "scripts": { diff --git a/release-index.json b/release-index.json index 3aefb95..95ce2bc 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,36 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.2.20", + "ref": "refs/tags/v0.2.20", + "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 comma-separated aliases for conditional command reply rules. Existing commands, policies, groups, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets are preserved.", + "plugins": { + "auto-vc": "0.1.6", + "birthday": "0.1.3", + "economy-framework": "0.2.10", + "economy-games": "0.1.7", + "expression-interaction": "0.2.1", + "lumi_ai": "0.8.5", + "moderation": "0.1.5", + "okf": "0.1.1", + "quotes": "0.1.2", + "sample-plugin": "0.1.0", + "throne_wishlist": "0.1.2", + "welcome_messages": "0.1.1" + }, + "tools": { + "lumi_ai_web_search": "0.1.1" + } + }, { "version": "0.2.19", "ref": "refs/tags/v0.2.19", diff --git a/scripts/verify-command-policies.js b/scripts/verify-command-policies.js index 8dbf2d4..0cfc5dc 100644 --- a/scripts/verify-command-policies.js +++ b/scripts/verify-command-policies.js @@ -27,6 +27,22 @@ try { assert.strictEqual(fuzzy.match.keyword, "backseating"); assert.strictEqual(fuzzy.fuzzy, true); assert.strictEqual(conditional.findConditionalReply(replies, "something unrelated").match, null); + const aliases = conditional.conditionalRepliesFromBody({ + conditional_keyword: "backseat, backseating, hints", + conditional_response: "Please ask before helping." + }); + assert.strictEqual(aliases.ok, true); + assert.strictEqual(aliases.replies[0].keyword, "backseat, backseating, hints"); + assert.strictEqual(conditional.normalizeConditionalReplyRows(JSON.stringify(aliases.replies))[0].keyword, "backseat, backseating, hints"); + for (const alias of ["backseat", "backseating", "hints"]) { + assert.strictEqual(conditional.findConditionalReply(aliases.replies, alias).match.response, "Please ask before helping."); + } + const duplicateAlias = conditional.conditionalRepliesFromBody({ + conditional_keyword: ["spoilers, leaks", "leaks"], + conditional_response: ["First", "Second"] + }); + assert.strictEqual(duplicateAlias.ok, false); + assert.match(duplicateAlias.errors[0], /leaks.*more than once/); const groupId = policies.createGroup({ name: "Verification group", @@ -102,6 +118,7 @@ try { assert(view.includes("data-policy-search") && view.includes("data-policy-setting-filter")); assert(view.includes("Protected default") && view.includes("data-command-policy-batch")); assert(commandView.includes('value="conditional"') && commandView.includes("data-conditional-replies")); + assert(commandView.includes("Separate aliases with commas")); database.db.close(); database = null; diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index a1a66c7..5a16643 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,8 +4,8 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.2.19"; -const previousCoreVersion = "0.2.18"; +const releaseVersion = "0.2.20"; +const previousCoreVersion = "0.2.19"; const earliestCompatibleCoreVersion = "0.1.9"; const changedPlugins = { "auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, @@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); -console.log("Release metadata verification passed: core 0.2.19, Lumi AI 0.8.5, and synchronized package metadata."); +console.log("Release metadata verification passed: core 0.2.20, Lumi AI 0.8.5, and synchronized package metadata."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index 511845c..26b483f 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -16,7 +16,7 @@ function readJson(relativePath) { const releaseIndex = readJson("release-index.json"); const releaseVersions = releaseIndex.releases.map((release) => release.version); -assert.deepEqual(releaseVersions, ["0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); +assert.deepEqual(releaseVersions, ["0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); for (const release of releaseIndex.releases) { assert.equal(normalizeRepositoryRef(release.ref), release.ref); @@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) { const baseTarget = { current_version: "0.2.4", available_versions: [ + { version: "0.2.20", ref: "refs/tags/v0.2.20", rollback_safe: true }, { version: "0.2.19", ref: "refs/tags/v0.2.19", rollback_safe: true }, { version: "0.2.18", ref: "refs/tags/v0.2.18", rollback_safe: true }, { version: "0.2.17", ref: "refs/tags/v0.2.17", rollback_safe: true }, @@ -79,7 +80,7 @@ const corrected = buildStatus({ channel: "stable" }); assert.equal(corrected.version_correction, true); -assert.equal(corrected.safe_target_version, "0.2.19"); +assert.equal(corrected.safe_target_version, "0.2.20"); assert.equal(corrected.update_available, true); assert.equal(corrected.blocked, false); diff --git a/src/services/command-conditional.js b/src/services/command-conditional.js index 6455b7e..28e7be1 100644 --- a/src/services/command-conditional.js +++ b/src/services/command-conditional.js @@ -9,21 +9,46 @@ function normalizeConditionalKey(value) { .trim(); } +function splitConditionalKeywords(value) { + return String(value || "") + .split(",") + .map((keyword) => keyword.trim().slice(0, 100)) + .filter(Boolean); +} + function normalizeConditionalReplies(value) { + return normalizeConditionalReplyRows(value).flatMap((entry) => splitConditionalKeywords(entry.keyword).map((keyword) => ({ + keyword, + normalized: normalizeConditionalKey(keyword), + response: entry.response + }))); +} + +function normalizeConditionalReplyRows(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 rows = []; + let aliasCount = 0; + for (const entry of input) { 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 }]; - }); + if (!response) continue; + const aliases = []; + for (const keyword of splitConditionalKeywords(entry?.keyword)) { + const normalized = normalizeConditionalKey(keyword); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + aliases.push(keyword); + aliasCount += 1; + if (aliasCount >= MAX_CONDITIONAL_REPLIES) break; + } + if (aliases.length) rows.push({ keyword: aliases.join(", "), response }); + if (aliasCount >= MAX_CONDITIONAL_REPLIES) break; + } + return rows; } function conditionalRepliesFromBody(body = {}) { @@ -32,26 +57,28 @@ function conditionalRepliesFromBody(body = {}) { const replies = []; const errors = []; const seen = new Set(); + let aliasCount = 0; const count = Math.max(keywords.length, responses.length); for (let index = 0; index < count; index += 1) { const keyword = String(keywords[index] || "").trim(); + const aliases = splitConditionalKeywords(keyword); const response = String(responses[index] || "").trim(); if (!keyword && !response) continue; - if (!keyword || !response) { + if (!aliases.length || !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; + for (const alias of aliases) { + const normalized = normalizeConditionalKey(alias); + if (seen.has(normalized)) errors.push(`The keyword “${alias}” is listed more than once.`); + seen.add(normalized); } - seen.add(normalized); - replies.push({ keyword: keyword.slice(0, 100), response: response.slice(0, 4000) }); + aliasCount += aliases.length; + replies.push({ keyword: aliases.join(", "), 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) }; + if (aliasCount > MAX_CONDITIONAL_REPLIES) errors.push(`Use no more than ${MAX_CONDITIONAL_REPLIES} keywords.`); + return { ok: errors.length === 0, errors, replies }; } function findConditionalReply(replies, argument, { fuzzy = true } = {}) { @@ -107,5 +134,7 @@ module.exports = { conditionalRepliesFromBody, findConditionalReply, normalizeConditionalKey, - normalizeConditionalReplies + normalizeConditionalReplies, + normalizeConditionalReplyRows, + splitConditionalKeywords }; diff --git a/src/web/server.js b/src/web/server.js index 9801c99..8275f2e 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -79,7 +79,8 @@ const { getClient: getTwitchClient } = require("../services/twitch"); const { getClient: getYouTubeClient } = require("../services/youtube"); const { conditionalRepliesFromBody, - normalizeConditionalReplies + normalizeConditionalReplies, + normalizeConditionalReplyRows } = require("../services/command-conditional"); const { createGroup: createCommandGroup, @@ -1320,7 +1321,7 @@ function buildRandomCommandPresentation(command) { } function buildConditionalCommandPresentation(command) { - return { replies: normalizeConditionalReplies(command.conditional_replies_json) }; + return { replies: normalizeConditionalReplyRows(command.conditional_replies_json) }; } function validateConditionalReplyTemplates(config, user) { diff --git a/src/web/views/admin-commands.ejs b/src/web/views/admin-commands.ejs index abf14a6..6b5c018 100644 --- a/src/web/views/admin-commands.ejs +++ b/src/web/views/admin-commands.ejs @@ -64,14 +64,14 @@
- +
-

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

+

Separate aliases with commas to give them the same reply. Example: backseat, backseating. Exact matches are preferred; fuzzy matching only accepts a clear close match.

@@ -138,7 +138,7 @@ <% if (command.random.rng !== null) { %>Example roll: <%= command.random.rng %><% } %> <% if (command.random.error) { %><%= command.random.error %><% } %> <% } else if (command.mode === "conditional") { %> - <%= command.conditional.replies.length %> keyword repl<%= command.conditional.replies.length === 1 ? "y" : "ies" %> + <%= command.conditional.replies.length %> reply rule<%= command.conditional.replies.length === 1 ? "" : "s" %> <%= command.conditional.replies.map((reply) => reply.keyword).join(", ") %> <% } else if (command.preview.status === "ready") { %> <% const previewId = `command-preview-${command.id}`; %> @@ -239,7 +239,7 @@
<% (command.conditional.replies.length ? command.conditional.replies : [{ keyword: "", response: "" }]).forEach((reply) => { %>
- +
@@ -247,6 +247,7 @@
+

Separate aliases with commas to give them the same reply.

@@ -309,8 +310,8 @@ row.dataset.conditionalReplyRow = ""; const keyword = document.createElement("input"); keyword.name = "conditional_keyword"; - keyword.placeholder = "keyword"; - keyword.setAttribute("aria-label", "Keyword"); + keyword.placeholder = "keyword, alias"; + keyword.setAttribute("aria-label", "Keywords, separated with commas"); const response = document.createElement("input"); response.name = "conditional_response"; response.placeholder = "The reply for this keyword"; diff --git a/update-manifest.json b/update-manifest.json index d853936..55a628d 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,6 +1,6 @@ { "name": "Lumi Core", - "version": "0.2.19", + "version": "0.2.20", "channel": "stable", "released_at": "2026-07-19", "compatible_from": "0.1.9", @@ -8,7 +8,7 @@ "replaces_versions": [ "1.2.0" ], - "migration_notes": "Improves command-policy administration with a protected inherited default group, conditional effective-value summaries, fuzzy searching and filters, clearer command origins, and atomic multi-command saves. Existing commands, policies, groups, usage totals, overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.", + "migration_notes": "Adds comma-separated aliases for conditional command reply rules. Existing commands, policies, groups, usage totals, overlays, website URLs, CSS, tokens, scenes, sources, settings, databases, logs, plugin data, community knowledge, AI models, runtimes, uploads, feedback, and secrets are preserved.", "rollback_safe": true, "requirements": [ "Node.js 18 or newer" @@ -253,6 +253,18 @@ ], "rollback_safe": true, "migration_notes": "Improves command-policy administration with a protected inherited default group, conditional effective-value summaries, fuzzy searching and filters, clearer command origins, and atomic multi-command saves; existing commands, policies, groups, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved." + }, + { + "version": "0.2.20", + "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 comma-separated aliases for conditional command reply rules; existing commands, policies, groups, usage totals, overlays, tokens, scenes, sources, settings, databases, plugin data, models, uploads, feedback, and secrets remain preserved." } ] }