Sitemap

Build Your Own AI-Powered Telegram Bot with Python

11 min readFeb 15, 2026

A step-by-step guide to building a Telegram chatbot powered by any LLM — Ollama, OpenAI, Gemini, or OpenRouter.

Research: {Alan Ho}

Introduction

Imagine having a personal AI assistant sitting inside your Telegram — available 24/7, responding instantly to your team, your customers, or just you. Not a canned FAQ bot with scripted replies, but a real language model that understands context, answers nuanced questions, and adapts its tone to your needs.

LLM-powered bots on messaging platforms are genuinely useful:

  • Customer support — Handle first-line inquiries without human agents, around the clock.
  • Internal tooling — Let your team query documentation, summarize reports, or brainstorm ideas directly from group chats.
  • Personal productivity — A private AI assistant in your pocket, accessible from any device with Telegram installed.
  • Education & training — Build interactive tutors, quiz bots, or language practice partners.
  • Domain-specific expertise — Feed it a custom system prompt and you have a cybersecurity advisor, a coding mentor, a legal researcher — whatever you need.

At VXRL, we built exactly this — a Telegram bot that acts as a cybersecurity advisor, powered by our own LLM backend. Team members and clients can ask it about penetration testing techniques, incident response playbooks, or OWASP Top 10 threats right from Telegram. It’s a live demo of what we’ll build in this guide.

The best part? You’re not locked into any single AI provider. In this guide, we’ll use Ollama (a free, local LLM) as the primary example, but we’ll also show you how to swap in OpenAI, Google Gemini, or OpenRouter with just a few lines of code.

We’ll build the bot from scratch using Python and Quart (an async Flask-like framework). The bot receives messages via Telegram’s Webhook API, sends them to your LLM of choice, and replies with AI-generated responses.

By the end, you’ll have:

  • A Telegram bot that responds to any message with AI-generated text
  • Webhook-based architecture (no polling)
  • Signature verification for security
  • Message chunking for long responses
  • A clean, extensible codebase that works with any LLM provider

Let’s get started.

Prerequisites

Before diving in, make sure you have:

  • Python 3.10+ installed
  • An LLM backend — one of the following:
  • Ollama installed locally with a model pulled (e.g., ollama pull llama3) — free, no API key needed
  • An OpenAI API key
  • A Google Gemini API key
  • An OpenRouter API key (access to 100+ models with one key)
  • A Telegram account
  • A public server or tunnel (e.g., ngrok) so Telegram can reach your webhook
  • Basic familiarity with Python async programming

Step 1: Create Your Telegram Bot with BotFather

  1. Open Telegram and search for @BotFather
  2. Send /newbot
  3. Follow the prompts — choose a name and username for your bot
  4. BotFather will give you a Bot Token — save this, you’ll need it
Press enter or click to view image in full size

Step 2: Project Structure

Here’s the structure we’ll build:

my-tg-bot/
├── app/
│ ├── __init__.py
│ ├── app.py # Quart app factory
│ ├── routes.py # Webhook route
│ ├── config.json # Bot token & Ollama config
│ └── services/
│ ├── llm_api.py # LLM interface
│ └── webhooks/
│ ├── base.py # Base webhook handler
│ └── telegram.py # Telegram-specific handler
├── requirements.txt
└── Dockerfile (optional)

Step 3: Install Dependencies

Create a requirements.txt:

quart
uvicorn
httpx

Install:

pip install -r requirements.txt

Step 4: Configuration

Create app/config.json. Pick the LLM provider section that matches your setup:

