From 625fd5b3e3e2df10ce87f4b6d4de28f8a7c32ecc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Apr 2026 07:50:09 +0100 Subject: [PATCH] refactor: centralize inbound mention policy --- .../monitor/message-handler.preflight.test.ts | 39 ++++ .../src/monitor/message-handler.preflight.ts | 54 +++-- extensions/googlechat/runtime-api.ts | 2 +- .../googlechat/src/monitor-access.test.ts | 9 +- extensions/googlechat/src/monitor-access.ts | 30 +-- .../src/monitor/inbound-processing.ts | 22 +- extensions/line/src/bot-handlers.test.ts | 96 +++++--- extensions/line/src/bot-handlers.ts | 27 ++- extensions/matrix/src/test-runtime.ts | 6 + .../src/monitor-handler/message-handler.ts | 44 ++-- extensions/nextcloud-talk/src/policy.ts | 23 +- .../signal/src/monitor/event-handler.ts | 34 +-- .../src/monitor/message-handler/prepare.ts | 52 +++-- .../telegram/src/bot-message-context.body.ts | 38 ++-- .../monitor/group-gating.runtime.ts | 5 +- .../src/auto-reply/monitor/group-gating.ts | 33 ++- extensions/whatsapp/src/test-helpers.ts | 53 ++++- extensions/zalouser/runtime-api.ts | 2 +- extensions/zalouser/src/monitor.ts | 41 ++-- src/channels/mention-gating.test.ts | 196 +++++++++++++++- src/channels/mention-gating.ts | 215 ++++++++++++++++-- src/plugin-sdk/channel-inbound.ts | 11 + src/plugin-sdk/googlechat.ts | 6 +- src/plugin-sdk/msteams.ts | 6 +- src/plugin-sdk/nextcloud-talk.ts | 6 +- src/plugin-sdk/zalouser.ts | 6 +- .../plugin-sdk-runtime-api-guardrails.test.ts | 2 +- .../contracts/plugin-sdk-subpaths.test.ts | 10 + src/plugins/runtime/runtime-channel.ts | 6 + src/plugins/runtime/types-channel.ts | 2 + test/helpers/plugins/plugin-runtime-mock.ts | 6 + 31 files changed, 857 insertions(+), 225 deletions(-) diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index acd3abcf271..77f98f96130 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -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"; diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 7b75922d813..80c7740c69c 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -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, diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index 984be2e15d0..f56b9de6945 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -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 { diff --git a/extensions/googlechat/src/monitor-access.test.ts b/extensions/googlechat/src/monitor-access.test.ts index 1934d0f1ad0..042e44f7585 100644 --- a/extensions/googlechat/src/monitor-access.test.ts +++ b/extensions/googlechat/src/monitor-access.test.ts @@ -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, }); diff --git a/extensions/googlechat/src/monitor-access.ts b/extensions/googlechat/src/monitor-access.ts index a22aab6ac8b..2c8f2d84a8b 100644 --- a/extensions/googlechat/src/monitor-access.ts +++ b/extensions/googlechat/src/monitor-access.ts @@ -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 }; } diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index f4eae9006af..2458e3b221d 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -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, diff --git a/extensions/line/src/bot-handlers.test.ts b/extensions/line/src/bot-handlers.test.ts index f667296bd92..b9122666d9a 100644 --- a/extensions/line/src/bot-handlers.test.ts +++ b/extensions/line/src/bot-handlers.test.ts @@ -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: diff --git a/extensions/line/src/bot-handlers.ts b/extensions/line/src/bot-handlers.ts index 2d3dca7f07e..704c519f1fb 100644 --- a/extensions/line/src/bot-handlers.ts +++ b/extensions/line/src/bot-handlers.ts @@ -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"; diff --git a/extensions/matrix/src/test-runtime.ts b/extensions/matrix/src/test-runtime.ts index cd1dccb8ab4..64972725156 100644 --- a/extensions/matrix/src/test-runtime.ts +++ b/extensions/matrix/src/test-runtime.ts @@ -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(), diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index e07d37ee1cc..917cb2f265a 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -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, }); }; } diff --git a/extensions/nextcloud-talk/src/policy.ts b/extensions/nextcloud-talk/src/policy.ts index 849efac51e6..d6e8c893cd5 100644 --- a/extensions/nextcloud-talk/src/policy.ts +++ b/extensions/nextcloud-talk/src/policy.ts @@ -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 }; } diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index 612c4896fd3..6eb5b7ee9e3 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -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", diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index 1d3f0ca45f8..e743598ec5a 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -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 => { @@ -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, }), ); diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts index 6fc3fa360b9..f06bb7555ee 100644 --- a/extensions/telegram/src/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -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, }; diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.runtime.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.runtime.ts index 4502f9b8336..c0da26fd7ad 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.runtime.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.runtime.ts @@ -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"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts index 369c3e5da22..72da8cb1613 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts @@ -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}`, diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index 0011fdb5f5c..48073c03b27 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -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 ?? [], }; }, })); diff --git a/extensions/zalouser/runtime-api.ts b/extensions/zalouser/runtime-api.ts index a0bd7942848..9a5d6de648a 100644 --- a/extensions/zalouser/runtime-api.ts +++ b/extensions/zalouser/runtime-api.ts @@ -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"; diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 884830f8dbb..0a8d8242826 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -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", diff --git a/src/channels/mention-gating.test.ts b/src/channels/mention-gating.test.ts index c0237a37b17..30817596e3f 100644 --- a/src/channels/mention-gating.test.ts +++ b/src/channels/mention-gating.test.ts @@ -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([]); + }); +}); diff --git a/src/channels/mention-gating.ts b/src/channels/mention-gating.ts index 4be89785ec8..f212e294ed7 100644 --- a/src/channels/mention-gating.ts +++ b/src/channels/mention-gating.ts @@ -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, }; } diff --git a/src/plugin-sdk/channel-inbound.ts b/src/plugin-sdk/channel-inbound.ts index 7c64e7df4bf..1bf442e7e7e 100644 --- a/src/plugin-sdk/channel-inbound.ts +++ b/src/plugin-sdk/channel-inbound.ts @@ -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"; diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index 1e016d4fe6b..b8f59f60954 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -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, diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 5be659f642f..d21c75a6ef3 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -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, diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index ab400e3f036..300a981310f 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -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, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 29c7d8ce6e4..d558d25a7e8 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -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, diff --git a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts index e82df933046..bab542ba886 100644 --- a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts @@ -82,7 +82,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { '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";', diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index dd97312c4f1..925a3f733e0 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -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", diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index b319b65afb8..7efc64f87e7 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -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, diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index f8723b0573f..994e693a0bf 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -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; diff --git a/test/helpers/plugins/plugin-runtime-mock.ts b/test/helpers/plugins/plugin-runtime-mock.ts index b08330ad1e9..4cbc951a693 100644 --- a/test/helpers/plugins/plugin-runtime-mock.ts +++ b/test/helpers/plugins/plugin-runtime-mock.ts @@ -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 = { @@ -298,6 +302,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial = ? true : params.mentionRegexes.some((regex) => regex.test(params.text)), ) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"], + implicitMentionKindWhen, + resolveInboundMentionDecision, }, reactions: { shouldAckReaction,