From 02eef1d45aa98c71bae0196f002840e33c403ea0 Mon Sep 17 00:00:00 2001 From: Edward <53964601+edwluo@users.noreply.github.com> Date: Sun, 8 Mar 2026 08:47:57 +0800 Subject: [PATCH] fix(telegram): use group allowlist for native command auth in groups (#39267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(telegram): use group allowlist for native command auth in groups Native slash commands (/status, /model, etc.) in Telegram supergroups and forum topics reject authorized senders with "not authorized" even when the sender is in groupAllowFrom. The bug is in resolveTelegramCommandAuth — the final commandAuthorized check only passes DM allowFrom as an authorizer, so senders who are authorized via groupAllowFrom get rejected. Regular messages don't have this problem because they go through evaluateTelegramGroupPolicyAccess which correctly uses effectiveGroupAllow. Add effectiveGroupAllow as a second authorizer when the message comes from a group. resolveCommandAuthorizedFromAuthorizers uses .some(), so either DM or group allowlist matching is sufficient. Fixes #28216 Fixes #29135 Fixes #30234 * fix(test): resolve TS2769 type errors in group-auth test Remove explicit tuple type annotations on mock.calls.filter() callbacks that conflicted with vitest's mock call types. Co-Authored-By: Claude Opus 4.6 * test(telegram): cover topic auth rejection routing * changelog: note telegram native group command auth fix --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + .../bot-native-commands.group-auth.test.ts | 153 ++++++++++++++++++ src/telegram/bot-native-commands.ts | 10 +- 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/telegram/bot-native-commands.group-auth.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dc40220915..e59e1fed5e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,6 +173,7 @@ Docs: https://docs.openclaw.ai - Telegram/DM draft duplicate display: clear stale DM draft previews after materializing the real final message, including threadless fallback when DM topic lookup fails, so partial streaming no longer briefly shows duplicate replies. (#36746) Thanks @joelnishanth. - Telegram/draft preview boundary + silent-token reliability: stabilize answer-lane message boundaries across late-partial/message-start races, preserve/reset finalized preview state at the correct boundaries, and suppress `NO_REPLY` lead-fragment leaks without broad heartbeat-prefix false positives. (#33169) Thanks @obviyus. - Telegram/`groupAllowFrom` sender-ID validation: restore sender-only runtime validation so negative chat/group IDs remain invalid entries instead of appearing accepted while still being unable to authorize group access. (#37134) Thanks @qiuyuemartin-max and @vincentkoc. +- Telegram/native group command auth: authorize native commands in groups and forum topics against `groupAllowFrom` and per-group/topic sender overrides, while keeping auth rejection replies in the originating topic thread. (#39267) Thanks @edwluo. - Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow. - Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow. - Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow. diff --git a/src/telegram/bot-native-commands.group-auth.test.ts b/src/telegram/bot-native-commands.group-auth.test.ts new file mode 100644 index 00000000000..d839b51072e --- /dev/null +++ b/src/telegram/bot-native-commands.group-auth.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { ChannelGroupPolicy } from "../config/group-policy.js"; +import type { TelegramAccountConfig } from "../config/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { registerTelegramNativeCommands } from "./bot-native-commands.js"; + +const getPluginCommandSpecs = vi.hoisted(() => vi.fn(() => [])); +const matchPluginCommand = vi.hoisted(() => vi.fn(() => null)); +const executePluginCommand = vi.hoisted(() => vi.fn(async () => ({ text: "ok" }))); + +vi.mock("../plugins/commands.js", () => ({ + getPluginCommandSpecs, + matchPluginCommand, + executePluginCommand, +})); + +const deliverReplies = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("./bot/delivery.js", () => ({ deliverReplies })); + +vi.mock("../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: vi.fn(async () => []), +})); + +describe("native command auth in groups", () => { + function setup(params: { + allowFrom?: string[]; + groupAllowFrom?: string[]; + useAccessGroups?: boolean; + groupConfig?: Record; + }) { + const handlers: Record Promise> = {}; + const sendMessage = vi.fn().mockResolvedValue(undefined); + const bot = { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage, + }, + command: (name: string, handler: (ctx: unknown) => Promise) => { + handlers[name] = handler; + }, + } as const; + + registerTelegramNativeCommands({ + bot: bot as unknown as Parameters[0]["bot"], + cfg: {} as OpenClawConfig, + runtime: {} as unknown as RuntimeEnv, + accountId: "default", + telegramCfg: {} as TelegramAccountConfig, + allowFrom: params.allowFrom ?? [], + groupAllowFrom: params.groupAllowFrom ?? [], + replyToMode: "off", + textLimit: 4000, + useAccessGroups: params.useAccessGroups ?? false, + nativeEnabled: true, + nativeSkillsEnabled: false, + nativeDisabledExplicit: false, + resolveGroupPolicy: () => + ({ + allowlistEnabled: false, + allowed: true, + }) as ChannelGroupPolicy, + resolveTelegramGroupConfig: () => ({ + groupConfig: params.groupConfig as undefined, + topicConfig: undefined, + }), + shouldSkipUpdate: () => false, + opts: { token: "token" }, + }); + + return { handlers, sendMessage }; + } + + it("authorizes native commands in groups when sender is in groupAllowFrom", async () => { + const { handlers, sendMessage } = setup({ + groupAllowFrom: ["12345"], + useAccessGroups: true, + // no allowFrom — sender is NOT in DM allowlist + }); + + const ctx = { + message: { + chat: { id: -100999, type: "supergroup", is_forum: true }, + from: { id: 12345, username: "testuser" }, + message_thread_id: 42, + message_id: 1, + date: 1700000000, + }, + match: "", + }; + + await handlers.status?.(ctx); + + // should NOT send "not authorized" rejection + const notAuthCalls = sendMessage.mock.calls.filter( + (call) => typeof call[1] === "string" && call[1].includes("not authorized"), + ); + expect(notAuthCalls).toHaveLength(0); + }); + + it("rejects native commands in groups when sender is in neither allowlist", async () => { + const { handlers, sendMessage } = setup({ + allowFrom: ["99999"], + groupAllowFrom: ["99999"], + useAccessGroups: true, + }); + + const ctx = { + message: { + chat: { id: -100999, type: "supergroup", is_forum: true }, + from: { id: 12345, username: "intruder" }, + message_thread_id: 42, + message_id: 1, + date: 1700000000, + }, + match: "", + }; + + await handlers.status?.(ctx); + + const notAuthCalls = sendMessage.mock.calls.filter( + (call) => typeof call[1] === "string" && call[1].includes("not authorized"), + ); + expect(notAuthCalls.length).toBeGreaterThan(0); + }); + + it("replies in the originating forum topic when auth is rejected", async () => { + const { handlers, sendMessage } = setup({ + allowFrom: ["99999"], + groupAllowFrom: ["99999"], + useAccessGroups: true, + }); + + const ctx = { + message: { + chat: { id: -100999, type: "supergroup", is_forum: true }, + from: { id: 12345, username: "intruder" }, + message_thread_id: 42, + message_id: 1, + date: 1700000000, + }, + match: "", + }; + + await handlers.status?.(ctx); + + expect(sendMessage).toHaveBeenCalledWith( + -100999, + "You are not authorized to use this command.", + expect.objectContaining({ message_thread_id: 42 }), + ); + }); +}); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 94dcd111ba1..bb171287c0d 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -285,9 +285,17 @@ async function resolveTelegramCommandAuth(params: { senderId, senderUsername, }); + const groupSenderAllowed = isGroup + ? isSenderAllowed({ allow: effectiveGroupAllow, senderId, senderUsername }) + : false; const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, - authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }], + authorizers: [ + { configured: dmAllow.hasEntries, allowed: senderAllowed }, + ...(isGroup + ? [{ configured: effectiveGroupAllow.hasEntries, allowed: groupSenderAllowed }] + : []), + ], modeWhenAccessGroupsOff: "configured", }); if (requireAuth && !commandAuthorized) {