Support conditional command keyword aliases

This commit is contained in:
Franz Rolfsvaag 2026-07-19 20:34:37 +02:00
parent 4853092956
commit 66d0b63077
14 changed files with 137 additions and 36 deletions

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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

4
package-lock.json generated
View File

@ -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",

View File

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

View File

@ -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",

View File

@ -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;

View File

@ -4,8 +4,8 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.2.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.");

View File

@ -16,7 +16,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.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);

View File

@ -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
};

View File

@ -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) {

View File

@ -64,14 +64,14 @@
<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_keyword" placeholder="backseat, backseating" aria-label="Keywords, separated with commas" />
<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>
<p class="hint">Separate aliases with commas to give them the same reply. Example: <code>backseat, backseating</code>. Exact matches are preferred; fuzzy matching only accepts a clear close match.</p>
</div>
<div class="field full js-field-random">
<label>Random replies</label>
@ -138,7 +138,7 @@
<% if (command.random.rng !== null) { %><span class="hint">Example roll: <%= command.random.rng %></span><% } %>
<% if (command.random.error) { %><span class="command-preview-unavailable"><%= command.random.error %></span><% } %>
<% } else if (command.mode === "conditional") { %>
<span><strong><%= command.conditional.replies.length %> keyword repl<%= command.conditional.replies.length === 1 ? "y" : "ies" %></strong></span>
<span><strong><%= command.conditional.replies.length %> reply rule<%= command.conditional.replies.length === 1 ? "" : "s" %></strong></span>
<span class="hint"><%= command.conditional.replies.map((reply) => reply.keyword).join(", ") %></span>
<% } else if (command.preview.status === "ready") { %>
<% const previewId = `command-preview-${command.id}`; %>
@ -239,7 +239,7 @@
<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_keyword" value="<%= reply.keyword %>" placeholder="backseat, backseating" aria-label="Keywords, separated with commas" />
<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>
@ -247,6 +247,7 @@
</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>
<p class="hint">Separate aliases with commas to give them the same reply.</p>
</div>
<div class="field full js-field-random">
<label>Random replies</label>
@ -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";

View File

@ -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."
}
]
}