fix: unify mention gating across providers

This commit is contained in:
Peter Steinberger
2026-01-06 01:32:17 +01:00
parent 48d52d13f1
commit 811ec8b78b
10 changed files with 253 additions and 51 deletions

View File

@@ -36,6 +36,7 @@
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251.
- Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249.
- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks @regenrek for PR #242.
### Maintenance
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.

View File

@@ -1,6 +1,35 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
export function buildMentionRegexes(cfg: ClawdbotConfig | undefined): RegExp[] {
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
return patterns
.map((pattern) => {
try {
return new RegExp(pattern, "i");
} catch {
return null;
}
})
.filter((value): value is RegExp => Boolean(value));
}
export function normalizeMentionText(text: string): string {
return (text ?? "")
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
.toLowerCase();
}
export function matchesMentionPatterns(
text: string,
mentionRegexes: RegExp[],
): boolean {
if (mentionRegexes.length === 0) return false;
const cleaned = normalizeMentionText(text ?? "");
if (!cleaned) return false;
return mentionRegexes.some((re) => re.test(cleaned));
}
export function stripStructuralPrefixes(text: string): string {
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
// detection still works in group batches that include history/context.

View File

@@ -147,4 +147,59 @@ describe("monitorDiscordProvider tool results", () => {
expect(sendMock.mock.calls[0][1]).toBe("PFX tool update");
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
});
it("accepts guild messages when mentionPatterns match", async () => {
config = {
messages: { responsePrefix: "PFX" },
discord: {
dm: { enabled: true },
guilds: { "*": { requireMention: true } },
},
routing: {
allowFrom: [],
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
},
};
replyMock.mockResolvedValue({ text: "hi" });
const controller = new AbortController();
const run = monitorDiscordProvider({
token: "token",
abortSignal: controller.signal,
});
const discord = await import("discord.js");
const client = await waitForClient();
if (!client) throw new Error("Discord client not created");
client.emit(discord.Events.MessageCreate, {
id: "m2",
content: "clawd: hello",
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
member: { displayName: "Ada" },
channelId: "c1",
channel: {
type: discord.ChannelType.GuildText,
name: "general",
isSendable: () => false,
},
guild: { id: "g1", name: "Guild" },
mentions: {
has: () => false,
everyone: false,
users: { size: 0 },
roles: { size: 0 },
},
attachments: { first: () => undefined },
type: discord.MessageType.Default,
createdTimestamp: Date.now(),
});
await flush();
controller.abort();
await run;
expect(replyMock).toHaveBeenCalledTimes(1);
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
});
});

View File

