[Step 3-A-2/3] Giving Your Bot a Brain — And Keeping It in Its Lane (Office Worker Edition)

📌 Expert Workflows · Step 3: AI Telegram Bot · Edition A (Office Worker) · Part 2 of 3

⚙️ How to use this tutorial. Treat the code as a provider-compatible starting point, not a production deployment. Test privately with non-sensitive sample text, confirm the selected model and account policy, and keep both Telegram and AI credentials out of code, screenshots, logs, and shared files.

🎯 Why Bother With This

In part 1 you built a bot that echoes messages back. Today it becomes something your team will actually use: paste a long email thread and get /summarize, describe a situation and get /draft for a first-pass reply.

But there’s a second job in this post that matters at least as much, and it’s the one that separates a bot people trust from a bot that gets muted after a week. A bot in a work chat is answering in front of your colleagues, and it will confidently answer questions it has no business answering — what the leave policy is, whether a contract clause is enforceable, what someone’s salary band should be. Nobody asked it to do that. It’ll do it anyway, because that’s what these models do when you don’t tell them otherwise.

So today has two halves: give it intelligence, then give it boundaries. By the end you’ll have a bot that’s genuinely useful and knows what it isn’t.

Project context

Edition A — Office Worker. This edition uses an operations-team scenario to demonstrate explicit commands for summarizing supplied text and drafting a first-pass reply. It does not claim a real employee, workplace deployment, user study, or measured result.

More technical? Edition C (AI Student) goes deeper on async and deployment. Customer-facing? Edition B (Reseller).

🛠️ Prerequisites

  • Part 1 complete — bot registered and echoing
  • An AI-provider API key with current access to the selected model — ChatGPT vs. Claude vs. Gemini compares the options

📝 Step 1: Decide the Scope Before You Write Code

Define and review the bot’s permitted tasks before adding a provider call.

The instinct is to build a bot that answers anything, because that’s impressive. The right move is the opposite: decide the two or three jobs it does, and make everything else an explicit, polite refusal.

Example scope, written down before any code:

Will do: summarize a pasted message or thread · draft a first-pass reply · extract action items and dates.

Will not do: answer questions about company policy, HR, contracts, pay, or anything legal · make decisions · pretend to know internal information it hasn’t been given.

That second list is the important one, and here’s why it exists. Ask an AI “what’s our parental leave policy?” and it will produce a fluent, plausible, entirely invented answer based on parental leave policies in general. In a group chat, that answer arrives looking exactly as authoritative as a correct one — and somebody will act on it. This is the hallucination problem from AI Hallucination and Ethics with an audience attached, which makes it substantially worse than getting a wrong answer alone at your desk.

Deciding scope also settles a design question: commands, not ambient listening. Your bot responds to /summarize and /draft, not to whatever is being discussed. That’s clearer for users, cheaper in API calls, and — as covered in part 1 — it means your bot isn’t quietly forwarding your colleagues’ conversation to an external service.

📝 Step 2: Adding the AI (Asynchronously)

Install the client and add your key:

pip install openai

In .env:

TELEGRAM_TOKEN=your_telegram_token
AI_API_KEY=your_ai_key
AI_BASE_URL=https://your-provider-url/v1
AI_MODEL=your-model-name

Now the important part. The python-telegram-bot library you’re using is built on Python’s async machinery, which means your bot handles many conversations by switching between them while each waits. A blocking network call can delay other updates handled by the same event loop. Actual concurrency depends on the python-telegram-bot configuration, handler settings, connection limits, and provider latency.

This example uses an asynchronous provider client so the handler can await the network request:

from openai import AsyncOpenAI

ai = AsyncOpenAI(
    api_key=os.getenv("AI_API_KEY", "").strip(),
    base_url=os.getenv("AI_BASE_URL", "").strip(),
    timeout=60.0,
)
MODEL = os.getenv("AI_MODEL", "").strip()

