← stitchkitDocumentation

Telegram bots

stitchkit/telegram holds what the Bot API itself asks of every bot, with no bot library; stitchkit/application/grammy makes a grammY bot a set of application resources. Together they are what a bot is assembled from, so that a bot’s own code is its product and nothing else. A generated bot to start from: bun create stitchkit my-bot --template telegram-bot. Why it is shaped this way: ADR 0201.

Job Where
the bot as resources: command menu, then long polling with updates admitted by batch grammyBotResources — application kernel
a bot on a webhook: set only where it is this process’s, updates recorded before Telegram is answered claimTelegramWebhook, createTelegramUpdateIntake, receiveTelegramWebhook — below
text for Telegram’s HTML: cleaned, cut to the limit, checked stitchkit/telegram/html — below
the process journal createJsonLogger from stitchkit/observability
the operator’s chat: product events, apart from the journal createTelegramOperatorChannel
a broadcast that survives crashes, deploys and Ctrl-C runTelegramBroadcast
a file the local Bot API server wrote to disk createTelegramLocalFiles
why a send was refused classifyTelegramSendFailure — auth and errors
Mini App initData verifyTelegramInitData — auth and errors
one Bot API call without a bot library callTelegramBotApi
menus: screens, buttons, actions, input and message editing stitchkit/telegram/screens — Telegram screens

The journal

A bot’s journal is createJsonLogger: one JSON object per line on standard output, in pino’s shape — numeric level (debug 20, info 30, warn 40, error 50), time in epoch milliseconds, msg — so pino-pretty reads it on a terminal and a supervisor matching "level":50 sees errors. Unlike pino, an Error under any key is written with its name, message, stack and cause; an error logged as { error } does not become "error":{}, and its own fields — a TelegramBotApiError’s error_code and parameters.retry_after — are on the line beside them.

A bot token is masked wherever it appears — a request URL in an error message, a local Bot API file path — with no configuration: 123456:[redacted] keeps the bot’s id, which is public and is what tells two bots apart in one journal. So is the password in an address and a query parameter named as a secret (?token=).

import { createJsonLogger } from 'stitchkit/observability'

export const logger = createJsonLogger({
  level: env.LOG_LEVEL,
  fields: { service: 'my-bot' },
})

logger.error('Application startup failed', { error })

A provider’s key, a webhook secret or a database password has no shape a pattern could guess, so the journal also masks exact values — and the environment already names them. secretValuesFromEnv collects every variable named as a token, secret, key, password or credential, the password and secret query values inside any URL variable (DATABASE_URL), and each URL-encoded:

import { createJsonLogger, secretValuesFromEnv } from 'stitchkit/observability'

export const logger = createJsonLogger({
  sensitiveValues: secretValuesFromEnv(process.env),
})

A string of your own — a message for a person, a report — masks bot tokens with redactTelegramBotToken(text) from stitchkit/telegram.

A key added to the environment is masked from its first line; a URL’s user name is left alone, since it names a role that appears in ordinary text.

The error line contract, for whatever recognises a failure from the journal: a line whose level is 50 or more is an error. There is no separate fatal; an unrecoverable end is an error line followed by a non-zero exit.

The operator channel

The chat where people read about new users, payments and failures is not the journal. The journal must not lose a line to a slow network; the chat must not slow the bot down. post returns at once and never throws; sends are paced below Telegram’s per-chat limit (one every 3 s by default), 429s wait the time Telegram named, and when the chat cannot keep up the oldest message is dropped and reported.

import { createTelegramOperatorChannel, telegramOperatorSender } from 'stitchkit/telegram'

const operators = createTelegramOperatorChannel<'users' | 'payments' | 'errors'>({
  chatId: env.OPERATOR_CHAT_ID,
  topics: { users: 2, payments: 3, errors: 4 },   // forum message_thread_id
  send: telegramOperatorSender({ token: env.BOT_TOKEN }),
  onDropped: (drop) => logger.warn('Operator message dropped', { ...drop }),
})

operators.post(`New user ${user.id}`, 'users')

