From 05d149a49b2152c6dd7a0017727d0263cd27c600 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 21:50:44 +0000 Subject: [PATCH] fix: treat reply-to-bot as implicit mention across channels --- CHANGELOG.md | 1 + docs/concepts/groups.md | 2 + .../src/monitor-handler/message-handler.ts | 37 ++++++- extensions/msteams/src/reply-dispatcher.ts | 4 +- .../msteams/src/sent-message-cache.test.ts | 16 +++ extensions/msteams/src/sent-message-cache.ts | 41 +++++++ src/channels/mention-gating.test.ts | 38 +++++++ src/channels/mention-gating.ts | 21 ++++ ...ild-messages-mentionpatterns-match.test.ts | 100 ++++++++++++++++++ .../monitor/message-handler.preflight.ts | 18 +++- .../monitor/message-handler.process.ts | 4 +- ...ends-tool-summaries-responseprefix.test.ts | 43 ++++++++ src/slack/monitor/message-handler/prepare.ts | 20 +++- src/telegram/bot-message-context.ts | 17 ++- .../auto-reply/monitor/group-gating.test.ts | 55 ++++++++++ src/web/auto-reply/monitor/group-gating.ts | 22 +++- src/web/inbound/extract.ts | 4 + src/web/inbound/monitor.ts | 2 + src/web/inbound/types.ts | 2 + 19 files changed, 427 insertions(+), 20 deletions(-) create mode 100644 extensions/msteams/src/sent-message-cache.test.ts create mode 100644 extensions/msteams/src/sent-message-cache.ts create mode 100644 src/channels/mention-gating.test.ts create mode 100644 src/channels/mention-gating.ts create mode 100644 src/web/auto-reply/monitor/group-gating.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 430c7febf..da0a191a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ - Discord: truncate skill command descriptions for slash command limits. (#1018) — thanks @evalexpr. - macOS: resolve gateway token/password using config mode/remote URL, and warn when `launchctl setenv` overrides config. (#1022, #1021) — thanks @kkarimi. - Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2. +- Groups: treat replies to the bot as implicit mentions across supported channels. ## 2026.1.14-1 diff --git a/docs/concepts/groups.md b/docs/concepts/groups.md index 51aa5dbc3..43358be97 100644 --- a/docs/concepts/groups.md +++ b/docs/concepts/groups.md @@ -177,6 +177,8 @@ Quick mental model (evaluation order for group messages): ## Mention gating (default) Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`. +Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams. + ```json5 { channels: { diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index c4dd29a4e..0e89c18bd 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -11,6 +11,7 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry, } from "../../../../src/auto-reply/reply/history.js"; +import { resolveMentionGating } from "../../../../src/channels/mention-gating.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; import { @@ -42,6 +43,7 @@ import { } from "../policy.js"; import { extractMSTeamsPollVote } from "../polls.js"; import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js"; +import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js"; import type { MSTeamsTurnContext } from "../sdk-types.js"; import { resolveMSTeamsInboundMedia } from "./inbound-media.js"; @@ -74,6 +76,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { text: string; attachments: MSTeamsAttachmentLike[]; wasMentioned: boolean; + implicitMention: boolean; }; const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => { @@ -301,8 +304,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }); if (!isDirectMessage) { - const mentioned = params.wasMentioned; - if (requireMention && !mentioned) { + const mentionGate = resolveMentionGating({ + requireMention: Boolean(requireMention), + canDetectMention: true, + wasMentioned: params.wasMentioned, + implicitMention: params.implicitMention, + shouldBypassMention: false, + }); + const mentioned = mentionGate.effectiveWasMentioned; + if (requireMention && mentionGate.shouldSkip) { log.debug("skipping message (mention required)", { teamId, channelId, @@ -379,7 +389,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { Surface: "msteams" as const, MessageSid: activity.id, Timestamp: timestamp?.getTime() ?? Date.now(), - WasMentioned: isDirectMessage || params.wasMentioned, + WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention, CommandAuthorized: true, OriginatingChannel: "msteams" as const, OriginatingTo: teamsTo, @@ -401,6 +411,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { context, replyStyle, textLimit, + onSentMessageIds: (ids) => { + for (const id of ids) { + recordMSTeamsSentMessage(conversationId, id); + } + }, }); log.info("dispatching to agent", { sessionKey: route.sessionKey }); @@ -480,12 +495,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { .filter(Boolean) .join("\n"); const wasMentioned = entries.some((entry) => entry.wasMentioned); + const implicitMention = entries.some((entry) => entry.implicitMention); await handleTeamsMessageNow({ context: last.context, rawText: combinedRawText, text: combinedText, attachments: [], wasMentioned, + implicitMention, }); }, onError: (err) => { @@ -501,7 +518,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { ? (activity.attachments as unknown as MSTeamsAttachmentLike[]) : []; const wasMentioned = wasMSTeamsBotMentioned(activity); + const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? ""); + const replyToId = activity.replyToId ?? undefined; + const implicitMention = Boolean( + conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId), + ); - await inboundDebouncer.enqueue({ context, rawText, text, attachments, wasMentioned }); + await inboundDebouncer.enqueue({ + context, + rawText, + text, + attachments, + wasMentioned, + implicitMention, + }); }; } diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 81496000e..373e40b93 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -28,6 +28,7 @@ export function createMSTeamsReplyDispatcher(params: { context: MSTeamsTurnContext; replyStyle: MSTeamsReplyStyle; textLimit: number; + onSentMessageIds?: (ids: string[]) => void; }) { const sendTypingIndicator = async () => { try { @@ -46,7 +47,7 @@ export function createMSTeamsReplyDispatcher(params: { chunkText: true, mediaMode: "split", }); - await sendMSTeamsMessages({ + const ids = await sendMSTeamsMessages({ replyStyle: params.replyStyle, adapter: params.adapter, appId: params.appId, @@ -62,6 +63,7 @@ export function createMSTeamsReplyDispatcher(params: { }); }, }); + if (ids.length > 0) params.onSentMessageIds?.(ids); }, onError: (err, info) => { const errMsg = formatUnknownError(err); diff --git a/extensions/msteams/src/sent-message-cache.test.ts b/extensions/msteams/src/sent-message-cache.test.ts new file mode 100644 index 000000000..f61782538 --- /dev/null +++ b/extensions/msteams/src/sent-message-cache.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { + clearMSTeamsSentMessageCache, + recordMSTeamsSentMessage, + wasMSTeamsMessageSent, +} from "./sent-message-cache.js"; + +describe("msteams sent message cache", () => { + it("records and resolves sent message ids", () => { + clearMSTeamsSentMessageCache(); + recordMSTeamsSentMessage("conv-1", "msg-1"); + expect(wasMSTeamsMessageSent("conv-1", "msg-1")).toBe(true); + expect(wasMSTeamsMessageSent("conv-1", "msg-2")).toBe(false); + }); +}); diff --git a/extensions/msteams/src/sent-message-cache.ts b/extensions/msteams/src/sent-message-cache.ts new file mode 100644 index 000000000..00a810f9f --- /dev/null +++ b/extensions/msteams/src/sent-message-cache.ts @@ -0,0 +1,41 @@ +const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +type CacheEntry = { + messageIds: Set; + timestamps: Map; +}; + +const sentMessages = new Map(); + +function cleanupExpired(entry: CacheEntry): void { + const now = Date.now(); + for (const [msgId, timestamp] of entry.timestamps) { + if (now - timestamp > TTL_MS) { + entry.messageIds.delete(msgId); + entry.timestamps.delete(msgId); + } + } +} + +export function recordMSTeamsSentMessage(conversationId: string, messageId: string): void { + if (!conversationId || !messageId) return; + let entry = sentMessages.get(conversationId); + if (!entry) { + entry = { messageIds: new Set(), timestamps: new Map() }; + sentMessages.set(conversationId, entry); + } + entry.messageIds.add(messageId); + entry.timestamps.set(messageId, Date.now()); + if (entry.messageIds.size > 200) cleanupExpired(entry); +} + +export function wasMSTeamsMessageSent(conversationId: string, messageId: string): boolean { + const entry = sentMessages.get(conversationId); + if (!entry) return false; + cleanupExpired(entry); + return entry.messageIds.has(messageId); +} + +export function clearMSTeamsSentMessageCache(): void { + sentMessages.clear(); +} diff --git a/src/channels/mention-gating.test.ts b/src/channels/mention-gating.test.ts new file mode 100644 index 000000000..d962c4f67 --- /dev/null +++ b/src/channels/mention-gating.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMentionGating } from "./mention-gating.js"; + +describe("resolveMentionGating", () => { + it("combines explicit, implicit, and bypass mentions", () => { + const res = resolveMentionGating({ + requireMention: true, + canDetectMention: true, + wasMentioned: false, + implicitMention: true, + shouldBypassMention: false, + }); + expect(res.effectiveWasMentioned).toBe(true); + expect(res.shouldSkip).toBe(false); + }); + + it("skips when mention required and none detected", () => { + const res = resolveMentionGating({ + requireMention: true, + canDetectMention: true, + wasMentioned: false, + implicitMention: false, + shouldBypassMention: false, + }); + expect(res.effectiveWasMentioned).toBe(false); + expect(res.shouldSkip).toBe(true); + }); + + it("does not skip when mention detection is unavailable", () => { + const res = resolveMentionGating({ + requireMention: true, + canDetectMention: false, + wasMentioned: false, + }); + expect(res.shouldSkip).toBe(false); + }); +}); diff --git a/src/channels/mention-gating.ts b/src/channels/mention-gating.ts new file mode 100644 index 000000000..0dbf582f0 --- /dev/null +++ b/src/channels/mention-gating.ts @@ -0,0 +1,21 @@ +export type MentionGateParams = { + requireMention: boolean; + canDetectMention: boolean; + wasMentioned: boolean; + implicitMention?: boolean; + shouldBypassMention?: boolean; +}; + +export type MentionGateResult = { + effectiveWasMentioned: boolean; + shouldSkip: 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 }; +} diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts index a000ac0b9..0d495abac 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts +++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts @@ -126,6 +126,106 @@ describe("discord tool result dispatch", () => { expect(sendMock).toHaveBeenCalledTimes(1); }, 20_000); + it("accepts guild reply-to-bot messages as implicit mentions", async () => { + const { createDiscordMessageHandler } = await import("./monitor.js"); + const cfg = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: "/tmp/clawd", + }, + }, + session: { store: "/tmp/clawdbot-sessions.json" }, + channels: { + discord: { + dm: { enabled: true, policy: "open" }, + groupPolicy: "open", + guilds: { "*": { requireMention: true } }, + }, + }, + } as ReturnType; + + const handler = createDiscordMessageHandler({ + cfg, + discordConfig: cfg.channels.discord, + accountId: "default", + token: "token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: "bot-id", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2000, + replyToMode: "off", + dmEnabled: true, + groupDmEnabled: false, + guildEntries: { "*": { requireMention: true } }, + }); + + const client = { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.GuildText, + name: "general", + }), + } as unknown as Client; + + await handler( + { + message: { + id: "m3", + content: "following up", + channelId: "c1", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "u1", bot: false, username: "Ada" }, + referencedMessage: { + id: "m2", + channelId: "c1", + content: "bot reply", + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: { id: "bot-id", bot: true, username: "Clawdbot" }, + }, + }, + author: { id: "u1", bot: false, username: "Ada" }, + member: { nickname: "Ada" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", + channel: { id: "c1", type: ChannelType.GuildText }, + client, + data: { + id: "m3", + content: "following up", + channel_id: "c1", + guild_id: "g1", + type: MessageType.Default, + mentions: [], + }, + }, + client, + ); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const payload = dispatchMock.mock.calls[0]?.[0]?.ctx as Record; + expect(payload.WasMentioned).toBe(true); + }); + it("forks thread sessions and injects starter context", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); let capturedCtx: diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index ca7ba5131..a2944be6f 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -14,6 +14,7 @@ import { upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import { resolveMentionGating } from "../../channels/mention-gating.js"; import { sendMessageDiscord } from "../send.js"; import { allowListMatches, @@ -164,6 +165,12 @@ export async function preflightDiscordMessage( !isDirectMessage && (Boolean(botId && message.mentionedUsers?.some((user: User) => user.id === botId)) || matchesMentionPatterns(baseText, mentionRegexes)); + const implicitMention = Boolean( + !isDirectMessage && + botId && + message.referencedMessage?.author?.id && + message.referencedMessage.author.id === botId, + ); if (shouldLogVerbose()) { logVerbose( `discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`, @@ -327,10 +334,17 @@ export async function preflightDiscordMessage( !hasAnyMention && commandAuthorized && hasControlCommand(baseText, params.cfg); - const effectiveWasMentioned = wasMentioned || shouldBypassMention; const canDetectMention = Boolean(botId) || mentionRegexes.length > 0; + const mentionGate = resolveMentionGating({ + requireMention: Boolean(shouldRequireMention), + canDetectMention, + wasMentioned, + implicitMention, + shouldBypassMention, + }); + const effectiveWasMentioned = mentionGate.effectiveWasMentioned; if (isGuildMessage && shouldRequireMention) { - if (botId && !wasMentioned && !shouldBypassMention) { + if (botId && mentionGate.shouldSkip) { logVerbose(`discord: drop guild message (mention required, botId=${botId})`); logger.info( { diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index dae301d04..ee2695b78 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -56,10 +56,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) isGroupDm, baseText, messageText, - wasMentioned, shouldRequireMention, canDetectMention, - shouldBypassMention, effectiveWasMentioned, historyEntry, threadChannel, @@ -94,7 +92,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) if (!isGuildMessage) return false; if (!shouldRequireMention) return false; if (!canDetectMention) return false; - return wasMentioned || shouldBypassMention; + return effectiveWasMentioned; } return false; }; diff --git a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index e377789e8..33792f6f6 100644 --- a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -498,6 +498,49 @@ describe("monitorSlackProvider tool results", () => { expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); }); + it("treats replies to bot threads as implicit mentions", async () => { + config = { + channels: { + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + channels: { C1: { allow: true, requireMention: true } }, + }, + }, + }; + replyMock.mockResolvedValue({ text: "hi" }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "following up", + ts: "124", + thread_ts: "123", + parent_user_id: "bot-user", + channel: "C1", + channel_type: "channel", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(replyMock).toHaveBeenCalledTimes(1); + expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); + }); + it("accepts channel messages without mention when channels.slack.requireMention is false", async () => { config = { channels: { diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index d07e1c41a..60a4c30a3 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -10,6 +10,7 @@ import { buildPairingReply } from "../../../pairing/pairing-messages.js"; import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; +import { resolveMentionGating } from "../../../channels/mention-gating.js"; import type { ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; @@ -172,6 +173,12 @@ export async function prepareSlackMessage(params: { (!isDirectMessage && (Boolean(ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`)) || matchesMentionPatterns(message.text ?? "", mentionRegexes))); + const implicitMention = Boolean( + !isDirectMessage && + ctx.botUserId && + message.thread_ts && + message.parent_user_id === ctx.botUserId, + ); const sender = message.user ? await ctx.resolveUserName(message.user) : null; const senderName = @@ -215,9 +222,16 @@ export async function prepareSlackMessage(params: { commandAuthorized && hasControlCommand(message.text ?? "", cfg); - const effectiveWasMentioned = wasMentioned || shouldBypassMention; const canDetectMention = Boolean(ctx.botUserId) || mentionRegexes.length > 0; - if (isRoom && shouldRequireMention && canDetectMention && !wasMentioned && !shouldBypassMention) { + const mentionGate = resolveMentionGating({ + requireMention: Boolean(shouldRequireMention), + canDetectMention, + wasMentioned, + implicitMention, + shouldBypassMention, + }); + const effectiveWasMentioned = mentionGate.effectiveWasMentioned; + if (isRoom && shouldRequireMention && mentionGate.shouldSkip) { ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping room message"); return null; } @@ -242,7 +256,7 @@ export async function prepareSlackMessage(params: { if (!isRoom) return false; if (!shouldRequireMention) return false; if (!canDetectMention) return false; - return wasMentioned || shouldBypassMention; + return effectiveWasMentioned; } return false; }; diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index 11c109b8d..271db0f50 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -10,6 +10,7 @@ import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; +import { resolveMentionGating } from "../channels/mention-gating.js"; import { buildGroupFromLabel, buildGroupLabel, @@ -222,7 +223,7 @@ export const buildTelegramMessageContext = async ({ // Reply-chain detection: replying to a bot message acts like an implicit mention. const botId = primaryCtx.me?.id; const replyFromId = msg.reply_to_message?.from?.id; - const isReplyToBot = botId != null && replyFromId === botId; + const implicitMention = botId != null && replyFromId === botId; const shouldBypassMention = isGroup && requireMention && @@ -230,11 +231,17 @@ export const buildTelegramMessageContext = async ({ !hasAnyMention && commandAuthorized && hasControlCommand(msg.text ?? msg.caption ?? "", cfg, { botUsername }); - const shouldBypassForReplyChain = isGroup && requireMention && isReplyToBot; - const effectiveWasMentioned = wasMentioned || shouldBypassMention || shouldBypassForReplyChain; const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0; + const mentionGate = resolveMentionGating({ + requireMention: Boolean(requireMention), + canDetectMention, + wasMentioned, + implicitMention: isGroup && Boolean(requireMention) && implicitMention, + shouldBypassMention, + }); + const effectiveWasMentioned = mentionGate.effectiveWasMentioned; if (isGroup && requireMention && canDetectMention) { - if (!wasMentioned && !shouldBypassMention && !shouldBypassForReplyChain) { + if (mentionGate.shouldSkip) { logger.info({ chatId, reason: "no-mention" }, "skipping group message"); return null; } @@ -252,7 +259,7 @@ export const buildTelegramMessageContext = async ({ if (!isGroup) return false; if (!requireMention) return false; if (!canDetectMention) return false; - return wasMentioned || shouldBypassMention || shouldBypassForReplyChain; + return effectiveWasMentioned; } return false; }; diff --git a/src/web/auto-reply/monitor/group-gating.test.ts b/src/web/auto-reply/monitor/group-gating.test.ts new file mode 100644 index 000000000..91fe7cbb5 --- /dev/null +++ b/src/web/auto-reply/monitor/group-gating.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { applyGroupGating } from "./group-gating.js"; + +const baseConfig = { + channels: { + whatsapp: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, + }, + session: { store: "/tmp/clawdbot-sessions.json" }, +} as const; + +describe("applyGroupGating", () => { + it("treats reply-to-bot as implicit mention", () => { + const groupHistories = new Map(); + const result = applyGroupGating({ + cfg: baseConfig as unknown as ReturnType, + msg: { + id: "m1", + from: "123@g.us", + conversationId: "123@g.us", + to: "+15550000", + accountId: "default", + body: "following up", + timestamp: Date.now(), + chatType: "group", + chatId: "123@g.us", + selfJid: "15551234567@s.whatsapp.net", + selfE164: "+15551234567", + replyToId: "m0", + replyToBody: "bot said hi", + replyToSender: "+15551234567", + replyToSenderJid: "15551234567@s.whatsapp.net", + replyToSenderE164: "+15551234567", + sendComposing: async () => {}, + reply: async () => {}, + sendMedia: async () => {}, + }, + conversationId: "123@g.us", + groupHistoryKey: "group:123@g.us", + agentId: "main", + sessionKey: "agent:main:whatsapp:group:123@g.us", + baseMentionConfig: { mentionRegexes: [] }, + groupHistories, + groupHistoryLimit: 10, + groupMemberNames: new Map(), + logVerbose: () => {}, + replyLogger: { debug: () => {} }, + }); + + expect(result.shouldProcess).toBe(true); + }); +}); diff --git a/src/web/auto-reply/monitor/group-gating.ts b/src/web/auto-reply/monitor/group-gating.ts index 735ca76d6..0af62b2c3 100644 --- a/src/web/auto-reply/monitor/group-gating.ts +++ b/src/web/auto-reply/monitor/group-gating.ts @@ -2,6 +2,7 @@ import { hasControlCommand } from "../../../auto-reply/command-detection.js"; import { parseActivationCommand } from "../../../auto-reply/group-activation.js"; import type { loadConfig } from "../../../config/config.js"; import { normalizeE164 } from "../../../utils.js"; +import { resolveMentionGating } from "../../../channels/mention-gating.js"; import type { MentionConfig } from "../mentions.js"; import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js"; import type { WebInboundMsg } from "../types.js"; @@ -94,7 +95,6 @@ export function applyGroupGating(params: { "group mention debug", ); const wasMentioned = mentionDebug.wasMentioned; - params.msg.wasMentioned = wasMentioned; const activation = resolveGroupActivationFor({ cfg: params.cfg, agentId: params.agentId, @@ -102,7 +102,25 @@ export function applyGroupGating(params: { conversationId: params.conversationId, }); const requireMention = activation !== "always"; - if (!shouldBypassMention && requireMention && !wasMentioned) { + const selfJid = params.msg.selfJid?.replace(/:\\d+/, ""); + const replySenderJid = params.msg.replyToSenderJid?.replace(/:\\d+/, ""); + const selfE164 = params.msg.selfE164 ? normalizeE164(params.msg.selfE164) : null; + const replySenderE164 = params.msg.replyToSenderE164 + ? normalizeE164(params.msg.replyToSenderE164) + : null; + const implicitMention = Boolean( + (selfJid && replySenderJid && selfJid === replySenderJid) || + (selfE164 && replySenderE164 && selfE164 === replySenderE164), + ); + const mentionGate = resolveMentionGating({ + requireMention, + canDetectMention: true, + wasMentioned, + implicitMention, + shouldBypassMention, + }); + params.msg.wasMentioned = mentionGate.effectiveWasMentioned; + if (!shouldBypassMention && requireMention && mentionGate.shouldSkip) { params.logVerbose( `Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`, ); diff --git a/src/web/inbound/extract.ts b/src/web/inbound/extract.ts index a044cbe71..e48ac7be1 100644 --- a/src/web/inbound/extract.ts +++ b/src/web/inbound/extract.ts @@ -238,6 +238,8 @@ export function describeReplyContext(rawMessage: proto.IMessage | undefined): { id?: string; body: string; sender: string; + senderJid?: string; + senderE164?: string; } | null { const message = unwrapMessage(rawMessage); if (!message) return null; @@ -265,5 +267,7 @@ export function describeReplyContext(rawMessage: proto.IMessage | undefined): { id: contextInfo?.stanzaId ? String(contextInfo.stanzaId) : undefined, body, sender, + senderJid, + senderE164, }; } diff --git a/src/web/inbound/monitor.ts b/src/web/inbound/monitor.ts index a7d4f0031..c6e6189b6 100644 --- a/src/web/inbound/monitor.ts +++ b/src/web/inbound/monitor.ts @@ -284,6 +284,8 @@ export async function monitorWebInbox(options: { replyToId: replyContext?.id, replyToBody: replyContext?.body, replyToSender: replyContext?.sender, + replyToSenderJid: replyContext?.senderJid, + replyToSenderE164: replyContext?.senderE164, groupSubject, groupParticipants, mentionedJids: mentionedJids ?? undefined, diff --git a/src/web/inbound/types.ts b/src/web/inbound/types.ts index 591c7a39b..5f861fcc8 100644 --- a/src/web/inbound/types.ts +++ b/src/web/inbound/types.ts @@ -24,6 +24,8 @@ export type WebInboundMessage = { replyToId?: string; replyToBody?: string; replyToSender?: string; + replyToSenderJid?: string; + replyToSenderE164?: string; groupSubject?: string; groupParticipants?: string[]; mentionedJids?: string[];