SYSTEM_RULES = (
    "You are a work assistant in a team chat. You summarize and draft text that "
    "users give you. Use ONLY the text provided. "
    "You must refuse questions about company policy, HR, pay, contracts, legal "
    "matters, or any internal information you were not given in the message. "
    "For those, reply exactly: 'I can't answer that — please check with the right "
    "person internally.' Never guess at facts about the company."
)


async def ask_ai(instruction: str, text: str) -> str:
    response = await ai.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": SYSTEM_RULES},
            {"role": "user", "content": f"{instruction}\n\n---\n{text}\n---"},
        ],
        temperature=0.3,
        max_tokens=700,
    )
    return response.choices[0].message.content.strip()

That SYSTEM_RULES block is your scope decision turned into an instruction the model receives on every single request. Note that it does two things: it forbids categories, and it supplies the exact refusal wording. Without the second half, models improvise refusals — and an improvised refusal often includes a helpful guess anyway, which defeats the purpose entirely.

Now the handlers:

from telegram.constants import ChatAction

MAX_INPUT = 8000


async def summarize_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    text = extract_target_text(update, context)

    if not text:
        await update.message.reply_text(
            "Send me text to summarize, like:\n"
            "/summarize <paste the message here>\n"
            "…or reply to a message with /summarize"
        )
        return

    await update.message.chat.send_action(ChatAction.TYPING)

    try:
        result = await ask_ai(
            "Summarize this in 3-5 bullet points, then list any dates, "
            "deadlines or actions on separate lines.",
            text[:MAX_INPUT],
        )
        await send_long(update, result)
    except Exception as error:
        await update.message.reply_text("Sorry — the provider request failed. Try again later.")


def extract_target_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> str:
    """Use the replied-to message if there is one, else the command arguments."""
    if update.message.reply_to_message and update.message.reply_to_message.text:
        return update.message.reply_to_message.text
    return " ".join(context.args) if context.args else ""

That extract_target_text helper is a small thing that makes a large difference in practice. It means colleagues can reply to any message with /summarize rather than copying and pasting it into a command — which is how people naturally want to use a bot in a chat, and how they’ll try to use it whether you support it or not.

Register the handler in main() alongside the others from part 1:

app.add_handler(CommandHandler("summarize", summarize_cmd))

📝 Verification boundary. The walkthrough uses illustrative inputs and expected behaviours. Provider SDKs, model limits, account terms, retention settings, and Telegram behaviour can change; verify them against the chosen provider and the linked official documentation before deployment.

Troubleshooting 3 — The bot that froze for everyone

🔴 BEFORE

A synchronous provider call inside an asynchronous handler can block other updates. The symptom may appear only under concurrent use: commands wait, health checks lag, and replies arrive late. The corrected example uses an asynchronous client; if a library exposes only a blocking function, move that call to a worker thread and add concurrency and timeout tests.

🤔 Cause

A blocking call inside an asynchronous handler can delay other work on the same event loop. During the wait, other commands may queue instead of being processed promptly. The exact impact depends on the library configuration, handler concurrency, connection limits, and provider latency.

🟢 AFTER

UseAsyncOpenAIandawaitthe call, exactly as above. Now while one request waits on the network, the bot happily handles everyone else.

If you’re using a library with no async version, wrap the blocking call so it runs off the main loop:

result = await asyncio.to_thread(blocking_function, argument)

📝 Step 3: Making It Feel Alive

An AI call takes several seconds. In a chat, several seconds of nothing looks identical to broken — and people re-send the command, which spends another API call and confuses the bot.

Two fixes. Thesend_action(ChatAction.TYPING)you already added shows the “typing…” indicator colleagues recognise instantly. It lasts a few seconds, so for longer jobs, refresh it or send a brief acknowledgment first.

Then handle Telegram’s message limit:

TELEGRAM_LIMIT = 4000  # actual cap is 4096; leave headroom


async def send_long(update: Update, text: str) -> None:
    if len(text) <= TELEGRAM_LIMIT:
        await update.message.reply_text(text)
        return

    for i in range(0, len(text), TELEGRAM_LIMIT):
        await update.message.reply_text(text[i:i + TELEGRAM_LIMIT])

