← stitchkitDocumentation

ADR 0211 — Telegram screens are declared views over grammY

Context

Every bot with a menu writes the same layer on top of its bot library: a router from callback_data to a handler, a packer that fits a route and its arguments into 64 bytes, code that edits the right message or sends a new one, a way to wait for the next message, and a place to remember which messages the menu occupies. In the consuming project that prompted this decision the layer was a hand-written router of about 900 lines, and each screen still did part of the work itself. The measured failures were the ones this layer invites:

  • navigation state lived in process memory, so a release lost the menu’s message ids and every “waiting for input” — a user’s reply after a deploy went nowhere;
  • buttons carried only a route: their arguments were lost, a button’s action was packed as a placeholder, the 64-byte limit was checked by a log line at run time, and an outdated button could not be told apart;
  • screens bypassed the router to edit their own messages, found a header by the convention “header id = body id − 1”, and deleted error messages on a setTimeout that did not survive a restart;
  • keyboards and loader data were any, parse_mode was patched onto the API client, and every screen read “message is not modified” and “message can’t be edited” its own way.

grammY’s plugins cover parts of this and not the whole. @grammyjs/menu is the keyboard of one message, with string payloads and a render from ctx: no screen of several messages, no typed params, no input. @grammyjs/conversations is a replay engine in which every side effect has to be wrapped in conversation.external(), which sits badly with application services that already own their effects.

Decision

stitchkit/telegram/screens declares a bot’s menus as screens and reconciles their views against the chat. A screen is a path, an optional load, actions, input handlers and a pure view. The view says what the chat should show; a pure reconciler compares it with the chat’s record by message key and plans the fewest calls — keep, edit the part that changed, or send and delete — keeping the messages’ order. An executor carries the plan out and absorbs the three refusals a live chat produces (“not modified” is success; a message gone is sent again with those after it; an undeletable message loses its buttons). Any other refusal stops the update, and the transition does not happen: the record stays on the screen the chat was on and lists every message the chat now shows, in the chat’s order.

It is middleware over what the bot already has, not an engine (I5). It calls the bot’s own ctx.api, stores through grammY’s StorageAdapter, and passes every update it does not own to next(). grammY is named in the declarations and never imported at run time, so the entrypoint is an isolated subpath leaf whose bundle stays free of it (I7), proven on the packed artifact by the optional-peer matrix on Bun and Node.

Navigation is the path tree. A screen’s parent is the nearest declared screen whose path is a prefix of its own; back goes there. There is no history stack to grow, to lose on restart or to disagree with the chat. A group shares a path prefix, a params schema and a load that runs before every screen, action and input under it — the one place access is checked, because params come from a button and are user input. A screen with a param no load sees — none above it, or one added below the last — is refused when the bot is composed. Input is deleted before any load runs: it may be a secret, and a load may redirect or fail.

A press is trusted only as far as Telegram vouches for it (I12). Telegram sends the pressed message with its keyboard; callback_data that is not on that keyboard was sent, not pressed, and never reaches a handler. What remains is still parsed by the params and input schemas. Buttons carry typed values in a compact codec; data past 64 bytes is kept in the record behind a random token.

State is one small record per chat in a grammY StorageAdapter, not the application’s StateStore or a keyspace: it is a single value per key, read and written whole, with no need for watched reads or holding every chat in memory. It never holds message text or input. It is written after Telegram answers, so it never names a message that was not sent; a crash in between leaves the chat ahead of its record, and the next press is handled as a press on a message the record does not know. Updates of a chat are serialised within a process through a bounded queue (I10); two processes on one chat are not coordinated, and the last write wins.

It lives in the telegram part. The part’s rule stays: it imports only internal, and grammY only as types — a test holds both.

Consequences

  • A bot’s menu code becomes declarations: the same screens a hand-written router needed ~900 lines to carry are typed end to end, from the path literal to link arguments and action inputs, and a missing param is a compile error.
  • Deleting a user’s input before its handler, pending messages, expiring remarks and “one live menu per chat” are behaviours of the library rather than conventions each screen reimplements.
  • Reply keyboards, inline mode, media uploads and a history stack are out of scope; a screen is shown only in answer to an update (a notification carries screens.button(link(…)) instead).
  • A chat served by two processes at once can see a stale record; the next update repairs it. Serialising across processes would need a lock the application does not otherwise have, and is not taken on here.
  • The entrypoint starts evolving (ADR 0103, ADR 0198).