mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 10:34:44 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user