fix: treat reply-to-bot as implicit mention across channels

This commit is contained in:
Peter Steinberger
2026-01-16 21:50:44 +00:00
parent 97a41a6509
commit 05d149a49b
19 changed files with 427 additions and 20 deletions

View File

@@ -90,6 +90,7 @@
- Discord: truncate skill command descriptions for slash command limits. (#1018) — thanks @evalexpr. - 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. - 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. - 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 ## 2026.1.14-1

View File

@@ -177,6 +177,8 @@ Quick mental model (evaluation order for group messages):
## Mention gating (default) ## Mention gating (default)
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`. 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 ```json5
{ {
channels: { channels: {

View File

@@ -11,6 +11,7 @@ import {
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry, type HistoryEntry,
} from "../../../../src/auto-reply/reply/history.js"; } from "../../../../src/auto-reply/reply/history.js";
import { resolveMentionGating } from "../../../../src/channels/mention-gating.js";
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
import { import {
@@ -42,6 +43,7 @@ import {
} from "../policy.js"; } from "../policy.js";
import { extractMSTeamsPollVote } from "../polls.js"; import { extractMSTeamsPollVote } from "../polls.js";
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js"; import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
import type { MSTeamsTurnContext } from "../sdk-types.js"; import type { MSTeamsTurnContext } from "../sdk-types.js";
import { resolveMSTeamsInboundMedia } from "./inbound-media.js"; import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
@@ -74,6 +76,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
text: string; text: string;
attachments: MSTeamsAttachmentLike[]; attachments: MSTeamsAttachmentLike[];
wasMentioned: boolean; wasMentioned: boolean;
implicitMention: boolean;
}; };
const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => { const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
@@ -301,8 +304,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}); });
if (!isDirectMessage) { if (!isDirectMessage) {
const mentioned = params.wasMentioned; const mentionGate = resolveMentionGating({
if (requireMention && !mentioned) { 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)", { log.debug("skipping message (mention required)", {
teamId, teamId,
channelId, channelId,
@@ -379,7 +389,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
Surface: "msteams" as const, Surface: "msteams" as const,
MessageSid: activity.id, MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(), Timestamp: timestamp?.getTime() ?? Date.now(),
WasMentioned: isDirectMessage || params.wasMentioned, WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
CommandAuthorized: true, CommandAuthorized: true,
OriginatingChannel: "msteams" as const, OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo, OriginatingTo: teamsTo,
@@ -401,6 +411,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
context, context,
replyStyle, replyStyle,
textLimit, textLimit,
onSentMessageIds: (ids) => {
for (const id of ids) {
recordMSTeamsSentMessage(conversationId, id);
}
},
}); });
log.info("dispatching to agent", { sessionKey: route.sessionKey }); log.info("dispatching to agent", { sessionKey: route.sessionKey });
@@ -480,12 +495,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
const wasMentioned = entries.some((entry) => entry.wasMentioned); const wasMentioned = entries.some((entry) => entry.wasMentioned);
const implicitMention = entries.some((entry) => entry.implicitMention);
await handleTeamsMessageNow({ await handleTeamsMessageNow({
context: last.context, context: last.context,
rawText: combinedRawText, rawText: combinedRawText,
text: combinedText, text: combinedText,
attachments: [], attachments: [],
wasMentioned, wasMentioned,
implicitMention,
}); });
}, },
onError: (err) => { onError: (err) => {
@@ -501,7 +518,19 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
? (activity.attachments as unknown as MSTeamsAttachmentLike[]) ? (activity.attachments as unknown as MSTeamsAttachmentLike[])
: []; : [];
const wasMentioned = wasMSTeamsBotMentioned(activity); 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,
});
}; };
} }

View File

