All notes

A Telegram bot in one evening: what breaks on day two

You really can build a lead bot in an evening. Then someone types HTML into the name field. Seven things I build into Bot Kit from day one.

(`・ω・´)ゞ 28 September 2026 · 4 min read PythonaiogramTelegram

“We need a simple bot: a menu, a few questions and a lead sent to the manager. Can you do it in an evening?” Yes, I can . The bot will work and the client will be happy… until day two.

Evening, day one
The bot asks for a name, a phone and the task. The lead lands with the admin. Neat, fast, 80 lines.
Morning, day two
Someone abandoned the form halfway. Someone typed their name as <b>Oleh</b> and broke the admin’s message. The bot restarted — and every half-filled form vanished.

Here’s what I put into Bot Kit from the start, so day two never happens.

1. A form is a state machine, not a pile of ifs

Each question is its own state: name → contact → task → confirm. A user can vanish at any step, come back an hour later or hit /start mid-form. With a state machine (a StatesGroup in aiogram) the bot always knows what it’s waiting for and never mistakes a phone number for a name.

2. State lives in Redis, not in memory

The default storage is process memory. A restart, an update, a crash — and all unfinished forms are gone. Redis lives separately from the bot:

def storage() -> BaseStorage:
    url = os.getenv("REDIS_URL")
    return RedisStorage.from_url(url) if url else MemoryStorage()

3. Always escape user input

The bot sends the lead to the admin with HTML markup (bold labels look nice). But paste the user’s text as is, and a <b> in the name field becomes markup — and an unclosed tag makes Telegram reject the message, so the lead never arrives.

def summary(data: dict) -> str:
    esc = lambda k: html.escape(str(data.get(k, "—")))  # user input never becomes markup
    return f"<b>Name:</b> {esc('name')}\n<b>Contact:</b> {esc('contact')}\n<b>Task:</b> {esc('task')}"

4. Limit the length

Someone will paste an entire contract into the “Task” field. A Telegram message is capped at 4096 characters, so I trim every field up front: the name to 100 characters, the task to 1500.

5. The token lives only in environment variables

BOT_TOKEN and ADMIN_ID come from environment variables; the code has neither. If the token leaks (pasted into a chat, committed by accident), you reissue it in @BotFather in a minute without touching the code. Yes, I’ve received a token in a chat too .

6. A test without the network

You can test a bot without Telegram: swap the network layer and “play” the conversation as updates.

class FakeSession(BaseSession):
    async def make_request(self, bot, method, timeout=None):
        sent.append((type(method).__name__, getattr(method, "chat_id", None), getattr(method, "text", None)))
        return Message.model_validate({...})   # a reply "from Telegram"

for up in [msg("/start"), cb("lead"), msg("Olha"), msg("@olha"), msg("Need a website"), cb("send")]:
    await dp.feed_update(bot, up)
assert any(chat == ADMIN_ID for _, chat, _ in sent)   # the lead reached the admin

Ten seconds, and you know the whole form works, from /start to “Thanks!”.

7. Logs and auto-restart

A bot should live on a server that restarts it after a crash and keeps logs you can read. Then “why didn’t the lead arrive?” has an answer instead of a guess. On bebb Cloud that’s searchable “Logs” and a Telegram alert when a bot is crash-looping.

Bottom line

A bot in an evening is real. A bot that won’t let you down on day two is a few more hours of states, escaping and tests. Those hours are already in Bot Kit — take it and adapt it .

Got an idea? Let’s talk

Describe your task in a few sentences — I’ll reply with questions and a first estimate.