fix(matrix): detect commands after bot mentions

This commit is contained in:
Gustavo Madeira Santana
2026-04-19 15:25:07 -04:00
parent 13f60314c9
commit b751fbc134
6 changed files with 173 additions and 54 deletions

View File

@@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai
- Active Memory: raise the blocking recall timeout ceiling to 120 seconds and reject larger config values during plugin schema validation. Fixes #68410. (#68480) Thanks @Bartok9.
- Control UI/chat: keep history-backed user image uploads visible after chat reload while filtering blocked or non-image transcript media paths. (#68415) Thanks @mraleko.
- Matrix/plugins: keep remaining Matrix event helpers on the canonical `matrix-js-sdk` subpath so build and plugin-load entrypoint checks stay consistent. (#68498) Thanks @masatohoshino.
- Matrix/commands: recognize slash commands that are prefixed with the bot's Matrix mention, so room messages like `@bot:server /new` trigger the command path without requiring custom mention regexes. (#68570) thanks @nightq.
## 2026.4.15

View File

@@ -1,104 +1,141 @@
import { describe, expect, it } from "vitest";
import { stripMatrixMentionPrefixes } from "./handler.js";
import { stripMatrixMentionPrefix } from "./mentions.js";
describe("stripMatrixMentionPrefixes", () => {
it("returns original text when mentionRegexes is empty", () => {
describe("stripMatrixMentionPrefix", () => {
it("strips full Matrix user id without configured mention regexes", () => {
const text = "@bot:server /new";
const result = stripMatrixMentionPrefixes(text, []);
expect(result).toBe("@bot:server /new");
const result = stripMatrixMentionPrefix({
text,
userId: "@bot:server",
mentionRegexes: [],
});
expect(result).toBe("/new");
});
it("strips Matrix localpart without configured mention regexes", () => {
const result = stripMatrixMentionPrefix({
text: "@bot /new",
userId: "@bot:server",
mentionRegexes: [],
});
expect(result).toBe("/new");
});
it("strips display name with separator", () => {
const result = stripMatrixMentionPrefix({
text: "OpenClaw Bot: /model",
displayName: "OpenClaw Bot",
mentionRegexes: [],
});
expect(result).toBe("/model");
});
it("strips @display name with comma separator", () => {
const result = stripMatrixMentionPrefix({
text: "@OpenClaw Bot, /model",
displayName: "OpenClaw Bot",
mentionRegexes: [],
});
expect(result).toBe("/model");
});
it("returns original text when text is empty", () => {
const result = stripMatrixMentionPrefixes("", [/\s*@bot:server\s*/]);
const result = stripMatrixMentionPrefix({
text: "",
userId: "@bot:server",
mentionRegexes: [/\s*@bot:server\s*/],
});
expect(result).toBe("");
});
it("strips mention prefix before slash command (issue #68547)", () => {
it("falls back to configured mention regexes before slash command", () => {
const mentionRegexes = [/@bot:server\b/];
const text = "@bot:server /new";
const result = stripMatrixMentionPrefixes(text, mentionRegexes);
const result = stripMatrixMentionPrefix({ text, mentionRegexes });
expect(result).toBe("/new");
});
it("strips mention prefix with extra whitespace", () => {
const mentionRegexes = [/@bot:server\b/];
const text = "@bot:server /help";
const result = stripMatrixMentionPrefixes(text, mentionRegexes);
const result = stripMatrixMentionPrefix({ text, mentionRegexes });
expect(result).toBe("/help");
});
it("strips mention prefix with display name (case-insensitive)", () => {
// Regex with case-insensitive flag (as produced by buildMentionRegexes)
const mentionRegexes = [/@OpenClaw Bot\b/i];
const text = "@openclaw bot /model";
const result = stripMatrixMentionPrefixes(text, mentionRegexes);
const result = stripMatrixMentionPrefix({ text, mentionRegexes });
expect(result).toBe("/model");
});
it("strips mention prefix with display name (exact case)", () => {
const mentionRegexes = [/@OpenClaw Bot\b/i];
const text = "@OpenClaw Bot /model";
const result = stripMatrixMentionPrefixes(text, mentionRegexes);
const result = stripMatrixMentionPrefix({ text, mentionRegexes });
expect(result).toBe("/model");
});
it("does not strip mention from middle of text", () => {
const mentionRegexes = [/@bot:server\b/];
const text = "Hello @bot:server how are you";
const result = stripMatrixMentionPrefixes(text, mentionRegexes);
const result = stripMatrixMentionPrefix({ text, userId: "@bot:server", mentionRegexes });
expect(result).toBe("Hello @bot:server how are you");
});
it("does not strip non-matching patterns", () => {
const mentionRegexes = [/@otherbot:server\b/];
const text = "@bot:server /new";
const result = stripMatrixMentionPrefixes(text, mentionRegexes);
const result = stripMatrixMentionPrefix({ text, mentionRegexes });
expect(result).toBe("@bot:server /new");
});
it("strips only the first mention prefix", () => {
const mentionRegexes = [/@bot:server\b/];
const text = "@bot:server @bot:server /new";
const result = stripMatrixMentionPrefixes(text, mentionRegexes);
const result = stripMatrixMentionPrefix({ text, mentionRegexes });
expect(result).toBe("@bot:server /new");
});
it("handles multiple regex patterns and strips first match", () => {
const mentionRegexes = [/@otherbot:server\b/, /@bot:server\b/];
const text = "@bot:server /new";
const result = stripMatrixMentionPrefixes(text, mentionRegexes);
// First pattern doesn't match, second does
const result = stripMatrixMentionPrefix({ text, mentionRegexes });
expect(result).toBe("/new");
});
it("preserves original text when no patterns match", () => {
const mentionRegexes = [/@otherbot:server\b/, /@anotherbot:server\b/];
const text = "@bot:server /new";
const result = stripMatrixMentionPrefixes(text, mentionRegexes);
const result = stripMatrixMentionPrefix({ text, mentionRegexes });
expect(result).toBe("@bot:server /new");
});
it("handles regex with special characters in mention", () => {
const mentionRegexes = [/@bot\+123:server\.com\b/];
const text = "@bot+123:server.com /status";
const result = stripMatrixMentionPrefixes(text, mentionRegexes);
const result = stripMatrixMentionPrefix({ text, mentionRegexes });
expect(result).toBe("/status");
});
it("preserves regular message without slash command after stripping", () => {
const mentionRegexes = [/@bot:server\b/];
const text = "@bot:server hello world";
const result = stripMatrixMentionPrefixes(text, mentionRegexes);
const result = stripMatrixMentionPrefix({ text, mentionRegexes });
expect(result).toBe("hello world");
});
it("preserves regex flags when stripping (case-insensitive match)", () => {
// This test specifically verifies the fix for the regex flags issue
// The regex has the 'i' flag for case-insensitive matching
const mentionRegexes = [/@TestBot:server\b/i];
// Text with different casing should still match and be stripped
const text = "@TESTBOT:SERVER /command";
const result = stripMatrixMentionPrefixes(text, mentionRegexes);
const result = stripMatrixMentionPrefix({ text, mentionRegexes });
expect(result).toBe("/command");
});
it("does not carry global regex state across calls", () => {
const mentionRegexes = [/@bot:server\b/gi];
const params = { text: "@bot:server /new", mentionRegexes };
expect(stripMatrixMentionPrefix(params)).toBe("/new");
expect(stripMatrixMentionPrefix(params)).toBe("/new");
});
});

View File

@@ -50,7 +50,7 @@ type MatrixHandlerTestHarnessOptions = {
upsertPairingRequest?: MatrixMonitorHandlerParams["core"]["channel"]["pairing"]["upsertPairingRequest"];
buildPairingReply?: () => string;
shouldHandleTextCommands?: () => boolean;
hasControlCommand?: () => boolean;
hasControlCommand?: MatrixMonitorHandlerParams["core"]["channel"]["text"]["hasControlCommand"];
resolveMarkdownTableMode?: () => string;
resolveAgentRoute?: () => typeof DEFAULT_ROUTE;
resolveStorePath?: () => string;

View File

@@ -512,6 +512,29 @@ describe("matrix monitor handler pairing account scope", () => {
expect(readAllowFromStore).not.toHaveBeenCalled();
});
it("strips the Matrix self user id before room slash command detection", async () => {
const hasControlCommand = vi.fn((text?: string) => text === "/new");
const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
cfg: { commands: { useAccessGroups: false } },
isDirectMessage: false,
mentionRegexes: [],
shouldHandleTextCommands: () => true,
hasControlCommand,
getMemberDisplayName: async () => "sender",
});
await handler(
"!room:example.org",
createMatrixTextMessageEvent({
eventId: "$mxid-command",
body: "@bot:example.org /new",
}),
);
expect(hasControlCommand).toHaveBeenCalledWith("/new", expect.anything());
expect(recordInboundSession).toHaveBeenCalled();
});
it("processes room messages mentioned via displayName in formatted_body", async () => {
const recordInboundSession = vi.fn(async () => {});
const { handler } = createMatrixHandlerTestHarness({

View File

@@ -42,7 +42,7 @@ import type { MatrixResolvedAllowlistEntry } from "./config.js";
import type { MatrixInboundEventDeduper } from "./inbound-dedupe.js";
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
import { downloadMatrixMedia } from "./media.js";
import { resolveMentions } from "./mentions.js";
import { resolveMentions, stripMatrixMentionPrefix } from "./mentions.js";
import { deliverMatrixReplies } from "./replies.js";
import { createMatrixReplyContextResolver } from "./reply-context.js";
import { createRoomHistoryTracker } from "./room-history.js";
@@ -268,28 +268,6 @@ function resolveMatrixMentionPrecheckText(params: {
return "";
}
/**
* Strip mention prefixes from text for slash command detection.
* This ensures that messages like "@bot:server /new" are recognized as slash commands.
* Similar to Feishu's normalizeMentions and Mattermost's stripMentionPrefix.
*/
export function stripMatrixMentionPrefixes(text: string, mentionRegexes: RegExp[]): string {
if (!text || mentionRegexes.length === 0) {
return text;
}
let result = text;
for (const pattern of mentionRegexes) {
// Match mention at the start of the text, followed by optional whitespace
// Preserve the original regex flags (e.g., "i" for case-insensitive)
const match = result.match(new RegExp(`^(${pattern.source})\\s*`, pattern.flags));
if (match) {
result = result.slice(match[0].length).trimStart();
break; // Only strip the first mention prefix
}
}
return result;
}
function hasBundledMatrixReplacementRelation(event: MatrixRawEvent) {
const relations = event.unsigned?.["m.relations"];
if (!relations || typeof relations !== "object") {
@@ -957,12 +935,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
surface: "matrix",
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
// Strip mention prefixes before checking for slash commands so that messages
// like "@bot:server /new" are recognized as slash commands (#68547)
const commandCheckText = stripMatrixMentionPrefixes(
mentionPrecheckText,
agentMentionRegexes,
);
// Keep this scoped to command precheck so mention/history/body semantics
// continue to see the original Matrix message.
const commandCheckText = stripMatrixMentionPrefix({
text: mentionPrecheckText,
userId: selfUserId,
displayName: selfDisplayName,
mentionRegexes: agentMentionRegexes,
});
const hasControlCommandInMessage = core.channel.text.hasControlCommand(
commandCheckText,
cfg,

View File

@@ -62,6 +62,84 @@ function resolveMatrixUserLocalpart(userId: string): string | null {
return trimmed.slice(1, colonIndex).trim() || null;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function appendUniqueMentionCandidate(
candidates: string[],
seen: Set<string>,
candidate?: string | null,
) {
const trimmed = candidate?.trim();
if (!trimmed) {
return;
}
const normalized = normalizeLowercaseStringOrEmpty(trimmed);
if (seen.has(normalized)) {
return;
}
seen.add(normalized);
candidates.push(trimmed);
}
function stripNativeMatrixMentionPrefix(text: string, candidate: string): string | null {
const pattern = new RegExp(`^\\s*${escapeRegExp(candidate)}(?:\\s*[:,])?(?:\\s+|$)`, "i");
const match = text.match(pattern);
if (!match) {
return null;
}
return text.slice(match[0].length).trimStart();
}
function stripRegexMatrixMentionPrefix(text: string, pattern: RegExp): string | null {
const flags = pattern.flags.replace(/[gy]/g, "");
const anchored = new RegExp(`^\\s*(?:${pattern.source})(?:\\s*[:,])?(?:\\s+|$)`, flags);
const match = text.match(anchored);
if (!match) {
return null;
}
return text.slice(match[0].length).trimStart();
}
export function stripMatrixMentionPrefix(params: {
text: string;
userId?: string | null;
displayName?: string | null;
mentionRegexes?: RegExp[];
}): string {
const text = params.text;
if (!text) {
return text;
}
const candidates: string[] = [];
const seen = new Set<string>();
appendUniqueMentionCandidate(candidates, seen, params.userId);
const localpart = params.userId ? resolveMatrixUserLocalpart(params.userId) : null;
appendUniqueMentionCandidate(candidates, seen, localpart ? `@${localpart}` : null);
appendUniqueMentionCandidate(candidates, seen, params.displayName);
appendUniqueMentionCandidate(
candidates,
seen,
params.displayName ? `@${params.displayName}` : null,
);
for (const candidate of candidates) {
const stripped = stripNativeMatrixMentionPrefix(text, candidate);
if (stripped !== null) {
return stripped;
}
}
for (const pattern of params.mentionRegexes ?? []) {
const stripped = stripRegexMatrixMentionPrefix(text, pattern);
if (stripped !== null) {
return stripped;
}
}
return text;
}
function isVisibleMentionLabel(params: {
text: string;
userId: string;