refactor: centralize inbound mention policy

This commit is contained in:
Peter Steinberger
2026-04-07 07:50:09 +01:00
parent c8b7058058
commit 625fd5b3e3
31 changed files with 857 additions and 225 deletions

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { resolveMentionGating, resolveMentionGatingWithBypass } from "./mention-gating.js";
import {
implicitMentionKindWhen,
resolveInboundMentionDecision,
resolveMentionGating,
resolveMentionGatingWithBypass,
} from "./mention-gating.js";
describe("resolveMentionGating", () => {
it("combines explicit, implicit, and bypass mentions", () => {
@@ -65,3 +70,192 @@ describe("resolveMentionGatingWithBypass", () => {
expect(res.shouldSkip).toBe(shouldSkip);
});
});
describe("resolveInboundMentionDecision", () => {
it("allows matching implicit mention kinds by default", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
implicitMentionKinds: ["reply_to_bot"],
},
policy: {
isGroup: true,
requireMention: true,
allowTextCommands: true,
hasControlCommand: false,
commandAuthorized: false,
},
});
expect(res.implicitMention).toBe(true);
expect(res.matchedImplicitMentionKinds).toEqual(["reply_to_bot"]);
expect(res.effectiveWasMentioned).toBe(true);
expect(res.shouldSkip).toBe(false);
});
it("filters implicit mention kinds through the allowlist", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
implicitMentionKinds: ["reply_to_bot", "bot_thread_participant"],
},
policy: {
isGroup: true,
requireMention: true,
allowedImplicitMentionKinds: ["reply_to_bot"],
allowTextCommands: true,
hasControlCommand: false,
commandAuthorized: false,
},
});
expect(res.implicitMention).toBe(true);
expect(res.matchedImplicitMentionKinds).toEqual(["reply_to_bot"]);
expect(res.shouldSkip).toBe(false);
});
it("blocks implicit mention kinds excluded by policy", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
implicitMentionKinds: ["reply_to_bot"],
},
policy: {
isGroup: true,
requireMention: true,
allowedImplicitMentionKinds: [],
allowTextCommands: true,
hasControlCommand: false,
commandAuthorized: false,
},
});
expect(res.implicitMention).toBe(false);
expect(res.matchedImplicitMentionKinds).toEqual([]);
expect(res.effectiveWasMentioned).toBe(false);
expect(res.shouldSkip).toBe(true);
});
it("dedupes repeated implicit mention kinds", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
implicitMentionKinds: ["reply_to_bot", "reply_to_bot", "native"],
},
policy: {
isGroup: true,
requireMention: true,
allowTextCommands: true,
hasControlCommand: false,
commandAuthorized: false,
},
});
expect(res.matchedImplicitMentionKinds).toEqual(["reply_to_bot", "native"]);
});
it("keeps command bypass behavior unchanged", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
hasAnyMention: false,
implicitMentionKinds: [],
},
policy: {
isGroup: true,
requireMention: true,
allowTextCommands: true,
hasControlCommand: true,
commandAuthorized: true,
},
});
expect(res.shouldBypassMention).toBe(true);
expect(res.effectiveWasMentioned).toBe(true);
expect(res.shouldSkip).toBe(false);
});
it("does not allow command bypass when some other mention is present", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
hasAnyMention: true,
implicitMentionKinds: [],
},
policy: {
isGroup: true,
requireMention: true,
allowTextCommands: true,
hasControlCommand: true,
commandAuthorized: true,
},
});
expect(res.shouldBypassMention).toBe(false);
expect(res.effectiveWasMentioned).toBe(false);
expect(res.shouldSkip).toBe(true);
});
it("does not allow command bypass outside groups", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: false,
hasAnyMention: false,
implicitMentionKinds: [],
},
policy: {
isGroup: false,
requireMention: true,
allowTextCommands: true,
hasControlCommand: true,
commandAuthorized: true,
},
});
expect(res.shouldBypassMention).toBe(false);
expect(res.effectiveWasMentioned).toBe(false);
expect(res.shouldSkip).toBe(true);
});
it("does not skip when mention detection is unavailable", () => {
const res = resolveInboundMentionDecision({
facts: {
canDetectMention: false,
wasMentioned: false,
implicitMentionKinds: [],
},
policy: {
isGroup: true,
requireMention: true,
allowTextCommands: true,
hasControlCommand: false,
commandAuthorized: false,
},
});
expect(res.shouldSkip).toBe(false);
});
it("keeps the flat call shape for compatibility", () => {
const res = resolveInboundMentionDecision({
isGroup: true,
requireMention: true,
canDetectMention: true,
wasMentioned: false,
implicitMentionKinds: ["reply_to_bot"],
allowTextCommands: true,
hasControlCommand: false,
commandAuthorized: false,
});
expect(res.effectiveWasMentioned).toBe(true);
});
});
describe("implicitMentionKindWhen", () => {
it("returns a one-item list when enabled", () => {
expect(implicitMentionKindWhen("reply_to_bot", true)).toEqual(["reply_to_bot"]);
});
it("returns an empty list when disabled", () => {
expect(implicitMentionKindWhen("reply_to_bot", false)).toEqual([]);
});
});

