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
uncertainand 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
unreachableand never addressed again. A 429 waits Telegram’sretry_afterand sends the same recipient again; a server error is retried with backoff up tomaxAttempts, thenfailed. 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 becomesuncertainimmediately and halts the run; resume never sends to that recipient again. - Pacing is
ratePerSecond, 25 by default.dryRuncounts 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.