From aee799262986a53e2d075ae0c0ad35afa4ae0d2b Mon Sep 17 00:00:00 2001 From: Subash Natarajan Date: Sun, 29 Mar 2026 06:53:27 +0530 Subject: [PATCH] fix(telegram): accept approval callbacks from forwarding target recipients When approvals.exec.targets routes to a Telegram DM, the recipient receives inline approval buttons but may not have explicit channels.telegram.execApprovals configured. This adds a fallback isTelegramExecApprovalTargetRecipient check so those DM recipients can act on the buttons they were sent. Includes accountId scoping for multi-bot deployments and 9 new tests. --- .../telegram/src/bot-handlers.runtime.ts | 8 +- .../telegram/src/exec-approvals.test.ts | 76 +++++++++++++++++++ extensions/telegram/src/exec-approvals.ts | 33 +++++++- src/auto-reply/reply/commands-approve.ts | 18 ++--- src/plugin-sdk/telegram-runtime.ts | 1 + 5 files changed, 123 insertions(+), 13 deletions(-) diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 9f80786c692..da697caca4d 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -80,6 +80,7 @@ import { enforceTelegramDmAccess } from "./dm-access.js"; import { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, + isTelegramExecApprovalTargetRecipient, shouldEnableTelegramExecApprovalButtons, } from "./exec-approvals.js"; import { @@ -1289,9 +1290,12 @@ export const registerTelegramHandlers = ({ const runtimeCfg = telegramDeps.loadConfig(); if (isApprovalCallback) { + const isExplicitApprover = + isTelegramExecApprovalClientEnabled({ cfg: runtimeCfg, accountId }) && + isTelegramExecApprovalApprover({ cfg: runtimeCfg, accountId, senderId }); if ( - !isTelegramExecApprovalClientEnabled({ cfg: runtimeCfg, accountId }) || - !isTelegramExecApprovalApprover({ cfg: runtimeCfg, accountId, senderId }) + !isExplicitApprover && + !isTelegramExecApprovalTargetRecipient({ cfg: runtimeCfg, senderId, accountId }) ) { logVerbose( `Blocked telegram exec approval callback from ${senderId || "unknown"} (not an approver)`, diff --git a/extensions/telegram/src/exec-approvals.test.ts b/extensions/telegram/src/exec-approvals.test.ts index f56279318ea..ef57186202d 100644 --- a/extensions/telegram/src/exec-approvals.test.ts +++ b/extensions/telegram/src/exec-approvals.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../../src/config/config.js"; import { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, + isTelegramExecApprovalTargetRecipient, resolveTelegramExecApprovalTarget, shouldEnableTelegramExecApprovalButtons, shouldInjectTelegramExecApprovalButtons, @@ -89,4 +90,79 @@ describe("telegram exec approvals", () => { expect(shouldEnableTelegramExecApprovalButtons({ cfg, to: "123" })).toBe(false); }); + + describe("isTelegramExecApprovalTargetRecipient", () => { + function buildTargetConfig( + targets: Array<{ channel: string; to: string; accountId?: string }>, + ): OpenClawConfig { + return { + channels: { telegram: { botToken: "tok" } }, + approvals: { exec: { enabled: true, mode: "targets", targets } }, + } as OpenClawConfig; + } + + it("accepts sender who is a DM target", () => { + const cfg = buildTargetConfig([{ channel: "telegram", to: "12345" }]); + expect(isTelegramExecApprovalTargetRecipient({ cfg, senderId: "12345" })).toBe(true); + }); + + it("rejects sender not in any target", () => { + const cfg = buildTargetConfig([{ channel: "telegram", to: "12345" }]); + expect(isTelegramExecApprovalTargetRecipient({ cfg, senderId: "99999" })).toBe(false); + }); + + it("rejects group targets", () => { + const cfg = buildTargetConfig([{ channel: "telegram", to: "-100123456" }]); + expect(isTelegramExecApprovalTargetRecipient({ cfg, senderId: "123456" })).toBe(false); + }); + + it("ignores non-telegram targets", () => { + const cfg = buildTargetConfig([{ channel: "discord", to: "12345" }]); + expect(isTelegramExecApprovalTargetRecipient({ cfg, senderId: "12345" })).toBe(false); + }); + + it("returns false when no targets configured", () => { + const cfg = buildConfig(); + expect(isTelegramExecApprovalTargetRecipient({ cfg, senderId: "12345" })).toBe(false); + }); + + it("returns false when senderId is empty or null", () => { + const cfg = buildTargetConfig([{ channel: "telegram", to: "12345" }]); + expect(isTelegramExecApprovalTargetRecipient({ cfg, senderId: "" })).toBe(false); + expect(isTelegramExecApprovalTargetRecipient({ cfg, senderId: null })).toBe(false); + expect(isTelegramExecApprovalTargetRecipient({ cfg })).toBe(false); + }); + + it("matches across multiple targets", () => { + const cfg = buildTargetConfig([ + { channel: "slack", to: "U12345" }, + { channel: "telegram", to: "67890" }, + { channel: "telegram", to: "11111" }, + ]); + expect(isTelegramExecApprovalTargetRecipient({ cfg, senderId: "67890" })).toBe(true); + expect(isTelegramExecApprovalTargetRecipient({ cfg, senderId: "11111" })).toBe(true); + expect(isTelegramExecApprovalTargetRecipient({ cfg, senderId: "U12345" })).toBe(false); + }); + + it("scopes by accountId in multi-bot deployments", () => { + const cfg = buildTargetConfig([ + { channel: "telegram", to: "12345", accountId: "account-a" }, + { channel: "telegram", to: "67890", accountId: "account-b" }, + ]); + expect( + isTelegramExecApprovalTargetRecipient({ cfg, senderId: "12345", accountId: "account-a" }), + ).toBe(true); + expect( + isTelegramExecApprovalTargetRecipient({ cfg, senderId: "12345", accountId: "account-b" }), + ).toBe(false); + expect(isTelegramExecApprovalTargetRecipient({ cfg, senderId: "12345" })).toBe(true); + }); + + it("allows unscoped targets regardless of callback accountId", () => { + const cfg = buildTargetConfig([{ channel: "telegram", to: "12345" }]); + expect( + isTelegramExecApprovalTargetRecipient({ cfg, senderId: "12345", accountId: "any-account" }), + ).toBe(true); + }); + }); }); diff --git a/extensions/telegram/src/exec-approvals.ts b/extensions/telegram/src/exec-approvals.ts index af62222272e..811270f5574 100644 --- a/extensions/telegram/src/exec-approvals.ts +++ b/extensions/telegram/src/exec-approvals.ts @@ -4,7 +4,7 @@ import type { TelegramExecApprovalConfig } from "openclaw/plugin-sdk/config-runt import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramInlineButtonsConfigScope } from "./inline-buttons.js"; -import { resolveTelegramTargetChatType } from "./targets.js"; +import { isNumericTelegramChatId, resolveTelegramTargetChatType } from "./targets.js"; function normalizeApproverId(value: string | number): string { return String(value).trim(); @@ -47,6 +47,37 @@ export function isTelegramExecApprovalApprover(params: { return approvers.includes(senderId); } +/** Check if sender is an implicit approver via exec approval forwarding targets. */ +export function isTelegramExecApprovalTargetRecipient(params: { + cfg: OpenClawConfig; + senderId?: string | null; + accountId?: string | null; +}): boolean { + const senderId = params.senderId?.trim(); + if (!senderId) { + return false; + } + const targets = params.cfg.approvals?.exec?.targets; + if (!targets || !Array.isArray(targets)) { + return false; + } + const accountId = params.accountId?.trim() || undefined; + return targets.some((target) => { + const channel = target.channel?.trim().toLowerCase(); + if (channel !== "telegram") { + return false; + } + if (accountId && target.accountId && target.accountId !== accountId) { + return false; + } + const to = target.to?.trim(); + if (!to || !isNumericTelegramChatId(to) || to.startsWith("-")) { + return false; + } + return to === senderId; + }); +} + export function resolveTelegramExecApprovalTarget(params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index ceefabc2780..6ff94e17ec2 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -8,6 +8,7 @@ import { import { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, + isTelegramExecApprovalTargetRecipient, } from "../../plugin-sdk/telegram-runtime.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js"; @@ -140,16 +141,13 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm senderId: params.command.senderId, }; + const isImplicitTargetApprover = isTelegramExecApprovalTargetRecipient(telegramApproverContext); + if (!isPluginId) { - if ( - !isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.ctx.AccountId }) - ) { - return { - shouldContinue: false, - reply: { text: "❌ Telegram exec approvals are not enabled for this bot account." }, - }; - } - if (!isTelegramExecApprovalApprover(telegramApproverContext)) { + const isExplicitApprover = + isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.ctx.AccountId }) && + isTelegramExecApprovalApprover(telegramApproverContext); + if (!isExplicitApprover && !isImplicitTargetApprover) { return { shouldContinue: false, reply: { text: "❌ You are not authorized to approve exec requests on Telegram." }, @@ -160,7 +158,7 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm // Keep plugin-ID routing independent from exec approval client enablement so // forwarded plugin approvals remain resolvable, but still require explicit // Telegram approver membership for security parity. - if (isPluginId && !isTelegramExecApprovalApprover(telegramApproverContext)) { + if (isPluginId && !isTelegramExecApprovalApprover(telegramApproverContext) && !isImplicitTargetApprover) { return { shouldContinue: false, reply: { text: "❌ You are not authorized to approve plugin requests on Telegram." }, diff --git a/src/plugin-sdk/telegram-runtime.ts b/src/plugin-sdk/telegram-runtime.ts index ca04974e6d7..3b1fdffb552 100644 --- a/src/plugin-sdk/telegram-runtime.ts +++ b/src/plugin-sdk/telegram-runtime.ts @@ -16,6 +16,7 @@ export { inspectTelegramAccount, isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, + isTelegramExecApprovalTargetRecipient, listTelegramAccountIds, listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig,