Mattermost: honor onmessage mention override and add gating diagnostics tests (#27160)

Merged via squash.

Prepared head SHA: 6cefb1d5bf
Co-authored-by: turian <65918+turian@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
This commit is contained in:
Joseph Turian
2026-03-05 07:29:54 -05:00
committed by GitHub
parent 06ff25cce4
commit e5b6a4e19d
6 changed files with 291 additions and 26 deletions

View File

@@ -0,0 +1,46 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { describe, expect, it } from "vitest";
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
describe("resolveMattermostGroupRequireMention", () => {
it("defaults to requiring mention when no override is configured", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {},
},
};
const requireMention = resolveMattermostGroupRequireMention({ cfg, accountId: "default" });
expect(requireMention).toBe(true);
});
it("respects chatmode-derived account override", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
chatmode: "onmessage",
},
},
};
const requireMention = resolveMattermostGroupRequireMention({ cfg, accountId: "default" });
expect(requireMention).toBe(false);
});
it("prefers an explicit runtime override when provided", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
chatmode: "oncall",
},
},
};
const requireMention = resolveMattermostGroupRequireMention({
cfg,
accountId: "default",
requireMentionOverride: false,
});
expect(requireMention).toBe(false);
});
});

View File

@@ -1,15 +1,22 @@
import type { ChannelGroupContext } from "openclaw/plugin-sdk/mattermost";
import { resolveChannelGroupRequireMention, type ChannelGroupContext } from "openclaw/plugin-sdk";
import { resolveMattermostAccount } from "./mattermost/accounts.js";
export function resolveMattermostGroupRequireMention(
params: ChannelGroupContext,
params: ChannelGroupContext & { requireMentionOverride?: boolean },
): boolean | undefined {
const account = resolveMattermostAccount({
cfg: params.cfg,
accountId: params.accountId,
});
if (typeof account.requireMention === "boolean") {
return account.requireMention;
}
return true;
const requireMentionOverride =
typeof params.requireMentionOverride === "boolean"
? params.requireMentionOverride
: account.requireMention;
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "mattermost",
groupId: params.groupId,
accountId: params.accountId,
requireMentionOverride,
});
}

View File

@@ -0,0 +1,109 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import { resolveMattermostAccount } from "./accounts.js";
import {
evaluateMattermostMentionGate,
type MattermostMentionGateInput,
type MattermostRequireMentionResolverInput,
} from "./monitor.js";
function resolveRequireMentionForTest(params: MattermostRequireMentionResolverInput): boolean {
const root = params.cfg.channels?.mattermost;
const accountGroups = root?.accounts?.[params.accountId]?.groups;
const groups = accountGroups ?? root?.groups;
const groupConfig = params.groupId ? groups?.[params.groupId] : undefined;
const defaultGroupConfig = groups?.["*"];
const configMention =
typeof groupConfig?.requireMention === "boolean"
? groupConfig.requireMention
: typeof defaultGroupConfig?.requireMention === "boolean"
? defaultGroupConfig.requireMention
: undefined;
if (typeof configMention === "boolean") {
return configMention;
}
if (typeof params.requireMentionOverride === "boolean") {
return params.requireMentionOverride;
}
return true;
}
function evaluateMentionGateForMessage(params: { cfg: OpenClawConfig; threadRootId?: string }) {
const account = resolveMattermostAccount({ cfg: params.cfg, accountId: "default" });
const resolver = vi.fn(resolveRequireMentionForTest);
const input: MattermostMentionGateInput = {
kind: "channel",
cfg: params.cfg,
accountId: account.accountId,
channelId: "chan-1",
threadRootId: params.threadRootId,
requireMentionOverride: account.requireMention,
resolveRequireMention: resolver,
wasMentioned: false,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: false,
oncharTriggered: false,
canDetectMention: true,
};
const decision = evaluateMattermostMentionGate(input);
return { account, resolver, decision };
}
describe("mattermost mention gating", () => {
it("accepts unmentioned root channel posts in onmessage mode", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
chatmode: "onmessage",
groupPolicy: "open",
},
},
};
const { resolver, decision } = evaluateMentionGateForMessage({ cfg });
expect(decision.dropReason).toBeNull();
expect(decision.shouldRequireMention).toBe(false);
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "default",
groupId: "chan-1",
requireMentionOverride: false,
}),
);
});
it("accepts unmentioned thread replies in onmessage mode", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
chatmode: "onmessage",
groupPolicy: "open",
},
},
};
const { resolver, decision } = evaluateMentionGateForMessage({
cfg,
threadRootId: "thread-root-1",
});
expect(decision.dropReason).toBeNull();
expect(decision.shouldRequireMention).toBe(false);
const resolverCall = resolver.mock.calls.at(-1)?.[0];
expect(resolverCall?.groupId).toBe("chan-1");
expect(resolverCall?.groupId).not.toBe("thread-root-1");
});
it("rejects unmentioned channel posts in oncall mode", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
chatmode: "oncall",
groupPolicy: "open",
},
},
};
const { decision, account } = evaluateMentionGateForMessage({ cfg });
expect(account.requireMention).toBe(true);
expect(decision.shouldRequireMention).toBe(true);
expect(decision.dropReason).toBe("missing-mention");
});
});

View File

