From b751fbc134f477c959447c5df48ac47bcdef4f52 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 19 Apr 2026 15:25:07 -0400 Subject: [PATCH] fix(matrix): detect commands after bot mentions --- CHANGELOG.md | 1 + .../monitor/handler.strip-mention.test.ts | 85 +++++++++++++------ .../matrix/monitor/handler.test-helpers.ts | 2 +- .../matrix/src/matrix/monitor/handler.test.ts | 23 +++++ .../matrix/src/matrix/monitor/handler.ts | 38 ++------- .../matrix/src/matrix/monitor/mentions.ts | 78 +++++++++++++++++ 6 files changed, 173 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a69cf155626..3520ddfbf3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/matrix/src/matrix/monitor/handler.strip-mention.test.ts b/extensions/matrix/src/matrix/monitor/handler.strip-mention.test.ts index 455f5459371..1d375ec9b98 100644 --- a/extensions/matrix/src/matrix/monitor/handler.strip-mention.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.strip-mention.test.ts @@ -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"); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts index 7ec593ec38b..1b78636f1e7 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -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; diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index f2751f32682..e23c4f15b09 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -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({ diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index a794c5fb03c..c62a487b1cc 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -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, diff --git a/extensions/matrix/src/matrix/monitor/mentions.ts b/extensions/matrix/src/matrix/monitor/mentions.ts index 6b2a1ecd748..70349a0a72a 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.ts @@ -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, + 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(); + 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;