Five lessons, one payoff. This is the mission itself — building a fresh Telegram bot on Vercel without reverse-engineering w2tAgent again. Not new theory: a build order. Each step is a decision you now know how to make, tagged with the lesson it came from, with its sane default and the trap it avoids. By the end you'll have designed one on paper — and self-scored against everything you set out to learn.
Your brand-new bot's code is deployed to production. The webhook route exists, the env vars are set, the auto-register plugin is in place. You message the bot. Silence. What's the single most likely missing move — and why doesn't the auto-register plugin cover it?
This is the whole pattern, in the order you'd actually lay it down. Read it as the reusable checklist your mission asked for — the reference card is the same list, printable, for when you're not reading a lesson.
Default: create a prod bot and a dev bot in
BotFather. Store as
TELEGRAM_BOT_TOKEN and TELEGRAM_DEV_BOT_TOKEN.
Why first, why two: delivery mode is exclusive per token — a registered
webhook makes getUpdates 409. You physically cannot long-poll for dev while a
webhook owns the same token. A second account is the only clean way to run both modes at
once. Deciding this up front shapes every env var below.
Default: createBot(token) that registers every
command / message / callback handler and returns the bot. Explicit relative imports
only — no framework auto-imports — so both entrypoints below can import it unchanged.
Trap: writing handler logic directly inside the webhook route. Then dev
can't reuse it and you maintain two copies. The brain must not know how it's being delivered.
(Repo: server/telegram/bot.ts.)
Default — dev: a standalone script that calls
bot.start() (long polling) with the dev token.
Prod: an HTTP route at an unguessable path that hands the request to
webhookCallback(bot, 'std/http').
The shape to keep: each entrypoint is a few lines — a delivery adapter,
not logic. Swap Vercel for anything else later by rewriting only these.
(Repo: scripts/bot-dev.ts, server/api/telegram/webhook/[secret].post.ts.)
Default: identity keyed by chat.id; per-card
UI state keyed by message_id. Module-scope memory holds only config-derived,
rebuildable caches — never anything a user told you.
Trap: a Map of sessions in module scope. It survives a warm
invocation and vanishes on the next cold start — an intermittent bug that passes every local
test. If it came from a user, it goes in the DB. Derived values (ages, streaks, counts):
compute on read, store nothing.
Default: /start + an authorization check
(owner id, or an allowlist) writes a link doc: chatId → userId. Every other
handler resolves that link first and rejects unpaired chats.
Why: bots are public and have no login screen — pairing is your only
translation from Telegram's address to your app's identity, and the only thing standing
between a stranger and your handlers. (Repo: telegramLinks/{chatId}.)
Default: auto-register on boot, wrapped in
if (env.vercelEnv !== 'production') return; plus a manual
set-webhook script for the first deploy. Secret lives in env, carried in the URL
path (or use secret_token — pick one on purpose).
Trap: registering unconditionally. Every throwaway preview deploy would
then overwrite production's single registration and steal its updates. The
VERCEL_ENV gate is the safety story.
Default: validate each tap/command against stored truth before acting (stale or replayed → no-op); guard one-time writes with a precondition (e.g. a delete-precondition) so a redelivery can't double-commit.
Why: a slow 200 makes Telegram redeliver. "It worked in testing" is no defense — redelivery is a production-only event. Design for at-least-once from day one.
Default: if any dependency needs native Node APIs
(firebase-admin does), force the Node runtime, not Edge. If your bot is
pure-fetch, Edge is fine.
Trap: assuming serverless = Edge. The choice is dictated by your heaviest
dependency, not by fashion. (Repo forces Node via nitro.preset: 'vercel'.)
Pick a bot you'd actually want — say, a standup bot that DMs each team member every morning and collects a one-line reply (or invent your own). Without writing code, answer these six, each tracing to a step above:
Send me your six answers and I'll pressure-test them the way we've tested every lesson —
hunting for the module-scope Map, the ungated registration, the reused token.
1 · A teammate proposes: "One bot account. Dev long-polls it; before each dev
session we deleteWebhook, and we re-run set-webhook when we're done."
What's the fatal flaw?
2 · Your standup bot caches "who has replied today" in a module-scope
Set to avoid a DB read. It's flawless in dev and in every test. In production,
some people get nagged twice and some never get counted. One root cause fits every symptom —
which?
The five things you set out to be able to do. If any line doesn't yet feel like "from memory, at a whiteboard", that's the next thing to drill — tell me which.
setWebhook handshake without clobbering prod from previews — L5, step 6
Where next? Two natural directions: build it for real — scaffold the standup
bot and I review each file against the checklist as you go; or go deeper on one seam
— grammY sessions/middleware, the secret_token header path, or Firestore data
modeling for bot state. Say the word.