mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
46
extensions/mattermost/src/group-mentions.test.ts
Normal file
46
extensions/mattermost/src/group-mentions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
109
extensions/mattermost/src/mattermost/monitor.test.ts
Normal file
109
extensions/mattermost/src/mattermost/monitor.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user