refactor: unify group allowlist policy
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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(
|
||||
|
||||
85
src/config/group-policy.ts
Normal file
85
src/config/group-policy.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user