diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f87bc99ba..fd2a094d248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,6 +173,7 @@ Docs: https://docs.openclaw.ai - Telegram/DM draft final delivery: materialize text-only `sendMessageDraft` previews into one permanent final message and skip duplicate final payload sends, while preserving fallback behavior when materialization fails. (#34318) Thanks @Brotherinlaw-13. - 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/native commands `commands.allowFrom` precedence: make native Telegram commands honor `commands.allowFrom` as the command-specific authorization source, including group chats, instead of falling back to channel sender allowlists. (#28216) Thanks @toolsbybuddy and @vincentkoc. - 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. - Telegram/named-account DMs: restore non-default-account DM routing when a named Telegram account falls back to the default agent by keeping groups fail-closed but deriving a per-account session key for DMs, including identity-link canonicalization and regression coverage for account isolation. (from #32426; fixes #32351) Thanks @chengzhichao-xydt. diff --git a/src/telegram/bot-native-commands.group-auth.test.ts b/src/telegram/bot-native-commands.group-auth.test.ts index d839b51072e..77d73497c26 100644 --- a/src/telegram/bot-native-commands.group-auth.test.ts +++ b/src/telegram/bot-native-commands.group-auth.test.ts @@ -24,10 +24,13 @@ vi.mock("../pairing/pairing-store.js", () => ({ describe("native command auth in groups", () => { function setup(params: { + cfg?: OpenClawConfig; + telegramCfg?: TelegramAccountConfig; allowFrom?: string[]; groupAllowFrom?: string[]; useAccessGroups?: boolean; groupConfig?: Record; + resolveGroupPolicy?: () => ChannelGroupPolicy; }) { const handlers: Record Promise> = {}; const sendMessage = vi.fn().mockResolvedValue(undefined); @@ -43,10 +46,10 @@ describe("native command auth in groups", () => { registerTelegramNativeCommands({ bot: bot as unknown as Parameters[0]["bot"], - cfg: {} as OpenClawConfig, + cfg: params.cfg ?? ({} as OpenClawConfig), runtime: {} as unknown as RuntimeEnv, accountId: "default", - telegramCfg: {} as TelegramAccountConfig, + telegramCfg: params.telegramCfg ?? ({} as TelegramAccountConfig), allowFrom: params.allowFrom ?? [], groupAllowFrom: params.groupAllowFrom ?? [], replyToMode: "off", @@ -55,11 +58,13 @@ describe("native command auth in groups", () => { nativeEnabled: true, nativeSkillsEnabled: false, nativeDisabledExplicit: false, - resolveGroupPolicy: () => - ({ - allowlistEnabled: false, - allowed: true, - }) as ChannelGroupPolicy, + resolveGroupPolicy: + params.resolveGroupPolicy ?? + (() => + ({ + allowlistEnabled: false, + allowed: true, + }) as ChannelGroupPolicy), resolveTelegramGroupConfig: () => ({ groupConfig: params.groupConfig as undefined, topicConfig: undefined, @@ -98,6 +103,149 @@ describe("native command auth in groups", () => { expect(notAuthCalls).toHaveLength(0); }); + it("authorizes native commands in groups from commands.allowFrom.telegram", async () => { + const { handlers, sendMessage } = setup({ + cfg: { + commands: { + allowFrom: { + telegram: ["12345"], + }, + }, + } as OpenClawConfig, + allowFrom: ["99999"], + groupAllowFrom: ["99999"], + useAccessGroups: true, + }); + + 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); + + const notAuthCalls = sendMessage.mock.calls.filter( + (call) => typeof call[1] === "string" && call[1].includes("not authorized"), + ); + expect(notAuthCalls).toHaveLength(0); + }); + + it("uses commands.allowFrom.telegram as the sole auth source when configured", async () => { + const { handlers, sendMessage } = setup({ + cfg: { + commands: { + allowFrom: { + telegram: ["99999"], + }, + }, + } as OpenClawConfig, + groupAllowFrom: ["12345"], + useAccessGroups: true, + }); + + 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); + + expect(sendMessage).toHaveBeenCalledWith( + -100999, + "You are not authorized to use this command.", + expect.objectContaining({ message_thread_id: 42 }), + ); + }); + + it("keeps groupPolicy disabled enforced when commands.allowFrom is configured", async () => { + const { handlers, sendMessage } = setup({ + cfg: { + commands: { + allowFrom: { + telegram: ["12345"], + }, + }, + } as OpenClawConfig, + telegramCfg: { + groupPolicy: "disabled", + } as TelegramAccountConfig, + useAccessGroups: true, + resolveGroupPolicy: () => + ({ + allowlistEnabled: false, + allowed: false, + }) as ChannelGroupPolicy, + }); + + 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); + + expect(sendMessage).toHaveBeenCalledWith( + -100999, + "Telegram group commands are disabled.", + expect.objectContaining({ message_thread_id: 42 }), + ); + }); + + it("keeps group chat allowlists enforced when commands.allowFrom is configured", async () => { + const { handlers, sendMessage } = setup({ + cfg: { + commands: { + allowFrom: { + telegram: ["12345"], + }, + }, + } as OpenClawConfig, + useAccessGroups: true, + resolveGroupPolicy: () => + ({ + allowlistEnabled: true, + allowed: false, + }) as ChannelGroupPolicy, + }); + + 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); + + expect(sendMessage).toHaveBeenCalledWith( + -100999, + "This group is not allowed.", + expect.objectContaining({ message_thread_id: 42 }), + ); + }); + it("rejects native commands in groups when sender is in neither allowlist", async () => { const { handlers, sendMessage } = setup({ allowFrom: ["99999"], diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 0aec74b969e..cb29f258f10 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -1,6 +1,7 @@ import type { Bot, Context } from "grammy"; import { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js"; import { resolveChunkMode } from "../auto-reply/chunk.js"; +import { resolveCommandAuthorization } from "../auto-reply/command-auth.js"; import type { CommandArgs } from "../auto-reply/commands-registry.js"; import { buildCommandTextFromArgs, @@ -209,6 +210,28 @@ async function resolveTelegramCommandAuth(params: { const dmAllowFrom = groupAllowOverride ?? allowFrom; const senderId = msg.from?.id ? String(msg.from.id) : ""; const senderUsername = msg.from?.username ?? ""; + const commandsAllowFrom = cfg.commands?.allowFrom; + const commandsAllowFromConfigured = + commandsAllowFrom != null && + typeof commandsAllowFrom === "object" && + (Array.isArray(commandsAllowFrom.telegram) || Array.isArray(commandsAllowFrom["*"])); + const commandsAllowFromAccess = commandsAllowFromConfigured + ? resolveCommandAuthorization({ + ctx: { + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + AccountId: accountId, + ChatType: isGroup ? "group" : "direct", + From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, + SenderId: senderId || undefined, + SenderUsername: senderUsername || undefined, + }, + cfg, + // commands.allowFrom is the only auth source when configured. + commandAuthorized: false, + }) + : null; const sendAuthMessage = async (text: string) => { const threadParams = buildTelegramThreadParams(threadSpec) ?? {}; @@ -256,7 +279,7 @@ async function resolveTelegramCommandAuth(params: { resolveGroupPolicy, enforcePolicy: useAccessGroups, useTopicAndGroupOverrides: false, - enforceAllowlistAuthorization: requireAuth, + enforceAllowlistAuthorization: requireAuth && !commandsAllowFromConfigured, allowEmptyAllowlistEntries: true, requireSenderForAllowlistAuthorization: true, checkChatAllowlist: useAccessGroups, @@ -289,16 +312,18 @@ async function resolveTelegramCommandAuth(params: { const groupSenderAllowed = isGroup ? isSenderAllowed({ allow: effectiveGroupAllow, senderId, senderUsername }) : false; - const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [ - { configured: dmAllow.hasEntries, allowed: senderAllowed }, - ...(isGroup - ? [{ configured: effectiveGroupAllow.hasEntries, allowed: groupSenderAllowed }] - : []), - ], - modeWhenAccessGroupsOff: "configured", - }); + const commandAuthorized = commandsAllowFromConfigured + ? Boolean(commandsAllowFromAccess?.isAuthorizedSender) + : resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [ + { configured: dmAllow.hasEntries, allowed: senderAllowed }, + ...(isGroup + ? [{ configured: effectiveGroupAllow.hasEntries, allowed: groupSenderAllowed }] + : []), + ], + modeWhenAccessGroupsOff: "configured", + }); if (requireAuth && !commandAuthorized) { return await rejectNotAuthorized(); }