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

@@ -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);
}, 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 () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
let capturedCtx:

View File

@@ -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(
{

View File

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

View File

@@ -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: {

View File

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

View File

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

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 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}`,
);

View File

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

View File

@@ -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,

View File

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