fix(mattermost): keep bare @mention with empty body instead of dropping it (#93242)

Merged via squash.

Prepared head SHA: 7f6d21677b
Co-authored-by: iloveleon19 <37945260+iloveleon19@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
iloveleon19
2026-06-16 19:40:25 +08:00
committed by GitHub
parent fd61b1b6ee
commit 65805e519d
4 changed files with 175 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
// Mattermost tests cover monitor helpers plugin behavior.
import { describe, expect, it } from "vitest";
import { normalizeMention } from "./monitor-helpers.js";
import { normalizeMention, shouldDropEmptyMattermostBody } from "./monitor-helpers.js";
describe("normalizeMention", () => {
it("returns trimmed text when no mention provided", () => {
@@ -81,3 +81,106 @@ describe("normalizeMention", () => {
expect(result).toBe(" code line 1\n code line 2");
});
});
describe("shouldDropEmptyMattermostBody", () => {
it("drops a non-mention message that normalizes to an empty body", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "",
rawText: " ",
botUsername: "openclaw",
}),
).toBe(true);
});
it("keeps a message that still has body text", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "hello",
rawText: "hello",
botUsername: "openclaw",
}),
).toBe(false);
});
it("keeps a bare mention in a group", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "",
rawText: "@openclaw",
botUsername: "openclaw",
}),
).toBe(false);
});
it("keeps a bare mention in a direct message", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "",
rawText: "@OpenClaw",
botUsername: "openclaw",
}),
).toBe(false);
});
it("drops an empty body when the bot username is unknown", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "",
rawText: "@someoneelse",
botUsername: undefined,
}),
).toBe(true);
});
it("drops a blank post even when a generic mention pattern matched it", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "",
rawText: "",
botUsername: "openclaw",
}),
).toBe(true);
});
it("drops a bot mention with only a Unicode control residual", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "\u0085",
rawText: "@openclaw\u0085",
botUsername: "openclaw",
}),
).toBe(true);
});
it("drops a bot mention with only a combining-mark residual", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "\ufe0f",
rawText: "@openclaw\ufe0f",
botUsername: "openclaw",
}),
).toBe(true);
});
it.each([
"@openclaw @openclaw",
"@openclaw\n@openclaw",
"@openclaw\n",
"\n@openclaw",
"@openclaw\r\n",
"@openclaw\u2028",
"@openclaw\u2029",
"\v@openclaw\f",
"@openclaw\u00a0",
"\u2003@openclaw",
])("drops an invalid empty-body candidate: %j", (rawText) => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "",
rawText,
botUsername: "openclaw",
}),
).toBe(true);
});
});

View File

@@ -1,6 +1,7 @@
// Mattermost helper module supports monitor helpers behavior.
import { formatInboundFromLabel as formatInboundFromLabelShared } from "openclaw/plugin-sdk/channel-inbound";
import { resolveThreadSessionKeys as resolveThreadSessionKeysShared } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
export { rawDataToString };
@@ -53,3 +54,16 @@ export function normalizeMention(text: string, mention: string | undefined): str
return normalizedLines.map((line) => line.text).join("\n");
}
export function shouldDropEmptyMattermostBody(params: {
bodyText: string;
rawText: string;
botUsername?: string | null;
}): boolean {
if (/[^\p{White_Space}\p{Cc}\p{Cf}\p{M}]/u.test(params.bodyText)) {
return false;
}
const botUsername = normalizeLowercaseStringOrEmpty(params.botUsername ?? "");
const bareMention = params.rawText.match(/^[ \t]*(@\S+)[ \t]*$/u)?.[1];
return !botUsername || normalizeLowercaseStringOrEmpty(bareMention ?? "") !== `@${botUsername}`;
}

View File

@@ -445,6 +445,54 @@ describe("mattermost inbound user posts", () => {
expect(ctx?.Provider).toBe("mattermost");
});
it("dispatches a bare bot mention whose body is empty after normalization as a wake event", async () => {
const socket = new FakeWebSocket();
const abortController = new AbortController();
mockState.abortController = abortController;
const monitor = monitorMattermostProvider({
config: testConfig,
runtime: testRuntime(),
abortSignal: abortController.signal,
webSocketFactory: () => socket,
});
await vi.waitFor(() => {
expect(socket.openListenerCount).toBeGreaterThan(0);
});
socket.emitOpen();
await socket.emitMessage({
event: "posted",
data: {
channel_id: "chan-1",
channel_name: "town-square",
channel_display_name: "Town Square",
sender_name: "alice",
post: JSON.stringify({
id: "post-bare-mention",
channel_id: "chan-1",
user_id: "user-1",
message: "@openclaw",
create_at: 1_714_000_000_001,
}),
},
broadcast: {
channel_id: "chan-1",
user_id: "user-1",
},
});
socket.emitClose(1000);
await monitor;
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
const ctx = mockState.dispatchReplyFromConfig.mock.calls.at(0)?.[0].ctx;
expect(ctx?.BodyForAgent).toBe("@openclaw");
expect(ctx?.MessageSid).toBe("post-bare-mention");
expect(ctx?.OriginatingChannel).toBe("mattermost");
expect(ctx?.Provider).toBe("mattermost");
});
it("merges Mattermost progress preview updates and clears after message-tool delivery", async () => {
const socket = new FakeWebSocket();
const abortController = new AbortController();

View File

@@ -69,6 +69,7 @@ import {
formatInboundFromLabel,
normalizeMention,
resolveThreadSessionKeys,
shouldDropEmptyMattermostBody,
} from "./monitor-helpers.js";
import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js";
import { createMattermostMonitorResources, type MattermostMediaInfo } from "./monitor-resources.js";
@@ -1332,7 +1333,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
normalizeOptionalString(payload.data?.sender_name) ??
normalizeOptionalString((await resolveUserInfo(senderId))?.username) ??
senderId;
const rawText = normalizeOptionalString(post.message) ?? "";
const rawPostText = typeof post.message === "string" ? post.message : "";
const rawText = normalizeOptionalString(rawPostText) ?? "";
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "mattermost",
@@ -1526,12 +1528,15 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
const bodyText = normalizeMention(baseText, botUsername);
if (!bodyText) {
if (shouldDropEmptyMattermostBody({ bodyText, rawText: rawPostText, botUsername })) {
logVerboseMessage(
`mattermost: drop group message (empty body after normalization channel=${channelId} sender=${senderId})`,
`mattermost: drop message (empty body after normalization channel=${channelId} sender=${senderId} wasMentioned=${wasMentioned})`,
);
return;
}
// Mention-only turns need non-empty agent text; the shared reply runner rejects empty
// bodies before model invocation. The guard above ensures this fallback is a bot mention.
const bodyForAgent = bodyText || rawText.trim();
core.channel.activity.record({
channel: "mattermost",
@@ -1590,7 +1595,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
: undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: bodyText,
BodyForAgent: bodyForAgent,
InboundHistory: inboundHistory,
RawBody: bodyText,
CommandBody: commandBody,