Lesson 6 · Telegram Bots on Serverless

Capstone: A New Bot From a Blank Folder

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.

Warm-up (carry lesson 5 forward)

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?

The build order

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.

  1. Two bot accounts, two tokens Lesson 5

    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.

  2. One framework-agnostic bot module — the brain Criterion 2

    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.)

  3. Two thin entrypoints wrapping the one brain Lessons 1 & 4

    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.)

  4. All authoritative state in a database, keyed by Telegram ids Lessons 2 & 3

    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.

  5. A pairing handshake, checked on every other handler Lessons 2 & 3

    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}.)

  6. Registration, gated to production Lesson 5

    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.

  7. Idempotent handlers — assume every update arrives twice Lesson 4

    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.

  8. Match the runtime to your dependencies Repo constraint

    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'.)

The one-sentence version: one framework-agnostic brain, two thin delivery entrypoints (poll for dev, webhook for prod), all told-facts in a DB keyed by Telegram ids, and a production-gated registration handshake. Everything else is your bot's actual features.

Your turn — design one

Capstone task · bring your answers back to chat

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:

  1. Which env vars hold your two tokens, and why can't you reuse one? (step 1)
  2. Name the one module both entrypoints import, and one rule about its imports. (step 2)
  3. What are your two entrypoint files, and which delivery mode does each use? (step 3)
  4. List every piece of state your bot holds, and for each: DB, module-scope cache, or computed-on-read? (step 4)
  5. What does your pairing step store, keyed by what, and what's your authorization check? (step 5)
  6. Where's the gate on registration, and what's the one manual command for first deploy? (step 6)

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.

Two synthesis checks

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?

Mission scorecard

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.

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.