Every message is masked before it leaves: a bot token always — bots post their errors here, and an error about a local Bot API file carries the token in its path — plus any sensitivePatterns and exact sensitiveValues the bot adds; telegramOperatorSender also masks its own exact token, and with parseMode: 'HTML' repairs the markup — a text cut at Telegram’s limit never leaves a tag open.

A failure that repeats a hundred times, or a storm of a hundred different ones when a database goes down, is a line with a count for the person reading, not a hundred messages. dedupe sends one message per fingerprint per window — the topic and the text with numbers, UUIDs and long identifiers blanked — and the next one of that kind carries how many were held back; past maxPerWindow messages in a window the rest are counted onto the next message sent:

const operators = createTelegramOperatorChannel<'errors'>({
  chatId: env.OPERATOR_CHAT_ID,
  send: telegramOperatorSender({ token: env.BOT_TOKEN }),
  sensitiveValues: secretValuesFromEnv(process.env),
  dedupe: {
    windowMs: 10 * 60_000,
    maxPerWindow: 20,
    repeatedLine: (count) => `Same again: ${count}`,
  },
})

Held-back messages reach onDropped as repeated or over-budget.

A bot that already sends its alerts itself — its own bot instance, its own retry — takes the window alone instead of switching the channel off with parameters:

import { createTelegramOperatorDedupe } from 'stitchkit/telegram'

const admit = createTelegramOperatorDedupe<'errors'>({ windowMs: 10 * 60_000 })

const verdict = admit(text, 'errors')
if (verdict.send) await sendAlert(verdict.text) // the text carries the held-back counts

What to post stays the bot’s. On the way down, drain it within the grace period and close it: drain: (context) => operators.drain(context.signal), close: () => operators.close() in a resource.

Broadcasts

A broadcast is a job with an end, so it is a function, not a resource:

import { runTelegramBroadcast, telegramBroadcastSender } from 'stitchkit/telegram'

const report = await runTelegramBroadcast({
  name: '2026-04-28-price-lock',
  directory: env.STATE_DIRECTORY,            // a state root, never the release tree
  recipients: () => audience.activeSubscribers(),
  send: telegramBroadcastSender({
    token: env.BOT_TOKEN,
    message: { copyFrom: { chatId: env.DRAFTS_CHAT_ID, messageId: 42 } },
  }),
  signal: stop.signal,
  onProgress: (progress) => logger.info('Broadcast', { ...progress }),
})
  • Resumable by name. The audience is written once on the first run; running the same name again continues with that list. Progress is an append-only journal beside it, one line before and one after each send.
  • Nobody twice. A send that was in flight when the process died is recorded uncertain and not repeated. A signal stops between sends, after the one in flight is recorded.
  • Refusals by meaning. A blocked, deactivated or never-started recipient is unreachable and never addressed again. A 429 waits Telegram’s retry_after and sends the same recipient again; a server error is retried with backoff up to maxAttempts, then failed. A message Telegram cannot parse is not the recipient’s fault: the run halts with them still pending, so the run after the fix reaches them. An unknown transport outcome becomes uncertain immediately and halts the run; resume never sends to that recipient again.
  • Pacing is ratePerSecond, 25 by default. dryRun counts and writes nothing. One runner per name: a second concurrent run is refused.

The same call works from a script (the process’s signal) and inside a live application (the application’s signal, with admission held by the caller). The recipient state contains Telegram ids: keep it in the state directory, out of git.

Injecting another transport’s failure policy

classify(error) returns the public TelegramBroadcastFailure union. It lets an application use its own client without adding that SDK to Stitchkit:

const report = await runTelegramBroadcast({
  name: 'announcement',
  directory: env.STATE_DIRECTORY,
  recipients: () => audience.activeSubscribers(),
  send: ({ recipient }) => client.sendMessage(recipient, message),
  classify(error) {
    if (isFloodWait(error))
      return { kind: 'retry-after', retryAfterMs: error.seconds * 1000,
        reason: 'provider-rate-limit' }
    if (isRecipientGone(error))
      return { kind: 'permanent', recipientUnreachable: true,
        reason: 'recipient-gone' }
    return { kind: 'ambiguous', reason: 'unknown-provider-outcome' }
  },
  maxRetryDelayMs: 60_000,
  signal: stop.signal,
})

