refactor: unify group allowlist policy

This commit is contained in:
Peter Steinberger
2026-01-06 04:27:21 +01:00
parent b1bb3ff6a6
commit ca8f66f844
10 changed files with 298 additions and 71 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof loadConfig>,
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 &&

View File

@@ -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<string, unknown>,
) => Promise<void>;
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<

View File

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

View File

@@ -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<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
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);

View File

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