From ca8f66f84462c745702a9a59bc25296d7395b20a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 6 Jan 2026 04:27:21 +0100 Subject: [PATCH] refactor: unify group allowlist policy --- CHANGELOG.md | 2 + docs/groups.md | 3 ++ src/auto-reply/reply/groups.ts | 43 +++++------------ src/config/group-policy.ts | 85 ++++++++++++++++++++++++++++++++++ src/imessage/monitor.test.ts | 30 ++++++++++++ src/imessage/monitor.ts | 45 ++++++++++-------- src/telegram/bot.test.ts | 32 +++++++++++++ src/telegram/bot.ts | 45 ++++++++++++------ src/web/auto-reply.test.ts | 51 ++++++++++++++++++++ src/web/auto-reply.ts | 33 ++++++++++--- 10 files changed, 298 insertions(+), 71 deletions(-) create mode 100644 src/config/group-policy.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index df2e77a73..bdcf88493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ - Docs: clarify Slack manifest scopes (current vs optional) with references. Thanks @jarvis-medmatic for PR #235. - 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. +- Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241. - Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs. - Auto-reply: track compaction count in session status; verbose mode announces auto-compactions. - Telegram: send GIF media as animations (auto-play) and improve filename sniffing. @@ -63,6 +64,7 @@ - Skills: add CodexBar model usage helper with macOS requirement metadata. - Skills: add 1Password CLI skill with op examples. - Lint: organize imports and wrap long lines in reply commands. +- Refactor: centralize group allowlist/mention policy across providers. - Deps: update to latest across the repo. ## 2026.1.5-3 diff --git a/docs/groups.md b/docs/groups.md index 48a562ed4..7a605863c 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -54,6 +54,9 @@ Notes: - Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). - Discord defaults live in `discord.guilds."*"` (overridable per guild/channel). +## Group allowlists +When `whatsapp.groups`, `telegram.groups`, or `imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior. + ## Activation (owner-only) Group owners can toggle per-group activation: - `/activation mention` diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index c94f0ef73..fd9ccd40f 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -1,4 +1,5 @@ import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveProviderGroupRequireMention } from "../../config/group-policy.js"; import type { GroupKeyResolution, SessionEntry, @@ -53,38 +54,16 @@ export function resolveGroupRequireMention(params: { const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, ""); const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim(); const groupSpace = ctx.GroupSpace?.trim(); - if (surface === "telegram") { - if (groupId) { - const groupConfig = cfg.telegram?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - } - const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; - } - if (surface === "whatsapp") { - if (groupId) { - const groupConfig = cfg.whatsapp?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - } - const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; - } - if (surface === "imessage") { - if (groupId) { - const groupConfig = cfg.imessage?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - } - const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; + if ( + surface === "telegram" || + surface === "whatsapp" || + surface === "imessage" + ) { + return resolveProviderGroupRequireMention({ + cfg, + surface, + groupId, + }); } if (surface === "discord") { const guildEntry = resolveDiscordGuildEntry( diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts new file mode 100644 index 000000000..4f0337a1a --- /dev/null +++ b/src/config/group-policy.ts @@ -0,0 +1,85 @@ +import type { ClawdbotConfig } from "./config.js"; + +export type GroupPolicySurface = "whatsapp" | "telegram" | "imessage"; + +export type ProviderGroupConfig = { + requireMention?: boolean; +}; + +export type ProviderGroupPolicy = { + allowlistEnabled: boolean; + allowed: boolean; + groupConfig?: ProviderGroupConfig; + defaultConfig?: ProviderGroupConfig; +}; + +type ProviderGroups = Record; + +function resolveProviderGroups( + cfg: ClawdbotConfig, + surface: GroupPolicySurface, +): ProviderGroups | undefined { + if (surface === "whatsapp") return cfg.whatsapp?.groups; + if (surface === "telegram") return cfg.telegram?.groups; + if (surface === "imessage") return cfg.imessage?.groups; + return undefined; +} + +export function resolveProviderGroupPolicy(params: { + cfg: ClawdbotConfig; + surface: GroupPolicySurface; + groupId?: string | null; +}): ProviderGroupPolicy { + const { cfg, surface } = params; + const groups = resolveProviderGroups(cfg, surface); + const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0); + const normalizedId = params.groupId?.trim(); + const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined; + const defaultConfig = groups?.["*"]; + const allowAll = + allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*")); + const allowed = + !allowlistEnabled || + allowAll || + (normalizedId + ? Boolean(groups && Object.hasOwn(groups, normalizedId)) + : false); + return { + allowlistEnabled, + allowed, + groupConfig, + defaultConfig, + }; +} + +export function resolveProviderGroupRequireMention(params: { + cfg: ClawdbotConfig; + surface: GroupPolicySurface; + groupId?: string | null; + requireMentionOverride?: boolean; + overrideOrder?: "before-config" | "after-config"; +}): boolean { + const { requireMentionOverride, overrideOrder = "after-config" } = params; + const { groupConfig, defaultConfig } = resolveProviderGroupPolicy(params); + const configMention = + typeof groupConfig?.requireMention === "boolean" + ? groupConfig.requireMention + : typeof defaultConfig?.requireMention === "boolean" + ? defaultConfig.requireMention + : undefined; + + if ( + overrideOrder === "before-config" && + typeof requireMentionOverride === "boolean" + ) { + return requireMentionOverride; + } + if (typeof configMention === "boolean") return configMention; + if ( + overrideOrder !== "before-config" && + typeof requireMentionOverride === "boolean" + ) { + return requireMentionOverride; + } + return true; +} diff --git a/src/imessage/monitor.test.ts b/src/imessage/monitor.test.ts index 16810333c..e50765150 100644 --- a/src/imessage/monitor.test.ts +++ b/src/imessage/monitor.test.ts @@ -169,6 +169,36 @@ describe("monitorIMessageProvider", () => { expect(replyMock).toHaveBeenCalled(); }); + it("blocks group messages when imessage.groups is set without a wildcard", async () => { + config = { + ...config, + imessage: { groups: { "99": { requireMention: false } } }, + }; + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 13, + chat_id: 123, + sender: "+15550001111", + is_from_me: false, + text: "@clawd hello", + is_group: true, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + expect(sendMock).not.toHaveBeenCalled(); + }); + it("prefixes tool and final replies with responsePrefix", async () => { config = { ...config, diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 30f12e7ee..4a37d2fca 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -9,6 +9,10 @@ import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { loadConfig } from "../config/config.js"; +import { + resolveProviderGroupPolicy, + resolveProviderGroupRequireMention, +} from "../config/group-policy.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { mediaKindFromMime } from "../media/constants.js"; @@ -71,24 +75,6 @@ function resolveAllowFrom(opts: MonitorIMessageOpts): string[] { return raw.map((entry) => String(entry).trim()).filter(Boolean); } -function resolveGroupRequireMention( - cfg: ReturnType, - opts: MonitorIMessageOpts, - chatId?: number | null, -): boolean { - if (typeof opts.requireMention === "boolean") return opts.requireMention; - const groupId = chatId != null ? String(chatId) : undefined; - if (groupId) { - const groupConfig = cfg.imessage?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - } - const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; -} - async function deliverReplies(params: { replies: ReplyPayload[]; target: string; @@ -152,6 +138,21 @@ export async function monitorIMessageProvider( const isGroup = Boolean(message.is_group); if (isGroup && !chatId) return; + const groupId = isGroup ? String(chatId) : undefined; + if (isGroup) { + const groupPolicy = resolveProviderGroupPolicy({ + cfg, + surface: "imessage", + groupId, + }); + if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) { + logVerbose( + `imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`, + ); + return; + } + } + const commandAuthorized = isAllowedIMessageSender({ allowFrom, sender, @@ -168,7 +169,13 @@ export async function monitorIMessageProvider( const mentioned = isGroup ? matchesMentionPatterns(messageText, mentionRegexes) : true; - const requireMention = resolveGroupRequireMention(cfg, opts, chatId); + const requireMention = resolveProviderGroupRequireMention({ + cfg, + surface: "imessage", + groupId, + requireMentionOverride: opts.requireMention, + overrideOrder: "before-config", + }); const canDetectMention = mentionRegexes.length > 0; const shouldBypassMention = isGroup && diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 6dd39c4e5..b9005ecc4 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -411,6 +411,38 @@ describe("createTelegramBot", () => { } }); + it("blocks group messages when telegram.groups is set without a wildcard", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groups: { + "123": { requireMention: false }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 456, type: "group", title: "Ops" }, + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("skips group messages without mention when requireMention is enabled", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType< diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 53d35971e..f6f03ceec 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -17,6 +17,10 @@ import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { ReplyToMode } from "../config/config.js"; import { loadConfig } from "../config/config.js"; +import { + resolveProviderGroupPolicy, + resolveProviderGroupRequireMention, +} from "../config/group-policy.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -73,17 +77,20 @@ export function createTelegramBot(opts: TelegramBotOptions) { (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]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - if (typeof opts.requireMention === "boolean") return opts.requireMention; - return true; - }; + const resolveGroupPolicy = (chatId: string | number) => + resolveProviderGroupPolicy({ + cfg, + surface: "telegram", + groupId: String(chatId), + }); + const resolveGroupRequireMention = (chatId: string | number) => + resolveProviderGroupRequireMention({ + cfg, + surface: "telegram", + groupId: String(chatId), + requireMentionOverride: opts.requireMention, + overrideOrder: "after-config", + }); bot.on("message", async (ctx) => { try { @@ -93,6 +100,17 @@ export function createTelegramBot(opts: TelegramBotOptions) { const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + if (isGroup) { + const groupPolicy = resolveGroupPolicy(chatId); + if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) { + logger.info( + { chatId, title: msg.chat.title, reason: "not-allowed" }, + "skipping group message", + ); + return; + } + } + const sendTyping = async () => { try { await bot.api.sendChatAction(chatId, "typing"); @@ -143,16 +161,17 @@ export function createTelegramBot(opts: TelegramBotOptions) { const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some( (ent) => ent.type === "mention", ); + const requireMention = resolveGroupRequireMention(chatId); const shouldBypassMention = isGroup && - resolveGroupRequireMention(chatId) && + requireMention && !wasMentioned && !hasAnyMention && commandAuthorized && hasControlCommand(msg.text ?? msg.caption ?? ""); const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0; - if (isGroup && resolveGroupRequireMention(chatId) && canDetectMention) { + if (isGroup && requireMention && canDetectMention) { if (!wasMentioned && !shouldBypassMention) { logger.info( { chatId, reason: "no-mention" }, diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 02f77c23d..25cd46ea2 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1045,6 +1045,57 @@ describe("web auto-reply", () => { resetLoadConfigMock(); }); + it("blocks group messages when whatsapp groups is set without a wildcard", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "ok" }); + + setLoadConfigMock(() => ({ + whatsapp: { + allowFrom: ["*"], + groups: { "999@g.us": { requireMention: false } }, + }, + routing: { groupChat: { mentionPatterns: ["@clawd"] } }, + })); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "@clawd hello", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + id: "g-allowlist-block", + senderE164: "+111", + senderName: "Alice", + mentionedJids: ["999@s.whatsapp.net"], + selfE164: "+999", + selfJid: "999@s.whatsapp.net", + sendComposing, + reply, + sendMedia, + }); + + expect(resolver).not.toHaveBeenCalled(); + resetLoadConfigMock(); + }); + it("honors per-group mention overrides when conversationId uses session key", async () => { const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index d810a5879..022538141 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -20,6 +20,10 @@ import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { waitForever } from "../cli/wait.js"; import { loadConfig } from "../config/config.js"; +import { + resolveProviderGroupPolicy, + resolveProviderGroupRequireMention, +} from "../config/group-policy.js"; import { DEFAULT_IDLE_MINUTES, loadSessionStore, @@ -850,16 +854,24 @@ export async function monitorWebProvider( Surface: "whatsapp", }); + const resolveGroupPolicyFor = (conversationId: string) => { + const groupId = + resolveGroupResolution(conversationId)?.id ?? conversationId; + return resolveProviderGroupPolicy({ + cfg, + surface: "whatsapp", + groupId, + }); + }; + const resolveGroupRequireMentionFor = (conversationId: string) => { const groupId = resolveGroupResolution(conversationId)?.id ?? conversationId; - const groupConfig = cfg.whatsapp?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; + return resolveProviderGroupRequireMention({ + cfg, + surface: "whatsapp", + groupId, + }); }; const resolveGroupActivationFor = (conversationId: string) => { @@ -1275,6 +1287,13 @@ export async function monitorWebProvider( } if (msg.chatType === "group") { + const groupPolicy = resolveGroupPolicyFor(conversationId); + if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) { + logVerbose( + `Skipping group message ${conversationId} (not in allowlist)`, + ); + return; + } noteGroupMember(conversationId, msg.senderE164, msg.senderName); const commandBody = stripMentionsForCommand(msg.body, msg.selfE164); const activationCommand = parseActivationCommand(commandBody);