The four variants are retry-after (exact retryAfterMs), transient (bounded backoff), permanent (optionally recipientUnreachable or stopBroadcast), and ambiguous. Retry variants certify that the failed attempt had no external effect. Lack of an HTTP status does not establish that fact: a server may have accepted the message before the connection failed. ambiguous durably records uncertain, then halts; resuming the name skips that recipient.

maxRetryDelayMs defaults to 60 seconds. A provider wait above it halts with the recipient pending; it is never shortened to send early. maxAttempts is an integer from 1 to 100, maxRetryDelayMs is an integer from 0 to 2,147,483,647, and pacing/progress intervals must fit native timers. The optional sleep(milliseconds, signal) receives cancellation; the runner also stops waiting when the signal aborts if a custom sleeper ignores it. A send already in flight finishes and is recorded before stopping.

Without classify, known Bot API 429, unreachable-recipient, malformed-message, server-error and not-dispatched behavior remains. Unknown/no-status failures now halt as uncertain, rather than being automatically retried. Existing custom send implementations that relied on those retries must supply an explicit classifier for proven pre-effect failures. report.halt now carries TelegramBroadcastFailure; inspect halt.kind instead of the old send-failure scope/retryable fields.

The runner uses the shared process-instance lock and checks its generation before sends and journal writes. The audience and initial journal directory entry are synced before sending. Lost ownership cannot write a stale delivered receipt or start another send; the persisted intent becomes uncertain on recovery. Old pid-only broadcast locks have unknown ownership and require recovery after their writers are verified stopped, rather than automatic age-based deletion.

The installed telegram-broadcast-classifier.mjs fixture checks injected rate limits, ambiguity/no resend, wait budgets and abort on Bun and Node without a client SDK.

A transport that knows whether a request left

fetch folds establishing the connection and waiting for the answer into one failure. From that failure nobody can say whether Telegram saw the message, so the senders above treat every network failure as unknown: a broadcast records the recipient uncertain and the operator channel drops the message rather than risk a duplicate. On a route that sometimes fails to connect at all, that loses messages that never left.

createTelegramBotTransport() connects first and writes the request only on an established connection. Every failure before the first byte is written is TelegramNotDispatchedError (stage: lookup, connect or request), and classifyTelegramSendFailure reads it as not-dispatched — retryable, with evidence: 'transport'. A broadcast retries it as transient; the operator channel sends it again. A failure after the request was written stays an outcome nobody can know.

import { createTelegramBotTransport, telegramOperatorSender } from 'stitchkit/telegram'

const transport = createTelegramBotTransport()
const send = telegramOperatorSender({ token: env.BOT_TOKEN, fetch: transport })

Pass it as fetch to callTelegramBotApi, telegramBroadcastSender, telegramOperatorSender or the webhook calls. Addresses come in the system resolver’s order with no family forced; the next address starts 250 ms later (Happy Eyeballs), and a round with no connection is repeated with new sockets inside connectBudgetMs (default 5 s, which also bounds the address lookup; each round waits connectAttemptMs, default 2 s). The answer must arrive within responseTimeoutMs (default 30 s) and fit maxResponseBytes (default 1 MiB). It speaks HTTP/1.1 over https:, and over http: for a local Bot API server, and it uses no proxy: where the network needs one, keep fetch.

Message markup

stitchkit/telegram/html is Telegram’s HTML parse mode as a tree, with no peer and no DOM, so a server sending a message and a browser previewing it share one parser. Text written by someone else — a model, an editor, an owner’s greeting — becomes markup Telegram accepts: synonyms renamed (strong → b), block tags turned into line breaks, a link kept only with an href Telegram opens, anything Telegram forbids (a tag it does not know, formatting inside code, a quote inside a quote) left as its words.

import { sanitizeTelegramHtml, splitTelegramHtml } from 'stitchkit/telegram/html'

for (const part of splitTelegramHtml(summary)) {
  await ctx.reply(part, { parse_mode: 'HTML' })
}

