Skip to content

Commit e5b6a4e

Browse files
turianmukhtharcm
andauthored
Mattermost: honor onmessage mention override and add gating diagnostics tests (openclaw#27160)
Merged via squash. Prepared head SHA: 6cefb1d Co-authored-by: turian <65918+turian@users.noreply.github.com> Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com> Reviewed-by: @mukhtharcm
1 parent 06ff25c commit e5b6a4e

6 files changed

Lines changed: 291 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,7 @@ Docs: https://docs.openclaw.ai
715715
- Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into `channels.<channel>.accounts.default` before writing the new account so the original account keeps working without duplicated account values at channel root; `openclaw doctor --fix` now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.
716716
- iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman.
717717
- CI/Windows: shard the Windows `checks-windows` test lane into two matrix jobs and honor explicit shard index overrides in `scripts/test-parallel.mjs` to reduce CI critical-path wall time. (#27234) Thanks @joshavant.
718+
- Mattermost/mention gating: honor `chatmode: "onmessage"` account override in inbound group/channel mention-gate resolution, while preserving explicit group `requireMention` config precedence and adding verbose drop diagnostics for skipped inbound posts. (#27160) thanks @turian.
718719

719720
## 2026.2.25
720721

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
2+
import { describe, expect, it } from "vitest";
3+
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
4+
5+
describe("resolveMattermostGroupRequireMention", () => {
6+
it("defaults to requiring mention when no override is configured", () => {
7+
const cfg: OpenClawConfig = {
8+
channels: {
9+
mattermost: {},
10+
},
11+
};
12+
13+
const requireMention = resolveMattermostGroupRequireMention({ cfg, accountId: "default" });
14+
expect(requireMention).toBe(true);
15+
});
16+
17+
it("respects chatmode-derived account override", () => {
18+
const cfg: OpenClawConfig = {
19+
channels: {
20+
mattermost: {
21+
chatmode: "onmessage",
22+
},
23+
},
24+
};
25+
26+
const requireMention = resolveMattermostGroupRequireMention({ cfg, accountId: "default" });
27+
expect(requireMention).toBe(false);
28+
});
29+
30+
it("prefers an explicit runtime override when provided", () => {
31+
const cfg: OpenClawConfig = {
32+
channels: {
33+
mattermost: {
34+
chatmode: "oncall",
35+
},
36+
},
37+
};
38+
39+
const requireMention = resolveMattermostGroupRequireMention({
40+
cfg,
41+
accountId: "default",
42+
requireMentionOverride: false,
43+
});
44+
expect(requireMention).toBe(false);
45+
});
46+
});
Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
1-
import type { ChannelGroupContext } from "openclaw/plugin-sdk/mattermost";
1+
import { resolveChannelGroupRequireMention, type ChannelGroupContext } from "openclaw/plugin-sdk";
22
import { resolveMattermostAccount } from "./mattermost/accounts.js";
33

44
export function resolveMattermostGroupRequireMention(
5-
params: ChannelGroupContext,
5+
params: ChannelGroupContext & { requireMentionOverride?: boolean },
66
): boolean | undefined {
77
const account = resolveMattermostAccount({
88
cfg: params.cfg,
99
accountId: params.accountId,
1010
});
11-
if (typeof account.requireMention === "boolean") {
12-
return account.requireMention;
13-
}
14-
return true;
11+
const requireMentionOverride =
12+
typeof params.requireMentionOverride === "boolean"
13+
? params.requireMentionOverride
14+
: account.requireMention;
15+
return resolveChannelGroupRequireMention({
16+
cfg: params.cfg,
17+
channel: "mattermost",
18+
groupId: params.groupId,
19+
accountId: params.accountId,
20+
requireMentionOverride,
21+
});
1522
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { resolveMattermostAccount } from "./accounts.js";
4+
import {
5+
evaluateMattermostMentionGate,
6+
type MattermostMentionGateInput,
7+
type MattermostRequireMentionResolverInput,
8+
} from "./monitor.js";
9+
10+
function resolveRequireMentionForTest(params: MattermostRequireMentionResolverInput): boolean {
11+
const root = params.cfg.channels?.mattermost;
12+
const accountGroups = root?.accounts?.[params.accountId]?.groups;
13+
const groups = accountGroups ?? root?.groups;
14+
const groupConfig = params.groupId ? groups?.[params.groupId] : undefined;
15+
const defaultGroupConfig = groups?.["*"];
16+
const configMention =
17+
typeof groupConfig?.requireMention === "boolean"
18+
? groupConfig.requireMention
19+
: typeof defaultGroupConfig?.requireMention === "boolean"
20+
? defaultGroupConfig.requireMention
21+
: undefined;
22+
if (typeof configMention === "boolean") {
23+
return configMention;
24+
}
25+
if (typeof params.requireMentionOverride === "boolean") {
26+
return params.requireMentionOverride;
27+
}
28+
return true;
29+
}
30+
31+
function evaluateMentionGateForMessage(params: { cfg: OpenClawConfig; threadRootId?: string }) {
32+
const account = resolveMattermostAccount({ cfg: params.cfg, accountId: "default" });
33+
const resolver = vi.fn(resolveRequireMentionForTest);
34+
const input: MattermostMentionGateInput = {
35+
kind: "channel",
36+
cfg: params.cfg,
37+
accountId: account.accountId,
38+
channelId: "chan-1",
39+
threadRootId: params.threadRootId,
40+
requireMentionOverride: account.requireMention,
41+
resolveRequireMention: resolver,
42+
wasMentioned: false,
43+
isControlCommand: false,
44+
commandAuthorized: false,
45+
oncharEnabled: false,
46+
oncharTriggered: false,
47+
canDetectMention: true,
48+
};
49+
const decision = evaluateMattermostMentionGate(input);
50+
return { account, resolver, decision };
51+
}
52+
53+
describe("mattermost mention gating", () => {
54+
it("accepts unmentioned root channel posts in onmessage mode", () => {
55+
const cfg: OpenClawConfig = {
56+
channels: {
57+
mattermost: {
58+
chatmode: "onmessage",
59+
groupPolicy: "open",
60+
},
61+
},
62+
};
63+
const { resolver, decision } = evaluateMentionGateForMessage({ cfg });
64+
expect(decision.dropReason).toBeNull();
65+
expect(decision.shouldRequireMention).toBe(false);
66+
expect(resolver).toHaveBeenCalledWith(
67+
expect.objectContaining({
68+
accountId: "default",
69+
groupId: "chan-1",
70+
requireMentionOverride: false,
71+
}),
72+
);
73+
});
74+
75+
it("accepts unmentioned thread replies in onmessage mode", () => {
76+
const cfg: OpenClawConfig = {
77+
channels: {
78+
mattermost: {
79+
chatmode: "onmessage",
80+
groupPolicy: "open",
81+
},
82+
},
83+
};
84+
const { resolver, decision } = evaluateMentionGateForMessage({
85+
cfg,
86+
threadRootId: "thread-root-1",
87+
});
88+
expect(decision.dropReason).toBeNull();
89+
expect(decision.shouldRequireMention).toBe(false);
90+
const resolverCall = resolver.mock.calls.at(-1)?.[0];
91+
expect(resolverCall?.groupId).toBe("chan-1");
92+
expect(resolverCall?.groupId).not.toBe("thread-root-1");
93+
});
94+
95+
it("rejects unmentioned channel posts in oncall mode", () => {
96+
const cfg: OpenClawConfig = {
97+
channels: {
98+
mattermost: {
99+
chatmode: "oncall",
100+
groupPolicy: "open",
101+
},
102+
},
103+
};
104+
const { decision, account } = evaluateMentionGateForMessage({ cfg });
105+
expect(account.requireMention).toBe(true);
106+
expect(decision.shouldRequireMention).toBe(true);
107+
expect(decision.dropReason).toBe("missing-mention");
108+
});
109+
});

extensions/mattermost/src/mattermost/monitor.ts

Lines changed: 121 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,89 @@ function channelChatType(kind: ChatType): "direct" | "group" | "channel" {
156156
return "channel";
157157
}
158158

159+
export type MattermostRequireMentionResolverInput = {
160+
cfg: OpenClawConfig;
161+
channel: "mattermost";
162+
accountId: string;
163+
groupId: string;
164+
requireMentionOverride?: boolean;
165+
};
166+
167+
export type MattermostMentionGateInput = {
168+
kind: ChatType;
169+
cfg: OpenClawConfig;
170+
accountId: string;
171+
channelId: string;
172+
threadRootId?: string;
173+
requireMentionOverride?: boolean;
174+
resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean;
175+
wasMentioned: boolean;
176+
isControlCommand: boolean;
177+
commandAuthorized: boolean;
178+
oncharEnabled: boolean;
179+
oncharTriggered: boolean;
180+
canDetectMention: boolean;
181+
};
182+
183+
type MattermostMentionGateDecision = {
184+
shouldRequireMention: boolean;
185+
shouldBypassMention: boolean;
186+
effectiveWasMentioned: boolean;
187+
dropReason: "onchar-not-triggered" | "missing-mention" | null;
188+
};
189+
190+
export function evaluateMattermostMentionGate(
191+
params: MattermostMentionGateInput,
192+
): MattermostMentionGateDecision {
193+
const shouldRequireMention =
194+
params.kind !== "direct" &&
195+
params.resolveRequireMention({
196+
cfg: params.cfg,
197+
channel: "mattermost",
198+
accountId: params.accountId,
199+
groupId: params.channelId,
200+
requireMentionOverride: params.requireMentionOverride,
201+
});
202+
const shouldBypassMention =
203+
params.isControlCommand &&
204+
shouldRequireMention &&
205+
!params.wasMentioned &&
206+
params.commandAuthorized;
207+
const effectiveWasMentioned =
208+
params.wasMentioned || shouldBypassMention || params.oncharTriggered;
209+
if (
210+
params.oncharEnabled &&
211+
!params.oncharTriggered &&
212+
!params.wasMentioned &&
213+
!params.isControlCommand
214+
) {
215+
return {
216+
shouldRequireMention,
217+
shouldBypassMention,
218+
effectiveWasMentioned,
219+
dropReason: "onchar-not-triggered",
220+
};
221+
}
222+
if (
223+
params.kind !== "direct" &&
224+
shouldRequireMention &&
225+
params.canDetectMention &&
226+
!effectiveWasMentioned
227+
) {
228+
return {
229+
shouldRequireMention,
230+
shouldBypassMention,
231+
effectiveWasMentioned,
232+
dropReason: "missing-mention",
233+
};
234+
}
235+
return {
236+
shouldRequireMention,
237+
shouldBypassMention,
238+
effectiveWasMentioned,
239+
dropReason: null,
240+
};
241+
}
159242
type MattermostMediaInfo = {
160243
path: string;
161244
contentType?: string;
@@ -485,28 +568,36 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
485568
) => {
486569
const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id;
487570
if (!channelId) {
571+
logVerboseMessage("mattermost: drop post (missing channel id)");
488572
return;
489573
}
490574

491575
const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : [];
492576
if (allMessageIds.length === 0) {
577+
logVerboseMessage("mattermost: drop post (missing message id)");
493578
return;
494579
}
495580
const dedupeEntries = allMessageIds.map((id) =>
496581
recentInboundMessages.check(`${account.accountId}:${id}`),
497582
);
498583
if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) {
584+
logVerboseMessage(
585+
`mattermost: drop post (dedupe account=${account.accountId} ids=${allMessageIds.length})`,
586+
);
499587
return;
500588
}
501589

502590
const senderId = post.user_id ?? payload.broadcast?.user_id;
503591
if (!senderId) {
592+
logVerboseMessage("mattermost: drop post (missing sender id)");
504593
return;
505594
}
506595
if (senderId === botUserId) {
596+
logVerboseMessage(`mattermost: drop post (self sender=${senderId})`);
507597
return;
508598
}
509599
if (isSystemPost(post)) {
600+
logVerboseMessage(`mattermost: drop post (system post type=${post.type ?? "unknown"})`);
510601
return;
511602
}
512603

@@ -707,37 +798,48 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
707798
? stripOncharPrefix(rawText, oncharPrefixes)
708799
: { triggered: false, stripped: rawText };
709800
const oncharTriggered = oncharResult.triggered;
710-
711-
const shouldRequireMention =
712-
kind !== "direct" &&
713-
core.channel.groups.resolveRequireMention({
714-
cfg,
715-
channel: "mattermost",
716-
accountId: account.accountId,
717-
groupId: channelId,
718-
});
719-
const shouldBypassMention =
720-
isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized;
721-
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
722801
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
802+
const mentionDecision = evaluateMattermostMentionGate({
803+
kind,
804+
cfg,
805+
accountId: account.accountId,
806+
channelId,
807+
threadRootId,
808+
requireMentionOverride: account.requireMention,
809+
resolveRequireMention: core.channel.groups.resolveRequireMention,
810+
wasMentioned,
811+
isControlCommand,
812+
commandAuthorized,
813+
oncharEnabled,
814+
oncharTriggered,
815+
canDetectMention,
816+
});
817+
const { shouldRequireMention, shouldBypassMention } = mentionDecision;
723818

724-
if (oncharEnabled && !oncharTriggered && !wasMentioned && !isControlCommand) {
819+
if (mentionDecision.dropReason === "onchar-not-triggered") {
820+
logVerboseMessage(
821+
`mattermost: drop group message (onchar not triggered channel=${channelId} sender=${senderId})`,
822+
);
725823
recordPendingHistory();
726824
return;
727825
}
728826

729-
if (kind !== "direct" && shouldRequireMention && canDetectMention) {
730-
if (!effectiveWasMentioned) {
731-
recordPendingHistory();
732-
return;
733-
}
827+
if (mentionDecision.dropReason === "missing-mention") {
828+
logVerboseMessage(
829+
`mattermost: drop group message (missing mention channel=${channelId} sender=${senderId} requireMention=${shouldRequireMention} bypass=${shouldBypassMention} canDetectMention=${canDetectMention})`,
830+
);
831+
recordPendingHistory();
832+
return;
734833
}
735834
const mediaList = await resolveMattermostMedia(post.file_ids);
736835
const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList);
737836
const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
738837
const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
739838
const bodyText = normalizeMention(baseText, botUsername);
740839
if (!bodyText) {
840+
logVerboseMessage(
841+
`mattermost: drop group message (empty body after normalization channel=${channelId} sender=${senderId})`,
842+
);
741843
return;
742844
}
743845

@@ -841,7 +943,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
841943
ReplyToId: threadRootId,
842944
MessageThreadId: threadRootId,
843945
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
844-
WasMentioned: kind !== "direct" ? effectiveWasMentioned : undefined,
946+
WasMentioned: kind !== "direct" ? mentionDecision.effectiveWasMentioned : undefined,
845947
CommandAuthorized: commandAuthorized,
846948
OriginatingChannel: "mattermost" as const,
847949
OriginatingTo: to,

0 commit comments

Comments
 (0)