# ADR 0213: Telegram markup is a tree, and webhook intake is durable

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0213-telegram-markup-is-a-tree-and-webhook-intake-is-durable.md

---

# ADR 0213 — Telegram markup is a tree, and webhook intake is durable

## Context

A consuming bot kept five mechanisms of the same kind as `stitchkit/telegram`
already holds — facts about Telegram's protocols rather than about any product
— and offered them. Measured against the family and against the Bot API:

- **HTML parse mode was parsed four times** across two consuming projects, each
  with its own subset: one cleaner knew neither links nor custom emoji, one
  splitter cut `<b>…</b>` in half — Telegram then refuses the whole message —
  and every splitter measured the *markup* against 4096, while Telegram counts
  the text *after* parsing. The Bot API's own rules (Formatting options) are
  what none of the copies held completely: formatting may not appear inside
  `pre` or `code`, a quotation never inside a quotation, and links, code, quotes,
  custom emoji and times never contain each other.
- **`setWebhook` takes the bot from whoever held it.** A rehearsal started with
  the production token takes the production bot in one call; a start with the
  production address but another secret would have left production answering
  403 to every update, because Telegram never returns the secret to compare.
- **An update answered only after it was handled** is resent while a long
  handler still runs, and lost for good by a process that answered and died.
- **An operator chat flooded** by one failure repeated, or by a storm of
  different ones.
- **A journal masked by pattern only**: a provider key has no shape.

## Decision

**Markup is a tree.** `parseTelegramHtml` turns any markup into nodes that can
express only what Telegram accepts, and everything else is a walk over them:
`renderTelegramHtml`, `sanitizeTelegramHtml`, `splitTelegramHtml` and
`truncateTelegramHtml` cut by visible text in UTF-16 code units (the unit of
entity offsets, so within the limit however Telegram counts), reopening every
element across a cut with its attributes; `checkTelegramHtml` reports what
Telegram would refuse without repairing it. The parse is lenient because its
input is someone else's text: a tag Telegram does not know loses the tag and
keeps the words. It ships as **`stitchkit/telegram/html`**, peer-free and
browser-safe — a preview and a send must share one parser — apart from
`stitchkit/telegram`, which is server-only because it takes the bot token
(0143). `escapeTelegramHtml` moves there from screens, whose test chat now
refuses markup with the same check.

**A webhook is claimed, not set.** `claimTelegramWebhook` sets it only where it
already points to this address with this secret, or where `takeoverFrom` names
the current owner's host (`'none'` for no webhook — a polling bot is somebody's
bot). The address carries an `owner` tag, an HMAC of the secret, so "this
process" includes the secret without Telegram returning it. Refusals and checks
name hosts only. The takeover is named per start rather than configured once:
left in place it would be a standing permission.

**An update is recorded before Telegram is answered.** `receiveTelegramWebhook`
answers once `createTelegramUpdateIntake` wrote the body; handling runs after,
in chat order, with a renewed lease per attempt and a sweep that takes over
what a restart or a dead process left. Delivery is at least once, and says so.
The intake decides and the store only makes each step atomic on one record
(`TelegramUpdateStore`: add, claim, renew, settle, due, prune), so the bot's
own database can hold it; the reference stores are memory and SQLite over a
handle the application opened — `bun:sqlite` or `node:sqlite` as they are — so
the leaf still imports only `internal` (0201). The intake returns no
`ManagedResource`, for the same reason the operator channel does not.

**The operator channel deduplicates.** `dedupe` sends one message per
fingerprint per window and counts the rest onto the next one sent; a budget per
window turns a storm into one count. Held-back messages are drops like any
other, reported as `repeated` and `over-budget`. The channel still cuts a long
text by characters, which can end inside a tag; `telegramOperatorSender`, the
one place that knows the parse mode, parses HTML again and cuts it by what it
shows, so the message Telegram receives is valid.

**A journal masks exact secrets.** `sensitiveValues` on the sanitiser, the
bounded logger and the JSON journal masks exact values in every string;
`secretValuesFromEnv` collects them by variable name and from inside URLs. The
environment names the secret, so a key added there is masked from its first
line.

**Not taken: reading `forward_origin`.** grammY's `MessageOrigin` is already a
discriminated union with `hidden_user` as its own branch; what a product records
of it is the product's.

## Consequences

- A consuming bot switching to `telegramWebhookUrl` gets a different tag than
  its own former scheme, so its first start after the switch names its own host
  in `takeoverFrom`, once.
- `escapeTelegramHtml` is imported from `stitchkit/telegram/html`, not from
  `stitchkit/telegram/screens`; both were unreleased together.
- An operator channel with `parseMode: 'HTML'` sends markup cleaned by
  `sanitizeTelegramHtml`: a tag Telegram does not know is text, not a refusal.