Telegram’s limits — 4096 for a message, 1024 for a caption ({ limit: TELEGRAM_CAPTION_LIMIT }) — count the text after the markup is parsed, so a text is cut by what the reader sees, not by the length of its tags. A cut prefers a paragraph, then a line, then a space; it never splits a character or a custom emoji, and an element open across it is closed in one part and opened again, attributes and all, in the next. truncateTelegramHtml keeps one part with an ellipsis; telegramHtmlText is what the reader sees; parseTelegramHtml gives the nodes to render a preview or an email from. checkTelegramHtml says, without repairing anything, what Telegram would refuse — for an editor that should warn a person rather than change their text.

A bot on a webhook

A webhook is one place in all of Telegram, and setWebhook takes it without asking whose it was: a staging host or a developer’s machine started with the production token takes the production bot with one call. claimTelegramWebhook sets it only where it already points here — address and secret — or where the bot names the current owner’s host as the one it takes over from ('none' when there is no webhook: a bot on long polling is somebody’s bot too). Telegram never returns the secret, so the address carries an owner tag derived from it; a copy of the production address with another secret is refused as other-secret instead of quietly installing its own.

import {
  checkTelegramWebhook,
  claimTelegramWebhook,
  createTelegramUpdateIntake,
  receiveTelegramWebhook,
  sqliteTelegramUpdateStore,
} from 'stitchkit/telegram'
import { Database } from 'bun:sqlite'
import type { Update } from 'grammy/types'

const webhook = { token: env.BOT_TOKEN, url: env.WEBHOOK_URL, secret: env.WEBHOOK_SECRET }
const updateStore = sqliteTelegramUpdateStore({ database: new Database(env.UPDATES_DB) })

const intake = createTelegramUpdateIntake<Update>({
  store: updateStore,
  handle: (update) => bot.handleUpdate(update),
  onFailure: (failure) => logger.warn('Update failed', { ...failure }),
})

// start: set the webhook, then handle what was recorded before the restart
await bot.init()
await claimTelegramWebhook({ ...webhook, takeoverFrom: env.WEBHOOK_TAKEOVER_FROM })
await intake.start()

// the route
const route = (request: Request) =>
  receiveTelegramWebhook(request, { secret: webhook.secret, accept: intake.accept })

// stop admission: `await intake.close()` waits for the handlers in flight

A refusal names the owner’s host, never its path — a foreign webhook’s path may itself be a secret — and says what to pass: set the takeover for one start and remove it. checkTelegramWebhook on a timer notices a webhook that moved elsewhere, which this process cannot see any other way.

Moving to it from a claim of your own. A bot that tagged its webhook itself holds an address this one does not recognise, so the first claim is refused — other-secret when the address is the same, owned-elsewhere otherwise — and names its own host. Start once with takeoverFrom set to that host, then remove it. The tag is derived from the secret with a fixed context, so it is the same across stitchkit releases: that takeover is needed once per bot, not per upgrade. A secret rotation is a new owner, and takes a takeover too.

The update is recorded before Telegram is answered. A handler that runs longer than Telegram waits — a large file from a local Bot API server — gets the same update again while it still works, and a process that answers and then dies loses the update for good. receiveTelegramWebhook answers 200 once the intake recorded the body; the handler runs after, outside the request. Updates of one chat run in order, other chats alongside (maxConcurrent, 32). An attempt holds a lease it renews while the handler lives; a sweep takes over what was left — pending whose process never got to it (pendingGraceMs), failed whose retry is due, processing whose process died. A failure is retried with backoff or after Telegram’s retry_after; one Telegram said repeating cannot fix (a refused message, a blocked user) is abandoned; maxAttempts (5) ends the rest. Delivery is at least once: a handler that finished but could not be recorded as finished runs again.

Use handleAttempt when the handler needs the immutable attempt fence and an ownership-loss signal. A signal asks slow work to stop; the fence is what authorizes a domain write. Check it through a transaction-bound reference store inside the same database transaction as that write:

handleAttempt: async (update, { fence, ownerLost }) => {
  ownerLost.throwIfAborted()
  await database.transaction(async (tx) => {
    const transactionStore = postgresTelegramUpdateStore({
      query: (text, parameters) => tx.query(text, parameters),
      createTable: false,
    })
    if (!(await transactionStore.owns(fence, Date.now()))) return
    await applyDomainTransition(tx, update)
  })
}

