Telegram
Build a Chatbot: From API Call to Deployed Bot
Chapter 9 ยท A Telegram Bot
Telegram is the easiest place to put a bot in front of real people: creating a bot takes a minute and costs nothing, there is no app review, and it works from a laptop behind a home router. It is also the first time the bot meets a platform with its own rules: message length limits, flood control, groups where many people share one conversation, and written terms for bot developers. This chapter builds a Telegram adapter (the one place in the code that knows about Telegram), and reads those rules.
Bot behind it, and 26 new tests; the project now has 201 passing tests.
The incoming updates in the tests are written by hand from the fields the Bot API documents, not recorded from Telegram (the outline for this chapter said "recorded updates"; without a token there is nothing to record). Sources I read: Telegram's Bot API reference (but only the first 100,000 of about 580,000 characters were available to me),
the Bots FAQ, the Bots features page, and the Bot Developer Terms. The message-length limit, the typing-indicator duration and the format of retry_after come from library documentation that quotes the official reference, not from the official page itself, and are marked below. Starting the bot for real is described at the end and is not run here.
How a Telegram Bot Works
- Creating one. You talk to Telegram's own bot, @BotFather.
/newbotasks for a name and a username (usernames must end in "bot", per the features page) and returns the token. The token is the bot's whole identity: it appears in every API URL,https://api.telegram.org/bot<token>/METHOD, and anyone who has it can act as your bot. The developer terms say to keep tokens confidential and that anyone authenticating with them is treated as acting for you. - Talking to it. Users start a private chat (the Start button sends
/start; a link of the formhttps://t.me/<bot>?start=<param>passes a parameter along). The features page says/start,/helpand/settingsare the commands all bots are asked to support. Commands are up to 32 characters (Latin letters, digits, underscores); you register the list with BotFather's/setcommandsor thesetMyCommandsmethod so Telegram shows them in a menu. I could not confirm from Telegram's own pages whether a bot can ever message a person who has not started it, so plan on the user starting the conversation. - Groups and privacy mode. A bot in a group is, by default, in privacy mode: it sees commands aimed at it, replies to its own messages and a few other things, but not ordinary chat. Admin bots see everything. Bots never see other bots' messages. Privacy mode is changed with BotFather's
/setprivacy, and the bot has to be re-added to the group for it to apply. The features page also notes that updates do not say which command scope a command came from, so a backend should validate every command. - Getting messages: polling or webhook. Two mutually exclusive ways. With
getUpdates(long polling) your program asks Telegram for new updates, and Telegram holds the request open for up totimeoutseconds if there are none. You confirm updates by passingoffset= the highestupdate_idyou have handled plus one; unreceived updates are kept for no more than 24 hours;getUpdatesdoes not work while a webhook is set. With a webhook (setWebhook) Telegram makes an HTTPS request to your URL for each update, on port 443, 80, 88 or 8443; you can supply asecret_token(1 to 256 characters of letters, digits,_and-) that Telegram sends back in theX-Telegram-Bot-Api-Secret-Tokenheader; and if your server answers outside the 2xx range, Telegram retries a limited number of times.
| Long polling | Webhook | |
|---|---|---|
| Needs a public HTTPS address | No: only outbound connections, works behind a firewall or home router | Yes, with a valid certificate, on one of four ports |
| Good for | Development, small bots, a single process | Production behind a proper web server, many bots, serverless hosts |
| Authenticity | You made the connection, so you know who answered | You must check the secret-token header: anyone can POST to a public URL |
| Delivery | You confirm with offset | Telegram retries on non-2xx, so the same update can arrive twice |
The adapter below supports both; start with polling.
The Design: One Door, Many Rooms
The adapter follows the rule from chapter 1: the core knows nothing about the channel. The adapter turns an update into a call to bot.handle(session_id, text, user_id=...) and turns the reply into messages:
| From the update | Becomes |
|---|---|
chat.id | session telegram:<chat id>: the conversation. In a group this is shared by everyone in the group |
from.id | user telegram:<user id>: notes, strikes, budgets and /deleteme belong to the person |
text | the message. Commands arrive as text starting with /, so chapter 4's commands work unchanged; /cmd@BotName is turned into /cmd |
from.is_bot, chat.type = channel | ignored |
no text (a sticker, a photo) | in a private chat: "I can only read text messages."; in a group: silence |
/start | a welcome that says it is an AI and points at /help and /privacy; no model call |
The Code
A small API client written with httpx (so it is easy to test; the popular libraries python-telegram-bot, aiogram and grammY do this and more), the splitting function, the adapter, the polling loop and the webhook route, in one file:
The Token Is in the URL: Keep It Out of Logs and Errors
Because the token is part of every request URL, anything that prints a URL prints the token. Two defences are in the code and tested. The httpx logger, which logs each request's URL at INFO level, is set to WARNING. And a failed call raises a TelegramError whose text is only the method name and the exception class, built with
from None so that the original exception, whose message can include the URL, is not attached. A test checks that neither the error text nor its cause contains the token, for both a bad HTTP response and an unreachable server.
Splitting Long Replies
A single Telegram message holds at most 4096 characters: the Bot API documents sendMessage text as "1-4096 characters after entities parsing" (I saw this quoted in library documentation, not on the official page I could read). split_message cuts a long reply into pieces that fit, preferring a paragraph break, then a line break, then a space, and cutting hard only if it must.
It counts in UTF-16 code units, where an emoji outside the basic plane counts as two, which is a cautious choice because I do not know which unit Telegram counts in. From Exercise 1, on a 7,103-character text:
Two more things in that output. At limit 1,000 the first cut falls mid-sentence because the paragraph break lay just beyond the limit; splitting at sentence ends would read better and needs more code. And 2,500 emoji are 5,000 UTF-16 units but only 2,500 characters, so they split into 4,096 and 904 units. Between pieces the adapter waits a second, because Telegram's FAQ asks bots to avoid sending more than about one message a second to a single chat.
max_output_chars is 2,000: the bot trimmed the reply (adding an ellipsis) before the Telegram code ever saw it. Splitting only matters if you allow replies longer than that. If you want Telegram's full length, raise the guard
(GuardConfig(max_output_chars=...)), and ask whether you really want 4,000-character answers in a chat.
Groups
In a group the bot must not answer everything, even when it can see everything (it can if it is an admin). The adapter answers only messages addressed to it: a mention of @BotName, a command with the bot's name (/facts@BotName), or a reply to one of its messages. A bare /facts is ignored, because several bots may share the group and the update does not say which one the person meant.
The mention is removed from the text before the model sees it, and the reply is attached to the question with reply_parameters so it is clear what it answers. All eight cases from Exercise 2:
The text sent to the model in the mid-sentence mention case is hey , any news?: removing the mention leaves awkward punctuation. It is harmless for a model and untidy; fixing it is a small exercise.
A privacy consequence to think about. The session is per chat, so everyone in a group shares one conversation history, while remembered notes stay per person. The model can see what one member said earlier when answering another. For a public group bot that is probably acceptable; for anything sensitive it is not, and the safer design is to give each member their own session even inside a group.
Typing, Pacing and Flood Control
- Typing indicator. While the reply is being prepared the adapter sends
sendChatActionwithtypingevery 4 seconds. Library documentation describes the status as lasting 5 seconds or less, which is why it is repeated. The test checks that actions are sent while a slow provider works (at least three in 0.1 s with a short interval) and that they stop once the reply is sent. - Flood control. When Telegram says you are sending too fast, the failed response carries
parameters.retry_after, the number of seconds to wait (again documented in the library references I could read). The adapter waits exactly that long and retries, up to three attempts, then logssend_failedand moves on; it never crashes the bot. Exercise 3 runs this. - Duplicates. Webhook delivery is retried when your server answers late or with an error, so the adapter remembers the last 1,000
update_idvalues and ignores repeats. - Different chats at the same time. Updates are handled in background tasks, so a slow reply to one person does not hold up another (the test shows two 0.1 s replies finishing in under 0.18 s). Within one chat the bot's per-session lock keeps messages in order.
- One bad update never stops the bot. Each update runs inside a handler that records the exception class and carries on (tested with a deliberately malformed update).
An End-to-End Run
To see everything working together I wrote a small fake Telegram server (a few dozen lines using the standard library) that hands out three batches of updates and records every API call it receives. Then I ran the real polling loop against it with the real client, the real adapter and the real bot (a fake model underneath). The output is the server's view:
Read it as a story. The bot asks who it is (getMe) and polls with no offset. The first batch holds updates 100 and 101, so the next poll asks for offset 102: that is the confirmation. The private chat gets the welcome and then the answer. The second batch is a note to remember and a request for a "full history" from a different user (chat 43), whose long reply is split into three messages. In the third batch,
the group chatter (update 104) is ignored (the adapter's event log at the bottom records ignored:not_addressed), and the mention in update 105 is answered as a reply to message 105. Notice that chat -500's answer appears between chat 43's pieces: different chats really are handled concurrently. After that the poller keeps asking, finding nothing, until stopped.
Telegram's Rules for Bot Developers
The Telegram Bot Developer Terms are short and specific. I read them in full for this chapter (the page shows no effective date; changes take effect when posted, so re-read them before you launch). The parts that touch this bot, and where the bot stands (this is my reading, not legal advice):
| What the terms say (section) | This bot |
|---|---|
| Collect and store only the data needed to operate the bot (4) | Meets the intent: flagged messages are not stored, notes only on request, retention of 30 days |
| Every bot needs an easily accessible privacy policy covering what is stored, how it is collected and why; otherwise Telegram's standard policy for bots applies, and you must set your own in @BotFather if that does not fit (4) | The bot has a /privacy notice. You still must check whether Telegram's standard policy fits and, if not, publish your own and register it with BotFather |
| Delete user data without undue delay when a user asks, when you no longer need it, or when you stop running the bot (4.2) | /deleteme and the retention purge cover the first two. Deleting everything when you shut the bot down is on you, and so are backups |
| Encrypt user data at rest and store it apart from its key; alert users to a data breach as the law requires (4.4) | Not met. The SQLite file is plain. To comply you would encrypt the disk or volume (or use an encrypted database build) and keep keys elsewhere. I have not implemented it; chapter 12 returns to it |
| No data collection aimed at building large datasets or training AI; you may use data users submit to the bot only with a clear statement of the intended use and individual, explicit, active, revocable consent (4.3) | The bot stores chats to answer the next message and for nothing else. Do not feed the conversations into training data or evaluation sets without that consent |
| You are responsible for all content the bot sends, including moderation of user-generated content (5.1); no harassing or spamming unsolicited messages (5.2) | Chapters 5 and 6 are the answer; the bot only replies to people who wrote to it |
| Behave predictably; do not misrepresent your services; tell users about significant changes (5.2) | The welcome and the first-reply disclosure say it is an AI |
| Keep credentials such as tokens confidential (4.5) | The token comes from an environment variable, is never logged and never appears in errors |
The terms do not contain an explicit "you must say you are a bot" rule (the nearest are the bans on impersonation and on misrepresenting your services), but Anthropic's usage policy, discussed in chapter 6, asks consumer chatbots to disclose that they are AI, so the bot does.
Running It for Real (not run here)
- In Telegram open @BotFather, send
/newbot, choose a name and a username ending in "bot", and copy the token. Treat it like a password: put it in an environment variable, never in code or a repository. (If it ever leaks, ask BotFather for a new token, which should make the old one stop working; check its menu for how.) - Optionally send BotFather
/setcommandswith your command list, and/setprivacyif you want the bot to see all group messages. - Set the environment variables and start the script. With
CHATBOT_PROVIDER=fakeit needs no model account at all, which is the right first test. Without the token it refuses to start, which I did run: it prints "Set TELEGRAM_BOT_TOKEN to the token BotFather gave you" and exits with code 1.
Open your bot in Telegram, press Start, and watch the terminal. Check, in order: the welcome appears; a plain question is answered; /remember and /facts work; a long answer arrives in several messages; the typing indicator shows on a slow model; and /deleteme empties your data.
- My helper's parameter clashed with Telegram's.
TelegramAPI.call()took atimeoutargument for the HTTP timeout, butgetUpdateshas its owntimeoutparameter (the long-poll seconds), so building the request failed with "multiple values for keyword argument 'timeout'". A test caught it before any real call. I renamed minehttp_timeout. The takeaway: when a wrapper forwards**paramsto an API, give your own options names the API cannot use. - The earlier guard truncated long replies (explained above); a test with a realistic long reply exposed it. Limits set in different chapters interact.
- "Recorded updates" were never recorded. The plan promised updates recorded from Telegram. Without a token I could only write them from the documentation, so they test that the adapter understands the documented shapes, not that Telegram sends exactly those. When you have a token, capture a few real updates (with personal details removed) and add them to the tests.
What This Chapter Leaves Out
- Formatting. Replies are plain text. Telegram supports MarkdownV2 and HTML in messages, but both require escaping special characters, and one unescaped character makes a message fail; plain text avoids a class of bugs.
- Streaming replies. Telegram has no incremental typing of one message, short of editing it repeatedly (which runs into the same rate limits), so the adapter sends complete replies.
- Files, photos, voice, inline mode, buttons. Text only.
- Webhook operation (setting it, certificates, the proxy in front) belongs to deployment, chapter 12. The route and its secret check are built and tested, but not exercised against Telegram.
- Several processes. Polling with two copies of the bot running at once does not work (Telegram delivers each update to one poller), and the duplicate-update memory is in one process.
Hands-On Exercises
Use split_message on a 7,000-character text at limits of 4096, 1000 and 300. Print the number and sizes of the chunks, show where the first cut falls and that nothing was lost, and split 2,500 emoji at 4096. Explain the emoji result.
Build eight group messages (plain chat, a mention, a mention in another case, a bare command, a command with the bot's name, a reply to the bot, a mention of a different bot, a message from another bot) and print which ones the adapter answers and what text reaches the model. Which rule would you change, and what is the risk?
๐ View solutionMake the send call fail twice with flood control (retry_after of 3 and then 6 seconds) and show the adapter waiting and then succeeding; then redeliver the same update_id and show it is ignored; finally fail three times in a row and show the bot gives up cleanly without crashing.
Chapter 9 Quick Reference
- BotFather:
/newbot(username ends in "bot") gives the token;/setcommands,/setprivacy,/setjoingroups. The token is in every URL: keep it out of code, logs and error text - API:
POST https://api.telegram.org/bot<token>/METHOD, JSON in; reply{ok, result}or{ok: false, error_code, description, parameters.retry_after} - Polling:
getUpdates(offset, timeout, allowed_updates), offset = lastupdate_id+ 1 confirms; not usable while a webhook is set; unreceived updates kept 24 h. Webhook: HTTPS on 443/80/88/8443, checkX-Telegram-Bot-Api-Secret-Token, answer fast, expect redelivery - Session =
telegram:<chat id>, user =telegram:<user id>; ignore bots and channels;/startgets a welcome; strip/cmd@BotName - Groups: answer only mentions,
/cmd@BotNameand replies to the bot; the group shares one conversation - Replies: plain text; split at 4096 (counted cautiously in UTF-16 units) on paragraph, line, word boundaries; about one message a second per chat; on flood control wait
retry_afterand retry; typing action every 4 s - Developer terms: privacy policy, delete on request or when you stop, encrypt at rest (not done here), no dataset-building or AI training without explicit revocable consent, responsible for your bot's content
- Limits interact: the output guard's 2,000-character default shortens replies before Telegram's 4,096 matters