mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:00:44 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user