Troubleshooting 4 — “Message is too long”

🔴 BEFORE

Someone pasted an entire email thread and asked for a summary. The bot thought about it, then crashed the handler:

telegram.error.BadRequest: Message is too long

From the group’s perspective, the bot simply ignored them.

🤔 Cause

Telegram documents a 4,096-character limit for a text message. A generated answer that exceeds the limit can fail at delivery even when the provider returned a response successfully.

There’s a second version of this same failure that’s easier to miss: input length. A thread longer than the model’s context window either errors or gets silently truncated, producing a summary of the first part only — which looks complete and isn’t. That’s whyMAX_INPUTexists in the handler above, and why real length handling means splitting the input as we did in Step 1.

🟢 AFTER

Thesend_longhelper above splits replies into safe chunks. And cap the input explicitly, telling the user rather than silently trimming:

if len(text) > MAX_INPUT:
    await update.message.reply_text(
        f"That's {len(text)} characters — I'll summarize the first "
        f"{MAX_INPUT} only. For long documents, use the PDF tool instead."
    )

📝 Step 4: Testing the Boundaries

Before your colleagues do it, try to make your bot misbehave. Ask it directly:

  • “What’s our parental leave policy?”
  • “Am I allowed to expense this?”
  • “Is this contract clause enforceable?”

You should get your exact refusal line every time. If it answers instead — even partially, even hedged — strengthenSYSTEM_RULESand test again. Models vary in how firmly they hold instructions, so this is empirical work, not a one-time write.

Test the awkward middle cases too. “Summarize this message about our leave policy” is legitimate: it’s summarizing supplied text, not answering from thin air. Your bot should do that one. If it refuses, your rules are too broad and people will stop using it.

That balance — refuse the invented answer, allow the supplied one — is the whole skill.

⚠️ Loose Ends Worth Tying

Text submitted to an external model is transmitted to the selected provider. Retention, training use, regional processing, and opt-out controls vary by provider, account type, region, and settings. Check the current terms for the exact account before workplace use, obtain approval where required, and tell participants what is sent and why.

Refusals reduce risk; they don’t eliminate it.A determined or accidental phrasing can still coax an answer out. Treat the guardrail as a strong filter, not a guarantee, and keep the bot away from anything where a confident wrong answer would be genuinely costly.

Log usage, not content.Knowingthatthe bot handled twelve requests today is operationally useful. Storing what people asked creates a record of your colleagues’ work conversations that you now own and are responsible for. Count, don’t archive.

✅ What You Have Now

A bot that summarizes pasted text or any message it’s replying to, drafts first-pass replies, shows a typing indicator so it doesn’t look dead, splits long answers to fit Telegram’s limit, reports when it truncates input, and refuses — in consistent, predictable wording — the categories you decided it has no business answering. For the authoritative details, seethe Telegram Bot API reference.

Using an asynchronous client avoids one known blocking pattern, but it does not prove multi-user capacity. Test concurrent requests, timeouts, rate limits, and recovery in the intended environment before wider use.

💡 An Optional Upgrade

Add/actions— same plumbing, different instruction:“List only the action items, each with who it belongs to and any date mentioned. If ownership is unclear, write ‘owner unclear’.”For a bounded work-chat use case, explicit output fields and refusal rules are easier to test than a broad assistant prompt. They still require representative tests and human review.

❓ Your Turn

Run a bounded test with informed participants and synthetic or non-sensitive text. Test two requests at once, empty input, overlong input, provider failure, an out-of-scope request, and the exact refusal text. Record pass/fail results without logging message content.

🔗 Next in This Series

[Step 3-A-3/3] Keeping It Running — Errors, Limits and the Laptop Problem— where we handle the thing that will actually kill this project: your bot only exists while your laptop is awake. Plus rate limiting so one enthusiastic colleague can’t exhaust your quota, and error handling that keeps the bot alive when something unexpected arrives.