{
"LLM_PROVIDER": "ollama",
"OLLAMA": {
"ENDPOINT": "http://localhost:11434",
"MODEL_NAME": "llama3"
},
"OPENAI": {
"API_KEY": "sk-...",
"MODEL_NAME": "gpt-4o-mini"
},
"GEMINI": {
"API_KEY": "AIza...",
"MODEL_NAME": "gemini-2.0-flash"
},
"OPENROUTER": {
"API_KEY": "sk-or-...",
"MODEL_NAME": "meta-llama/llama-3-8b-instruct"
},
"WEBHOOKS": {
"TELEGRAM": {
"BOT_TOKEN": "YOUR_BOT_TOKEN_FROM_BOTFATHER",
"WEBHOOK_SECRET": "a-random-secret-string-you-choose"
}
}
}
  • LLM_PROVIDER: Set to "ollama", "openai", "gemini", or "openrouter" — this controls which backend the bot uses.
  • BOT_TOKEN: The token BotFather gave you.
  • WEBHOOK_SECRET: An arbitrary string. Telegram will send this in a header so you can verify requests are genuine.
  • Only the section matching your chosen provider needs valid credentials. You can leave the others empty.

Step 5: Build the Base Webhook Handler

First, create a generic base class that any messaging platform can extend. This keeps the architecture clean if you ever add Discord, Slack, or WhatsApp.

app/services/webhooks/base.py:

from dataclasses import dataclass, field
from typing import Any, Dict, Optional

@dataclass
class WebhookMessage:
"""Parsed message from any IM platform."""
platform: str
text: str
sender_id: str
chat_id: str
raw: Dict[str, Any] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)

class WebhookHandler:
"""Base class for platform webhook handlers."""
platform: str = "unknown"

def __init__(self, config: Dict[str, Any]):
self.config = config

def verify_request(self, headers: Dict[str, str], body: bytes) -> bool:
"""Verify that an incoming request is authentic."""
return True

def parse_message(self, data: Dict[str, Any]) -> Optional[WebhookMessage]:
"""Parse incoming webhook payload into a WebhookMessage."""
raise NotImplementedError

async def send_reply(self, chat_id: str, text: str) -> bool:
"""Send a reply back to the platform."""
raise NotImplementedError

The WebhookMessage dataclass normalizes messages from any platform into a uniform shape — platform name, text, sender ID, chat ID, and optional metadata. This means your LLM logic doesn't care whether the message came from Telegram, Discord, or anything else.

Step 6: Build the Telegram Webhook Handler

Now the Telegram-specific implementation.

app/services/webhooks/telegram.py:

import logging
import hmac
from typing import Any, Dict, Optional
import httpx
from app.services.webhooks.base import WebhookHandler, WebhookMessage
logger = logging.getLogger(__name__)

class TelegramWebhook(WebhookHandler):
platform = "telegram"

def __init__(self, config: Dict[str, Any]):
super().__init__(config)
self.bot_token = config.get("BOT_TOKEN", "")
self.webhook_secret = config.get("WEBHOOK_SECRET", "")
self.api_base = f"https://api.telegram.org/bot{self.bot_token}"

def verify_request(self, headers: Dict[str, str], body: bytes) -> bool:
"""Verify Telegram webhook via secret_token header."""
if not self.webhook_secret:
return True
token = headers.get("X-Telegram-Bot-Api-Secret-Token", "")
return hmac.compare_digest(token, self.webhook_secret)

def parse_message(self, data: Dict[str, Any]) -> Optional[WebhookMessage]:
"""Parse Telegram update object."""
message = data.get("message") or data.get("edited_message")
if not message:
return None

text = message.get("text", "")
if not text:
return None

chat = message.get("chat", {})
sender = message.get("from", {})

return WebhookMessage(
platform=self.platform,
text=text,
sender_id=str(sender.get("id", "")),
chat_id=str(chat.get("id", "")),
raw=data,
metadata={
"username": sender.get("username", ""),
"first_name": sender.get("first_name", ""),
"chat_type": chat.get("type", "private"),
"message_id": message.get("message_id"),
}
)

async def send_reply(self, chat_id: str, text: str) -> bool:
"""Send message via Telegram Bot API."""
if not self.bot_token:
logger.error("Telegram BOT_TOKEN not configured")
return False

