From 988f7627dee48784ae9a1b7207bb2977c5ce66c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 3 Apr 2026 00:16:54 +0900 Subject: [PATCH] refactor(telegram): centralize approval callback shaping --- extensions/telegram/src/approval-buttons.ts | 21 +++------ .../src/approval-callback-data.test.ts | 33 ++++++++++++++ .../telegram/src/approval-callback-data.ts | 23 ++++++++++ extensions/telegram/src/button-types.ts | 44 ++++++------------- extensions/telegram/src/channel.ts | 1 - 5 files changed, 77 insertions(+), 45 deletions(-) create mode 100644 extensions/telegram/src/approval-callback-data.test.ts create mode 100644 extensions/telegram/src/approval-callback-data.ts diff --git a/extensions/telegram/src/approval-buttons.ts b/extensions/telegram/src/approval-buttons.ts index 1aba4159414..8343b9d6bbe 100644 --- a/extensions/telegram/src/approval-buttons.ts +++ b/extensions/telegram/src/approval-buttons.ts @@ -1,12 +1,7 @@ import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime"; +import { sanitizeTelegramCallbackData } from "./approval-callback-data.js"; import type { TelegramInlineButtons } from "./button-types.js"; -const MAX_CALLBACK_DATA_BYTES = 64; - -function fitsCallbackData(value: string): boolean { - return Buffer.byteLength(value, "utf8") <= MAX_CALLBACK_DATA_BYTES; -} - export function buildTelegramExecApprovalButtons( approvalId: string, ): TelegramInlineButtons | undefined { @@ -21,23 +16,21 @@ function buildTelegramExecApprovalButtonsForDecisions( approvalId: string, allowedDecisions: readonly ExecApprovalReplyDecision[], ): TelegramInlineButtons | undefined { - const allowOnce = `/approve ${approvalId} allow-once`; - if (!allowedDecisions.includes("allow-once") || !fitsCallbackData(allowOnce)) { + const allowOnce = sanitizeTelegramCallbackData(`/approve ${approvalId} allow-once`); + if (!allowedDecisions.includes("allow-once") || !allowOnce) { return undefined; } const primaryRow: Array<{ text: string; callback_data: string }> = [ { text: "Allow Once", callback_data: allowOnce }, ]; - // Use a shorter decision alias so full plugin: IDs still fit Telegram's - // 64-byte callback_data limit for the "Allow Always" action. - const allowAlways = `/approve ${approvalId} always`; - if (allowedDecisions.includes("allow-always") && fitsCallbackData(allowAlways)) { + const allowAlways = sanitizeTelegramCallbackData(`/approve ${approvalId} allow-always`); + if (allowedDecisions.includes("allow-always") && allowAlways) { primaryRow.push({ text: "Allow Always", callback_data: allowAlways }); } const rows: Array> = [primaryRow]; - const deny = `/approve ${approvalId} deny`; - if (allowedDecisions.includes("deny") && fitsCallbackData(deny)) { + const deny = sanitizeTelegramCallbackData(`/approve ${approvalId} deny`); + if (allowedDecisions.includes("deny") && deny) { rows.push([{ text: "Deny", callback_data: deny }]); } return rows; diff --git a/extensions/telegram/src/approval-callback-data.test.ts b/extensions/telegram/src/approval-callback-data.test.ts new file mode 100644 index 00000000000..0b56605f45d --- /dev/null +++ b/extensions/telegram/src/approval-callback-data.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + fitsTelegramCallbackData, + rewriteTelegramApprovalDecisionAlias, + sanitizeTelegramCallbackData, +} from "./approval-callback-data.js"; + +describe("approval callback data", () => { + it("enforces Telegram callback byte boundaries", () => { + expect(fitsTelegramCallbackData("x".repeat(63))).toBe(true); + expect(fitsTelegramCallbackData("x".repeat(64))).toBe(true); + expect(fitsTelegramCallbackData("x".repeat(65))).toBe(false); + }); + + it("rewrites /approve allow-always callbacks to always", () => { + const approvalId = `plugin:${"a".repeat(36)}`; + expect(rewriteTelegramApprovalDecisionAlias(`/approve ${approvalId} allow-always`)).toBe( + `/approve ${approvalId} always`, + ); + }); + + it("keeps rewritten allow-always callbacks when canonical form would overflow", () => { + const approvalId = `plugin:${"a".repeat(36)}`; + expect(sanitizeTelegramCallbackData(`/approve ${approvalId} allow-always`)).toBe( + `/approve ${approvalId} always`, + ); + }); + + it("keeps 64-byte callbacks and drops 65-byte callbacks through sanitize", () => { + expect(sanitizeTelegramCallbackData("x".repeat(64))).toBe("x".repeat(64)); + expect(sanitizeTelegramCallbackData("x".repeat(65))).toBeUndefined(); + }); +}); diff --git a/extensions/telegram/src/approval-callback-data.ts b/extensions/telegram/src/approval-callback-data.ts new file mode 100644 index 00000000000..71f97a0f400 --- /dev/null +++ b/extensions/telegram/src/approval-callback-data.ts @@ -0,0 +1,23 @@ +const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64; + +const TELEGRAM_APPROVE_ALLOW_ALWAYS_PATTERN = + /^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+allow-always$/i; + +export function fitsTelegramCallbackData(value: string): boolean { + return Buffer.byteLength(value, "utf8") <= TELEGRAM_CALLBACK_DATA_MAX_BYTES; +} + +export function rewriteTelegramApprovalDecisionAlias(value: string): string { + if (!value.endsWith(" allow-always")) { + return value; + } + if (!TELEGRAM_APPROVE_ALLOW_ALWAYS_PATTERN.test(value)) { + return value; + } + return value.slice(0, -"allow-always".length) + "always"; +} + +export function sanitizeTelegramCallbackData(value: string): string | undefined { + const rewritten = rewriteTelegramApprovalDecisionAlias(value); + return fitsTelegramCallbackData(rewritten) ? rewritten : undefined; +} diff --git a/extensions/telegram/src/button-types.ts b/extensions/telegram/src/button-types.ts index e144f8353aa..cb9864448dd 100644 --- a/extensions/telegram/src/button-types.ts +++ b/extensions/telegram/src/button-types.ts @@ -4,6 +4,7 @@ import { type InteractiveReply, type InteractiveReplyButton, } from "openclaw/plugin-sdk/interactive-runtime"; +import { sanitizeTelegramCallbackData } from "./approval-callback-data.js"; export type TelegramButtonStyle = "danger" | "success" | "primary"; @@ -16,11 +17,6 @@ export type TelegramInlineButton = { export type TelegramInlineButtons = ReadonlyArray>; const TELEGRAM_INTERACTIVE_ROW_SIZE = 3; -const MAX_CALLBACK_DATA_BYTES = 64; - -function fitsTelegramCallbackData(value: string): boolean { - return Buffer.byteLength(value, "utf8") <= MAX_CALLBACK_DATA_BYTES; -} function toTelegramButtonStyle( style?: InteractiveReplyButton["style"], @@ -28,36 +24,24 @@ function toTelegramButtonStyle( return style === "danger" || style === "success" || style === "primary" ? style : undefined; } -function rewriteTelegramApprovalAlias(value: string): string { - if (!value.endsWith(" allow-always")) { - return value; - } - const approvePrefixMatch = value.match( - /^\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+allow-always$/i, - ); - if (!approvePrefixMatch) { - return value; - } - return value.slice(0, -"allow-always".length) + "always"; -} - function chunkInteractiveButtons( buttons: readonly InteractiveReplyButton[], rows: TelegramInlineButton[][], ) { for (let i = 0; i < buttons.length; i += TELEGRAM_INTERACTIVE_ROW_SIZE) { - const row = buttons - .slice(i, i + TELEGRAM_INTERACTIVE_ROW_SIZE) - .map((button) => ({ - ...button, - value: rewriteTelegramApprovalAlias(button.value), - })) - .filter((button) => fitsTelegramCallbackData(button.value)) - .map((button) => ({ - text: button.label, - callback_data: button.value, - style: toTelegramButtonStyle(button.style), - })); + const row = buttons.slice(i, i + TELEGRAM_INTERACTIVE_ROW_SIZE).flatMap((button) => { + const callbackData = sanitizeTelegramCallbackData(button.value); + if (!callbackData) { + return []; + } + return [ + { + text: button.label, + callback_data: callbackData, + style: toTelegramButtonStyle(button.style), + }, + ]; + }); if (row.length > 0) { rows.push(row); } diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index b510c75bcc2..c9f984cdf43 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -40,7 +40,6 @@ import { } from "./accounts.js"; import { resolveTelegramAutoThreadId } from "./action-threading.js"; import { lookupTelegramChatId } from "./api-fetch.js"; -import { buildTelegramExecApprovalButtons } from "./approval-buttons.js"; import { telegramApprovalCapability } from "./approval-native.js"; import * as auditModule from "./audit.js"; import { buildTelegramGroupPeerId } from "./bot/helpers.js";