The fence contains the claim’s own { updateId, attempt, claimId }. The opaque claimId is persisted and never reused, so an old handler cannot adopt either a newer attempt or a pruned and recreated updateId. Existing handle(update) remains unchanged, including direct functions whose own optional second argument has a different meaning.

For a final obligation, configure handleExhaustion. The reference store keeps the terminal record unacknowledged until this handler resolves, including across restart; a throw leaves it for the next sweep. Delivery is at least once, so use the complete { updateId, attempt, claimId } as an idempotency key in the consumer database:

handleExhaustion: ({ updateId, attempt, claimId, update, error }) =>
  recordFinalFailureOnce({ key: `telegram:${updateId}:${attempt}:${claimId}`, update, error })

This reuses the update row and table. Without handleExhaustion, terminal abandonment retains its previous behavior.

With grammY the handler is bot.handleUpdate, which wraps a middleware’s error in a BotError; the intake unwraps it, so retry, onFailure and the error stored with the update are what the middleware threw — a retry that returns false for the bot’s own terminal error sees that error.

The base store remains the six atomic steps of TelegramUpdateStore. A TelegramUpdateFencedStore adds claimOwned, renewOwned, owns and settleOwned; TelegramUpdateDurableStore adds the final-obligation methods. Reference stores implement both, so the bot’s own database holds the fence and final obligation without a second queue:

Store Takes
sqliteTelegramUpdateStore a bun:sqlite Database or node:sqlite DatabaseSync as it is
postgresTelegramUpdateStore one query(text, parameters) over the client the bot already holds
memoryTelegramUpdateStore nothing — tests, or a bot that accepts losing unhandled updates to a restart
import { postgresTelegramUpdateStore } from 'stitchkit/telegram'

// Bun
postgresTelegramUpdateStore({ query: (text, parameters) => sql.unsafe(text, [...parameters]) })
// Prisma
postgresTelegramUpdateStore({
  query: (text, parameters) => prisma.$queryRawUnsafe(text, ...parameters),
  createTable: false, // the migration below owns the table
})
// pg
postgresTelegramUpdateStore({
  query: (text, parameters) => pool.query(text, [...parameters]).then((result) => result.rows),
})

Each rule is one conditional statement, so a claim is atomic across every process sharing the table. By default the table is created on first use; where migrations own the schema, postgresTelegramUpdateStoreSchema(table) is the statement to put in one. Apply it before using handleAttempt or handleExhaustion; it adds claim_id idempotently. Legacy handle(update) uses base PostgreSQL operations that do not reference that column. SQLite adds the column automatically and tolerates two rolling processes racing the migration. A direct base-store claim with durableExhaustion: true also opts into the column-backed durable capability.

A store of your own — over an ORM, another database — is put through the same rules the shipped ones are, concurrent claims included:

import { checkTelegramUpdateStore } from 'stitchkit/telegram'

test('our update store keeps the rules', async () => {
  expect(await checkTelegramUpdateStore(() => ourStore(freshTable()))).toEqual([])
})

A store that reads and then writes in two statements passes every test with one process and runs one update twice in production; the check races eight claims against each other to catch it.

Files from a local Bot API server

A local telegram-bot-api keeps downloaded files under <dir>/<bot token>/, and a bot sharing that directory reads them from disk and deletes them when done. file_path comes from a server over the network, so it is resolved only inside the bot’s directory — ../, an absolute path elsewhere and a link pointing out are refused, judged on real paths.

import { createTelegramLocalFiles } from 'stitchkit/telegram'

const files = createTelegramLocalFiles({ root: env.BOT_API_FILES_ROOT, token: env.BOT_TOKEN })

const { file_path } = await ctx.getFile()
const path = await files.resolve(file_path)   // throws TelegramLocalFileError
// … process it …
await files.remove(file_path)

The recommended variable for the root is BOT_API_FILES_ROOT — the server’s --dir, absolute, as the bot’s process sees it. A relative file_path (a server without --local) and an absolute one inside the bot’s directory (a server with --local) both resolve. A refusal carries a reason (root-not-absolute, bot-directory-unavailable, outside-bot-directory, missing, not-a-file) and never the path or the token. files.check() answers whether the directory is there to read and delete from; a resource that throws on { ready: false } in start keeps the bot from answering before it can read its media.