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.
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.
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.
In this repo, that loop is exactly what scripts/bot-dev.ts starts:
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.
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.
In production, that URL is a Nitro server route:
server/api/telegram/webhook/[secret].post.tsexport 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.
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.
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.
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:
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.
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.tsexport 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.