Lesson 3 · Telegram Bots on Serverless

The Nouns of a Telegram Bot: Chats, Messages, Cards, and Their IDs

Lessons 1 and 2 used words like chat id, message id, and card in passing. This lesson stops and defines every noun properly, then draws the map of how they point at each other — because a bot's entire architecture is really just app state hung off Telegram's ids. There's now a glossary for these terms; from here on, every lesson sticks to it.

Warm-up (30 seconds, from lesson 2)

The teach card's cursor position — which of the three kinds of state is it?

The bot: an account, whose name is a password

A bot is just an account on Telegram — a peer that can be in chats, send and receive messages — except a program drives it instead of a phone. You create one by talking to BotFather (Telegram's bot for making bots), and what you get back is a token: one string, something like 7213...:AAH9f..., that is simultaneously the bot's identity and its password. Every API call your code makes embeds this token — that's the entire authentication story, and it's why the token lives in env and never in code.

Note what this means for this repo: TELEGRAM_BOT_TOKEN and TELEGRAM_DEV_BOT_TOKEN are two different bots — two separate Telegram accounts with separate usernames, chats, and update queues. Same brain (createBot), two bodies. That's also why dev long-polling can run while prod's webhook stays registered: each bot has its own queue, so they never fight.

The chat and its id: an address, not a person

A chat is one conversation — you-and-the-bot, a group, a channel. Telegram gives every chat a permanent numeric chat.id, and that number is the bot's postal address for it: sending anything means sendMessage(chatId, …).

A subtlety worth engraving: the chat id identifies the conversation, not the person — even though in a private 1-on-1 chat Telegram deliberately sets the chat id equal to the other party's user id. That's true across all bots: your private chat with the dev bot and with the prod bot carry the same number — yours. What keeps them apart is the token the API call is made with, so the full address of a private conversation is really (bot token, chat id). The two concepts still diverge the moment a group is involved: a group chat has its own (negative) id shared by all members, and the same human talking to your bot privately and in a group shows up under two different chat ids. w2tAgent only ever deals with one private chat, so pairing is a single link: telegramLinks/{chatId} → userId — Telegram's address on one side, your app's identity on the other. That translation is the whole point of the pairing table.

The message and its id: unique only inside its chat

A message is one bubble in a chat. Every message gets a message_id — but here is the trap: message ids are unique only within their chat, they're just a counter that increments per conversation. Message #1042 exists in millions of chats at once. So the true, global address of any bubble on Telegram is the pair (chat_id, message_id) — which is exactly the signature of the edit API: editMessageText(chat_id, message_id, new_text).

Two more facts you'll lean on constantly: a bot can edit its own messages (not the user's), and the id of a message the bot just sent comes back in the API response — you don't invent ids, Telegram assigns them and tells you:

server/telegram/teach.ts
const message = await api.sendMessage(link.chatId, text, { … })
const session: TeachSession = {
  chatId: link.chatId,
  activeMessageId: message.message_id,   // ← Telegram just assigned this
  cursor: 0,
  …
}

The inline keyboard: buttons that belong to a message

An inline keyboard is a grid of buttons attached to one specific message — part of the bubble itself, not of the chat. Each button carries a hidden string, callback_data, at most 64 bytes. Lesson 2's rule follows directly: the button carries a verb (teach:next) or coordinates (ans:2:1:0), never state.

The callback query: a tap, as an event

When a user taps an inline button, no message is sent. Instead Telegram creates a callback query — a little event object that answers the three questions your handler needs:

What was tapped? → the button's callback_data.
Where was it tapped? → the full message the keyboard was attached to, including its message_id.
Who tapped? → the tapping user.

That middle answer is the linchpin of this repo's whole session design. The tap arrives already knowing which bubble it happened on — so a verb-only button plus the tapped message's id is enough to find the right state:

server/telegram/teach.ts
const messageId = ctx.callbackQuery?.message?.message_id
const session = await getTeachSessionByMessage(scope, messageId)
// … mutate cursor, save, then re-render THAT bubble:
await ctx.editMessageText(text, { reply_markup: keyboard })

One duty comes with every callback query: the client shows a spinner until the bot calls answerCallbackQuery, so the handler must always answer — even for stale buttons and unauthorized chats (that's the onCallback wrapper in server/telegram/bot.ts).

The update: the envelope around everything

Everything above — a text message arriving, a button tapped — reaches your code wrapped in an update: Telegram's delivery envelope, with a sequence number (update_id) and exactly one payload inside (either a message or a callback query or …). Updates are what lesson 1 was about moving: the webhook POSTs one update per request; long polling pulls them in batches. grammY opens the envelope and routes the payload to bot.on('message:text') or bot.callbackQuery(…) for you.

The card: not a Telegram thing at all

Now the word we've been using loosely. Telegram has no "cards" — it has messages. A card is an application pattern built from three entities you now know:

card = one bot message + an inline keyboard + a DB session doc keyed by that message's id, redrawn in place with editMessageText on every tap. The message is the screen, the keyboard is the controls, the session doc is the program state — a tiny app living inside one chat bubble.

That's why w2tAgent's teach sessions are named teach-{messageId}: one screen, one state doc, joined by the id Telegram assigned at sendMessage time. Every past day's card keeps working forever because each bubble can find its own state.

The whole map

Telegram's world your app's world (Firestore) ──────────────────────────────────── ───────────────────────────── Bot ──(token in env)── your code │ ├── Chat (chat.id 42…) ◄────────────── telegramLinks/{chatId} → userId │ │ (pairing: address → identity) │ └── Message (message_id, per-chat counter) │ │ ◄────── sessions/teach-{messageId} │ │ (the card's state doc) │ └── InlineKeyboard │ └── button (callback_data ≤ 64 B, verbs only) │ │ │ tap │ ▼ └── CallbackQuery { data, message ─► message_id, from } │ wrapped in an Update ──► webhook POST ──► your handler

Read the arrows on the right and you'll see the design rule of the last lesson again from a new angle: every piece of app state is keyed by a Telegram id. Identity state hangs off the chat id; per-card UI state hangs off the message id. When you build your next bot, choosing those keys is designing the bot.

Check the map

1 · Your bot logged "message_id: 1042" yesterday and you want to edit that message now. Is the id enough?

2 · A user taps ▶ on a teach card from four days ago. How does the bot know to move THAT card's cursor and not today's?

3 · Same word, both bots: you send "Hund" to the dev bot and to the prod bot. How many chats, and how do their chat ids compare?

Anything fuzzy — group chats and negative ids, what else rides in an update, how grammY's ctx maps onto these objects? Ask your teacher (me) directly, any time.