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) {