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.

What was run, and what was not
The bot was never connected to Telegram: there is no bot token on this machine. What ran: the real Telegram API client and the real polling loop talking over real HTTP to a small fake Telegram server I wrote, the real 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. /newbot asks 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 form https://t.me/<bot>?start=<param> passes a parameter along). The features page says /start, /help and /settings are 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 /setcommands or the setMyCommands method 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 to timeout seconds if there are none. You confirm updates by passing offset = the highest update_id you have handled plus one; unreceived updates are kept for no more than 24 hours; getUpdates does 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 a secret_token (1 to 256 characters of letters, digits, _ and -) that Telegram sends back in the X-Telegram-Bot-Api-Secret-Token header; and if your server answers outside the 2xx range, Telegram retries a limited number of times.
Long pollingWebhook
Needs a public HTTPS addressNo: only outbound connections, works behind a firewall or home routerYes, with a valid certificate, on one of four ports
Good forDevelopment, small bots, a single processProduction behind a proper web server, many bots, serverless hosts
AuthenticityYou made the connection, so you know who answeredYou must check the secret-token header: anyone can POST to a public URL
DeliveryYou confirm with offsetTelegram 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 updateBecomes
chat.idsession telegram:<chat id>: the conversation. In a group this is shared by everyone in the group
from.iduser telegram:<user id>: notes, strikes, budgets and /deleteme belong to the person
textthe 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 = channelignored
no text (a sticker, a photo)in a private chat: "I can only read text messages."; in a group: silence
/starta 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:

