Support conditional command keyword aliases
This commit is contained in:
parent
4853092956
commit
66d0b63077
@ -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.
|
||||
|
||||
3
TODO.md
3
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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.2.19",
|
||||
"version": "0.2.20",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.");
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user