Repository navigation
Description
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.
Imageelements andCardElement.imageUrlare dropped.- Fields become
Label: valuelines.
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, andsendWidgethas nobusiness_connection_idparameter. - 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, orTextInput. Widgets have no form controls or keyboard input. - The Bot API server supports
sendWidget. A customapiUrlserver 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
cardToFallbackTextoutput, up to 1,024 characters, so clients that can't show the widget still show the content. - Buttons: a
ButtoncallsTelegramWidget.request(data)with theencodeTelegramCallbackDatapayload. Widget query data has the same 64-byte limit as callback data. The adapter setsallow_queries: true, routeswidget_queryupdates tochat.processAction, and answers them withanswerWidgetQuerywithin 15 seconds. ALinkButtoncallsTelegramWidget.openLink. - Images: widgets can't load external resources, so
ImageandimageUrlare omitted, as they are in text cards today. - Updates: add
widget_querytoTELEGRAM_DEFAULT_ALLOWED_UPDATES, which the adapter sends as an explicitallowed_updateslist in business mode.
Open questions
-
How should
editMessagehandle a widget card? Widget HTML is fixed once sent. Onlychat_dataandpublic_datacan change, up to 1,024 bytes each, througheditMessageWidgetData. json-render'sstreamCardedits 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 calleditMessageWidgetData. 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,
editMessagecallseditMessageTexttoday, so the adapter needs to track which message ids are widgets. - Option A: send one fixed card-renderer HTML and store the serialized card in
-
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:
- Widget types and query routing. Add the Bot API 10.4 types to
types.ts(Widget,WidgetQuery, and thesendWidgetparameters). Routewidget_queryupdates tochat.processActionand answer them withanswerWidgetQuery. Addwidget_querytoTELEGRAM_DEFAULT_ALLOWED_UPDATES, and add a captured payload tosample-messages.md. No output changes for existing bots. - Post cards as widgets. Add the
widgetCardsoption and the card-to-HTML renderer in a newwidget.tsnext tocards.ts. Send widgets withsendWidgetin 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 withoutsendWidget. Update the Telegram docs page and the@chat-adapter/catalogentry. The renderer and the option ship together, becausekniprejects unused exports. - Edit and stream widget cards. Implement the
editMessageoption chosen in open question 1, track widget message ids, and teststreamCardfrom@json-render/chatend 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
sendWidgetdirectly from bot code. This bypassesthread.post, so json-render'sstreamCardandonActionrouting don't work. - Subclassing
TelegramAdapterand overriding the protectedsendPostableandprocessUpdate. 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
sendWidget- Widgets guide: HTML limits, the sandbox,
TelegramWidget.request, and the limit of 5 queries per 5 seconds per user editMessageWidgetDataandanswerWidgetQuery- Bot API changelog
streamCardin@json-render/chat
Activity