fix(matrix): strip mention prefix before slash command matching (#68570)

Merged via squash.

Prepared head SHA: d2c1ed5832
Co-authored-by: nightq <3429433+nightq@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Mr.NightQ
2026-04-20 04:50:06 +08:00
committed by GitHub
parent efc19f0ddb
commit 733c0c2fda
7 changed files with 256 additions and 3 deletions

View File

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

View File

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

View File

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

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";
@@ -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({

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 resolveMatrixMentionPrefixCandidates(params: {
userId?: string | null;
displayName?: string | null;
}): string[] {
const candidates: string[] = [];
const seen = new Set<string>();
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;