diff --git a/CHANGELOG.md b/CHANGELOG.md index 580f6c74f0d..787e05abb78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -715,6 +715,7 @@ Docs: https://docs.openclaw.ai - 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..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. - 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. - 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. +- 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. ## 2026.2.25 diff --git a/extensions/mattermost/src/group-mentions.test.ts b/extensions/mattermost/src/group-mentions.test.ts new file mode 100644 index 00000000000..24624d68161 --- /dev/null +++ b/extensions/mattermost/src/group-mentions.test.ts @@ -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); + }); +}); diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts index 22e5d53dc78..45e70209e20 100644 --- a/extensions/mattermost/src/group-mentions.ts +++ b/extensions/mattermost/src/group-mentions.ts @@ -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, + }); } diff --git a/extensions/mattermost/src/mattermost/monitor.test.ts b/extensions/mattermost/src/mattermost/monitor.test.ts new file mode 100644 index 00000000000..2903d1a5d80 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor.test.ts @@ -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"); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 0b7111fb941..3a0241c84f8 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -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, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 32d0f3cfd79..7a7c43a53c9 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -398,7 +398,7 @@ export type { ScopeTokenProvider } from "./fetch-auth.js"; export { rawDataToString } from "../infra/ws.js"; export { isWSLSync, isWSL2Sync, isWSLEnv } from "../infra/wsl.js"; export { isTruthyEnvValue } from "../infra/env.js"; -export { resolveToolsBySender } from "../config/group-policy.js"; +export { resolveChannelGroupRequireMention, resolveToolsBySender } from "../config/group-policy.js"; export { buildPendingHistoryContextFromMap, clearHistoryEntries,