import asyncio import hmac import logging import re from collections import deque import httpx from bot.core import Bot from bot.providers import ProviderError # The bot token is part of every Telegram API URL, and httpx logs request URLs at INFO level. # Keep that logger quiet so the token never reaches a log file. logging.getLogger("httpx").setLevel(logging.WARNING) MESSAGE_LIMIT = 4096 # Bot API: sendMessage text is 1-4096 characters after entity parsing (as quoted by library docs) WELCOME = ("Hello! I'm an AI assistant, not a person. Ask me a question, or type /help to see what I can do " "and /privacy to see what I store.") class TelegramError(Exception): """A failed Bot API call. retry_after (seconds) is set when Telegram says to slow down.""" def __init__(self, description: str, *, error_code: int | None = None, retry_after: float | None = None) -> None: super().__init__(description) self.error_code = error_code self.retry_after = retry_after class TelegramAPI: """A small client for the Telegram Bot API: https://api.telegram.org/bot<token>/<METHOD>, JSON in and out.""" def __init__(self, token: str, *, base_url: str = "https://api.telegram.org", timeout: float = 15.0, transport: httpx.AsyncBaseTransport | None = None) -> None: self.timeout = timeout self._client = httpx.AsyncClient(base_url=f"{base_url}/bot{token}/", timeout=timeout, transport=transport) async def call(self, method: str, http_timeout: float | None = None, **params): try: response = await self._client.post(method, json=params, timeout=http_timeout or self.timeout) except httpx.HTTPError as e: # deliberately NOT chaining the original error: its text can contain the URL, and the URL contains the token raise TelegramError(f"{method}: {type(e).__name__}") from None try: body = response.json() except ValueError: raise TelegramError(f"{method}: HTTP {response.status_code} with a non-JSON body", error_code=response.status_code) from None if not body.get("ok"): retry_after = (body.get("parameters") or {}).get("retry_after") raise TelegramError(body.get("description", "unknown error"), error_code=body.get("error_code"), retry_after=float(retry_after) if retry_after is not None else None) return body["result"] async def get_me(self): return await self.call("getMe") async def get_updates(self, offset: int | None, poll_seconds: int = 30): """Long polling: Telegram holds the request open for up to poll_seconds waiting for news.""" params = {"timeout": poll_seconds, "allowed_updates": ["message"]} if offset is not None: params["offset"] = offset # one more than the highest update_id already handled: this confirms the earlier ones return await self.call("getUpdates", http_timeout=poll_seconds + 10, **params) async def send_message(self, chat_id: int, text: str, reply_to: int | None = None): params = {"chat_id": chat_id, "text": text} if reply_to is not None: params["reply_parameters"] = {"message_id": reply_to, "allow_sending_without_reply": True} return await self.call("sendMessage", **params) async def send_chat_action(self, chat_id: int, action: str = "typing"): return await self.call("sendChatAction", chat_id=chat_id, action=action) async def set_webhook(self, url: str, secret_token: str): return await self.call("setWebhook", url=url, secret_token=secret_token, allowed_updates=["message"]) async def delete_webhook(self, drop_pending_updates: bool = False): return await self.call("deleteWebhook", drop_pending_updates=drop_pending_updates) async def aclose(self) -> None: await self._client.aclose() def utf16_len(text: str) -> int: """Length in UTF-16 code units: emoji outside the basic plane count as two. A cautious way to measure a limit.""" return len(text.encode("utf-16-le")) // 2 def split_message(text: str, limit: int = MESSAGE_LIMIT) -> list[str]: """Cut a long reply into pieces that each fit the limit, preferring paragraph, line, then word boundaries.""" rest = text.strip() chunks: list[str] = [] while utf16_len(rest) > limit: fit = len(rest) while utf16_len(rest[:fit]) > limit: # the longest prefix that fits fit -= max(1, (utf16_len(rest[:fit]) - limit) // 2) window = rest[:fit] cut = fit for separator in ("\n\n", "\n", " "): at = window.rfind(separator) if at > fit // 2: # not so early that the chunk would be tiny cut = at break chunks.append(rest[:cut].rstrip()) rest = rest[cut:].lstrip() if rest: chunks.append(rest) return chunks class TelegramAdapter: """Turns Telegram updates into Bot calls and the Bot's replies into Telegram messages. The core knows nothing about Telegram; this class is the only place that does. Sessions are per chat (`telegram:<chat id>`) and users per sender (`telegram:<user id>`).""" def __init__(self, bot: Bot, api: TelegramAPI, *, username: str | None = None, bot_id: int | None = None, welcome: str = WELCOME, typing_every: float = 4.0, send_gap: float = 1.0, sleep=asyncio.sleep) -> None: self.bot, self.api = bot, api self.username, self.bot_id = username, bot_id self.welcome = welcome self.typing_every, self.send_gap, self._sleep = typing_every, send_gap, sleep self.events: list[str] = [] self._seen: deque[int] = deque(maxlen=1000) self._tasks: set[asyncio.Task] = set() async def start(self) -> None: """Ask Telegram who we are: the username is needed to recognise messages addressed to us in groups.""" me = await self.api.get_me() self.username, self.bot_id = me["username"], me["id"] # --- entry points --------------------------------------------------------------------------------- def dispatch(self, update: dict) -> None: """Handle an update in the background, so the caller (the poller, or the webhook) is not kept waiting.""" task = asyncio.create_task(self.safe_handle(update)) self._tasks.add(task) task.add_done_callback(self._tasks.discard) async def drain(self) -> None: while self._tasks: await asyncio.gather(*list(self._tasks)) async def safe_handle(self, update: dict) -> None: try: await self.handle_update(update) except Exception as e: # noqa: BLE001 - one bad update must never stop the bot self.events.append(f"update_error:{type(e).__name__}") # --- the logic ---------------------------------------------------------------------------------------- async def handle_update(self, update: dict) -> None: update_id = update.get("update_id") if update_id in self._seen: # Telegram redelivers when a webhook answers late or with an error self.events.append("duplicate_update") return if update_id is not None: self._seen.append(update_id) message = update.get("message") if not message: self.events.append("ignored:not_a_message") return sender, chat = message.get("from") or {}, message["chat"] if sender.get("is_bot") or chat["type"] == "channel": self.events.append("ignored:bot_or_channel") return private = chat["type"] == "private" text = message.get("text") if text is None: if private: await self._send(chat["id"], "I can only read text messages.", None) self.events.append("ignored:no_text") return if not private: text = self._addressed_to_us(message, text) if text is None: self.events.append("ignored:not_addressed") return elif text.startswith("/") and self.username: text = re.sub(rf"^(/\w+)@{re.escape(self.username)}\b", r"\1", text, flags=re.I) if (text.split() or [""])[0].lower() == "/start": # Telegram's "Start" button sends /start (possibly with a deep-link payload) await self._send(chat["id"], self.welcome, None if private else message["message_id"]) return reply_to = None if private else message["message_id"] typing = asyncio.create_task(self._typing(chat["id"])) try: answer = await self.bot.handle(f"telegram:{chat['id']}", text, user_id=f"telegram:{sender.get('id', chat['id'])}") except ProviderError as e: self.events.append(f"provider_error:{type(e).__name__}") answer = self.bot.error_reply finally: typing.cancel() await asyncio.gather(typing, return_exceptions=True) for number, piece in enumerate(split_message(answer)): if number: await self._sleep(self.send_gap) # Telegram asks bots to stay under about one message a second per chat await self._send(chat["id"], piece, reply_to if number == 0 else None) def _addressed_to_us(self, message: dict, text: str) -> str | None: """In a group, answer only commands aimed at us, mentions of us, and replies to us. Returns the cleaned text or None.""" if not self.username: return None tag = f"@{self.username}" replied_to_us = ((message.get("reply_to_message") or {}).get("from") or {}).get("id") == self.bot_id mentioned = tag.lower() in text.lower() if not (mentioned or replied_to_us): return None cleaned = re.sub(re.escape(tag), "", text, flags=re.I).strip() return cleaned or None async def _typing(self, chat_id: int) -> None: """Show 'typing...' while the reply is being prepared. Telegram clears it after about five seconds, so repeat it.""" while True: try: await self.api.send_chat_action(chat_id, "typing") except TelegramError: pass await self._sleep(self.typing_every) async def _send(self, chat_id: int, text: str, reply_to: int | None) -> None: for attempt in range(1, 4): try: await self.api.send_message(chat_id, text, reply_to) return except TelegramError as e: if e.retry_after is None or attempt == 3: self.events.append(f"send_failed:{e.error_code}") return await self._sleep(e.retry_after) # flood control: wait exactly as long as Telegram says async def run_polling(adapter: TelegramAdapter, api: TelegramAPI, *, poll_seconds: int = 30, stop: asyncio.Event | None = None, sleep=asyncio.sleep, max_backoff: float = 30.0) -> None: """Long polling: ask Telegram for updates in a loop. Outbound connections only, so it works behind a firewall.""" await adapter.start() offset: int | None = None failures = 0 while not (stop and stop.is_set()): try: updates = await api.get_updates(offset, poll_seconds) except TelegramError as e: failures += 1 adapter.events.append(f"poll_error:{e.error_code}") await sleep(e.retry_after or min(max_backoff, 2 ** failures)) continue failures = 0 for update in updates: offset = update["update_id"] + 1 # confirms this update (and all before it) on the next call adapter.dispatch(update) await adapter.drain() def add_webhook_route(app, adapter: TelegramAdapter, *, secret_token: str, path: str = "/telegram/webhook") -> None: """Webhook mode: Telegram POSTs updates to us. The secret token header proves the request is from Telegram.""" from fastapi import Request from fastapi.responses import JSONResponse @app.post(path) async def webhook(request: Request): given = request.headers.get("x-telegram-bot-api-secret-token", "") if not hmac.compare_digest(given, secret_token): return JSONResponse({"detail": "forbidden"}, status_code=403) adapter.dispatch(await request.json()) return {"ok": True} # answer at once; the reply is sent in the background

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:

whole text: 7103 characters, 7103 UTF-16 units limit 4096: 2 chunks, sizes [4057, 3042], largest 4057 limit 1000: 8 chunks, sizes [997, 994, 999, 998, 993, 998, 997, 119], largest 999 limit 300: 25 chunks, sizes [297, 299, 299, 294, 299, 299, 234, 297, 299, 299, 294, 299, 299, 234, 297, 299, 299, 294, 299, 299, 234, 297, 299, 299, 114], largest 299 first chunk ends with: ' This sentence is here to' second chunk starts with: 'take up space. \n\nParagrap' nothing lost: True 2500 emoji: 5000 UTF-16 units -> [4096, 904] | characters per piece: [2048, 452]

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.

An earlier guard silently shortens replies
My first test of a long reply failed: the reply arrived as one message, not several. The cause was chapter 5's output guard, whose default 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:

group message answered? text sent to the model plain chat between members no mentions the bot yes when do you open? mentions it in the middle, other case yes hey , any news? a command without the bot's name no a command with the bot's name yes (a command, no model call) a reply to one of the bot's messages yes thanks! addresses a DIFFERENT bot no message from another bot that mentions us no

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 sendChatAction with typing every 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 logs send_failed and 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_id values 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:

What the Telegram server saw (method and key fields): getMe getUpdates offset=None timeout=1 allowed_updates=['message'] getUpdates offset=102 timeout=1 allowed_updates=['message'] sendMessage chat=42 reply_to=None chars=129 text="Hello! I'm an AI assistant, not a person. Ask me" sendMessage chat=42 reply_to=None chars=28 text='We open at nine on weekdays.' getUpdates offset=104 timeout=1 allowed_updates=['message'] sendMessage chat=42 reply_to=None chars=17 text='Remembered as #1.' sendMessage chat=43 reply_to=None chars=3623 text='Section 1. Words go here. Words go here. Words g' getUpdates offset=106 timeout=1 allowed_updates=['message'] sendMessage chat=-500 reply_to=105 chars=15 text='Yes, I am here.' sendMessage chat=43 reply_to=None chars=3623 text='Section 3. Words go here. Words go here. Words g' sendMessage chat=43 reply_to=None chars=1810 text='Section 5. Words go here. Words go here. Words g' getUpdates offset=106 timeout=1 allowed_updates=['message'] ... the same getUpdates repeated many more times while nothing new arrived adapter events: ['ignored:not_addressed']

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)

  1. 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.)
  2. Optionally send BotFather /setcommands with your command list, and /setprivacy if you want the bot to see all group messages.
  3. Set the environment variables and start the script. With CHATBOT_PROVIDER=fake it 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.
"""Run the bot on Telegram by long polling. set TELEGRAM_BOT_TOKEN=<the token BotFather gave you> set CHATBOT_PROVIDER=fake (or claude / grok / openai / ollama: see the provider table in chapter 2) py run_telegram.py """ import asyncio import os import pathlib import sys from bot.channels.telegram import TelegramAPI, TelegramAdapter, run_polling from bot.core import Bot from bot.limits import Limits from bot.moderation import KeywordModerator, SafetyPolicy from bot.persona import load_persona from bot.providers import ProviderConfig, make_provider from bot.reliability import BreakerProvider, FallbackProvider, RetryingProvider from bot.store import Store CONFIGS = { "fake": ProviderConfig("fake"), "claude": ProviderConfig("claude", model=os.environ.get("CHATBOT_MODEL", ""), api_key_env="ANTHROPIC_API_KEY"), "grok": ProviderConfig("openai-compatible", model=os.environ.get("CHATBOT_MODEL", ""), base_url="https://api.x.ai/v1", api_key_env="XAI_API_KEY"), "openai": ProviderConfig("openai-compatible", model=os.environ.get("CHATBOT_MODEL", ""), base_url="https://api.openai.com/v1", api_key_env="OPENAI_API_KEY"), "ollama": ProviderConfig("openai-compatible", model=os.environ.get("CHATBOT_MODEL", ""), base_url="http://localhost:11434/v1"), } async def main() -> None: token = os.environ.get("TELEGRAM_BOT_TOKEN") if not token: sys.exit("Set TELEGRAM_BOT_TOKEN to the token BotFather gave you (it looks like 123456:ABC-DEF...).") choice = os.environ.get("CHATBOT_PROVIDER", "fake") provider = BreakerProvider(RetryingProvider(make_provider(CONFIGS[choice]))) persona = load_persona(pathlib.Path("persona.toml").read_text(encoding="utf8")) bot = Bot(provider, persona=persona, store=Store("telegram_bot.db"), moderator=KeywordModerator({"harassment": ("you idiot",)}), safety=SafetyPolicy(), limits=Limits(burst=5, per_minute=20, user_daily_tokens=50_000)) api = TelegramAPI(token) adapter = TelegramAdapter(bot, api) stop = asyncio.Event() try: await run_polling(adapter, api, stop=stop) except KeyboardInterrupt: stop.set() finally: await api.aclose() if __name__ == "__main__": asyncio.run(main())

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.

Mistakes while building this
  • My helper's parameter clashed with Telegram's. TelegramAPI.call() took a timeout argument for the HTTP timeout, but getUpdates has its own timeout parameter (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 mine http_timeout. The takeaway: when a wrapper forwards **params to 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

Exercise 1

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.

๐Ÿ“„ View solution
Exercise 2

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 solution
Exercise 3

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

๐Ÿ“„ View solution

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 = last update_id + 1 confirms; not usable while a webhook is set; unreceived updates kept 24 h. Webhook: HTTPS on 443/80/88/8443, check X-Telegram-Bot-Api-Secret-Token, answer fast, expect redelivery
  • Session = telegram:<chat id>, user = telegram:<user id>; ignore bots and channels; /start gets a welcome; strip /cmd@BotName
  • Groups: answer only mentions, /cmd@BotName and 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_after and 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
Coming next
Build a Chatbot: From API Call to Deployed Bot 10 adds two more platforms to prove the design: Discord (the gateway, slash commands, the Message Content intent, the 2,000-character limit, threads) and Slack (Bolt, Socket Mode versus HTTP, scopes, the three-second acknowledgement rule). The same core, two more adapters, each tested with fake events. I will read each platform's own current rules first; Discord's developer policy refused to load while I was writing chapter 6, so I will try again.