# Telegram has a 4096 char limit per message
chunks = [text[i:i+4096] for i in range(0, len(text), 4096)]

try:
async with httpx.AsyncClient(timeout=30) as client:
for chunk in chunks:
resp = await client.post(
f"{self.api_base}/sendMessage",
json={
"chat_id": chat_id,
"text": chunk,
"parse_mode": "Markdown",
}
)
if resp.status_code != 200:
logger.error(
f"Telegram send failed: {resp.status_code} {resp.text}"
)
return False
return True
except Exception as e:
logger.error(f"Telegram send error: {e}")
return False

Key Design Decisions

  • verify_request: Telegram lets you set a secret_token when registering your webhook. On every update, Telegram sends it in the X-Telegram-Bot-Api-Secret-Token header. We use hmac.compare_digest for timing-safe comparison.
  • parse_message: Telegram sends a complex update object — we extract only what we need and normalize it into our WebhookMessage dataclass. We also handle edited_message so edited messages get responses too.
  • send_reply: Telegram's sendMessage has a 4096 character limit. For long LLM responses, we chunk the text and send multiple messages. We use httpx.AsyncClient for non-blocking HTTP calls.

Step 7: The LLM Interface

This is where you connect to your LLM. We’ll build a provider-agnostic interface that supports Ollama, OpenAI, Gemini, and OpenRouter — controlled by a single config value.

app/services/llm_api.py:

import json
import logging
import httpx

logger = logging.getLogger(__name__)

# Load config
with open("app/config.json") as f:
config = json.load(f)

PROVIDER = config.get("LLM_PROVIDER", "ollama")

SYSTEM_PROMPT = (
"You are a helpful AI assistant available via Telegram. "
"Keep responses concise — ideally under 200 words — since users are on a messaging app. "
"Use short paragraphs for readability."
)

def _call_ollama(user_message: str) -> str:
"""Call Ollama's native API."""
cfg = config.get("OLLAMA", {})
endpoint = cfg.get("ENDPOINT", "http://localhost:11434")
model = cfg.get("MODEL_NAME", "llama3")

with httpx.Client(timeout=120) as client:
resp = client.post(
f"{endpoint}/api/chat",
json={
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message}
],
"stream": False
}
)
resp.raise_for_status()
return resp.json()["message"]["content"]

def _call_openai_compatible(user_message: str, base_url: str, api_key: str, model: str) -> str:
"""Call any OpenAI-compatible API (OpenAI, OpenRouter, etc.)."""
with httpx.Client(timeout=120) as client:
resp = client.post(
f"{base_url}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message}
]
}
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]

def _call_gemini(user_message: str) -> str:
"""Call Google Gemini API."""
cfg = config.get("GEMINI", {})
api_key = cfg.get("API_KEY", "")
model = cfg.get("MODEL_NAME", "gemini-2.0-flash")

with httpx.Client(timeout=120) as client:
resp = client.post(
f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}",
json={
"system_instruction": {"parts": [{"text": SYSTEM_PROMPT}]},
"contents": [{"parts": [{"text": user_message}]}]
}
)
resp.raise_for_status()
return resp.json()["candidates"][0]["content"]["parts"][0]["text"]


def generate_text(user_message: str) -> str:
"""Generate a response using the configured LLM provider."""
try:
if PROVIDER == "ollama":
return _call_ollama(user_message)
elif PROVIDER == "openai":
cfg = config.get("OPENAI", {})
return _call_openai_compatible(
user_message,
base_url="https://api.openai.com/v1",
api_key=cfg.get("API_KEY", ""),
model=cfg.get("MODEL_NAME", "gpt-4o-mini")
)
elif PROVIDER == "gemini":
return _call_gemini(user_message)
elif PROVIDER == "openrouter":
cfg = config.get("OPENROUTER", {})
return _call_openai_compatible(
user_message,
base_url="https://openrouter.ai/api/v1",
api_key=cfg.get("API_KEY", ""),
model=cfg.get("MODEL_NAME", "meta-llama/llama-3-8b-instruct")
)
else:
raise ValueError(f"Unknown LLM_PROVIDER: {PROVIDER}")
except Exception as e:
logger.error(f"LLM error ({PROVIDER}): {e}")
return "Sorry, I'm having trouble processing your request right now."