@@ -156,6 +156,89 @@ function channelChatType(kind: ChatType): "direct" | "group" | "channel" {
return "channel";
}
export type MattermostRequireMentionResolverInput = {
cfg: OpenClawConfig;
channel: "mattermost";
accountId: string;
groupId: string;
requireMentionOverride?: boolean;
};
export type MattermostMentionGateInput = {
kind: ChatType;
cfg: OpenClawConfig;
accountId: string;
channelId: string;
threadRootId?: string;
requireMentionOverride?: boolean;
resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean;
wasMentioned: boolean;
isControlCommand: boolean;
commandAuthorized: boolean;
oncharEnabled: boolean;
oncharTriggered: boolean;
canDetectMention: boolean;
};
type MattermostMentionGateDecision = {
shouldRequireMention: boolean;
shouldBypassMention: boolean;
effectiveWasMentioned: boolean;
dropReason: "onchar-not-triggered" | "missing-mention" | null;
};
export function evaluateMattermostMentionGate(
params: MattermostMentionGateInput,
): MattermostMentionGateDecision {
const shouldRequireMention =
params.kind !== "direct" &&
params.resolveRequireMention({
cfg: params.cfg,
channel: "mattermost",
accountId: params.accountId,
groupId: params.channelId,
requireMentionOverride: params.requireMentionOverride,
});
const shouldBypassMention =
params.isControlCommand &&
shouldRequireMention &&
!params.wasMentioned &&
params.commandAuthorized;
const effectiveWasMentioned =
params.wasMentioned || shouldBypassMention || params.oncharTriggered;
if (
params.oncharEnabled &&
!params.oncharTriggered &&
!params.wasMentioned &&
!params.isControlCommand
) {
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: "onchar-not-triggered",
};
}
if (
params.kind !== "direct" &&
shouldRequireMention &&
params.canDetectMention &&
!effectiveWasMentioned
) {
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: "missing-mention",
};
}
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: null,
};
}
type MattermostMediaInfo = {
path: string;
contentType?: string;
@@ -485,28 +568,36 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
) => {
const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id;
if (!channelId) {
logVerboseMessage("mattermost: drop post (missing channel id)");
return;
}
const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : [];
if (allMessageIds.length === 0) {
logVerboseMessage("mattermost: drop post (missing message id)");
return;
}
const dedupeEntries = allMessageIds.map((id) =>
recentInboundMessages.check(`${account.accountId}:${id}`),
);
if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) {
logVerboseMessage(
`mattermost: drop post (dedupe account=${account.accountId} ids=${allMessageIds.length})`,
);
return;
}
const senderId = post.user_id ?? payload.broadcast?.user_id;
if (!senderId) {
logVerboseMessage("mattermost: drop post (missing sender id)");
return;
}
if (senderId === botUserId) {
logVerboseMessage(`mattermost: drop post (self sender=${senderId})`);
return;
}
if (isSystemPost(post)) {
logVerboseMessage(`mattermost: drop post (system post type=${post.type ?? "unknown"})`);
return;
}
@@ -707,30 +798,38 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
? stripOncharPrefix(rawText, oncharPrefixes)
: { triggered: false, stripped: rawText };
const oncharTriggered = oncharResult.triggered;
const shouldRequireMention =
kind !== "direct" &&
core.channel.groups.resolveRequireMention({
cfg,
channel: "mattermost",
accountId: account.accountId,
groupId: channelId,
});
const shouldBypassMention =
isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized;
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
const mentionDecision = evaluateMattermostMentionGate({
kind,
cfg,
accountId: account.accountId,
channelId,
threadRootId,
requireMentionOverride: account.requireMention,
resolveRequireMention: core.channel.groups.resolveRequireMention,
wasMentioned,
isControlCommand,
commandAuthorized,
oncharEnabled,
oncharTriggered,
canDetectMention,
});
const { shouldRequireMention, shouldBypassMention } = mentionDecision;
if (oncharEnabled && !oncharTriggered && !wasMentioned && !isControlCommand) {
if (mentionDecision.dropReason === "onchar-not-triggered") {
logVerboseMessage(
`mattermost: drop group message (onchar not triggered channel=${channelId} sender=${senderId})`,
);
recordPendingHistory();
return;
}
if (kind !== "direct" && shouldRequireMention && canDetectMention) {
if (!effectiveWasMentioned) {
recordPendingHistory();
return;
}
if (mentionDecision.dropReason === "missing-mention") {
logVerboseMessage(
`mattermost: drop group message (missing mention channel=${channelId} sender=${senderId} requireMention=${shouldRequireMention} bypass=${shouldBypassMention} canDetectMention=${canDetectMention})`,
);
recordPendingHistory();
return;
}
const mediaList = await resolveMattermostMedia(post.file_ids);
const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList);
@@ -738,6 +837,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
const bodyText = normalizeMention(baseText, botUsername);
if (!bodyText) {
logVerboseMessage(
`mattermost: drop group message (empty body after normalization channel=${channelId} sender=${senderId})`,
);
return;
}
@@ -841,7 +943,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
ReplyToId: threadRootId,
MessageThreadId: threadRootId,
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
WasMentioned: kind !== "direct" ? effectiveWasMentioned : undefined,
WasMentioned: kind !== "direct" ? mentionDecision.effectiveWasMentioned : undefined,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "mattermost" as const,
OriginatingTo: to,