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.
This commit is contained in:
Subash Natarajan 2026-03-29 06:53:27 +05:30 committed by Ayaan Zaidi
parent 3a43401924
commit aee7992629
5 changed files with 123 additions and 13 deletions

View File

@ -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)`,

View File

@ -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);
});
});
});

View File

@ -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;

View File

@ -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." },

View File

@ -16,6 +16,7 @@ export {
inspectTelegramAccount,
isTelegramExecApprovalApprover,
isTelegramExecApprovalClientEnabled,
isTelegramExecApprovalTargetRecipient,
listTelegramAccountIds,
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,