feat(whatsapp,telegram): add groupPolicy config option (#216)
Co-authored-by: Marcus Neves <conhecendo.contato@gmail.com> Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
@@ -78,6 +78,13 @@ export type AgentElevatedAllowFromConfig = {
|
||||
export type WhatsAppConfig = {
|
||||
/** Optional allowlist for WhatsApp direct chats (E.164). */
|
||||
allowFrom?: 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
|
||||
*/
|
||||
groupPolicy?: "open" | "disabled" | "allowlist";
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
groups?: Record<
|
||||
@@ -207,6 +214,13 @@ export type TelegramConfig = {
|
||||
}
|
||||
>;
|
||||
allowFrom?: 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
|
||||
*/
|
||||
groupPolicy?: "open" | "disabled" | "allowlist";
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
mediaMaxMb?: number;
|
||||
|
||||
@@ -81,6 +81,12 @@ const ReplyToModeSchema = z.union([
|
||||
z.literal("all"),
|
||||
]);
|
||||
|
||||
// GroupPolicySchema: controls how group messages are handled
|
||||
// Used with .default("open").optional() pattern:
|
||||
// - .optional() allows field omission in input config
|
||||
// - .default("open") ensures runtime always resolves to "open" if not provided
|
||||
const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
|
||||
|
||||
const QueueModeBySurfaceSchema = z
|
||||
.object({
|
||||
whatsapp: QueueModeSchema.optional(),
|
||||
@@ -592,6 +598,7 @@ export const ClawdbotSchema = z.object({
|
||||
whatsapp: z
|
||||
.object({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
groupPolicy: GroupPolicySchema.default("open").optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
groups: z
|
||||
.record(
|
||||
@@ -622,6 +629,7 @@ export const ClawdbotSchema = z.object({
|
||||
)
|
||||
.optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.default("open").optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
proxy: z.string().optional(),
|
||||
|
||||
@@ -643,4 +643,494 @@ describe("createTelegramBot", () => {
|
||||
});
|
||||
expect(sendPhotoSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// groupPolicy tests
|
||||
it("blocks all group messages when groupPolicy is 'disabled'", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: ["123456789"],
|
||||
},
|
||||
});
|
||||
|
||||
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: "@clawdbot_bot hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
// Should NOT call getReplyFromConfig because groupPolicy is disabled
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["123456789"], // Does not include sender 999999
|
||||
},
|
||||
});
|
||||
|
||||
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: 999999, username: "notallowed" }, // Not in allowFrom
|
||||
text: "@clawdbot_bot hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows group messages from senders in allowFrom (by ID) when groupPolicy is 'allowlist'", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["123456789"],
|
||||
groups: { "*": { requireMention: false } }, // Skip mention check
|
||||
},
|
||||
});
|
||||
|
||||
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" }, // In allowFrom
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows group messages from senders in allowFrom (by username) when groupPolicy is 'allowlist'", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["@testuser"], // By username
|
||||
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: 12345, username: "testuser" }, // Username matches @testuser
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows group messages from telegram:-prefixed allowFrom entries when groupPolicy is 'allowlist'", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["telegram:77112533"],
|
||||
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: 77112533, username: "mneves" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows group messages from tg:-prefixed allowFrom entries case-insensitively when groupPolicy is 'allowlist'", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["TG:77112533"],
|
||||
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: 77112533, username: "mneves" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows all group messages when groupPolicy is 'open' (default)", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
// groupPolicy not set, should default to "open"
|
||||
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: 999999, username: "random" }, // Random sender
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("matches usernames case-insensitively when groupPolicy is 'allowlist'", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["@TestUser"], // Uppercase in config
|
||||
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: 12345, username: "testuser" }, // Lowercase in message
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows direct messages regardless of groupPolicy", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groupPolicy: "disabled", // Even with disabled, DMs should work
|
||||
allowFrom: ["123456789"],
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = onSpy.mock.calls[0][1] as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123456789, type: "private" }, // Direct message
|
||||
from: { id: 123456789, username: "testuser" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows direct messages with tg/Telegram-prefixed allowFrom entries", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
allowFrom: [" TG:123456789 "],
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = onSpy.mock.calls[0][1] as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123456789, type: "private" }, // Direct message
|
||||
from: { id: 123456789, username: "testuser" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows direct messages with telegram:-prefixed allowFrom entries", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
allowFrom: ["telegram:123456789"],
|
||||
},
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = onSpy.mock.calls[0][1] as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123456789, type: "private" },
|
||||
from: { id: 123456789, username: "testuser" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows group messages with wildcard in allowFrom when groupPolicy is 'allowlist'", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["*"], // Wildcard allows everyone
|
||||
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: 999999, username: "random" }, // Random sender, but wildcard allows
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks group messages with no sender ID when groupPolicy is 'allowlist'", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["123456789"],
|
||||
},
|
||||
});
|
||||
|
||||
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" },
|
||||
// No `from` field (e.g., channel post or anonymous admin)
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches telegram:-prefixed allowFrom entries in group allowlist", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["telegram:123456789"], // Prefixed format
|
||||
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" }, // Matches after stripping prefix
|
||||
text: "hello from prefixed user",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
// Should call reply because sender ID matches after stripping telegram: prefix
|
||||
expect(replySpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches tg:-prefixed allowFrom entries case-insensitively in group allowlist", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["TG:123456789"], // Prefixed format (case-insensitive)
|
||||
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" }, // Matches after stripping tg: prefix
|
||||
text: "hello from prefixed user",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
// Should call reply because sender ID matches after stripping tg: prefix
|
||||
expect(replySpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,6 +86,14 @@ 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 replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off";
|
||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
@@ -127,14 +135,10 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
};
|
||||
|
||||
// allowFrom for direct chats
|
||||
if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||
if (!isGroup && normalizedAllowFrom.length > 0) {
|
||||
const candidate = String(chatId);
|
||||
const allowed = allowFrom.map(String);
|
||||
const allowedWithPrefix = allowFrom.map((v) => `telegram:${String(v)}`);
|
||||
const permitted =
|
||||
allowed.includes(candidate) ||
|
||||
allowedWithPrefix.includes(`telegram:${candidate}`) ||
|
||||
allowed.includes("*");
|
||||
hasAllowFromWildcard || normalizedAllowFrom.includes(candidate);
|
||||
if (!permitted) {
|
||||
logVerbose(
|
||||
`Blocked unauthorized telegram sender ${candidate} (not in allowFrom)`,
|
||||
@@ -144,21 +148,18 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
}
|
||||
|
||||
const botUsername = primaryCtx.me?.username?.toLowerCase();
|
||||
const allowFromList = Array.isArray(allowFrom)
|
||||
? allowFrom.map((entry) => String(entry).trim()).filter(Boolean)
|
||||
: [];
|
||||
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 ||
|
||||
allowFromList.includes("*") ||
|
||||
hasAllowFromWildcard ||
|
||||
(senderId && allowFromList.includes(senderId)) ||
|
||||
(senderId && allowFromList.includes(`telegram:${senderId}`)) ||
|
||||
(senderUsername &&
|
||||
allowFromList.some(
|
||||
normalizedAllowFromLower.some(
|
||||
(entry) =>
|
||||
entry.toLowerCase() === senderUsername.toLowerCase() ||
|
||||
entry.toLowerCase() === `@${senderUsername.toLowerCase()}`,
|
||||
entry === senderUsernameLower || entry === `@${senderUsernameLower}`,
|
||||
));
|
||||
const wasMentioned =
|
||||
(Boolean(botUsername) && hasBotMention(msg, botUsername)) ||
|
||||
@@ -350,10 +351,47 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
const isGroup =
|
||||
msg.chat.type === "group" || msg.chat.type === "supergroup";
|
||||
|
||||
// Group policy check - skip disallowed groups early
|
||||
if (isGroup) {
|
||||
const groupPolicy = resolveGroupPolicy(chatId);
|
||||
if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) {
|
||||
// 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
|
||||
const groupPolicy = cfg.telegram?.groupPolicy ?? "open";
|
||||
if (groupPolicy === "disabled") {
|
||||
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
// For allowlist mode, the sender (msg.from.id) must be in allowFrom
|
||||
const senderId = msg.from?.id;
|
||||
if (senderId == null) {
|
||||
logVerbose(
|
||||
`Blocked telegram group message (no sender ID, groupPolicy: allowlist)`,
|
||||
);
|
||||
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 (!hasAllowFromWildcard && !senderIdAllowed && !usernameAllowed) {
|
||||
logVerbose(
|
||||
`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Group allowlist based on configured group IDs.
|
||||
const groupAllowlist = resolveGroupPolicy(chatId);
|
||||
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
|
||||
logger.info(
|
||||
{ chatId, title: msg.chat.title, reason: "not-allowed" },
|
||||
"skipping group message",
|
||||
|
||||
@@ -176,16 +176,44 @@ export async function monitorWebInbox(options: {
|
||||
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;
|
||||
const normalizedAllowFrom =
|
||||
allowFrom && allowFrom.length > 0 ? allowFrom.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
|
||||
const groupPolicy = cfg.whatsapp?.groupPolicy ?? "open";
|
||||
if (group && groupPolicy === "disabled") {
|
||||
logVerbose(`Blocked group message (groupPolicy: disabled)`);
|
||||
continue;
|
||||
}
|
||||
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
|
||||
const senderAllowed =
|
||||
hasWildcard ||
|
||||
(senderE164 != null && normalizedAllowFrom.includes(senderE164));
|
||||
if (!senderAllowed) {
|
||||
logVerbose(
|
||||
`Blocked group message from ${senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// DM allowlist filtering (unchanged behavior)
|
||||
const allowlistEnabled =
|
||||
!group && Array.isArray(allowFrom) && allowFrom.length > 0;
|
||||
if (!isSamePhone && allowlistEnabled) {
|
||||
const candidate = from;
|
||||
const allowedList = allowFrom.map(normalizeE164);
|
||||
if (!allowFrom.includes("*") && !allowedList.includes(candidate)) {
|
||||
if (!hasWildcard && !normalizedAllowFrom.includes(candidate)) {
|
||||
logVerbose(
|
||||
`Blocked unauthorized sender ${candidate} (not in allowFrom list)`,
|
||||
);
|
||||
continue; // Skip processing entirely
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -670,6 +670,175 @@ describe("web monitor inbox", () => {
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("blocks all group messages when groupPolicy is 'disabled'", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
whatsapp: {
|
||||
allowFrom: ["+1234"],
|
||||
groupPolicy: "disabled",
|
||||
},
|
||||
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-disabled",
|
||||
fromMe: false,
|
||||
remoteJid: "11111@g.us",
|
||||
participant: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "group message should be blocked" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Should NOT call onMessage because groupPolicy is disabled
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
whatsapp: {
|
||||
allowFrom: ["+1234"], // Does not include +999
|
||||
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-blocked",
|
||||
fromMe: false,
|
||||
remoteJid: "11111@g.us",
|
||||
participant: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "unauthorized group sender" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Should NOT call onMessage because sender +999 not in allowFrom
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("allows group messages from senders in allowFrom when groupPolicy is 'allowlist'", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
whatsapp: {
|
||||
allowFrom: ["+15551234567"], // Includes the sender
|
||||
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-allowed",
|
||||
fromMe: false,
|
||||
remoteJid: "11111@g.us",
|
||||
participant: "15551234567@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "authorized group sender" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Should call onMessage because sender is in allowFrom
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
const payload = onMessage.mock.calls[0][0];
|
||||
expect(payload.chatType).toBe("group");
|
||||
expect(payload.senderE164).toBe("+15551234567");
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("allows all group senders with wildcard in groupPolicy allowlist", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
whatsapp: {
|
||||
allowFrom: ["*"], // Wildcard allows everyone
|
||||
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-wildcard-test",
|
||||
fromMe: false,
|
||||
remoteJid: "22222@g.us",
|
||||
participant: "9999999999@s.whatsapp.net", // Random sender
|
||||
},
|
||||
message: { conversation: "wildcard group sender" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sock.ev.emit("messages.upsert", upsert);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Should call onMessage because wildcard allows all senders
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
const payload = onMessage.mock.calls[0][0];
|
||||
expect(payload.chatType).toBe("group");
|
||||
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("allows messages from senders in allowFrom list", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
whatsapp: {
|
||||
|
||||
Reference in New Issue
Block a user