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

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