@@ -28,6 +28,7 @@ export function createMSTeamsReplyDispatcher(params: {
context: MSTeamsTurnContext; context: MSTeamsTurnContext;
replyStyle: MSTeamsReplyStyle; replyStyle: MSTeamsReplyStyle;
textLimit: number; textLimit: number;
onSentMessageIds?: (ids: string[]) => void;
}) { }) {
const sendTypingIndicator = async () => { const sendTypingIndicator = async () => {
try { try {
@@ -46,7 +47,7 @@ export function createMSTeamsReplyDispatcher(params: {
chunkText: true, chunkText: true,
mediaMode: "split", mediaMode: "split",
}); });
await sendMSTeamsMessages({ const ids = await sendMSTeamsMessages({
replyStyle: params.replyStyle, replyStyle: params.replyStyle,
adapter: params.adapter, adapter: params.adapter,
appId: params.appId, appId: params.appId,
@@ -62,6 +63,7 @@ export function createMSTeamsReplyDispatcher(params: {
}); });
}, },
}); });
if (ids.length > 0) params.onSentMessageIds?.(ids);
}, },
onError: (err, info) => { onError: (err, info) => {
const errMsg = formatUnknownError(err); const errMsg = formatUnknownError(err);

View File

@@ -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);
});
});

View File

@@ -0,0 +1,41 @@
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
type CacheEntry = {
messageIds: Set<string>;
timestamps: Map<string, number>;
};
const sentMessages = new Map<string, CacheEntry>();
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();
}

View File

@@ -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);
});
});

View File

@@ -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 };
}

View File

