Files
openclaw/extensions/telegram/src/interactive-fallback.ts
Peter Steinberger 81a201df26 feat: add portable table presentation blocks (#103583)
* feat(presentation): add portable table blocks

* chore(presentation): refresh generated contracts

* fix(slack): preserve table fallback payloads

* docs(changelog): note portable message tables

* fix(presentation): preserve cross-channel fallback delivery

* chore(plugin-sdk): refresh rebased contracts

* test(slack): align accessibility expectations

* fix(presentation): harden cross-channel fallback delivery

* chore(plugin-sdk): refresh rebased contracts

* docs(changelog): defer portable table release note

* fix(presentation): satisfy extension lint

* chore(plugin-sdk): refresh surface budgets

* fix(telegram): preserve reactions after progress replies

* fix(slack): preserve rendered preview fallback text

* fix(feishu): preserve oversized presentation fallbacks

* docs(changelog): note portable message tables

* docs(changelog): defer portable table release note

* chore(plugin-sdk): refresh rebased contracts

---------

Co-authored-by: Peter Steinberger <steipete@openai.com>
2026-07-10 22:29:13 -07:00

199 lines
6.8 KiB
TypeScript

// Telegram plugin module implements interactive fallback behavior.
import {
adaptMessagePresentationForChannel,
interactiveReplyToPresentation,
isMessagePresentationInteractiveBlock,
normalizeMessagePresentation,
normalizeInteractiveReply,
renderMessagePresentationFallbackText,
resolveInteractiveTextFallback,
type MessagePresentation,
type MessagePresentationInteractiveBlock,
} from "openclaw/plugin-sdk/interactive-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { buildTelegramPresentationButtons, resolveTelegramInlineButtons } from "./button-types.js";
import { buildInlineKeyboard } from "./inline-keyboard.js";
const TELEGRAM_CONTROL_ONLY_FALLBACK = "Choose an option.";
export const TELEGRAM_PRESENTATION_CAPABILITIES = {
supported: true,
buttons: true,
selects: true,
context: true,
divider: false,
limits: {
actions: {
maxActions: 100,
maxActionsPerRow: 3,
maxLabelLength: 64,
supportsStyles: false,
supportsDisabled: false,
},
selects: {
maxOptions: 100,
maxLabelLength: 64,
},
text: {
markdownDialect: "markdown" as const,
},
},
};
function canEncodeTelegramPresentationControl(block: MessagePresentationInteractiveBlock): boolean {
return Boolean(buildTelegramPresentationButtons({ blocks: [block] })?.length);
}
function partitionTelegramPresentationBlocks(params: {
presentation: MessagePresentation;
presentationControlsSelected: boolean;
}): {
fallbackBlocks: MessagePresentation["blocks"];
nativeControlBlocks: MessagePresentationInteractiveBlock[];
} {
const fallbackBlocks: MessagePresentation["blocks"] = [];
const nativeControlBlocks: MessagePresentationInteractiveBlock[] = [];
for (const block of params.presentation.blocks) {
if (!isMessagePresentationInteractiveBlock(block)) {
fallbackBlocks.push(block);
continue;
}
if (!params.presentationControlsSelected) {
fallbackBlocks.push(block);
continue;
}
if (block.type === "buttons") {
const nativeButtons: typeof block.buttons = [];
const fallbackButtons: typeof block.buttons = [];
for (const button of block.buttons) {
const target = canEncodeTelegramPresentationControl({ type: "buttons", buttons: [button] })
? nativeButtons
: fallbackButtons;
target.push(button);
}
if (nativeButtons.length > 0) {
nativeControlBlocks.push({ type: "buttons", buttons: nativeButtons });
}
if (fallbackButtons.length > 0) {
fallbackBlocks.push({ type: "buttons", buttons: fallbackButtons });
}
continue;
}
const nativeOptions: typeof block.options = [];
const fallbackOptions: typeof block.options = [];
for (const option of block.options) {
const target = canEncodeTelegramPresentationControl({ type: "select", options: [option] })
? nativeOptions
: fallbackOptions;
target.push(option);
}
if (nativeOptions.length > 0) {
nativeControlBlocks.push({ ...block, options: nativeOptions });
}
if (fallbackOptions.length > 0) {
fallbackBlocks.push({ ...block, options: fallbackOptions });
} else if (block.placeholder) {
// Telegram maps selects to buttons, so retain the select prompt in message text.
fallbackBlocks.push({ type: "text", text: block.placeholder });
}
}
return { fallbackBlocks, nativeControlBlocks };
}
/** Convert portable presentation into the one Telegram payload shape used by every send funnel. */
export function canonicalizeTelegramPresentationPayload(payload: ReplyPayload): ReplyPayload {
const normalizedPresentation = normalizeMessagePresentation(payload.presentation);
const telegramData = payload.channelData?.telegram as
| (Record<string, unknown> & {
buttons?: Parameters<typeof resolveTelegramInlineButtons>[0]["buttons"];
})
| undefined;
if (!normalizedPresentation) {
const nativeButtons = resolveTelegramInlineButtons({ buttons: telegramData?.buttons });
if (!buildInlineKeyboard(nativeButtons) || payload.text?.trim()) {
return payload;
}
// Native-only controls need the same visible message anchor as portable controls.
return { ...payload, text: TELEGRAM_CONTROL_ONLY_FALLBACK };
}
const presentation = adaptMessagePresentationForChannel({
presentation: normalizedPresentation,
capabilities: TELEGRAM_PRESENTATION_CAPABILITIES,
});
const interactive = normalizeInteractiveReply(payload.interactive);
const existingButtons = resolveTelegramInlineButtons({
buttons: telegramData?.buttons,
interactive,
});
const presentationControlsSelected = existingButtons === undefined;
const { fallbackBlocks, nativeControlBlocks } = partitionTelegramPresentationBlocks({
presentation,
presentationControlsSelected,
});
const presentationButtons = buildTelegramPresentationButtons({
blocks: nativeControlBlocks,
});
const buttons = existingButtons ?? presentationButtons;
const fallbackText = renderMessagePresentationFallbackText({
presentation: { ...presentation, blocks: fallbackBlocks },
});
const currentText =
resolveInteractiveTextFallback({ text: payload.text, interactive })?.trim() ?? "";
const hasFallback =
fallbackText.length > 0 &&
(currentText === fallbackText || currentText.endsWith(`\n\n${fallbackText}`));
const text = hasFallback ? currentText : [currentText, fallbackText].filter(Boolean).join("\n\n");
const { presentation: _presentation, ...withoutPresentation } = payload;
const canonical: ReplyPayload = {
...withoutPresentation,
text: text || (buttons ? TELEGRAM_CONTROL_ONLY_FALLBACK : ""),
};
if (buttons) {
canonical.channelData = {
...payload.channelData,
telegram: {
...telegramData,
buttons,
},
};
}
return canonical;
}
export function resolveTelegramInteractiveTextFallback(params: {
text?: string | null;
interactive?: unknown;
presentation?: unknown;
}): string | undefined {
const interactive = normalizeInteractiveReply(params.interactive);
const text = resolveInteractiveTextFallback({
text: params.text ?? undefined,
interactive,
});
if (text?.trim()) {
return text;
}
const presentation = normalizeMessagePresentation(params.presentation);
if (presentation) {
const fallback = renderMessagePresentationFallbackText({
text: params.text ?? undefined,
presentation,
});
if (fallback.trim()) {
return fallback;
}
}
if (!interactive) {
return text;
}
const interactivePresentation = interactiveReplyToPresentation(interactive);
if (!interactivePresentation) {
return text;
}
const fallback = renderMessagePresentationFallbackText({ presentation: interactivePresentation });
return fallback.trim() ? fallback : text;
}