From 733c0c2fda829e60c31f32b6c256a7424165f0d4 Mon Sep 17 00:00:00 2001 From: "Mr.NightQ" Date: Mon, 20 Apr 2026 04:50:06 +0800 Subject: [PATCH] fix(matrix): strip mention prefix before slash command matching (#68570) Merged via squash. Prepared head SHA: d2c1ed58326608e6be647ada093461e0e0afdf17 Co-authored-by: nightq <3429433+nightq@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + .../monitor/handler.body-for-agent.test.ts | 2 + .../monitor/handler.strip-mention.test.ts | 141 ++++++++++++++++++ .../matrix/monitor/handler.test-helpers.ts | 2 +- .../matrix/src/matrix/monitor/handler.test.ts | 23 +++ .../matrix/src/matrix/monitor/handler.ts | 12 +- .../matrix/src/matrix/monitor/mentions.ts | 78 ++++++++++ 7 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 extensions/matrix/src/matrix/monitor/handler.strip-mention.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a69cf155626..2081c97d963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Cron/gateway: ignore disabled channels when announce delivery ambiguity is checked, and validate main-session delivery patches against the live cron service default agent so hot-reloaded agent config does not falsely reject valid updates. (#69040) Thanks @obviyus. - Matrix/allowlists: hot-reload `dm.allowFrom` and `groupAllowFrom` entries on inbound messages while keeping config removals authoritative, so Matrix allowlist changes no longer require a channel restart to add or revoke a sender. (#68546) Thanks @johnlanni. - BlueBubbles: always set `method` explicitly on outbound text sends (`"private-api"` when available, `"apple-script"` otherwise), and prefer Private API on macOS 26 even for plain text. Fixes silent delivery failure on macOS setups without Private API where an omitted `method` let BB Server fall back to version-dependent default behavior that silently drops the message (#64480), and the AppleScript `-1700` error on macOS 26 Tahoe plain text sends (#53159). (#69070) Thanks @xqing3. +- 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 and @johnlanni. ## 2026.4.19-beta.2 diff --git a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts index 57642745f8e..4095e757af8 100644 --- a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts @@ -30,6 +30,7 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => { channels: { matrix: { contextVisibility, + groupAllowFrom: ["@alice:example.org"], }, }, }, @@ -338,6 +339,7 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => { channels: { matrix: { contextVisibility: "allowlist", + groupAllowFrom: ["@alice:example.org"], }, }, }, diff --git a/extensions/matrix/src/matrix/monitor/handler.strip-mention.test.ts b/extensions/matrix/src/matrix/monitor/handler.strip-mention.test.ts new file mode 100644 index 00000000000..1d375ec9b98 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/handler.strip-mention.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { stripMatrixMentionPrefix } from "./mentions.js"; + +describe("stripMatrixMentionPrefix", () => { + it("strips full Matrix user id without configured mention regexes", () => { + const text = "@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 = stripMatrixMentionPrefix({ + text: "", + userId: "@bot:server", + mentionRegexes: [/\s*@bot:server\s*/], + }); + expect(result).toBe(""); + }); + + it("falls back to configured mention regexes before slash command", () => { + const mentionRegexes = [/@bot:server\b/]; + const text = "@bot:server /new"; + 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 = stripMatrixMentionPrefix({ text, mentionRegexes }); + expect(result).toBe("/help"); + }); + + it("strips mention prefix with display name (case-insensitive)", () => { + const mentionRegexes = [/@OpenClaw Bot\b/i]; + const text = "@openclaw bot /model"; + 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = stripMatrixMentionPrefix({ text, mentionRegexes }); + expect(result).toBe("hello world"); + }); + + it("preserves regex flags when stripping (case-insensitive match)", () => { + const mentionRegexes = [/@TestBot:server\b/i]; + const text = "@TESTBOT:SERVER /command"; + 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 57792075810..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"; @@ -935,8 +935,16 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam surface: "matrix", }); const useAccessGroups = cfg.commands?.useAccessGroups !== false; + // 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( - mentionPrecheckText, + commandCheckText, cfg, ); const commandGate = resolveControlCommandGate({ diff --git a/extensions/matrix/src/matrix/monitor/mentions.ts b/extensions/matrix/src/matrix/monitor/mentions.ts index 6b2a1ecd748..38a99cfc712 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 resolveMatrixMentionPrefixCandidates(params: { + userId?: string | null; + displayName?: string | null; +}): string[] { + const candidates: string[] = []; + const seen = new Set(); + + const append = (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); + }; + + append(params.userId); + const localpart = params.userId ? resolveMatrixUserLocalpart(params.userId) : null; + append(localpart ? `@${localpart}` : null); + append(params.displayName); + append(params.displayName ? `@${params.displayName}` : null); + + return candidates; +} + +function stripMatchedMatrixMentionPrefix(text: string, pattern: RegExp): string | null { + const match = text.match(pattern); + if (!match) { + return null; + } + return text.slice(match[0].length).trimStart(); +} + +function stripNativeMatrixMentionPrefix(text: string, candidate: string): string | null { + const pattern = new RegExp(`^\\s*${escapeRegExp(candidate)}(?:\\s*[:,])?(?:\\s+|$)`, "i"); + return stripMatchedMatrixMentionPrefix(text, pattern); +} + +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); + return stripMatchedMatrixMentionPrefix(text, anchored); +} + +export function stripMatrixMentionPrefix(params: { + text: string; + userId?: string | null; + displayName?: string | null; + mentionRegexes?: RegExp[]; +}): string { + const text = params.text; + if (!text) { + return text; + } + + for (const candidate of resolveMatrixMentionPrefixCandidates(params)) { + 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;