@@ -126,6 +126,106 @@ describe("discord tool result dispatch", () => {
expect(sendMock).toHaveBeenCalledTimes(1); expect(sendMock).toHaveBeenCalledTimes(1);
}, 20_000); }, 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<typeof import("../config/config.js").loadConfig>;
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<string, unknown>;
expect(payload.WasMentioned).toBe(true);
});
it("forks thread sessions and injects starter context", async () => { it("forks thread sessions and injects starter context", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js"); const { createDiscordMessageHandler } = await import("./monitor.js");
let capturedCtx: let capturedCtx:

View File

@@ -14,6 +14,7 @@ import {
upsertChannelPairingRequest, upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js"; } from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { resolveMentionGating } from "../../channels/mention-gating.js";
import { sendMessageDiscord } from "../send.js"; import { sendMessageDiscord } from "../send.js";
import { import {
allowListMatches, allowListMatches,
@@ -164,6 +165,12 @@ export async function preflightDiscordMessage(
!isDirectMessage && !isDirectMessage &&
(Boolean(botId && message.mentionedUsers?.some((user: User) => user.id === botId)) || (Boolean(botId && message.mentionedUsers?.some((user: User) => user.id === botId)) ||
matchesMentionPatterns(baseText, mentionRegexes)); matchesMentionPatterns(baseText, mentionRegexes));
const implicitMention = Boolean(
!isDirectMessage &&
botId &&
message.referencedMessage?.author?.id &&
message.referencedMessage.author.id === botId,
);
if (shouldLogVerbose()) { if (shouldLogVerbose()) {
logVerbose( 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"}`, `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 && !hasAnyMention &&
commandAuthorized && commandAuthorized &&
hasControlCommand(baseText, params.cfg); hasControlCommand(baseText, params.cfg);
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0; 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 (isGuildMessage && shouldRequireMention) {
if (botId && !wasMentioned && !shouldBypassMention) { if (botId && mentionGate.shouldSkip) {
logVerbose(`discord: drop guild message (mention required, botId=${botId})`); logVerbose(`discord: drop guild message (mention required, botId=${botId})`);
logger.info( logger.info(
{ {

View File

@@ -56,10 +56,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
isGroupDm, isGroupDm,
baseText, baseText,
messageText, messageText,
wasMentioned,
shouldRequireMention, shouldRequireMention,
canDetectMention, canDetectMention,
shouldBypassMention,
effectiveWasMentioned, effectiveWasMentioned,
historyEntry, historyEntry,
threadChannel, threadChannel,
@@ -94,7 +92,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
if (!isGuildMessage) return false; if (!isGuildMessage) return false;
if (!shouldRequireMention) return false; if (!shouldRequireMention) return false;
if (!canDetectMention) return false; if (!canDetectMention) return false;
return wasMentioned || shouldBypassMention; return effectiveWasMentioned;
} }
return false; return false;
}; };

View File

@@ -498,6 +498,49 @@ describe("monitorSlackProvider tool results", () => {
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); 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 () => { it("accepts channel messages without mention when channels.slack.requireMention is false", async () => {
config = { config = {
channels: { channels: {

View File

@@ -10,6 +10,7 @@ import { buildPairingReply } from "../../../pairing/pairing-messages.js";
import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js"; import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../../routing/resolve-route.js"; import { resolveAgentRoute } from "../../../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../../routing/session-key.js"; import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
import { resolveMentionGating } from "../../../channels/mention-gating.js";
import type { ResolvedSlackAccount } from "../../accounts.js"; import type { ResolvedSlackAccount } from "../../accounts.js";
import { reactSlackMessage } from "../../actions.js"; import { reactSlackMessage } from "../../actions.js";
@@ -172,6 +173,12 @@ export async function prepareSlackMessage(params: {
(!isDirectMessage && (!isDirectMessage &&
(Boolean(ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`)) || (Boolean(ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`)) ||
matchesMentionPatterns(message.text ?? "", mentionRegexes))); 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 sender = message.user ? await ctx.resolveUserName(message.user) : null;
const senderName = const senderName =
@@ -215,9 +222,16 @@ export async function prepareSlackMessage(params: {
commandAuthorized && commandAuthorized &&
hasControlCommand(message.text ?? "", cfg); hasControlCommand(message.text ?? "", cfg);
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(ctx.botUserId) || mentionRegexes.length > 0; 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"); ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping room message");
return null; return null;
} }
@@ -242,7 +256,7 @@ export async function prepareSlackMessage(params: {
if (!isRoom) return false; if (!isRoom) return false;
if (!shouldRequireMention) return false; if (!shouldRequireMention) return false;
if (!canDetectMention) return false; if (!canDetectMention) return false;
return wasMentioned || shouldBypassMention; return effectiveWasMentioned;
} }
return false; return false;
}; };

View File

@@ -10,6 +10,7 @@ import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { logVerbose, shouldLogVerbose } from "../globals.js"; import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js"; import { recordChannelActivity } from "../infra/channel-activity.js";
import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveMentionGating } from "../channels/mention-gating.js";
import { import {
buildGroupFromLabel, buildGroupFromLabel,
buildGroupLabel, buildGroupLabel,
@@ -222,7 +223,7 @@ export const buildTelegramMessageContext = async ({
// Reply-chain detection: replying to a bot message acts like an implicit mention. // Reply-chain detection: replying to a bot message acts like an implicit mention.
const botId = primaryCtx.me?.id; const botId = primaryCtx.me?.id;
const replyFromId = msg.reply_to_message?.from?.id; const replyFromId = msg.reply_to_message?.from?.id;
const isReplyToBot = botId != null && replyFromId === botId; const implicitMention = botId != null && replyFromId === botId;
const shouldBypassMention = const shouldBypassMention =
isGroup && isGroup &&
requireMention && requireMention &&
@@ -230,11 +231,17 @@ export const buildTelegramMessageContext = async ({
!hasAnyMention && !hasAnyMention &&
commandAuthorized && commandAuthorized &&
hasControlCommand(msg.text ?? msg.caption ?? "", cfg, { botUsername }); 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 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 (isGroup && requireMention && canDetectMention) {
if (!wasMentioned && !shouldBypassMention && !shouldBypassForReplyChain) { if (mentionGate.shouldSkip) {
logger.info({ chatId, reason: "no-mention" }, "skipping group message"); logger.info({ chatId, reason: "no-mention" }, "skipping group message");
return null; return null;
} }
@@ -252,7 +259,7 @@ export const buildTelegramMessageContext = async ({
if (!isGroup) return false; if (!isGroup) return false;
if (!requireMention) return false; if (!requireMention) return false;
if (!canDetectMention) return false; if (!canDetectMention) return false;
return wasMentioned || shouldBypassMention || shouldBypassForReplyChain; return effectiveWasMentioned;
} }
return false; return false;
}; };

View File

@@ -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<typeof import("../../../config/config.js").loadConfig>,
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);
});
});

View File

@@ -2,6 +2,7 @@ import { hasControlCommand } from "../../../auto-reply/command-detection.js";
import { parseActivationCommand } from "../../../auto-reply/group-activation.js"; import { parseActivationCommand } from "../../../auto-reply/group-activation.js";
import type { loadConfig } from "../../../config/config.js"; import type { loadConfig } from "../../../config/config.js";
import { normalizeE164 } from "../../../utils.js"; import { normalizeE164 } from "../../../utils.js";
import { resolveMentionGating } from "../../../channels/mention-gating.js";
import type { MentionConfig } from "../mentions.js"; import type { MentionConfig } from "../mentions.js";
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js"; import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
import type { WebInboundMsg } from "../types.js"; import type { WebInboundMsg } from "../types.js";
@@ -94,7 +95,6 @@ export function applyGroupGating(params: {
"group mention debug", "group mention debug",
); );
const wasMentioned = mentionDebug.wasMentioned; const wasMentioned = mentionDebug.wasMentioned;
params.msg.wasMentioned = wasMentioned;
const activation = resolveGroupActivationFor({ const activation = resolveGroupActivationFor({
cfg: params.cfg, cfg: params.cfg,
agentId: params.agentId, agentId: params.agentId,
@@ -102,7 +102,25 @@ export function applyGroupGating(params: {
conversationId: params.conversationId, conversationId: params.conversationId,
}); });
const requireMention = activation !== "always"; 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( params.logVerbose(
`Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`, `Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`,
); );

View File

@@ -238,6 +238,8 @@ export function describeReplyContext(rawMessage: proto.IMessage | undefined): {
id?: string; id?: string;
body: string; body: string;
sender: string; sender: string;
senderJid?: string;
senderE164?: string;
} | null { } | null {
const message = unwrapMessage(rawMessage); const message = unwrapMessage(rawMessage);
if (!message) return null; if (!message) return null;
@@ -265,5 +267,7 @@ export function describeReplyContext(rawMessage: proto.IMessage | undefined): {
id: contextInfo?.stanzaId ? String(contextInfo.stanzaId) : undefined, id: contextInfo?.stanzaId ? String(contextInfo.stanzaId) : undefined,
body, body,
sender, sender,
senderJid,
senderE164,
}; };
} }

View File

@@ -284,6 +284,8 @@ export async function monitorWebInbox(options: {
replyToId: replyContext?.id, replyToId: replyContext?.id,
replyToBody: replyContext?.body, replyToBody: replyContext?.body,
replyToSender: replyContext?.sender, replyToSender: replyContext?.sender,
replyToSenderJid: replyContext?.senderJid,
replyToSenderE164: replyContext?.senderE164,
groupSubject, groupSubject,
groupParticipants, groupParticipants,
mentionedJids: mentionedJids ?? undefined, mentionedJids: mentionedJids ?? undefined,

View File

@@ -24,6 +24,8 @@ export type WebInboundMessage = {
replyToId?: string; replyToId?: string;
replyToBody?: string; replyToBody?: string;
replyToSender?: string; replyToSender?: string;
replyToSenderJid?: string;
replyToSenderE164?: string;
groupSubject?: string; groupSubject?: string;
groupParticipants?: string[]; groupParticipants?: string[];
mentionedJids?: string[]; mentionedJids?: string[];