refactor(telegram): centralize approval callback shaping

This commit is contained in:
Peter Steinberger
2026-04-03 00:16:54 +09:00
parent efe9464f5f
commit 988f7627de
5 changed files with 77 additions and 45 deletions

View File

@@ -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:<uuid> 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<Array<{ text: string; callback_data: string }>> = [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;

View File

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

View File

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

View File

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

View File

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