Visitar URL original
Telegram: send cards as HTML widgets (Bot API 10.4) · Issue #1138 · vercel/chat · GitHub
Skip to content

Telegram: send cards as HTML widgets (Bot API 10.4) #1138

Open

Description

@mamrezme

Problem Statement

The Telegram adapter sends a card as a MarkdownV2 text message with an inline keyboard. sendPostable renders the body with cardToFallbackText from @chat-adapter/shared and the buttons with cardToTelegramInlineKeyboard in cards.ts. Cards don't use rich messages, because resolveRichMessage returns null when the message has a card. As a result:

  • Tables and charts become ASCII tables padded with spaces, outside a code block.
  • Image elements and CardElement.imageUrl are dropped.
  • Fields become Label: value lines.

Bot API 10.4 added HTML widgets. A bot sends one with sendWidget. A widget can show layout, tables, SVG or canvas graphics, and buttons that send queries back to the bot. The adapter has no widget support.

This also affects json-render. streamCard from @json-render/chat calls only thread.post(card) and message.edit(card), so json-render specs render as text cards on Telegram.

Proposed Solution

Send a card as an HTML widget when the bot opts in and the chat supports it. In every other case, send the card as MarkdownV2 text with an inline keyboard, as the adapter does today. json-render posts and edits ordinary CardElement values, so it gets widget support without changes to json-render.

When the adapter sends a widget

All of these must hold:

  • The bot enabled widgets, for example with widgetCards: true. The default is off, so existing bots don't change.
  • The thread is a private chat (isDM(threadId)) and not a business-connection thread. Production widgets are limited to private chats, and sendWidget has no business_connection_id parameter.
  • The rendered HTML is 1 to 32,768 bytes, and at most 4,096 bytes after zlib compression at level 9.
  • The card has no Select, RadioSelect, or TextInput. Widgets have no form controls or keyboard input.
  • The Bot API server supports sendWidget. A custom apiUrl server may run an older Bot API. After the not-found or missing-method errors that already turn off rich messages (richMessagesAvailable), the adapter falls back to text cards.

How a widget card works

  • Body: HTML rendered from the card by a new renderer.
  • Caption: the existing cardToFallbackText output, up to 1,024 characters, so clients that can't show the widget still show the content.
  • Buttons: a Button calls TelegramWidget.request(data) with the encodeTelegramCallbackData payload. Widget query data has the same 64-byte limit as callback data. The adapter sets allow_queries: true, routes widget_query updates to chat.processAction, and answers them with answerWidgetQuery within 15 seconds. A LinkButton calls TelegramWidget.openLink.
  • Images: widgets can't load external resources, so Image and imageUrl are omitted, as they are in text cards today.
  • Updates: add widget_query to TELEGRAM_DEFAULT_ALLOWED_UPDATES, which the adapter sends as an explicit allowed_updates list in business mode.

Open questions

  1. How should editMessage handle a widget card? Widget HTML is fixed once sent. Only chat_data and public_data can change, up to 1,024 bytes each, through editMessageWidgetData. json-render's streamCard edits the message every 500 ms by default while the spec streams.

    • Option A: send one fixed card-renderer HTML and store the serialized card in chat_data. Edits call editMessageWidgetData. Streaming works, but only for cards whose serialized form fits in 1,024 bytes, and the renderer must fit the 4,096-byte compressed limit. I haven't measured whether it does.
    • Option B: render each card to static HTML. Widget messages can't be edited, so streaming posts only the final card.

    Either way, editMessage calls editMessageText today, so the adapter needs to track which message ids are widgets.

  2. Should the switch apply to the whole adapter (widgetCards), or should each card opt in?

Roadmap

Three PRs, each small enough to review and able to merge on its own:

  1. Widget types and query routing. Add the Bot API 10.4 types to types.ts (Widget, WidgetQuery, and the sendWidget parameters). Route widget_query updates to chat.processAction and answer them with answerWidgetQuery. Add widget_query to TELEGRAM_DEFAULT_ALLOWED_UPDATES, and add a captured payload to sample-messages.md. No output changes for existing bots.
  2. Post cards as widgets. Add the widgetCards option and the card-to-HTML renderer in a new widget.ts next to cards.ts. Send widgets with sendWidget in private chats, with the fallback caption. Validate the size limits, and fall back to text cards for cards that don't fit, for unsupported elements, and for servers without sendWidget. Update the Telegram docs page and the @chat-adapter/catalog entry. The renderer and the option ship together, because knip rejects unused exports.
  3. Edit and stream widget cards. Implement the editMessage option chosen in open question 1, track widget message ids, and test streamCard from @json-render/chat end to end in a private chat.

Each PR includes a changeset, docs updates where behavior changes, and a passing pnpm validate.

Follow-ups, out of scope for these PRs:

  • A Telegram example in the json-render docs (vercel-labs/json-render).
  • Widgets in groups and inline mode, once Telegram allows them in production. Both are test-server only today.
  • Shared widgets (is_shared, sendSharedWidget, editSharedWidgetData) and TON wallet query types.

Alternatives Considered

  • Calling sendWidget directly from bot code. This bypasses thread.post, so json-render's streamCard and onAction routing don't work.
  • Subclassing TelegramAdapter and overriding the protected sendPostable and processUpdate. This works for one app, but every bot would rebuild the renderer and the query handling.
  • Adding a widget renderer to json-render. It would cover json-render specs, but not cards built with Chat SDK JSX.

Use Case

import { Chat } from "chat";
import { createTelegramAdapter } from "@chat-adapter/telegram";
import { createMemoryState } from "@chat-adapter/state-memory";
import { defineCatalog } from "@json-render/core";
import {
  schema,
  standardComponentDefinitions,
  streamCard,
} from "@json-render/chat";
import { streamText } from "ai";
import { model } from "./model"; // any AI SDK model

const bot = new Chat({
  userName: "mybot",
  adapters: {
    // Proposed option
    telegram: createTelegramAdapter({ widgetCards: true }),
  },
  state: createMemoryState(),
});

const catalog = defineCatalog(schema, {
  components: standardComponentDefinitions,
});

bot.onNewMention(async (thread, message) => {
  const result = streamText({
    model,
    system: catalog.prompt(),
    prompt: message.text,
  });
  // Private chat: an HTML widget. Groups: the current MarkdownV2 card.
  await streamCard(thread, result.textStream);
});

// Widget button taps arrive here, like inline keyboard taps
bot.onAction("approve", async (event) => {
  await event.thread?.post("Approved");
});

Priority

Important

Contribution

  • I am willing to help implement this feature

Additional Context

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions