fix(telegram): accept messages from group members in allowlisted groups (#9775)

* fix(telegram): accept messages from group members in allowlisted groups

Issue #4559: Telegram bot was silently dropping messages from non-paired users
in allowlisted group chats due to overly strict sender filtering.

The fix adds a check to distinguish between:
1. Group itself is allowlisted → accept messages from any member
2. Group is NOT allowlisted → only accept from allowlisted senders

Changes:
- Check if group ID is in the allowlist (or allowlist is wildcard)
- Only reject sender if they're not in allowlist AND group is not allowlisted
- Improved logging to indicate the actual reason for rejection

This preserves security controls while fixing the UX issue where group members
couldn't participate unless individually allowlisted.

Backwards compatible: existing allowlists continue to work as before.

* style: format telegram fix for oxfmt compliance

* refactor(telegram): clarify group allowlist semantics in fix for #4559

Changes:
- Rename 'isGroupInAllowlist' to 'isGroupChatIdInAllowlist' for clarity
- Expand comments to explain the semantic distinction:
  * Group chat ID in allowlist -> accept any group member (fixes #4559)
  * Group chat ID NOT in allowlist -> enforce sender allowlist (preserves security)
- This addresses concerns about config semantics raised in code review

The fix maintains backward compatibility:
- 'groupAllowFrom' with group chat IDs now correctly acts as group enablement
- 'groupAllowFrom' with sender IDs continues to work as sender allowlist
- Operators should use group chat IDs for group enablement, sender IDs for sender control

Note: If operators were using 'groupAllowFrom' with group IDs expecting sender-level
filtering, they should migrate to a separate sender allowlist config. This is the
intended behavior per issue #4559.

* Telegram: allow per-group groupPolicy overrides

* Telegram: support per-group groupPolicy overrides (#9775) (thanks @nicolasstanley)

---------

Co-authored-by: George Pickett <gpickett00@gmail.com>
This commit is contained in:
nicolasstanley
2026-02-05 23:45:45 +01:00
committed by GitHub
parent c18452598a
commit 4a5e9f0a4f
6 changed files with 112 additions and 2 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- Telegram: remove last `@ts-nocheck` from `bot-handlers.ts`, use Grammy types directly, deduplicate `StickerMetadata`. Zero `@ts-nocheck` remaining in `src/telegram/`. (#9206)
- Telegram: remove `@ts-nocheck` from `bot-message.ts`, type deps via `Omit<BuildTelegramMessageContextParams>`, widen `allMedia` to `TelegramMediaRef[]`. (#9180)
- Telegram: remove `@ts-nocheck` from `bot.ts`, fix duplicate `bot.catch` error handler (Grammy overrides), remove dead reaction `message_thread_id` routing, harden sticker cache guard. (#9077)
- Telegram: allow per-group and per-topic `groupPolicy` overrides under `channels.telegram.groups`. (#9775) Thanks @nicolasstanley.
- Feishu: expand channel handling (posts with images, doc links, routing, reactions/typing, replies, native commands). (#8975) Thanks @jiulingyun.
- Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan.
- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.

View File

@@ -392,6 +392,23 @@ Two independent controls:
Most users want: `groupPolicy: "allowlist"` + `groupAllowFrom` + specific groups listed in `channels.telegram.groups`
To allow **any group member** to talk in a specific group (while still keeping control commands restricted to authorized senders), set a per-group override:
```json5
{
channels: {
telegram: {
groups: {
"-1001234567890": {
groupPolicy: "open",
requireMention: false,
},
},
},
},
}
```
## Long-polling vs webhook
- Default: long-polling (no public URL required).
@@ -714,12 +731,14 @@ Provider options:
- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.telegram.groupAllowFrom`: group sender allowlist (ids/usernames).
- `channels.telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
- `channels.telegram.groups.<id>.groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.requireMention`: mention gating default.
- `channels.telegram.groups.<id>.skills`: skill filter (omit = all skills, empty = none).
- `channels.telegram.groups.<id>.allowFrom`: per-group sender allowlist override.
- `channels.telegram.groups.<id>.systemPrompt`: extra system prompt for the group.
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.

View File

@@ -142,6 +142,8 @@ export type TelegramAccountConfig = {
export type TelegramTopicConfig = {
requireMention?: boolean;
/** Per-topic override for group message policy (open|disabled|allowlist). */
groupPolicy?: GroupPolicy;
/** If specified, only load these skills for this topic. Omit = all skills; empty = no skills. */
skills?: string[];
/** If false, disable the bot for this topic. */
@@ -154,6 +156,8 @@ export type TelegramTopicConfig = {
export type TelegramGroupConfig = {
requireMention?: boolean;
/** Per-group override for group message policy (open|disabled|allowlist). */
groupPolicy?: GroupPolicy;
/** Optional tool policy overrides for this group. */
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;

View File

@@ -37,6 +37,7 @@ const TelegramCapabilitiesSchema = z.union([
export const TelegramTopicSchema = z
.object({
requireMention: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
@@ -47,6 +48,7 @@ export const TelegramTopicSchema = z
export const TelegramGroupSchema = z
.object({
requireMention: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional(),
tools: ToolPolicySchema,
toolsBySender: ToolPolicyBySenderSchema,
skills: z.array(z.string()).optional(),

View File

@@ -363,7 +363,13 @@ export const registerTelegramHandlers = ({
}
}
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
const groupPolicy = firstDefined(
topicConfig?.groupPolicy,
groupConfig?.groupPolicy,
telegramCfg.groupPolicy,
defaultGroupPolicy,
"open",
);
if (groupPolicy === "disabled") {
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
return;
@@ -719,7 +725,13 @@ export const registerTelegramHandlers = ({
// - "disabled": block all group messages entirely
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
const groupPolicy = firstDefined(
topicConfig?.groupPolicy,
groupConfig?.groupPolicy,
telegramCfg.groupPolicy,
defaultGroupPolicy,
"open",
);
if (groupPolicy === "disabled") {
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
return;

View File

@@ -2013,6 +2013,78 @@ describe("createTelegramBot", () => {
expect(replySpy).not.toHaveBeenCalled();
});
it("allows group messages for per-group groupPolicy open override (global groupPolicy allowlist)", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "allowlist",
groups: {
"-100123456789": {
groupPolicy: "open",
requireMention: false,
},
},
},
},
});
readChannelAllowFromStore.mockResolvedValueOnce(["123456789"]);
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: -100123456789, type: "group", title: "Test Group" },
from: { id: 999999, username: "random" },
text: "hello",
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("blocks control commands from unauthorized senders in per-group open groups", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "allowlist",
groups: {
"-100123456789": {
groupPolicy: "open",
requireMention: false,
},
},
},
},
});
readChannelAllowFromStore.mockResolvedValueOnce(["123456789"]);
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: -100123456789, type: "group", title: "Test Group" },
from: { id: 999999, username: "random" },
text: "/status",
date: 1736380800,
},
me: { username: "openclaw_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>;