diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.test.ts b/extensions/mattermost/src/mattermost/monitor-helpers.test.ts index e064d78aa3aa..aa6b03dbda63 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.test.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.test.ts @@ -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); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts index 443973c22fd9..2892a58939ca 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -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}`; +} diff --git a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts index 51bd71d043e8..30aa01b5061a 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -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(); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 4990678e84e9..28f25852449b 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -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,