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.
Separate aliases with commas to give them the same reply.