View File

@@ -1,3 +1,4 @@
/** @deprecated Prefer `resolveInboundMentionDecision({ facts, policy })`. */
export type MentionGateParams = {
requireMention: boolean;
canDetectMention: boolean;
@@ -6,11 +7,13 @@ export type MentionGateParams = {
shouldBypassMention?: boolean;
};
/** @deprecated Prefer `InboundMentionDecision`. */
export type MentionGateResult = {
effectiveWasMentioned: boolean;
shouldSkip: boolean;
};
/** @deprecated Prefer `resolveInboundMentionDecision({ facts, policy })`. */
export type MentionGateWithBypassParams = {
isGroup: boolean;
requireMention: boolean;
@@ -23,37 +26,207 @@ export type MentionGateWithBypassParams = {
commandAuthorized: boolean;
};
/** @deprecated Prefer `InboundMentionDecision`. */
export type MentionGateWithBypassResult = MentionGateResult & {
shouldBypassMention: boolean;
};
export function resolveMentionGating(params: MentionGateParams): MentionGateResult {
const implicit = params.implicitMention === true;
const bypass = params.shouldBypassMention === true;
const effectiveWasMentioned = params.wasMentioned || implicit || bypass;
const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned;
return { effectiveWasMentioned, shouldSkip };
export type InboundImplicitMentionKind =
| "reply_to_bot"
| "quoted_bot"
| "bot_thread_participant"
| "native";
export type InboundMentionFacts = {
canDetectMention: boolean;
wasMentioned: boolean;
hasAnyMention?: boolean;
implicitMentionKinds?: readonly InboundImplicitMentionKind[];
};
export type InboundMentionPolicy = {
isGroup: boolean;
requireMention: boolean;
allowedImplicitMentionKinds?: readonly InboundImplicitMentionKind[];
allowTextCommands: boolean;
hasControlCommand: boolean;
commandAuthorized: boolean;
};
/** @deprecated Prefer the nested `{ facts, policy }` call shape for new code. */
export type ResolveInboundMentionDecisionFlatParams = InboundMentionFacts & InboundMentionPolicy;
export type ResolveInboundMentionDecisionNestedParams = {
facts: InboundMentionFacts;
policy: InboundMentionPolicy;
};
export type ResolveInboundMentionDecisionParams =
| ResolveInboundMentionDecisionFlatParams
| ResolveInboundMentionDecisionNestedParams;
export type InboundMentionDecision = MentionGateResult & {
implicitMention: boolean;
matchedImplicitMentionKinds: InboundImplicitMentionKind[];
shouldBypassMention: boolean;
};
export function implicitMentionKindWhen(
kind: InboundImplicitMentionKind,
enabled: boolean,
): InboundImplicitMentionKind[] {
return enabled ? [kind] : [];
}
function resolveMatchedImplicitMentionKinds(params: {
implicitMentionKinds?: readonly InboundImplicitMentionKind[];
allowedImplicitMentionKinds?: readonly InboundImplicitMentionKind[];
}): InboundImplicitMentionKind[] {
const inputKinds = params.implicitMentionKinds ?? [];
if (inputKinds.length === 0) {
return [];
}
const allowedKinds = params.allowedImplicitMentionKinds
? new Set(params.allowedImplicitMentionKinds)
: null;
const matched: InboundImplicitMentionKind[] = [];
for (const kind of inputKinds) {
if (allowedKinds && !allowedKinds.has(kind)) {
continue;
}
if (!matched.includes(kind)) {
matched.push(kind);
}
}
return matched;
}
function resolveMentionDecisionCore(params: {
requireMention: boolean;
canDetectMention: boolean;
wasMentioned: boolean;
implicitMentionKinds?: readonly InboundImplicitMentionKind[];
allowedImplicitMentionKinds?: readonly InboundImplicitMentionKind[];
shouldBypassMention: boolean;
}): InboundMentionDecision {
const matchedImplicitMentionKinds = resolveMatchedImplicitMentionKinds({
implicitMentionKinds: params.implicitMentionKinds,
allowedImplicitMentionKinds: params.allowedImplicitMentionKinds,
});
const implicitMention = matchedImplicitMentionKinds.length > 0;
const effectiveWasMentioned =
params.wasMentioned || implicitMention || params.shouldBypassMention;
const shouldSkip = params.requireMention && params.canDetectMention && !effectiveWasMentioned;
return {
implicitMention,
matchedImplicitMentionKinds,
effectiveWasMentioned,
shouldBypassMention: params.shouldBypassMention,
shouldSkip,
};
}
function hasNestedMentionDecisionParams(
params: ResolveInboundMentionDecisionParams,
): params is ResolveInboundMentionDecisionNestedParams {
return "facts" in params && "policy" in params;
}
function normalizeMentionDecisionParams(
params: ResolveInboundMentionDecisionParams,
): ResolveInboundMentionDecisionNestedParams {
if (hasNestedMentionDecisionParams(params)) {
return params;
}
const {
canDetectMention,
wasMentioned,
hasAnyMention,
implicitMentionKinds,
isGroup,
requireMention,
allowedImplicitMentionKinds,
allowTextCommands,
hasControlCommand,
commandAuthorized,
} = params;
return {
facts: {
canDetectMention,
wasMentioned,
hasAnyMention,
implicitMentionKinds,
},
policy: {
isGroup,
requireMention,
allowedImplicitMentionKinds,
allowTextCommands,
hasControlCommand,
commandAuthorized,
},
};
}
export function resolveInboundMentionDecision(
params: ResolveInboundMentionDecisionParams,
): InboundMentionDecision {
const { facts, policy } = normalizeMentionDecisionParams(params);
const shouldBypassMention =
policy.isGroup &&
policy.requireMention &&
!facts.wasMentioned &&
!(facts.hasAnyMention ?? false) &&
policy.allowTextCommands &&
policy.commandAuthorized &&
policy.hasControlCommand;
return resolveMentionDecisionCore({
requireMention: policy.requireMention,
canDetectMention: facts.canDetectMention,
wasMentioned: facts.wasMentioned,
implicitMentionKinds: facts.implicitMentionKinds,
allowedImplicitMentionKinds: policy.allowedImplicitMentionKinds,
shouldBypassMention,
});
}
/** @deprecated Prefer `resolveInboundMentionDecision({ facts, policy })`. */
export function resolveMentionGating(params: MentionGateParams): MentionGateResult {
const result = resolveMentionDecisionCore({
requireMention: params.requireMention,
canDetectMention: params.canDetectMention,
wasMentioned: params.wasMentioned,
implicitMentionKinds: implicitMentionKindWhen("native", params.implicitMention === true),
shouldBypassMention: params.shouldBypassMention === true,
});
return {
effectiveWasMentioned: result.effectiveWasMentioned,
shouldSkip: result.shouldSkip,
};
}
/** @deprecated Prefer `resolveInboundMentionDecision({ facts, policy })`. */
export function resolveMentionGatingWithBypass(
params: MentionGateWithBypassParams,
): MentionGateWithBypassResult {
const shouldBypassMention =
params.isGroup &&
params.requireMention &&
!params.wasMentioned &&
!(params.hasAnyMention ?? false) &&
params.allowTextCommands &&
params.commandAuthorized &&
params.hasControlCommand;
return {
...resolveMentionGating({
requireMention: params.requireMention,
const result = resolveInboundMentionDecision({
facts: {
canDetectMention: params.canDetectMention,
wasMentioned: params.wasMentioned,
implicitMention: params.implicitMention,
shouldBypassMention,
}),
shouldBypassMention,
hasAnyMention: params.hasAnyMention,
implicitMentionKinds: implicitMentionKindWhen("native", params.implicitMention === true),
},
policy: {
isGroup: params.isGroup,
requireMention: params.requireMention,
allowTextCommands: params.allowTextCommands,
hasControlCommand: params.hasControlCommand,
commandAuthorized: params.commandAuthorized,
},
});
return {
effectiveWasMentioned: result.effectiveWasMentioned,
shouldSkip: result.shouldSkip,
shouldBypassMention: result.shouldBypassMention,
};
}

View File

@@ -26,13 +26,24 @@ export {
shouldDebounceTextInbound,
} from "../channels/inbound-debounce-policy.js";
export type {
InboundMentionFacts,
InboundMentionPolicy,
InboundImplicitMentionKind,
InboundMentionDecision,
MentionGateParams,
MentionGateResult,
MentionGateWithBypassParams,
MentionGateWithBypassResult,
ResolveInboundMentionDecisionFlatParams,
ResolveInboundMentionDecisionNestedParams,
ResolveInboundMentionDecisionParams,
} from "../channels/mention-gating.js";
export {
implicitMentionKindWhen,
resolveInboundMentionDecision,
// @deprecated Prefer `resolveInboundMentionDecision({ facts, policy })`.
resolveMentionGating,
// @deprecated Prefer `resolveInboundMentionDecision({ facts, policy })`.
resolveMentionGatingWithBypass,
} from "../channels/mention-gating.js";
export type { NormalizedLocation } from "../channels/location.js";

View File

@@ -11,7 +11,11 @@ export {
readReactionParams,
readStringParam,
} from "../agents/tools/common.js";
export { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
export {
resolveMentionGating,
resolveMentionGatingWithBypass,
resolveInboundMentionDecision,
} from "../channels/mention-gating.js";
export {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,

View File

@@ -19,7 +19,11 @@ export {
resolveDualTextControlCommandGate,
} from "../channels/command-gating.js";
export { logInboundDrop, logTypingFailure } from "../channels/logging.js";
export { resolveMentionGating } from "../channels/mention-gating.js";
export {
resolveInboundMentionDecision,
resolveMentionGating,
resolveMentionGatingWithBypass,
} from "../channels/mention-gating.js";
export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js";
export {
formatAllowlistMatchMeta,

View File

@@ -3,7 +3,11 @@
export { logInboundDrop } from "../channels/logging.js";
export { createAuthRateLimiter } from "../gateway/auth-rate-limit.js";
export { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
export {
resolveMentionGating,
resolveMentionGatingWithBypass,
resolveInboundMentionDecision,
} from "../channels/mention-gating.js";
export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js";
export {
buildChannelKeyCandidates,

View File

@@ -5,7 +5,11 @@ import { createOptionalChannelSetupSurface } from "./channel-setup.js";
export type { ReplyPayload } from "../auto-reply/types.js";
export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js";
export { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
export {
resolveMentionGating,
resolveMentionGatingWithBypass,
resolveInboundMentionDecision,
} from "../channels/mention-gating.js";
export {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,

View File

@@ -82,7 +82,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";',
'export { GoogleChatConfigSchema, type GoogleChatAccountConfig, type GoogleChatConfig } from "openclaw/plugin-sdk/googlechat-runtime-shared";',
'export { extractToolSend } from "openclaw/plugin-sdk/tool-send";',
'export { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-inbound";',
'export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";',
'export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";',
'export { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-path";',
'export { registerWebhookTargetWithPluginRoute, resolveWebhookTargetWithAuthOrReject, withResolvedWebhookRequestPipeline } from "openclaw/plugin-sdk/webhook-targets";',

View File

@@ -290,6 +290,13 @@ describe("plugin-sdk subpath exports", () => {
]) {
expectSourceMentions(subpath, ["chunkTextForOutbound"]);
}
for (const subpath of ["googlechat", "msteams", "nextcloud-talk", "zalouser"]) {
expectSourceMentions(subpath, [
"resolveInboundMentionDecision",
"resolveMentionGating",
"resolveMentionGatingWithBypass",
]);
}
expectSourceMentions("approval-auth-runtime", [
"createResolvedApproverActionAuthAdapter",
"resolveApprovalApprovers",
@@ -453,6 +460,7 @@ describe("plugin-sdk subpath exports", () => {
"recordInboundSession",
"recordInboundSessionMetaSafe",
"resolveInboundSessionEnvelopeContext",
"resolveInboundMentionDecision",
"resolveMentionGating",
"resolveMentionGatingWithBypass",
"resolveOutboundSendDep",
@@ -536,9 +544,11 @@ describe("plugin-sdk subpath exports", () => {
"formatInboundEnvelope",
"formatInboundFromLabel",
"formatLocationText",
"implicitMentionKindWhen",
"logInboundDrop",
"matchesMentionPatterns",
"matchesMentionWithExplicit",
"resolveInboundMentionDecision",
"normalizeMentionText",
"resolveInboundDebounceMs",
"resolveEnvelopeFormatOptions",

View File

@@ -35,6 +35,10 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import {
implicitMentionKindWhen,
resolveInboundMentionDecision,
} from "../../channels/mention-gating.js";
import {
setChannelConversationBindingIdleTimeoutBySessionKey,
setChannelConversationBindingMaxAgeBySessionKey,
@@ -128,6 +132,8 @@ export function createRuntimeChannel(): PluginRuntime["channel"] {
buildMentionRegexes,
matchesMentionPatterns,
matchesMentionWithExplicit,
implicitMentionKindWhen,
resolveInboundMentionDecision,
},
reactions: {
shouldAckReaction,

View File

@@ -83,6 +83,8 @@ export type PluginRuntimeChannel = {
buildMentionRegexes: typeof import("../../auto-reply/reply/mentions.js").buildMentionRegexes;
matchesMentionPatterns: typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns;
matchesMentionWithExplicit: typeof import("../../auto-reply/reply/mentions.js").matchesMentionWithExplicit;
implicitMentionKindWhen: typeof import("../../channels/mention-gating.js").implicitMentionKindWhen;
resolveInboundMentionDecision: typeof import("../../channels/mention-gating.js").resolveInboundMentionDecision;
};
reactions: {
shouldAckReaction: typeof import("../../channels/ack-reactions.js").shouldAckReaction;