@@ -18,6 +18,10 @@ import {
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
} from "../auto-reply/reply/mentions.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
@@ -140,6 +144,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
const textLimit = resolveTextChunkLimit(cfg, "discord");
const mentionRegexes = buildMentionRegexes(cfg);
const historyLimit = Math.max(
0,
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
@@ -202,13 +207,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
return;
}
const botId = client.user?.id;
const wasMentioned =
!isDirectMessage && Boolean(botId && message.mentions.has(botId));
const forwardedSnapshot = resolveForwardedSnapshot(message);
const forwardedText = forwardedSnapshot
? resolveDiscordSnapshotText(forwardedSnapshot.snapshot)
: "";
const baseText = resolveDiscordMessageText(message, forwardedText);
const wasMentioned =
!isDirectMessage &&
(Boolean(botId && message.mentions.has(botId)) ||
matchesMentionPatterns(baseText, mentionRegexes));
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=${baseText ? "yes" : "no"}`,
@@ -309,8 +316,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(baseText);
if (isGuildMessage && resolvedRequireMention) {
if (botId && !wasMentioned && !shouldBypassMention) {
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
if (isGuildMessage && resolvedRequireMention && canDetectMention) {
if (!wasMentioned && !shouldBypassMention) {
logVerbose(
`discord: drop guild message (mention required, botId=${botId})`,
);

View File

@@ -1,6 +1,10 @@
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
} from "../auto-reply/reply/mentions.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
@@ -67,20 +71,6 @@ function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
return raw.map((entry) => String(entry).trim()).filter(Boolean);
}
function resolveMentionRegexes(cfg: ReturnType<typeof loadConfig>): RegExp[] {
return (
cfg.routing?.groupChat?.mentionPatterns
?.map((pattern) => {
try {
return new RegExp(pattern, "i");
} catch {
return null;
}
})
.filter((val): val is RegExp => Boolean(val)) ?? []
);
}
function resolveGroupRequireMention(
cfg: ReturnType<typeof loadConfig>,
opts: MonitorIMessageOpts,
@@ -99,14 +89,6 @@ function resolveGroupRequireMention(
return true;
}
function isMentioned(text: string, regexes: RegExp[]): boolean {
if (!text) return false;
const cleaned = text
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
.toLowerCase();
return regexes.some((re) => re.test(cleaned));
}
async function deliverReplies(params: {
replies: ReplyPayload[];
target: string;
@@ -148,7 +130,7 @@ export async function monitorIMessageProvider(
const cfg = loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "imessage");
const allowFrom = resolveAllowFrom(opts);
const mentionRegexes = resolveMentionRegexes(cfg);
const mentionRegexes = buildMentionRegexes(cfg);
const includeAttachments =
opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false;
const mediaMaxBytes =
@@ -183,15 +165,24 @@ export async function monitorIMessageProvider(
}
const messageText = (message.text ?? "").trim();
const mentioned = isGroup ? isMentioned(messageText, mentionRegexes) : true;
const mentioned = isGroup
? matchesMentionPatterns(messageText, mentionRegexes)
: true;
const requireMention = resolveGroupRequireMention(cfg, opts, chatId);
const canDetectMention = mentionRegexes.length > 0;
const shouldBypassMention =
isGroup &&
requireMention &&
!mentioned &&
commandAuthorized &&
hasControlCommand(messageText);
if (isGroup && requireMention && !mentioned && !shouldBypassMention) {
if (
isGroup &&
requireMention &&
canDetectMention &&
!mentioned &&
!shouldBypassMention
) {
logVerbose(`imessage: skipping group message (no mention)`);
return;
}

View File

@@ -123,6 +123,51 @@ describe("monitorSlackProvider tool results", () => {
expect(sendMock.mock.calls[1][1]).toBe("PFX final reply");
});
it("accepts channel messages when mentionPatterns match", async () => {
config = {
messages: { responsePrefix: "PFX" },
slack: {
dm: { enabled: true },
groupDm: { enabled: false },
channels: { C1: { allow: true, requireMention: true } },
},
routing: {
allowFrom: [],
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
},
};
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: "clawd: hello",
ts: "123",
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("threads replies when incoming message is in a thread", async () => {
replyMock.mockResolvedValue({ text: "thread reply" });

View File

@@ -6,6 +6,10 @@ import bolt from "@slack/bolt";
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
} from "../auto-reply/reply/mentions.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
@@ -379,6 +383,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
opts.slashCommand ?? cfg.slack?.slashCommand,
);
const textLimit = resolveTextChunkLimit(cfg, "slack");
const mentionRegexes = buildMentionRegexes(cfg);
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.slack?.mediaMaxMb ?? 20) * 1024 * 1024;
@@ -581,7 +586,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const wasMentioned =
opts.wasMentioned ??
(!isDirectMessage &&
Boolean(botUserId && message.text?.includes(`<@${botUserId}>`)));
(Boolean(botUserId && message.text?.includes(`<@${botUserId}>`)) ||
matchesMentionPatterns(message.text ?? "", mentionRegexes)));
const sender = await resolveUserName(message.user);
const senderName = sender?.name ?? message.user;
const allowList = normalizeAllowListLower(allowFrom);
@@ -600,9 +606,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(message.text ?? "");
const canDetectMention = Boolean(botUserId) || mentionRegexes.length > 0;
if (
isRoom &&
channelConfig?.requireMention &&
canDetectMention &&
!wasMentioned &&
!shouldBypassMention
) {

View File

@@ -126,6 +126,73 @@ describe("createTelegramBot", () => {
expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing");
});
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<
typeof vi.fn
>;
replySpy.mockReset();
loadConfig.mockReturnValue({
identity: { name: "Bert" },
routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
telegram: { groups: { "*": { requireMention: true } } },
});
createTelegramBot({ token: "tok" });
const handler = onSpy.mock.calls[0][1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself",
date: 1736380800,
message_id: 1,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.WasMentioned).toBe(true);
});
it("skips group messages when requireMention is enabled and no mention matches", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<
typeof vi.fn
>;
replySpy.mockReset();
loadConfig.mockReturnValue({
routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
telegram: { groups: { "*": { requireMention: true } } },
});
createTelegramBot({ token: "tok" });
const handler = onSpy.mock.calls[0][1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "hello everyone",
date: 1736380800,
message_id: 2,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
});
it("includes reply-to context when a Telegram reply is received", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();

View File

@@ -7,6 +7,10 @@ import { Bot, InputFile, webhookCallback } from "grammy";
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
} from "../auto-reply/reply/mentions.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
@@ -67,6 +71,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024;
const logger = getChildLogger({ module: "telegram-auto-reply" });
const mentionRegexes = buildMentionRegexes(cfg);
const resolveGroupRequireMention = (chatId: string | number) => {
const groupId = String(chatId);
const groupConfig = cfg.telegram?.groups?.[groupId];
@@ -132,7 +137,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
entry.toLowerCase() === `@${senderUsername.toLowerCase()}`,
));
const wasMentioned =
Boolean(botUsername) && hasBotMention(msg, botUsername);
(Boolean(botUsername) && hasBotMention(msg, botUsername)) ||
matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes);
const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some(
(ent) => ent.type === "mention",
);
@@ -143,7 +149,9 @@ export function createTelegramBot(opts: TelegramBotOptions) {
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(msg.text ?? msg.caption ?? "");
if (isGroup && resolveGroupRequireMention(chatId) && botUsername) {
const canDetectMention =
Boolean(botUsername) || mentionRegexes.length > 0;
if (isGroup && resolveGroupRequireMention(chatId) && canDetectMention) {
if (!wasMentioned && !shouldBypassMention) {
logger.info(
{ chatId, reason: "no-mention" },
@@ -196,7 +204,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
ReplyToBody: replyTarget?.body,
ReplyToSender: replyTarget?.sender,
Timestamp: msg.date ? msg.date * 1000 : undefined,
WasMentioned: isGroup && botUsername ? wasMentioned : undefined,
WasMentioned: isGroup ? wasMentioned : undefined,
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,

View File

@@ -9,6 +9,10 @@ import {
HEARTBEAT_PROMPT,
stripHeartbeatToken,
} from "../auto-reply/heartbeat.js";
import {
buildMentionRegexes,
normalizeMentionText,
} from "../auto-reply/reply/mentions.js";
import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
@@ -147,17 +151,7 @@ type MentionConfig = {
};
function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
const gc = cfg.routing?.groupChat;
const mentionRegexes =
gc?.mentionPatterns
?.map((p) => {
try {
return new RegExp(p, "i");
} catch {
return null;
}
})
.filter((r): r is RegExp => Boolean(r)) ?? [];
const mentionRegexes = buildMentionRegexes(cfg);
return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
}
@@ -166,10 +160,8 @@ function isBotMentioned(
mentionCfg: MentionConfig,
): boolean {
const clean = (text: string) =>
text
// Remove zero-width and directionality markers WhatsApp injects around display names
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
.toLowerCase();
// Remove zero-width and directionality markers WhatsApp injects around display names
normalizeMentionText(text);
const isSelfChat = isSelfChatMode(msg.selfE164, mentionCfg.allowFrom);
@@ -212,9 +204,7 @@ function debugMention(
const details = {
from: msg.from,
body: msg.body,
bodyClean: msg.body
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
.toLowerCase(),
bodyClean: normalizeMentionText(msg.body),
mentionedJids: msg.mentionedJids ?? null,
selfJid: msg.selfJid ?? null,
selfE164: msg.selfE164 ?? null,