How the Providers Work

ProviderCostLatencyNotesOllamaFreeDepends on your GPURuns locally, full data privacy. No API key needed.OpenAIPay-per-tokenLowMost popular. gpt-4o-mini is cheap and fast.GeminiFree tier availableLowGoogle's models. Generous free tier for experimentation.OpenRouterVaries by modelVariesOne API key, 100+ models (Llama, Mistral, Claude, etc.). Great for experimenting.

Why _call_openai_compatible? OpenAI, OpenRouter, and many self-hosted solutions (vLLM, LM Studio, text-generation-webui) all use the same /v1/chat/completions API format. One function handles them all — just change the base_url.

Step 8: The Webhook Route

Wire everything together in your Quart app.

app/routes.py:

import logging
from quart import Blueprint, request, jsonify
from app.services.webhooks.telegram import TelegramWebhook
from app.services.llm_api import generate_text
logger = logging.getLogger(__name__)
main = Blueprint("main", __name__)

# Load your config (simplified — use your own config loader)
import json
with open("app/config.json") as f:
config = json.load(f)

telegram_config = config["WEBHOOKS"]["TELEGRAM"]
@main.route("/api/webhook/telegram", methods=["POST"])
async def webhook_telegram():
"""Telegram Bot webhook endpoint."""
handler = TelegramWebhook(telegram_config)
raw_body = await request.get_data()
data = await request.get_json()
headers = {k: v for k, v in request.headers}

# 1. Verify the request is from Telegram
if not handler.verify_request(headers, raw_body):
return jsonify({"error": "Invalid signature"}), 403

# 2. Parse the message
message = handler.parse_message(data)
if not message:
return jsonify({"status": "ok"}), 200

logger.info(f"Telegram message from {message.sender_id}: {message.text[:80]}")

# 3. Generate AI response
try:
ai_response = generate_text(user_message=message.text)
except Exception as e:
logger.error(f"LLM error: {e}")
ai_response = "Sorry, something went wrong. Please try again later."

# 4. Send the reply
sent = await handler.send_reply(message.chat_id, ai_response)
if not sent:
logger.warning(f"Failed to send reply to chat {message.chat_id}")

return jsonify({"status": "ok"}), 200

The flow is straightforward:

  1. Verify — reject anything that doesn’t pass the secret token check
  2. Parse — extract the user’s text message
  3. Generate — send message to Ollama, get AI response
  4. Reply — send the AI response back via the Telegram Bot API

Step 9: Create the Quart App

app/app.py:

from quart import Quart
from app.routes import main

def create_app():
app = Quart(__name__)
app.register_blueprint(main)
return app

Step 10: Run the Server

uvicorn app.app:create_app --factory --host 0.0.0.0 --port 5555 --reload

Your server is now running at http://localhost:5555.

Step 11: Expose Your Server with ngrok

Telegram needs a public HTTPS URL to send webhook updates. If you’re developing locally, use ngrok:

ngrok http 5555

You’ll get a URL like https://abc123.ngrok-free.app. Copy it.

Step 12: Register the Webhook with Telegram

Tell Telegram where to send updates using the Bot API:

curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \
-H "Content-Type: application/json" \
-d '{
"url": "https://abc123.ngrok-free.app/api/webhook/telegram",
"secret_token": "a-random-secret-string-you-choose"
}'

Replace:

  • <YOUR_BOT_TOKEN> with the token from BotFather
  • The url with your ngrok or production URL
  • The secret_token with the same value you set in config.json

You should get:

{"ok": true, "result": true, "description": "Webhook was set"}

To verify it’s working:

curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getWebhookInfo"

Or do it in browser:

https://api.telegram.org/bot[BOT_ID]:[BOT_TOKEN]/setWebhook?url=[WEBHOOK_URL]
Press enter or click to view image in full size

Step 13: Test It!

Open Telegram, find your bot by the username you chose, and send a message. You should see the “typing” indicator briefly, then get an AI-generated response.

Press enter or click to view image in full size

Bonus: Bot Personality via Query Parameters

One neat trick — you can switch the bot’s personality without redeploying by appending a query parameter to the webhook URL:

https://yourdomain.com/api/webhook/telegram?bot_type=vxbot

In your route, read the query parameter and map it to different system prompts:

BOT_TYPE_MAP = {
"generic": "bot", # General-purpose assistant
"vxbot": "chat", # Domain-specific personality (e.g., cybersecurity)
}

def _get_webhook_bot_type(platform_key: str) -> str:
# Query param takes priority
url_bot_type = request.args.get("bot_type", "").strip().lower()
if url_bot_type and url_bot_type in BOT_TYPE_MAP:
return url_bot_type
# Fall back to config.json
return config["WEBHOOKS"].get(platform_key, {}).get("BOT_TYPE", "generic")

Want a cybersecurity assistant on one webhook URL and a general assistant on another? Register two webhooks for two different bots with different ?bot_type= values. No code changes needed.

This is exactly how the VXRL AI bot works in production — the vxbot personality is a cybersecurity-focused advisor trained with VXRL's domain expertise (penetration testing, incident response, AI red teaming, and more). The generic personality is a general-purpose assistant. Same codebase, different system prompts, switchable via URL.

Bonus: Sender Whitelist

If you want to restrict who can talk to your bot (e.g., only your team), maintain a simple JSON whitelist file:

app/data/webhook_whitelist.json:

{
"enabled": true,
"telegram": {
"allowed_user_ids": ["123456789", "987654321"],
"allowed_chat_ids": []
}
}

The file is hot-reloaded on every request — just edit the file on your server and changes take effect immediately, no restart required.

Production Deployment Tips

Use a reverse proxy (Nginx, Caddy) with SSL in front of your Quart app — Telegram requires HTTPS.

Docker makes deployment reproducible:

FROM python:3.12-slim 
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "app.app:create_app", " - factory", " - host", "0.0.0.0", " - port", "5555"]

LLM backend: If using Ollama on a different machine, set ENDPOINT to its LAN IP (e.g., http://192.168.1.100:11434). If using a cloud API (OpenAI, Gemini, OpenRouter), no extra infra is needed — just set your API key.

Error monitoring: The bot silently swallows errors and returns a friendly fallback message. In production, add proper logging/alerting.

Conclusion

You now have a fully functional AI-powered Telegram bot — and the freedom to run it with whatever LLM backend fits your situation. Use Ollama for full data privacy and zero cost, OpenAI for best-in-class quality, Gemini for a generous free tier, or OpenRouter to experiment with dozens of models through one API key.

The architecture is intentionally modular: the WebhookHandler base class means you can add Discord, Slack, or WhatsApp support by implementing three methods (verify_request, parse_message, send_reply) without touching the core logic. And swapping LLM providers is a one-line config change.

The full source code used in this guide is available on our GitHub repository.

What to explore next:

  • Add conversation history (store chat context in Redis or a database)
  • Support image/voice messages using multimodal models
  • Add rate limiting to prevent abuse
  • Try different models via OpenRouter to find the best quality/cost balance
  • Deploy with Docker Compose alongside Ollama for a single docker-compose up

Happy building! 🤖

This post is part of the VXRL AI series. Visit vxrl.ai to try our cybersecurity AI tools, or check out vxrl.hk for our security services.

--

--

VXRL
VXRL

Written by VXRL

VXRL Team is founded by group of enthusiastic security researchers, providing information security services and contribute to the community. https://www.vxrl.hk