You know the entities (lesson 3) and where their state lives (lesson 2). Now follow a single ▶ tap through every hop it makes — because two facts about that journey (replies travel separately, and failures get retried) quietly dictate half the defensive code in this repo, and will dictate it in every bot you build.
Dev bot and prod bot share one Firestore project. Both would write telegramLinks/{yourUserId} — same key, since private chat ids equal your user id across all bots. When is that sharing fine, and when does it bite?
You tap ▶ on the teach card. Here is everything that happens, in order:
…/api/telegram/webhook/<secret> with that one update as the JSON body
(Marvin's guide).handler already built — otherwise a cold start: new instance, module
code runs from scratch, handler is null again, firebase-admin
re-initializes. Cold adds latency; it changes nothing else (lesson 2: everything
authoritative is in Firestore precisely so this moment is boring).webhookCallback parses the update,
sees a callback query with data teach:next, and routes to your
bot.callbackQuery(TEACH_NEXT, …) handler.telegramLink (who is
this chat?), read sessions/teach-{messageId} (which card, what cursor?), write
the moved cursor back.editMessageText and
answerCallbackQuery are their own HTTPS requests from your function
to api.telegram.org, happening mid-handler. They are not part of the
webhook exchange at all.200 OK
with an empty body. That 200 means exactly one thing to Telegram: “delivered — take it
off the queue.”ctx.reply(…) does not “answer the request” — it opens a brand-new HTTPS call to
Telegram's API while the inbound request is still being handled. Inbound and outbound are two
independent channels that happen to be active at the same moment. (grammY's docs on the
two deployment types draw the same
picture.)
Telegram treats the webhook like any unreliable postal address: if the POST times out or comes
back non-200, the update is not lost — it's redelivered, with growing pauses,
until your bot finally 200s it (or it expires after a long while). You can see the evidence at
any time via getWebhookInfo:
pending_update_count and last_error_message are Telegram telling you
"I'm holding mail you haven't accepted."
Sounds friendly — nothing is dropped! But look at it from your handler's side. A crash after step 6's Firestore write but before step 8's 200 means Telegram sends the same tap again — and your handler runs a second time against state it already mutated. Redelivery turns every webhook handler into code that must tolerate running twice.
That's the "why" behind defensive lines you may have already skimmed past. Three examples, all real:
server/telegram/practice.ts// …anything that doesn't match the authoritative state — a
// stale message, a replayed tap, an out-of-turn gap — is a safe no-op.
A redelivered ans:2:1:0 finds gap 1 already filled in the
PracticeDay and does nothing. The tap is validated against stored truth,
not trusted on arrival — lesson 2's authoritative state doing double duty as a replay shield.
// The delete precondition makes a double-fired completion
// (duplicate webhook delivery) fail outright instead of
// writing Results twice.
The nastiest case — the day-completing tap delivered twice — is answered at the database
level: the completion batch requires the PracticeDay doc to still exist to delete
it, so the second run fails cleanly instead of double-writing your Results. And the 🔁 retry
button re-runs the whole tick safely for the same reason: the tick's gate (one per
UTC day, must-be-completed) makes a duplicate attempt a polite refusal, not a double pipeline.
Two clocks are ticking while your handler runs: Telegram's patience with the POST, and your
platform's function timeout (generous on Vercel — minutes — so Telegram's is the one that
binds). The practical rule from
Telegram's webhook guide: answer
promptly; do heavy work elsewhere. Most of this bot's handlers are a few Firestore reads and
one edit — comfortably fast. The exception is instructive: the 🔁 retry button runs the entire
tick inside a callback handler — several LLM calls, easily many seconds. If that ever
exceeds Telegram's patience, the update gets redelivered mid-tick… and the answer to "isn't
that a disaster?" is the same gate again: the second delivery hits
already-ticked-today (or the in-flight day's lock conditions) and no-ops. The
architecture doesn't try to be fast there — it arranges to be safe while slow.
1 · Your handler calls ctx.reply('✓ added Hund'). Where does that text physically travel?
2 · A handler writes "gap 1 = katze" to Firestore, then crashes before returning 200. What does Telegram do, and what must your code guarantee?
3 · Production suddenly answers every webhook POST with 404 (say, the secret env var was wiped on a redeploy). What do users experience?
Want to go deeper on any hop — what getWebhookInfo shows for YOUR bot right now,
how grammY decides when the handler is "done", or what Vercel's timeout budget really is?
Ask your teacher (me) directly, any time.