mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:10:45 +00:00
fix(matrix): detect commands after bot mentions
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user