Lesson 1 · Telegram Bots on Serverless

How Telegram Actually Delivers Messages to Your Code

Telegram has your user's message. Your code needs it. There are exactly two ways to bridge that gap — and which one you use isn't a style preference, it's forced by where your code runs. This lesson uses the real files from w2tAgent's Telegram bot to show both.

The core problem

Telegram's servers hold every update — messages, button taps, everything — in a queue tied to your bot's token. Your job is to get those updates out of that queue and into your handler code. Telegram gives you two APIs for this, and a working bot always uses exactly one of them at a time.

Option one: long polling

Your code repeatedly calls Telegram's getUpdates method and asks "anything new?". Telegram holds the connection open — it doesn't answer immediately — and responds the instant something arrives, or after a timeout if nothing did. The moment you get a response, you ask again. This requires something that keeps running and keeps asking, forever.

your process→ "anything new?" →Telegram
your process← (waits… then) an update →Telegram
your process→ "anything new?" (again, immediately) →Telegram

In this repo, that loop is exactly what scripts/bot-dev.ts starts:

scripts/bot-dev.ts
const bot = createBot(env.telegramDevBotToken)

bot.start({
  onStart: () => console.log('Dev bot long-polling started. Ctrl+C to stop.'),
})

bot.start() is grammY's long-polling loop — it never returns while the process is alive. That's why you run it with npm run bot:dev in its own terminal window: it needs a process that just sits there.

Option two: webhooks

You tell Telegram, once, "here is a URL — push updates to it the moment they happen," via a call to setWebhook. From then on, Telegram makes an HTTPS POST request to that URL every time your bot has something new. Nothing needs to keep running between updates — your code only has to exist at the moment a request actually arrives.

Telegram→ HTTPS POST update →your webhook URL
your webhook URL→ 200 OK →Telegram
(nothing happens until the next update)

In production, that URL is a Nitro server route:

server/api/telegram/webhook/[secret].post.ts
export default defineEventHandler(async (event) => {
  const secret = getRouterParam(event, 'secret')
  if (!env.telegramWebhookSecret || secret !== env.telegramWebhookSecret) {
    throw createError({ statusCode: 404, statusMessage: 'Not found' })
  }
  if (!handler) {
    handler = webhookCallback(createBot(env.telegramBotToken), 'std/http')
  }
  return handler(toWebRequest(event))
})

webhookCallback is grammY's adapter — it takes the incoming web Request, feeds it to the bot as an update, and turns the bot's reaction into a Response. No polling loop anywhere; this function only does anything when Telegram calls it.

Why Vercel forces this choice

A Vercel serverless function is not a program that stays running. It wakes up to handle one request, sends a response, and is free to disappear afterward. That's structurally incompatible with long polling, which needs an unbroken loop holding a connection open indefinitely — there is no "keep running in the background" on a platform built around functions that start, respond, and stop.

The rule of thumb: if your bot's backend is a serverless function (Vercel, AWS Lambda, Cloudflare Workers…), you use a webhook in production. Long polling only works where you control an always-on process — a VPS, a container, or your own laptop during development.

That's exactly the split in this repo: bot-dev.ts long-polls from your machine because you, a human, are running npm run bot:dev and it stays open until you press Ctrl+C. The webhook route runs on Vercel because Vercel functions can't do the other thing.

Same brain, two bodies

The part worth internalizing — and reusing in your next bot — isn't the delivery mechanism itself, it's how w2tAgent keeps the two mechanisms from duplicating any logic. All the actual behavior (what /start does, what a bare text message means, who's allowed to talk to the bot) lives in one function, createBot, that knows nothing about HOW an update arrived:

server/telegram/bot.ts
export function createBot(token: string): Bot {
  const bot = new Bot(token)
  bot.command('start', async (ctx) => { /* … */ })
  bot.command('backlog', async (ctx) => { /* … */ })
  bot.on('message:text', async (ctx) => { /* … */ })
  return bot
}

bot-dev.ts and the webhook route are just two different adapters plugged into that same brain — one feeds it updates via long polling, the other via HTTP requests. If you ever find yourself writing bot logic twice, once "for local" and once "for prod," that's the signal to pull it out into a shared createBot-style function like this one.

The registration handshake

Telegram allows exactly one webhook URL per bot token at a time. Registering a new one silently replaces the old one — which is dangerous on a platform like Vercel where every preview deploy gets its own throwaway URL. If a preview deploy registered itself as the webhook, it would steal every incoming message from production the moment someone opened that preview.

w2tAgent guards against this with an explicit environment check:

server/plugins/register-webhook.ts
export default defineNitroPlugin(async () => {
  if (env.vercelEnv !== 'production') return
  // …call bot.api.setWebhook(url) only when this really is production…
})

Vercel sets VERCEL_ENV to production, preview, or development automatically — no configuration needed. There's also a manual escape hatch, npm run set-webhook, for the moments you need to (re)point the webhook by hand rather than waiting for a fresh deploy to trigger it. setWebhook is idempotent: calling it again with the same URL is a harmless no-op, so there's no danger in re-running it.

Quick check — you're building a brand new Telegram bot on AWS Lambda (also serverless, like Vercel). What should handle production delivery?

You just deployed a preview branch of a bot to Vercel for review. What should not happen?

Something here unclear, or want to go deeper on a piece — grammY's middleware system, how webhookCallback actually converts a request, or why state can't live in the function's memory? Just ask your teacher (me) directly, any time.