feat: unify group policy allowlists

This commit is contained in:
Peter Steinberger
2026-01-06 06:40:42 +00:00
parent 51e8bbd2a8
commit dbb51006cd
23 changed files with 729 additions and 88 deletions

View File

@@ -1,6 +1,7 @@
export type ReplyMode = "text" | "command";
export type SessionScope = "per-sender" | "global";
export type ReplyToMode = "off" | "first" | "all";
export type GroupPolicy = "open" | "disabled" | "allowlist";
export type SessionSendPolicyAction = "allow" | "deny";
export type SessionSendPolicyMatch = {
@@ -78,13 +79,15 @@ export type AgentElevatedAllowFromConfig = {
export type WhatsAppConfig = {
/** Optional allowlist for WhatsApp direct chats (E.164). */
allowFrom?: string[];
/** Optional allowlist for WhatsApp group senders (E.164). */
groupAllowFrom?: string[];
/**
* Controls how group messages are handled:
* - "open" (default): groups bypass allowFrom, only mention-gating applies
* - "disabled": block all group messages entirely
* - "allowlist": only allow group messages from senders in allowFrom
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: "open" | "disabled" | "allowlist";
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
groups?: Record<
@@ -214,13 +217,15 @@ export type TelegramConfig = {
}
>;
allowFrom?: Array<string | number>;
/** Optional allowlist for Telegram group senders (user ids or usernames). */
groupAllowFrom?: Array<string | number>;
/**
* Controls how group messages are handled:
* - "open" (default): groups bypass allowFrom, only mention-gating applies
* - "disabled": block all group messages entirely
* - "allowlist": only allow group messages from senders in allowFrom
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: "open" | "disabled" | "allowlist";
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
mediaMaxMb?: number;
@@ -296,6 +301,13 @@ export type DiscordConfig = {
/** If false, do not start the Discord provider. Default: true. */
enabled?: boolean;
token?: string;
/**
* Controls how guild channel messages are handled:
* - "open" (default): guild channels bypass allowlists; mention-gating applies
* - "disabled": block all guild channel messages
* - "allowlist": only allow channels present in discord.guilds.*.channels
*/
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 2000. */
textChunkLimit?: number;
mediaMaxMb?: number;
@@ -355,6 +367,13 @@ export type SlackConfig = {
enabled?: boolean;
botToken?: string;
appToken?: string;
/**
* Controls how channel messages are handled:
* - "open" (default): channels bypass allowlists; mention-gating applies
* - "disabled": block all channel messages
* - "allowlist": only allow channels present in slack.channels
*/
groupPolicy?: GroupPolicy;
textChunkLimit?: number;
mediaMaxMb?: number;
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
@@ -387,6 +406,15 @@ export type SignalConfig = {
ignoreStories?: boolean;
sendReadReceipts?: boolean;
allowFrom?: Array<string | number>;
/** Optional allowlist for Signal group senders (E.164). */
groupAllowFrom?: Array<string | number>;
/**
* Controls how group messages are handled:
* - "open" (default): groups bypass allowFrom, no extra gating
* - "disabled": block all group messages
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
mediaMaxMb?: number;
@@ -405,6 +433,15 @@ export type IMessageConfig = {
region?: string;
/** Optional allowlist for inbound handles or chat_id targets. */
allowFrom?: Array<string | number>;
/** Optional allowlist for group senders or chat_id targets. */
groupAllowFrom?: Array<string | number>;
/**
* Controls how group messages are handled:
* - "open" (default): groups bypass allowFrom; mention-gating applies
* - "disabled": block all group messages entirely
* - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
*/
groupPolicy?: GroupPolicy;
/** Include attachments + reactions in watch payloads. */
includeAttachments?: boolean;
/** Max outbound media size in MB. */

View File

@@ -598,7 +598,8 @@ export const ClawdbotSchema = z.object({
whatsapp: z
.object({
allowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.default("open").optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
groups: z
.record(
@@ -629,7 +630,8 @@ export const ClawdbotSchema = z.object({
)
.optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.default("open").optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
proxy: z.string().optional(),
@@ -642,6 +644,7 @@ export const ClawdbotSchema = z.object({
.object({
enabled: z.boolean().optional(),
token: z.string().optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
slashCommand: z
.object({
@@ -714,6 +717,7 @@ export const ClawdbotSchema = z.object({
enabled: z.boolean().optional(),
botToken: z.string().optional(),
appToken: z.string().optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
reactionNotifications: z
@@ -777,6 +781,8 @@ export const ClawdbotSchema = z.object({
ignoreStories: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
})
@@ -791,6 +797,8 @@ export const ClawdbotSchema = z.object({
.optional(),
region: z.string().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("open"),
includeAttachments: z.boolean().optional(),
mediaMaxMb: z.number().positive().optional(),
textChunkLimit: z.number().int().positive().optional(),

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
allowListMatches,
type DiscordGuildEntryResolved,
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordChannelConfig,
@@ -132,6 +133,58 @@ describe("discord guild/channel resolution", () => {
});
});
describe("discord groupPolicy gating", () => {
it("allows when policy is open", () => {
expect(
isDiscordGroupAllowedByPolicy({
groupPolicy: "open",
channelAllowlistConfigured: false,
channelAllowed: false,
}),
).toBe(true);
});
it("blocks when policy is disabled", () => {
expect(
isDiscordGroupAllowedByPolicy({
groupPolicy: "disabled",
channelAllowlistConfigured: true,
channelAllowed: true,
}),
).toBe(false);
});
it("blocks allowlist when no channel allowlist configured", () => {
expect(
isDiscordGroupAllowedByPolicy({
groupPolicy: "allowlist",
channelAllowlistConfigured: false,
channelAllowed: true,
}),
).toBe(false);
});
it("allows allowlist when channel is allowed", () => {
expect(
isDiscordGroupAllowedByPolicy({
groupPolicy: "allowlist",
channelAllowlistConfigured: true,
channelAllowed: true,
}),
).toBe(true);
});
it("blocks allowlist when channel is not allowed", () => {
expect(
isDiscordGroupAllowedByPolicy({
groupPolicy: "allowlist",
channelAllowlistConfigured: true,
channelAllowed: false,
}),
).toBe(false);
});
});
describe("discord group DM gating", () => {
it("allows all when no allowlist", () => {
expect(

View File

@@ -141,6 +141,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const dmConfig = cfg.discord?.dm;
const guildEntries = cfg.discord?.guilds;
const groupPolicy = cfg.discord?.groupPolicy ?? "open";
const allowFrom = dmConfig?.allowFrom;
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
@@ -159,7 +160,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
if (shouldLogVerbose()) {
logVerbose(
`discord: config dm=${dmEnabled ? "on" : "off"} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))}`,
`discord: config dm=${dmEnabled ? "on" : "off"} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))}`,
);
}
@@ -279,6 +280,32 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
});
if (isGroupDm && !groupDmAllowed) return;
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) &&
Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
if (
isGuildMessage &&
!isDiscordGroupAllowedByPolicy({
groupPolicy,
channelAllowlistConfigured,
channelAllowed,
})
) {
if (groupPolicy === "disabled") {
logVerbose("discord: drop guild message (groupPolicy: disabled)");
} else if (!channelAllowlistConfigured) {
logVerbose(
"discord: drop guild message (groupPolicy: allowlist, no channel allowlist)",
);
} else {
logVerbose(
`Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist)`,
);
}
return;
}
if (isGuildMessage && channelConfig?.allowed === false) {
logVerbose(
`Blocked discord channel ${message.channelId} not in guild channel allowlist`,
@@ -1169,6 +1196,18 @@ export function resolveDiscordChannelConfig(params: {
return { allowed: true };
}
export function isDiscordGroupAllowedByPolicy(params: {
groupPolicy: "open" | "disabled" | "allowlist";
channelAllowlistConfigured: boolean;
channelAllowed: boolean;
}): boolean {
const { groupPolicy, channelAllowlistConfigured, channelAllowed } = params;
if (groupPolicy === "disabled") return false;
if (groupPolicy === "open") return true;
if (!channelAllowlistConfigured) return false;
return channelAllowed;
}
export function resolveGroupDmAllow(params: {
channels: Array<string | number> | undefined;
channelId: string;

View File

@@ -266,10 +266,13 @@ describe("monitorIMessageProvider", () => {
);
});
it("honors allowFrom entries", async () => {
it("honors group allowlist when groupPolicy is allowlist", async () => {
config = {
...config,
imessage: { allowFrom: ["chat_id:101"] },
imessage: {
groupPolicy: "allowlist",
groupAllowFrom: ["chat_id:101"],
},
};
const run = monitorIMessageProvider();
await waitForSubscribe();
@@ -295,6 +298,35 @@ describe("monitorIMessageProvider", () => {
expect(replyMock).not.toHaveBeenCalled();
});
it("blocks group messages when groupPolicy is disabled", async () => {
config = {
...config,
imessage: { groupPolicy: "disabled" },
};
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
method: "message",
params: {
message: {
id: 10,
chat_id: 303,
sender: "+15550003333",
is_from_me: false,
text: "@clawd hi",
is_group: true,
},
},
});
await flush();
closeResolve?.();
await run;
expect(replyMock).not.toHaveBeenCalled();
});
it("updates last route with chat_id for direct messages", async () => {
replyMock.mockResolvedValueOnce({ text: "ok" });
const run = monitorIMessageProvider();

View File

@@ -52,6 +52,7 @@ export type MonitorIMessageOpts = {
cliPath?: string;
dbPath?: string;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
includeAttachments?: boolean;
mediaMaxMb?: number;
requireMention?: boolean;
@@ -75,6 +76,17 @@ function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
return raw.map((entry) => String(entry).trim()).filter(Boolean);
}
function resolveGroupAllowFrom(opts: MonitorIMessageOpts): string[] {
const cfg = loadConfig();
const raw =
opts.groupAllowFrom ??
cfg.imessage?.groupAllowFrom ??
(cfg.imessage?.allowFrom && cfg.imessage.allowFrom.length > 0
? cfg.imessage.allowFrom
: []);
return raw.map((entry) => String(entry).trim()).filter(Boolean);
}
async function deliverReplies(params: {
replies: ReplyPayload[];
target: string;
@@ -116,6 +128,8 @@ export async function monitorIMessageProvider(
const cfg = loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "imessage");
const allowFrom = resolveAllowFrom(opts);
const groupAllowFrom = resolveGroupAllowFrom(opts);
const groupPolicy = cfg.imessage?.groupPolicy ?? "open";
const mentionRegexes = buildMentionRegexes(cfg);
const includeAttachments =
opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false;
@@ -140,12 +154,37 @@ export async function monitorIMessageProvider(
const groupId = isGroup ? String(chatId) : undefined;
if (isGroup) {
const groupPolicy = resolveProviderGroupPolicy({
if (groupPolicy === "disabled") {
logVerbose("Blocked iMessage group message (groupPolicy: disabled)");
return;
}
if (groupPolicy === "allowlist") {
if (groupAllowFrom.length === 0) {
logVerbose(
"Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)",
);
return;
}
const allowed = isAllowedIMessageSender({
allowFrom: groupAllowFrom,
sender,
chatId: chatId ?? undefined,
chatGuid,
chatIdentifier,
});
if (!allowed) {
logVerbose(
`Blocked iMessage sender ${sender} (not in groupAllowFrom)`,
);
return;
}
}
const groupListPolicy = resolveProviderGroupPolicy({
cfg,
surface: "imessage",
groupId,
});
if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) {
if (groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) {
logVerbose(
`imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`,
);
@@ -153,14 +192,14 @@ export async function monitorIMessageProvider(
}
}
const commandAuthorized = isAllowedIMessageSender({
const dmAuthorized = isAllowedIMessageSender({
allowFrom,
sender,
chatId: chatId ?? undefined,
chatGuid,
chatIdentifier,
});
if (!commandAuthorized) {
if (!isGroup && !dmAuthorized) {
logVerbose(`Blocked iMessage sender ${sender} (not in allowFrom)`);
return;
}
@@ -177,6 +216,17 @@ export async function monitorIMessageProvider(
overrideOrder: "before-config",
});
const canDetectMention = mentionRegexes.length > 0;
const commandAuthorized = isGroup
? groupAllowFrom.length > 0
? isAllowedIMessageSender({
allowFrom: groupAllowFrom,
sender,
chatId: chatId ?? undefined,
chatGuid,
chatIdentifier,
})
: true
: dmAuthorized;
const shouldBypassMention =
isGroup &&
requireMention &&

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { isSignalGroupAllowed } from "./monitor.js";
describe("signal groupPolicy gating", () => {
it("allows when policy is open", () => {
expect(
isSignalGroupAllowed({
groupPolicy: "open",
allowFrom: [],
sender: "+15550001111",
}),
).toBe(true);
});
it("blocks when policy is disabled", () => {
expect(
isSignalGroupAllowed({
groupPolicy: "disabled",
allowFrom: ["+15550001111"],
sender: "+15550001111",
}),
).toBe(false);
});
it("blocks allowlist when empty", () => {
expect(
isSignalGroupAllowed({
groupPolicy: "allowlist",
allowFrom: [],
sender: "+15550001111",
}),
).toBe(false);
});
it("allows allowlist when sender matches", () => {
expect(
isSignalGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["+15550001111"],
sender: "+15550001111",
}),
).toBe(true);
});
it("allows allowlist wildcard", () => {
expect(
isSignalGroupAllowed({
groupPolicy: "allowlist",
allowFrom: ["*"],
sender: "+15550002222",
}),
).toBe(true);
});
});

View File

@@ -55,6 +55,7 @@ export type MonitorSignalOpts = {
ignoreStories?: boolean;
sendReadReceipts?: boolean;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
mediaMaxMb?: number;
};
@@ -97,6 +98,17 @@ function resolveAllowFrom(opts: MonitorSignalOpts): string[] {
return raw.map((entry) => String(entry).trim()).filter(Boolean);
}
function resolveGroupAllowFrom(opts: MonitorSignalOpts): string[] {
const cfg = loadConfig();
const raw =
opts.groupAllowFrom ??
cfg.signal?.groupAllowFrom ??
(cfg.signal?.allowFrom && cfg.signal.allowFrom.length > 0
? cfg.signal.allowFrom
: []);
return raw.map((entry) => String(entry).trim()).filter(Boolean);
}
function isAllowedSender(sender: string, allowFrom: string[]): boolean {
if (allowFrom.length === 0) return true;
if (allowFrom.includes("*")) return true;
@@ -107,6 +119,18 @@ function isAllowedSender(sender: string, allowFrom: string[]): boolean {
return normalizedAllow.includes(normalizedSender);
}
export function isSignalGroupAllowed(params: {
groupPolicy: "open" | "disabled" | "allowlist";
allowFrom: string[];
sender: string;
}): boolean {
const { groupPolicy, allowFrom, sender } = params;
if (groupPolicy === "disabled") return false;
if (groupPolicy === "open") return true;
if (allowFrom.length === 0) return false;
return isAllowedSender(sender, allowFrom);
}
async function waitForSignalDaemonReady(params: {
baseUrl: string;
abortSignal?: AbortSignal;
@@ -222,6 +246,8 @@ export async function monitorSignalProvider(
const baseUrl = resolveBaseUrl(opts);
const account = resolveAccount(opts);
const allowFrom = resolveAllowFrom(opts);
const groupAllowFrom = resolveGroupAllowFrom(opts);
const groupPolicy = cfg.signal?.groupPolicy ?? "open";
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.signal?.mediaMaxMb ?? 8) * 1024 * 1024;
const ignoreAttachments =
@@ -288,15 +314,37 @@ export async function monitorSignalProvider(
if (account && normalizeE164(sender) === normalizeE164(account)) {
return;
}
const commandAuthorized = isAllowedSender(sender, allowFrom);
if (!commandAuthorized) {
logVerbose(`Blocked signal sender ${sender} (not in allowFrom)`);
return;
}
const groupId = dataMessage.groupInfo?.groupId ?? undefined;
const groupName = dataMessage.groupInfo?.groupName ?? undefined;
const isGroup = Boolean(groupId);
if (isGroup && groupPolicy === "disabled") {
logVerbose("Blocked signal group message (groupPolicy: disabled)");
return;
}
if (isGroup && groupPolicy === "allowlist") {
if (groupAllowFrom.length === 0) {
logVerbose(
"Blocked signal group message (groupPolicy: allowlist, no groupAllowFrom)",
);
return;
}
if (!isAllowedSender(sender, groupAllowFrom)) {
logVerbose(
`Blocked signal group sender ${sender} (not in groupAllowFrom)`,
);
return;
}
}
const commandAuthorized = isGroup
? groupAllowFrom.length > 0
? isAllowedSender(sender, groupAllowFrom)
: true
: isAllowedSender(sender, allowFrom);
if (!isGroup && !commandAuthorized) {
logVerbose(`Blocked signal sender ${sender} (not in allowFrom)`);
return;
}
const messageText = (dataMessage.message ?? "").trim();
let mediaPath: string | undefined;

55
src/slack/monitor.test.ts Normal file
View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { isSlackRoomAllowedByPolicy } from "./monitor.js";
describe("slack groupPolicy gating", () => {
it("allows when policy is open", () => {
expect(
isSlackRoomAllowedByPolicy({
groupPolicy: "open",
channelAllowlistConfigured: false,
channelAllowed: false,
}),
).toBe(true);
});
it("blocks when policy is disabled", () => {
expect(
isSlackRoomAllowedByPolicy({
groupPolicy: "disabled",
channelAllowlistConfigured: true,
channelAllowed: true,
}),
).toBe(false);
});
it("blocks allowlist when no channel allowlist configured", () => {
expect(
isSlackRoomAllowedByPolicy({
groupPolicy: "allowlist",
channelAllowlistConfigured: false,
channelAllowed: true,
}),
).toBe(false);
});
it("allows allowlist when channel is allowed", () => {
expect(
isSlackRoomAllowedByPolicy({
groupPolicy: "allowlist",
channelAllowlistConfigured: true,
channelAllowed: true,
}),
).toBe(true);
});
it("blocks allowlist when channel is not allowed", () => {
expect(
isSlackRoomAllowedByPolicy({
groupPolicy: "allowlist",
channelAllowlistConfigured: true,
channelAllowed: false,
}),
).toBe(false);
});
});

View File

@@ -379,6 +379,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels);
const channelsConfig = cfg.slack?.channels;
const dmEnabled = dmConfig?.enabled ?? true;
const groupPolicy = cfg.slack?.groupPolicy ?? "open";
const reactionMode = cfg.slack?.reactionNotifications ?? "own";
const reactionAllowlist = cfg.slack?.reactionAllowlist ?? [];
const slashCommand = resolveSlackSlashCommandConfig(
@@ -517,7 +518,19 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
channelName: params.channelName,
channels: channelsConfig,
});
if (channelConfig?.allowed === false) return false;
const channelAllowed = channelConfig?.allowed !== false;
const channelAllowlistConfigured =
Boolean(channelsConfig) && Object.keys(channelsConfig ?? {}).length > 0;
if (
!isSlackRoomAllowedByPolicy({
groupPolicy,
channelAllowlistConfigured,
channelAllowed,
})
) {
return false;
}
if (!channelAllowed) return false;
}
return true;
@@ -1440,6 +1453,18 @@ type SlackRespondFn = (payload: {
response_type?: "ephemeral" | "in_channel";
}) => Promise<unknown>;
export function isSlackRoomAllowedByPolicy(params: {
groupPolicy: "open" | "disabled" | "allowlist";
channelAllowlistConfigured: boolean;
channelAllowed: boolean;
}): boolean {
const { groupPolicy, channelAllowlistConfigured, channelAllowed } = params;
if (groupPolicy === "disabled") return false;
if (groupPolicy === "open") return true;
if (!channelAllowlistConfigured) return false;
return channelAllowed;
}
async function deliverSlackSlashReplies(params: {
replies: ReplyPayload[];
respond: SlackRespondFn;

View File

@@ -1133,4 +1133,69 @@ describe("createTelegramBot", () => {
// Should call reply because sender ID matches after stripping tg: prefix
expect(replySpy).toHaveBeenCalled();
});
it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<
typeof vi.fn
>;
replySpy.mockReset();
loadConfig.mockReturnValue({
telegram: {
groupPolicy: "allowlist",
groups: { "*": { requireMention: false } },
},
});
createTelegramBot({ token: "tok" });
const handler = onSpy.mock.calls[0][1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
message: {
chat: { id: -100123456789, type: "group", title: "Test Group" },
from: { id: 123456789, username: "testuser" },
text: "hello",
date: 1736380800,
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
});
it("allows control commands with TG-prefixed groupAllowFrom entries", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<
typeof vi.fn
>;
replySpy.mockReset();
loadConfig.mockReturnValue({
telegram: {
groupPolicy: "allowlist",
groupAllowFrom: [" TG:123456789 "],
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: -100123456789, type: "group", title: "Test Group" },
from: { id: 123456789, username: "testuser" },
text: "/status",
date: 1736380800,
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -86,6 +86,7 @@ export type TelegramBotOptions = {
runtime?: RuntimeEnv;
requireMention?: boolean;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
mediaMaxMb?: number;
replyToMode?: ReplyToMode;
proxyFetch?: typeof fetch;
@@ -111,14 +112,46 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const cfg = loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "telegram");
const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom;
const normalizedAllowFrom = (allowFrom ?? [])
.map((value) => String(value).trim())
.filter(Boolean)
.map((value) => value.replace(/^(telegram|tg):/i, ""));
const normalizedAllowFromLower = normalizedAllowFrom.map((value) =>
value.toLowerCase(),
);
const hasAllowFromWildcard = normalizedAllowFrom.includes("*");
const groupAllowFrom =
opts.groupAllowFrom ??
cfg.telegram?.groupAllowFrom ??
(cfg.telegram?.allowFrom && cfg.telegram.allowFrom.length > 0
? cfg.telegram.allowFrom
: undefined) ??
(opts.allowFrom && opts.allowFrom.length > 0 ? opts.allowFrom : undefined);
const normalizeAllowFrom = (list?: Array<string | number>) => {
const entries = (list ?? [])
.map((value) => String(value).trim())
.filter(Boolean);
const hasWildcard = entries.includes("*");
const normalized = entries
.filter((value) => value !== "*")
.map((value) => value.replace(/^(telegram|tg):/i, ""));
const normalizedLower = normalized.map((value) => value.toLowerCase());
return {
entries: normalized,
entriesLower: normalizedLower,
hasWildcard,
hasEntries: entries.length > 0,
};
};
const isSenderAllowed = (params: {
allow: ReturnType<typeof normalizeAllowFrom>;
senderId?: string;
senderUsername?: string;
}) => {
const { allow, senderId, senderUsername } = params;
if (!allow.hasEntries) return true;
if (allow.hasWildcard) return true;
if (senderId && allow.entries.includes(senderId)) return true;
const username = senderUsername?.toLowerCase();
if (!username) return false;
return allow.entriesLower.some(
(entry) => entry === username || entry === `@${username}`,
);
};
const dmAllow = normalizeAllowFrom(allowFrom);
const groupAllow = normalizeAllowFrom(groupAllowFrom);
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off";
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
@@ -160,11 +193,9 @@ export function createTelegramBot(opts: TelegramBotOptions) {
};
// allowFrom for direct chats
if (!isGroup && normalizedAllowFrom.length > 0) {
if (!isGroup && dmAllow.hasEntries) {
const candidate = String(chatId);
const permitted =
hasAllowFromWildcard || normalizedAllowFrom.includes(candidate);
if (!permitted) {
if (!isSenderAllowed({ allow: dmAllow, senderId: candidate })) {
logVerbose(
`Blocked unauthorized telegram sender ${candidate} (not in allowFrom)`,
);
@@ -173,20 +204,13 @@ export function createTelegramBot(opts: TelegramBotOptions) {
}
const botUsername = primaryCtx.me?.username?.toLowerCase();
const allowFromList = normalizedAllowFrom;
const senderId = msg.from?.id ? String(msg.from.id) : "";
const senderUsername = msg.from?.username ?? "";
const senderUsernameLower = senderUsername.toLowerCase();
const commandAuthorized =
allowFromList.length === 0 ||
hasAllowFromWildcard ||
(senderId && allowFromList.includes(senderId)) ||
(senderUsername &&
normalizedAllowFromLower.some(
(entry) =>
entry === senderUsernameLower ||
entry === `@${senderUsernameLower}`,
));
const commandAuthorized = isSenderAllowed({
allow: isGroup ? groupAllow : dmAllow,
senderId,
senderUsername,
});
const wasMentioned =
(Boolean(botUsername) && hasBotMention(msg, botUsername)) ||
matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes);
@@ -388,7 +412,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
// Group policy filtering: controls how group messages are handled
// - "open" (default): groups bypass allowFrom, only mention-gating applies
// - "disabled": block all group messages entirely
// - "allowlist": only allow group messages from senders in allowFrom
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
const groupPolicy = cfg.telegram?.groupPolicy ?? "open";
if (groupPolicy === "disabled") {
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
@@ -403,18 +427,20 @@ export function createTelegramBot(opts: TelegramBotOptions) {
);
return;
}
const senderIdAllowed = normalizedAllowFrom.includes(
String(senderId),
);
// Also check username if available (with or without @ prefix)
const senderUsername = msg.from?.username?.toLowerCase();
const usernameAllowed =
senderUsername != null &&
normalizedAllowFromLower.some(
(value) =>
value === senderUsername || value === `@${senderUsername}`,
if (!groupAllow.hasEntries) {
logVerbose(
"Blocked telegram group message (groupPolicy: allowlist, no groupAllowFrom)",
);
if (!hasAllowFromWildcard && !senderIdAllowed && !usernameAllowed) {
return;
}
const senderUsername = msg.from?.username ?? "";
if (
!isSenderAllowed({
allow: groupAllow,
senderId: String(senderId),
senderUsername,
})
) {
logVerbose(
`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`,
);

View File

@@ -178,18 +178,30 @@ export async function monitorWebInbox(options: {
configuredAllowFrom && configuredAllowFrom.length > 0
? configuredAllowFrom
: defaultAllowFrom;
const groupAllowFrom =
cfg.whatsapp?.groupAllowFrom ??
(configuredAllowFrom && configuredAllowFrom.length > 0
? configuredAllowFrom
: undefined);
const isSamePhone = from === selfE164;
const isSelfChat = isSelfChatMode(selfE164, configuredAllowFrom);
// Pre-compute normalized allowlist for filtering (used by both group and DM checks)
const hasWildcard = allowFrom?.includes("*") ?? false;
// Pre-compute normalized allowlists for filtering
const dmHasWildcard = allowFrom?.includes("*") ?? false;
const normalizedAllowFrom =
allowFrom && allowFrom.length > 0 ? allowFrom.map(normalizeE164) : [];
allowFrom && allowFrom.length > 0
? allowFrom.filter((entry) => entry !== "*").map(normalizeE164)
: [];
const groupHasWildcard = groupAllowFrom?.includes("*") ?? false;
const normalizedGroupAllowFrom =
groupAllowFrom && groupAllowFrom.length > 0
? groupAllowFrom.filter((entry) => entry !== "*").map(normalizeE164)
: [];
// Group policy filtering: controls how group messages are handled
// - "open" (default): groups bypass allowFrom, only mention-gating applies
// - "disabled": block all group messages entirely
// - "allowlist": only allow group messages from senders in allowFrom
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
const groupPolicy = cfg.whatsapp?.groupPolicy ?? "open";
if (group && groupPolicy === "disabled") {
logVerbose(`Blocked group message (groupPolicy: disabled)`);
@@ -198,9 +210,15 @@ export async function monitorWebInbox(options: {
if (group && groupPolicy === "allowlist") {
// For allowlist mode, the sender (participant) must be in allowFrom
// If we can't resolve the sender E164, block the message for safety
if (!groupAllowFrom || groupAllowFrom.length === 0) {
logVerbose(
"Blocked group message (groupPolicy: allowlist, no groupAllowFrom)",
);
continue;
}
const senderAllowed =
hasWildcard ||
(senderE164 != null && normalizedAllowFrom.includes(senderE164));
groupHasWildcard ||
(senderE164 != null && normalizedGroupAllowFrom.includes(senderE164));
if (!senderAllowed) {
logVerbose(
`Blocked group message from ${senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`,
@@ -214,7 +232,7 @@ export async function monitorWebInbox(options: {
!group && Array.isArray(allowFrom) && allowFrom.length > 0;
if (!isSamePhone && allowlistEnabled) {
const candidate = from;
if (!hasWildcard && !normalizedAllowFrom.includes(candidate)) {
if (!dmHasWildcard && !normalizedAllowFrom.includes(candidate)) {
logVerbose(
`Blocked unauthorized sender ${candidate} (not in allowFrom list)`,
);

View File

@@ -711,10 +711,10 @@ describe("web monitor inbox", () => {
await listener.close();
});
it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => {
it("blocks group messages from senders not in groupAllowFrom when groupPolicy is 'allowlist'", async () => {
mockLoadConfig.mockReturnValue({
whatsapp: {
allowFrom: ["+1234"], // Does not include +999
groupAllowFrom: ["+1234"], // Does not include +999
groupPolicy: "allowlist",
},
messages: {
@@ -746,16 +746,16 @@ describe("web monitor inbox", () => {
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
// Should NOT call onMessage because sender +999 not in allowFrom
// Should NOT call onMessage because sender +999 not in groupAllowFrom
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("allows group messages from senders in allowFrom when groupPolicy is 'allowlist'", async () => {
it("allows group messages from senders in groupAllowFrom when groupPolicy is 'allowlist'", async () => {
mockLoadConfig.mockReturnValue({
whatsapp: {
allowFrom: ["+15551234567"], // Includes the sender
groupAllowFrom: ["+15551234567"], // Includes the sender
groupPolicy: "allowlist",
},
messages: {
@@ -787,7 +787,7 @@ describe("web monitor inbox", () => {
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
// Should call onMessage because sender is in allowFrom
// Should call onMessage because sender is in groupAllowFrom
expect(onMessage).toHaveBeenCalledTimes(1);
const payload = onMessage.mock.calls[0][0];
expect(payload.chatType).toBe("group");
@@ -799,7 +799,7 @@ describe("web monitor inbox", () => {
it("allows all group senders with wildcard in groupPolicy allowlist", async () => {
mockLoadConfig.mockReturnValue({
whatsapp: {
allowFrom: ["*"], // Wildcard allows everyone
groupAllowFrom: ["*"], // Wildcard allows everyone
groupPolicy: "allowlist",
},
messages: {
@@ -839,6 +839,45 @@ describe("web monitor inbox", () => {
await listener.close();
});
it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => {
mockLoadConfig.mockReturnValue({
whatsapp: {
groupPolicy: "allowlist",
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
timestampPrefix: false,
},
});
const onMessage = vi.fn();
const listener = await monitorWebInbox({ verbose: false, onMessage });
const sock = await createWaSocket();
const upsert = {
type: "notify",
messages: [
{
key: {
id: "grp-allowlist-empty",
fromMe: false,
remoteJid: "11111@g.us",
participant: "999@s.whatsapp.net",
},
message: { conversation: "blocked by empty allowlist" },
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("allows messages from senders in allowFrom list", async () => {
mockLoadConfig.mockReturnValue({
whatsapp: {