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

@@ -594,6 +594,45 @@ describe("preflightDiscordMessage", () => {
expect(result).not.toBeNull();
});
it("still drops bot control commands without a real mention when allowBots=mentions", async () => {
const channelId = "channel-bot-command-no-mention";
const guildId = "guild-bot-command-no-mention";
const message = createDiscordMessage({
id: "m-bot-command-no-mention",
channelId,
content: "/new incident room",
author: {
id: "relay-bot-1",
bot: true,
username: "Relay",
},
});
const result = await runMentionOnlyBotPreflight({ channelId, guildId, message });
expect(result).toBeNull();
});
it("still allows bot control commands with an explicit mention when allowBots=mentions", async () => {
const channelId = "channel-bot-command-with-mention";
const guildId = "guild-bot-command-with-mention";
const message = createDiscordMessage({
id: "m-bot-command-with-mention",
channelId,
content: "<@openclaw-bot> /new incident room",
mentionedUsers: [{ id: "openclaw-bot" }],
author: {
id: "relay-bot-1",
bot: true,
username: "Relay",
},
});
const result = await runMentionOnlyBotPreflight({ channelId, guildId, message });
expect(result).not.toBeNull();
});
it("treats @everyone as a mention when requireMention is true", async () => {
const channelId = "channel-everyone-mention";
const guildId = "guild-everyone-mention";

View File

@@ -3,9 +3,10 @@ import { Routes, type APIMessage } from "discord-api-types/v10";
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
import {
buildMentionRegexes,
implicitMentionKindWhen,
logInboundDrop,
matchesMentionWithExplicit,
resolveMentionGatingWithBypass,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native";
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
@@ -181,10 +182,10 @@ function resolveDiscordMentionState(params: {
referencedAuthorId?: string;
senderIsPluralKit: boolean;
transcript?: string;
}): { implicitMention: boolean; wasMentioned: boolean } {
}) {
if (params.isDirectMessage) {
return {
implicitMention: false,
implicitMentionKinds: [],
wasMentioned: false,
};
}
@@ -203,12 +204,15 @@ function resolveDiscordMentionState(params: {
},
transcript: params.transcript,
});
const implicitMention = Boolean(
params.botId && params.referencedAuthorId && params.referencedAuthorId === params.botId,
const implicitMentionKinds = implicitMentionKindWhen(
"reply_to_bot",
Boolean(params.botId) &&
Boolean(params.referencedAuthorId) &&
params.referencedAuthorId === params.botId,
);
return {
implicitMention,
implicitMentionKinds,
wasMentioned,
};
}
@@ -887,7 +891,7 @@ export async function preflightDiscordMessage(
}
const mentionText = hasTypedText ? baseText : "";
const { implicitMention, wasMentioned } = resolveDiscordMentionState({
const { implicitMentionKinds, wasMentioned } = resolveDiscordMentionState({
authorIsBot: Boolean(author.bot),
botId,
hasAnyMention,
@@ -946,23 +950,27 @@ export async function preflightDiscordMessage(
}
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
const mentionGate = resolveMentionGatingWithBypass({
isGroup: isGuildMessage,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
wasMentioned,
implicitMention,
hasAnyMention,
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention,
wasMentioned,
hasAnyMention,
implicitMentionKinds,
},
policy: {
isGroup: isGuildMessage,
requireMention: Boolean(shouldRequireMention),
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
},
});
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
logDebug(
`[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`,
`[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionDecision.shouldSkip=${mentionDecision.shouldSkip} wasMentioned=${wasMentioned}`,
);
if (isGuildMessage && shouldRequireMention) {
if (botId && mentionGate.shouldSkip) {
if (botId && mentionDecision.shouldSkip) {
logDebug(`[discord-preflight] drop: no-mention`);
logVerbose(`discord: drop guild message (mention required, botId=${botId})`);
logger.info(
@@ -983,7 +991,7 @@ export async function preflightDiscordMessage(
}
if (author.bot && !sender.isPluralKit && allowBotsMode === "mentions") {
const botMentioned = isDirectMessage || wasMentioned || implicitMention;
const botMentioned = isDirectMessage || wasMentioned || mentionDecision.implicitMention;
if (!botMentioned) {
logDebug(`[discord-preflight] drop: bot message missing mention (allowBots=mentions)`);
logVerbose("discord: drop bot message (allowBots=mentions, missing mention)");
@@ -998,7 +1006,7 @@ export async function preflightDiscordMessage(
ignoreOtherMentions &&
hasUserOrRoleMention &&
!wasMentioned &&
!implicitMention
!mentionDecision.implicitMention
) {
logDebug(`[discord-preflight] drop: other-mention`);
logVerbose(
@@ -1103,7 +1111,7 @@ export async function preflightDiscordMessage(
shouldRequireMention,
hasAnyMention,
allowTextCommands,
shouldBypassMention: mentionGate.shouldBypassMention,
shouldBypassMention: mentionDecision.shouldBypassMention,
effectiveWasMentioned,
canDetectMention,
historyEntry,

View File

@@ -47,7 +47,7 @@ export {
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 {

View File

@@ -6,11 +6,15 @@ const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
const resolveAllowlistProviderRuntimeGroupPolicy = vi.hoisted(() => vi.fn());
const resolveDefaultGroupPolicy = vi.hoisted(() => vi.fn());
const resolveDmGroupAccessWithLists = vi.hoisted(() => vi.fn());
const resolveMentionGatingWithBypass = vi.hoisted(() => vi.fn());
const resolveInboundMentionDecision = vi.hoisted(() => vi.fn());
const resolveSenderScopedGroupPolicy = vi.hoisted(() => vi.fn());
const warnMissingProviderGroupPolicyFallbackOnce = vi.hoisted(() => vi.fn());
const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/channel-inbound", () => ({
resolveInboundMentionDecision,
}));
vi.mock("../runtime-api.js", () => ({
GROUP_POLICY_BLOCKED_LABEL: { space: "space" },
createChannelPairingController,
@@ -19,7 +23,6 @@ vi.mock("../runtime-api.js", () => ({
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveDmGroupAccessWithLists,
resolveMentionGatingWithBypass,
resolveSenderScopedGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
}));
@@ -84,7 +87,7 @@ function allowInboundGroupTraffic(options?: {
effectiveAllowFrom: [],
effectiveGroupAllowFrom: options?.effectiveGroupAllowFrom ?? ["users/alice"],
});
resolveMentionGatingWithBypass.mockReturnValue({
resolveInboundMentionDecision.mockReturnValue({
shouldSkip: false,
effectiveWasMentioned: options?.effectiveWasMentioned ?? true,
});

View File

@@ -1,3 +1,4 @@
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
import {
GROUP_POLICY_BLOCKED_LABEL,
createChannelPairingController,
@@ -6,7 +7,6 @@ import {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveDmGroupAccessWithLists,
resolveMentionGatingWithBypass,
resolveSenderScopedGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
type OpenClawConfig,
@@ -321,19 +321,23 @@ export async function applyGoogleChatInboundAccessPolicy(params: {
cfg: config,
surface: "googlechat",
});
const mentionGate = resolveMentionGatingWithBypass({
isGroup: true,
requireMention,
canDetectMention: true,
wasMentioned: mentionInfo.wasMentioned,
implicitMention: false,
hasAnyMention: mentionInfo.hasAnyMention,
allowTextCommands,
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
commandAuthorized: commandAuthorized === true,
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: mentionInfo.wasMentioned,
hasAnyMention: mentionInfo.hasAnyMention,
implicitMentionKinds: [],
},
policy: {
isGroup: true,
requireMention,
allowTextCommands,
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
commandAuthorized: commandAuthorized === true,
},
});
effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (mentionGate.shouldSkip) {
effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
if (mentionDecision.shouldSkip) {
logVerbose(`drop group message (mention required, space=${spaceId})`);
return { ok: false };
}

View File

@@ -6,6 +6,7 @@ import {
logInboundDrop,
matchesMentionPatterns,
resolveEnvelopeFormatOptions,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/command-auth";
@@ -468,10 +469,23 @@ export function resolveIMessageInboundDecision(params: {
return { kind: "drop", reason: "control command (unauthorized)" };
}
const shouldBypassMention =
isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage;
const effectiveWasMentioned = mentioned || shouldBypassMention;
if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) {
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention,
wasMentioned: mentioned,
hasAnyMention: false,
implicitMentionKinds: [],
},
policy: {
isGroup,
requireMention,
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
},
});
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
if (isGroup && requireMention && canDetectMention && mentionDecision.shouldSkip) {
params.logVerbose?.(`imessage: skipping group message (no mention)`);
recordPendingHistoryEntryIfEnabled({
historyMap: params.groupHistories,

View File

@@ -11,32 +11,76 @@ type PostbackEvent = webhook.PostbackEvent;
vi.mock("openclaw/plugin-sdk/channel-inbound", () => ({
buildMentionRegexes: () => [],
matchesMentionPatterns: () => false,
resolveMentionGatingWithBypass: ({
isGroup,
requireMention,
canDetectMention,
wasMentioned,
hasAnyMention,
allowTextCommands,
hasControlCommand,
commandAuthorized,
}: {
isGroup: boolean;
requireMention: boolean;
canDetectMention: boolean;
wasMentioned: boolean;
hasAnyMention: boolean;
allowTextCommands: boolean;
hasControlCommand: boolean;
commandAuthorized: boolean;
}) => ({
shouldSkip:
isGroup &&
requireMention &&
canDetectMention &&
!wasMentioned &&
!(allowTextCommands && hasControlCommand && commandAuthorized && !hasAnyMention),
}),
resolveInboundMentionDecision: (params: {
facts?: {
canDetectMention: boolean;
wasMentioned: boolean;
hasAnyMention?: boolean;
};
policy?: {
isGroup: boolean;
requireMention: boolean;
allowTextCommands: boolean;
hasControlCommand: boolean;
commandAuthorized: boolean;
};
isGroup?: boolean;
requireMention?: boolean;
canDetectMention?: boolean;
wasMentioned?: boolean;
hasAnyMention?: boolean;
allowTextCommands?: boolean;
hasControlCommand?: boolean;
commandAuthorized?: boolean;
}) => {
const facts =
"facts" in params && params.facts
? params.facts
: {
canDetectMention: Boolean(params.canDetectMention),
wasMentioned: Boolean(params.wasMentioned),
hasAnyMention: params.hasAnyMention,
};
const policy =
"policy" in params && params.policy
? params.policy
: {
isGroup: Boolean(params.isGroup),
requireMention: Boolean(params.requireMention),
allowTextCommands: Boolean(params.allowTextCommands),
hasControlCommand: Boolean(params.hasControlCommand),
commandAuthorized: Boolean(params.commandAuthorized),
};
return {
effectiveWasMentioned:
facts.wasMentioned ||
(policy.allowTextCommands &&
policy.hasControlCommand &&
policy.commandAuthorized &&
!facts.hasAnyMention),
shouldSkip:
policy.isGroup &&
policy.requireMention &&
facts.canDetectMention &&
!facts.wasMentioned &&
!(
policy.allowTextCommands &&
policy.hasControlCommand &&
policy.commandAuthorized &&
!facts.hasAnyMention
),
shouldBypassMention:
policy.isGroup &&
policy.requireMention &&
!facts.wasMentioned &&
!facts.hasAnyMention &&
policy.allowTextCommands &&
policy.hasControlCommand &&
policy.commandAuthorized,
implicitMention: false,
matchedImplicitMentionKinds: [],
};
},
}));
vi.mock("openclaw/plugin-sdk/channel-pairing", () => ({
createChannelPairingChallengeIssuer:

View File

@@ -2,7 +2,7 @@ import type { webhook } from "@line/bot-sdk";
import {
buildMentionRegexes,
matchesMentionPatterns,
resolveMentionGatingWithBypass,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import { hasControlCommand, resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
@@ -501,17 +501,22 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte
const wasMentionedByPattern =
message.type === "text" ? matchesMentionPatterns(rawText, mentionRegexes) : false;
const wasMentioned = wasMentionedByNative || wasMentionedByPattern;
const mentionGate = resolveMentionGatingWithBypass({
isGroup: true,
requireMention,
canDetectMention: message.type === "text",
wasMentioned,
hasAnyMention: hasAnyLineMention(message),
allowTextCommands: true,
hasControlCommand: hasControlCommand(rawText, cfg),
commandAuthorized: decision.commandAuthorized,
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention: message.type === "text",
wasMentioned,
hasAnyMention: hasAnyLineMention(message),
implicitMentionKinds: [],
},
policy: {
isGroup: true,
requireMention,
allowTextCommands: true,
hasControlCommand: hasControlCommand(rawText, cfg),
commandAuthorized: decision.commandAuthorized,
},
});
if (mentionGate.shouldSkip) {
if (mentionDecision.shouldSkip) {
logVerbose(`line: skipping group message (requireMention, not mentioned)`);
const historyKey = groupId ?? roomId;
const senderId = sourceInfo.userId ?? "unknown";

View File

@@ -1,3 +1,7 @@
import {
implicitMentionKindWhen,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { vi } from "vitest";
import type { PluginRuntime } from "./runtime-api.js";
import { setMatrixRuntime } from "./runtime.js";
@@ -56,6 +60,8 @@ export function installMatrixMonitorTestRuntime(
options.matchesMentionPatterns ??
((text: string, patterns: RegExp[]) => patterns.some((pattern) => pattern.test(text))),
matchesMentionWithExplicit: () => false,
implicitMentionKindWhen,
resolveInboundMentionDecision,
},
media: {
fetchRemoteMedia: vi.fn(),

View File

@@ -1,3 +1,4 @@
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
@@ -9,7 +10,6 @@ import {
recordPendingHistoryEntryIfEnabled,
resolveChannelContextVisibilityMode,
resolveDualTextControlCommandGate,
resolveMentionGating,
resolveInboundSessionEnvelopeContext,
shouldIncludeSupplementalContext,
formatAllowlistMatchMeta,
@@ -160,7 +160,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
text: string;
attachments: MSTeamsAttachmentLike[];
wasMentioned: boolean;
implicitMention: boolean;
implicitMentionKinds: Array<"reply_to_bot">;
};
const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
@@ -458,17 +458,24 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
channelConfig,
});
const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp);
if (!isDirectMessage) {
const mentionGate = resolveMentionGating({
requireMention: Boolean(requireMention),
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: params.wasMentioned,
implicitMention: params.implicitMention,
shouldBypassMention: false,
});
const mentioned = mentionGate.effectiveWasMentioned;
if (requireMention && mentionGate.shouldSkip) {
implicitMentionKinds: params.implicitMentionKinds,
},
policy: {
isGroup: !isDirectMessage,
requireMention: Boolean(requireMention),
allowTextCommands: false,
hasControlCommand: false,
commandAuthorized: false,
},
});
if (!isDirectMessage) {
const mentioned = mentionDecision.effectiveWasMentioned;
if (requireMention && mentionDecision.shouldSkip) {
log.debug?.("skipping message (mention required)", {
teamId,
channelId,
@@ -647,7 +654,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
Surface: "msteams" as const,
MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(),
WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
WasMentioned: isDirectMessage || mentionDecision.effectiveWasMentioned,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
@@ -796,14 +803,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
.filter(Boolean)
.join("\n");
const wasMentioned = entries.some((entry) => entry.wasMentioned);
const implicitMention = entries.some((entry) => entry.implicitMention);
const implicitMentionKinds = entries.flatMap((entry) => entry.implicitMentionKinds);
await handleTeamsMessageNow({
context: last.context,
rawText: combinedRawText,
text: combinedText,
attachments: [],
wasMentioned,
implicitMention,
implicitMentionKinds,
});
},
onError: (err) => {
@@ -822,9 +829,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const wasMentioned = wasMSTeamsBotMentioned(activity);
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
const replyToId = activity.replyToId ?? undefined;
const implicitMention = Boolean(
conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId),
);
const implicitMentionKinds: Array<"reply_to_bot"> =
conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId)
? ["reply_to_bot"]
: [];
await inboundDebouncer.enqueue({
context,
@@ -832,7 +840,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
text,
attachments,
wasMentioned,
implicitMention,
implicitMentionKinds,
});
};
}

View File

@@ -1,3 +1,4 @@
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
import type {
AllowlistMatch,
ChannelGroupContext,
@@ -9,7 +10,6 @@ import {
evaluateMatchedGroupAccessForPolicy,
normalizeChannelSlug,
resolveChannelEntryMatchWithFallback,
resolveMentionGatingWithBypass,
resolveNestedAllowlistDecision,
} from "../runtime-api.js";
import type { NextcloudTalkRoomConfig } from "./types.js";
@@ -167,14 +167,19 @@ export function resolveNextcloudTalkMentionGate(params: {
hasControlCommand: boolean;
commandAuthorized: boolean;
}): { shouldSkip: boolean; shouldBypassMention: boolean } {
const result = resolveMentionGatingWithBypass({
isGroup: params.isGroup,
requireMention: params.requireMention,
canDetectMention: true,
wasMentioned: params.wasMentioned,
allowTextCommands: params.allowTextCommands,
hasControlCommand: params.hasControlCommand,
commandAuthorized: params.commandAuthorized,
const result = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned: params.wasMentioned,
implicitMentionKinds: [],
},
policy: {
isGroup: params.isGroup,
requireMention: params.requireMention,
allowTextCommands: params.allowTextCommands,
hasControlCommand: params.hasControlCommand,
commandAuthorized: params.commandAuthorized,
},
});
return { shouldSkip: result.shouldSkip, shouldBypassMention: result.shouldBypassMention };
}

View File

@@ -6,13 +6,11 @@ import {
formatInboundEnvelope,
formatInboundFromLabel,
matchesMentionPatterns,
resolveInboundMentionDecision,
resolveEnvelopeFormatOptions,
shouldDebounceTextInbound,
} from "openclaw/plugin-sdk/channel-inbound";
import {
logInboundDrop,
resolveMentionGatingWithBypass,
} from "openclaw/plugin-sdk/channel-inbound";
import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
@@ -672,19 +670,23 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
accountId: deps.accountId,
});
const canDetectMention = mentionRegexes.length > 0;
const mentionGate = resolveMentionGatingWithBypass({
isGroup,
requireMention: Boolean(requireMention),
canDetectMention,
wasMentioned,
implicitMention: false,
hasAnyMention: false,
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention,
wasMentioned,
hasAnyMention: false,
implicitMentionKinds: [],
},
policy: {
isGroup,
requireMention: Boolean(requireMention),
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
},
});
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (isGroup && requireMention && canDetectMention && mentionGate.shouldSkip) {
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
if (isGroup && requireMention && canDetectMention && mentionDecision.shouldSkip) {
logInboundDrop({
log: logVerbose,
channel: "signal",

View File

@@ -6,10 +6,11 @@ import {
import {
buildMentionRegexes,
formatInboundEnvelope,
implicitMentionKindWhen,
logInboundDrop,
matchesMentionWithExplicit,
resolveEnvelopeFormatOptions,
resolveMentionGatingWithBypass,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
@@ -383,14 +384,16 @@ export async function prepareSlackMessage(params: {
canResolveExplicit: Boolean(ctx.botUserId),
},
}));
const implicitMention = Boolean(
!ctx.threadRequireExplicitMention &&
!isDirectMessage &&
ctx.botUserId &&
message.thread_ts &&
(message.parent_user_id === ctx.botUserId ||
hasSlackThreadParticipation(account.accountId, message.channel, message.thread_ts)),
);
const implicitMentionKinds =
isDirectMessage || !ctx.botUserId || !message.thread_ts
? []
: [
...implicitMentionKindWhen("reply_to_bot", message.parent_user_id === ctx.botUserId),
...implicitMentionKindWhen(
"bot_thread_participant",
hasSlackThreadParticipation(account.accountId, message.channel, message.thread_ts),
),
];
let resolvedSenderName = message.username?.trim() || undefined;
const resolveSenderName = async (): Promise<string> => {
@@ -492,19 +495,24 @@ export async function prepareSlackMessage(params: {
// Allow "control commands" to bypass mention gating if sender is authorized.
const canDetectMention = Boolean(ctx.botUserId) || mentionRegexes.length > 0;
const mentionGate = resolveMentionGatingWithBypass({
isGroup: isRoom,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
wasMentioned,
implicitMention,
hasAnyMention,
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention,
wasMentioned,
hasAnyMention,
implicitMentionKinds,
},
policy: {
isGroup: isRoom,
requireMention: Boolean(shouldRequireMention),
allowedImplicitMentionKinds: ctx.threadRequireExplicitMention ? [] : undefined,
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
},
});
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (isRoom && shouldRequireMention && mentionGate.shouldSkip) {
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
if (isRoom && shouldRequireMention && mentionDecision.shouldSkip) {
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message");
const pendingText = (message.text ?? "").trim();
const fallbackFile = message.files?.[0]?.name
@@ -567,7 +575,7 @@ export async function prepareSlackMessage(params: {
requireMention: Boolean(shouldRequireMention),
canDetectMention,
effectiveWasMentioned,
shouldBypassMention: mentionGate.shouldBypassMention,
shouldBypassMention: mentionDecision.shouldBypassMention,
}),
);

View File

@@ -1,9 +1,10 @@
import {
buildMentionRegexes,
formatLocationText,
implicitMentionKindWhen,
logInboundDrop,
matchesMentionWithExplicit,
resolveMentionGatingWithBypass,
resolveInboundMentionDecision,
type NormalizedLocation,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native";
@@ -250,21 +251,28 @@ export async function resolveTelegramInboundBody(params: {
const replyToBotMessage = botId != null && replyFromId === botId;
const isReplyToServiceMessage =
replyToBotMessage && isTelegramForumServiceMessage(msg.reply_to_message);
const implicitMention = replyToBotMessage && !isReplyToServiceMessage;
const implicitMentionKinds = implicitMentionKindWhen(
"reply_to_bot",
replyToBotMessage && !isReplyToServiceMessage,
);
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
const mentionGate = resolveMentionGatingWithBypass({
isGroup,
requireMention: Boolean(requireMention),
canDetectMention,
wasMentioned,
implicitMention: isGroup && Boolean(requireMention) && implicitMention,
hasAnyMention,
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention,
wasMentioned,
hasAnyMention,
implicitMentionKinds: isGroup && Boolean(requireMention) ? implicitMentionKinds : [],
},
policy: {
isGroup,
requireMention: Boolean(requireMention),
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
},
});
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (isGroup && requireMention && canDetectMention && mentionGate.shouldSkip) {
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
if (isGroup && requireMention && canDetectMention && mentionDecision.shouldSkip) {
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
recordPendingHistoryEntryIfEnabled({
historyMap: groupHistories,
@@ -331,7 +339,7 @@ export async function resolveTelegramInboundBody(params: {
commandAuthorized,
effectiveWasMentioned,
canDetectMention,
shouldBypassMention: mentionGate.shouldBypassMention,
shouldBypassMention: mentionDecision.shouldBypassMention,
stickerCacheHit,
locationData: locationData ?? undefined,
};

View File

@@ -1,4 +1,7 @@
export { resolveMentionGating } from "openclaw/plugin-sdk/channel-inbound";
export {
implicitMentionKindWhen,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
export { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
export { recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-history";
export { parseActivationCommand } from "openclaw/plugin-sdk/reply-runtime";

View File

@@ -13,10 +13,11 @@ import { stripMentionsForCommand } from "./commands.js";
import { resolveGroupActivationFor, resolveGroupPolicyFor } from "./group-activation.js";
import {
hasControlCommand,
implicitMentionKindWhen,
normalizeE164,
parseActivationCommand,
recordPendingHistoryEntryIfEnabled,
resolveMentionGating,
resolveInboundMentionDecision,
} from "./group-gating.runtime.js";
import { noteGroupMember } from "./group-members.js";
@@ -152,16 +153,28 @@ export function applyGroupGating(params: ApplyGroupGatingParams) {
// should not count as implicit bot mentions unless the message explicitly
// mentioned the bot in text.
const implicitReplyToSelf = sharedNumberSelfChat && identitiesOverlap(self, sender);
const implicitMention = !implicitReplyToSelf && identitiesOverlap(self, replyContext?.sender);
const mentionGate = resolveMentionGating({
requireMention,
canDetectMention: true,
wasMentioned,
implicitMention,
shouldBypassMention,
const implicitMentionKinds = implicitMentionKindWhen(
"quoted_bot",
!implicitReplyToSelf && identitiesOverlap(self, replyContext?.sender),
);
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention: true,
wasMentioned,
implicitMentionKinds,
},
policy: {
isGroup: true,
requireMention,
allowTextCommands: false,
hasControlCommand: false,
commandAuthorized: false,
},
});
params.msg.wasMentioned = mentionGate.effectiveWasMentioned;
if (!shouldBypassMention && requireMention && mentionGate.shouldSkip) {
const effectiveWasMentioned =
mentionDecision.effectiveWasMentioned || Boolean(shouldBypassMention);
params.msg.wasMentioned = effectiveWasMentioned;
if (!shouldBypassMention && requireMention && mentionDecision.shouldSkip) {
return skipGroupMessageAndStoreHistory(
params,
`Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`,

View File

@@ -409,17 +409,54 @@ vi.mock("./auto-reply/monitor/group-gating.runtime.js", () => ({
const next = [...current, params.entry].slice(-params.limit);
params.historyMap.set(params.historyKey, next);
},
resolveMentionGating: (params: {
requireMention: boolean;
wasMentioned: boolean;
implicitMention?: boolean;
shouldBypassMention?: boolean;
resolveInboundMentionDecision: (params: {
facts?: {
canDetectMention: boolean;
wasMentioned: boolean;
implicitMentionKinds?: string[];
};
policy?: {
isGroup: boolean;
requireMention: boolean;
allowTextCommands: boolean;
hasControlCommand: boolean;
commandAuthorized: boolean;
};
isGroup?: boolean;
requireMention?: boolean;
canDetectMention?: boolean;
wasMentioned?: boolean;
implicitMentionKinds?: string[];
allowTextCommands?: boolean;
hasControlCommand?: boolean;
commandAuthorized?: boolean;
}) => {
const effectiveWasMentioned =
params.wasMentioned || Boolean(params.implicitMention) || Boolean(params.shouldBypassMention);
const facts =
"facts" in params && params.facts
? params.facts
: {
canDetectMention: Boolean(params.canDetectMention),
wasMentioned: Boolean(params.wasMentioned),
implicitMentionKinds: params.implicitMentionKinds,
};
const policy =
"policy" in params && params.policy
? params.policy
: {
isGroup: Boolean(params.isGroup),
requireMention: Boolean(params.requireMention),
allowTextCommands: Boolean(params.allowTextCommands),
hasControlCommand: Boolean(params.hasControlCommand),
commandAuthorized: Boolean(params.commandAuthorized),
};
const effectiveWasMentioned = facts.wasMentioned || Boolean(facts.implicitMentionKinds?.length);
return {
effectiveWasMentioned,
shouldSkip: params.requireMention && !effectiveWasMentioned,
shouldSkip:
policy.isGroup && policy.requireMention && facts.canDetectMention && !effectiveWasMentioned,
shouldBypassMention: false,
implicitMention: Boolean(facts.implicitMentionKinds?.length),
matchedImplicitMentionKinds: facts.implicitMentionKinds ?? [],
};
},
}));

View File

@@ -41,7 +41,7 @@ export {
summarizeMapping,
formatAllowFromLowercase,
} from "openclaw/plugin-sdk/allow-from";
export { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-inbound";
export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers";

View File

@@ -1,5 +1,8 @@
import { mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk/allow-from";
import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-inbound";
import {
implicitMentionKindWhen,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
import {
DM_GROUP_ACCESS_REASON,
@@ -488,28 +491,32 @@ async function processMessage(
})
: true;
const canDetectMention = mentionRegexes.length > 0 || explicitMention.canResolveExplicit;
const mentionGate = resolveMentionGatingWithBypass({
isGroup,
requireMention,
canDetectMention,
wasMentioned,
implicitMention: message.implicitMention === true,
hasAnyMention: explicitMention.hasAnyMention,
allowTextCommands: core.channel.commands.shouldHandleTextCommands({
cfg: config,
surface: "zalouser",
}),
hasControlCommand,
commandAuthorized: commandAuthorized === true,
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention,
wasMentioned,
hasAnyMention: explicitMention.hasAnyMention,
implicitMentionKinds: implicitMentionKindWhen("quoted_bot", message.implicitMention === true),
},
policy: {
isGroup,
requireMention,
allowTextCommands: core.channel.commands.shouldHandleTextCommands({
cfg: config,
surface: "zalouser",
}),
hasControlCommand,
commandAuthorized: commandAuthorized === true,
},
});
if (isGroup && requireMention && !canDetectMention && !mentionGate.effectiveWasMentioned) {
if (isGroup && requireMention && !canDetectMention && !mentionDecision.effectiveWasMentioned) {
runtime.error?.(
`[${account.accountId}] zalouser mention required but detection unavailable ` +
`(missing mention regexes and bot self id); dropping group ${chatId}`,
);
return;
}
if (isGroup && mentionGate.shouldSkip) {
if (isGroup && mentionDecision.shouldSkip) {
recordPendingHistoryEntryIfEnabled({
historyMap: historyState.groupHistories,
historyKey: historyKey ?? "",
@@ -605,7 +612,7 @@ async function processMessage(
GroupMembers: isGroup ? groupMembers : undefined,
SenderName: senderName || undefined,
SenderId: senderId,
WasMentioned: isGroup ? mentionGate.effectiveWasMentioned : undefined,
WasMentioned: isGroup ? mentionDecision.effectiveWasMentioned : undefined,
CommandAuthorized: commandAuthorized,
Provider: "zalouser",
Surface: "zalouser",

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;

View File

@@ -4,6 +4,10 @@ import {
removeAckReactionAfterReply,
shouldAckReaction,
} from "../../../src/channels/ack-reactions.js";
import {
implicitMentionKindWhen,
resolveInboundMentionDecision,
} from "../../../src/channels/mention-gating.js";
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
type DeepPartial<T> = {
@@ -298,6 +302,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
? true
: params.mentionRegexes.some((regex) => regex.test(params.text)),
) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
implicitMentionKindWhen,
resolveInboundMentionDecision,
},
reactions: {
shouldAckReaction,