fix: treat reply-to-bot as implicit mention across channels
This commit is contained in:
38
src/channels/mention-gating.test.ts
Normal file
38
src/channels/mention-gating.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
src/channels/mention-gating.ts
Normal file
21
src/channels/mention-gating.ts
Normal 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 };
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
55
src/web/auto-reply/monitor/group-gating.test.ts
Normal file
55
src/web/auto-reply/monitor/group-gating.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,6 +24,8 @@ export type WebInboundMessage = {
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
replyToSenderJid?: string;
|
||||
replyToSenderE164?: string;
|
||||
groupSubject?: string;
|
||||
groupParticipants?: string[];
|
||||
mentionedJids?: string[];
|
||||
|
||||
Reference in New Issue
Block a user