Telegram Bot + AI Vision: Build an Automated Feedback Triage System Step by Step
Your product feedback is scattered across Slack DMs, Line groups, and email attachments. The most valuable feedback is often a screenshot: an error page, a broken UI, or a competitor's killer feature. But moving that image from your phone to Jira, writing a description, and adding the right labels takes enough friction that half your feedback just dies in "Saved Messages."
I ran into this exact problem with my own side project, where roughly 10-15 screenshot feedbacks per week would get buried in chat history, and by the time I remembered to process them, days had passed. I built a system using a Telegram Bot paired with LLM Vision APIs that automatically classifies screenshots from receipt to structured output, with zero manual intervention. This guide walks you through building the entire system from scratch.
TL;DR
- Create a bot via BotFather and get your token + chat ID in 5 minutes
- Use Python with the getUpdates API to fetch messages, extracting text and images
- Send images to an LLM Vision API (GPT-4o or Claude) for automatic bug/feature classification
- Schedule with cron jobs for full automation
- Near-zero cost for personal use
Why Telegram Bot?
Among various feedback collection solutions, Telegram Bot stands out:
| Solution | Setup Difficulty | Cost | Mobile UX | Image Support |
|---|---|---|---|---|
| Telegram Bot | Low (5 min) | Free | Native app forwarding | Native |
| Slack Bot | Medium | Free/Paid | Decent | Supported |
| Google Forms | Low | Free | Browser-based | Upload friction |
| Custom App | High | Varies | Requires dev | Requires dev |
The Telegram Bot API is a pure HTTP API. No SDK installation required. You can call it with curl or any language's HTTP client. Creating a bot requires no review or app store submission. You can start receiving messages within minutes.
Step 1: Create a Telegram Bot and Get Your Token
- Open Telegram and search for @BotFather (look for the blue checkmark)
- Send
/newbot - Follow the prompts to set a display name and username (must end with
bot, e.g.,my_triage_bot) - BotFather will return your API Token, formatted like:
123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
Important: The token grants full control over your bot. Anyone with the token can operate your bot. Never commit it to git. Store it in environment variables.
Create a .env file for your token:
# .env
TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
Read it in Python:
import os
from dotenv import load_dotenv
load_dotenv()
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
Step 2: Get Your Chat ID
You need to know which chat your bot should monitor. The simplest method:
- Send any message to your bot in Telegram (e.g.,
hello) - Open this URL in your browser (replace with your token):
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates
- You'll see a JSON response like this:
{
"ok": true,
"result": [
{
"update_id": 123456789,
"message": {
"message_id": 1,
"from": {
"id": 987654321,
"first_name": "Alex",
"is_bot": false
},
"chat": {
"id": 987654321,
"type": "private"
},
"text": "hello"
}
}
]
}
chat.id is the value you need. Private chats use positive numbers. Group chat IDs are negative (e.g., -1001234567890).
Group Chat Considerations
If you want the bot to monitor group messages, there's a critical privacy setting to handle.
Per the official Telegram docs, bots have Privacy Mode enabled by default, meaning they can only see:
- Commands explicitly meant for that bot
- General commands (if the bot was the last bot to send a message in the group)
- Inline messages sent via the bot
- Replies to any messages from the bot
To let your bot see all group messages, you have two options:
- Disable Privacy Mode: In BotFather, go to Bot Settings > Group Privacy > Turn Off. After disabling, you must remove and re-add the bot to the group for the change to take effect.
- Make it an Admin: Set the bot as a group admin. Admins can receive all messages.
Step 3: Fetch Messages: Text, Images, and Sender Info
The getUpdates API is the core method for Telegram Bots. It returns all new messages since the last request.
Key parameters:
| Parameter | Type | Description |
|---|---|---|
offset | Integer | Start from this update_id. Set to last update_id + 1 to avoid duplicates |
limit | Integer | Number of updates to return, 1-100, default 100 |
timeout | Integer | Long polling wait time in seconds, default 0 (immediate return). Set to a positive integer to enable long polling |
Critical limitation: getUpdates only retains data for 24 hours. If you don't poll within 24 hours, previous messages are permanently lost. This is the most important factor when choosing your cron job interval.
Here's a Python function to fetch messages:
import requests
def fetch_new_messages(token, offset=None):
"""Fetch new messages from Telegram, return message list and new offset"""
url = f"https://api.telegram.org/bot{token}/getUpdates"
params = {"timeout": 10, "limit": 100}
if offset:
params["offset"] = offset
resp = requests.get(url, params=params).json()
if not resp.get("ok"):
print(f"Error: {resp}")
return [], offset
messages = []
new_offset = offset
for update in resp["result"]:
new_offset = update["update_id"] + 1
msg = update.get("message", {})
messages.append({
"update_id": update["update_id"],
"chat_id": msg.get("chat", {}).get("id"),
"from": msg.get("from", {}).get("first_name", "Unknown"),
"text": msg.get("text"),
"caption": msg.get("caption"),
"photo_file_id": msg["photo"][-1]["file_id"] if msg.get("photo") else None,
})
return messages, new_offset
Key points:
msg["photo"]is an array that in practice is sorted by resolution from lowest to highest (the official docs don't explicitly guarantee this order, but it's the widely accepted behavior). The last element is the highest resolution.captionis the text description attached to a photo. Some users add context when forwarding screenshots.offsetmust be continuously updated; otherwise, you'll receive duplicate messages every time.
Step 4: Download Images
After getting the file_id, downloading the actual image requires two steps:
- Call the getFile API to get the
file_path - Use
file_pathto construct the download URL
import base64
def download_photo(token, file_id):
"""Download a Telegram photo, return base64-encoded string"""
# Step 1: Get file_path
file_info = requests.get(
f"https://api.telegram.org/bot{token}/getFile",
params={"file_id": file_id}
).json()
file_path = file_info["result"]["file_path"]
# Step 2: Download image
download_url = f"https://api.telegram.org/file/bot{token}/{file_path}"
image_data = requests.get(download_url).content
return base64.b64encode(image_data).decode("utf-8")
Note: Per the Telegram Bot API docs, the download link is guaranteed to be valid for at least 1 hour. File size limit is 20MB.
Step 5: LLM Vision API for Feedback Classification
This is the core of the entire system. We send screenshots to a multimodal LLM and let it determine whether the feedback is a Bug, Feature Request, or UX improvement.
Model Selection
Any LLM with Vision (image input) support can handle this task. Recommended options:
- Google Gemini: The only option with a free tier — daily request limits vary by model (see official pricing), great for getting started at zero cost
- GPT-4o: Mature ecosystem, abundant documentation, fastest to get started
- Claude Haiku 4.5: Lowest cost among paid options, ideal for high-volume classification
For feedback classification, any of these models is more than capable. For a zero-cost start, try the Gemini API free tier first. The examples below use GPT-4o as the primary model. Switching to Claude requires only a few lines of code changes (shown later).
OpenAI GPT-4o Example
from openai import OpenAI
client = OpenAI() # Reads from OPENAI_API_KEY env var
SYSTEM_PROMPT = """You are a product feedback triage assistant. Analyze the provided
screenshot and text description, classify the feedback type, and output in JSON format.
Categories:
- bug: Clear errors, unexpected behavior, crashes
- feature: New feature requests or improvement suggestions
- ux: UI/UX experience issues (not bugs, but friction points)
- question: Usage questions, not fitting above categories
Output format:
{
"type": "bug|feature|ux|question",
"priority": "P1|P2|P3",
"summary": "One-line summary",
"description": "Detailed description including key info extracted from screenshot",
"components": ["affected modules"]
}"""
def classify_feedback(image_base64, text=None):
"""Classify feedback using GPT-4o Vision"""
content = []
if text:
content.append({"type": "text", "text": f"User description: {text}"})
if image_base64:
content.append({
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image_base64}",
"detail": "high"
}
})
response = client.chat.completions.create(
model="gpt-4o",
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": content}
],
max_tokens=500
)
return response.choices[0].message.content
Claude Alternative
import anthropic
client = anthropic.Anthropic() # Reads from ANTHROPIC_API_KEY env var
def classify_feedback_claude(image_base64, text=None):
"""Classify feedback using Claude Vision"""
content = []
if image_base64:
content.append({
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": image_base64
}
})
user_text = "Analyze this screenshot and classify the feedback."
if text:
user_text += f"\nUser description: {text}"
content.append({"type": "text", "text": user_text})
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=500,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": content}]
)
return response.content[0].text
Structured Output Example
{
"type": "bug",
"priority": "P2",
"summary": "SSO login returns 404 on mobile",
"description": "Screenshot shows a 404 error page after SSO redirect on mobile web. Error text reads 'Service Unavailable'. Affects all users attempting Google SSO login on mobile.",
"components": ["Auth", "Mobile Web"]
}
Step 6: Complete Polling Script
Combining all the functions above into a single executable script:
#!/usr/bin/env python3
"""triage_bot.py - Automated Telegram feedback triage script"""
import json
import os
import sys
import base64
import requests
from pathlib import Path
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
TARGET_CHAT_ID = int(os.getenv("TELEGRAM_CHAT_ID", "0"))
OFFSET_FILE = Path("offset.txt")
openai_client = OpenAI()
SYSTEM_PROMPT = """You are a product feedback triage assistant. Analyze the provided
screenshot and text description, classify the feedback type, and output in JSON format.
Categories:
- bug: Clear errors, unexpected behavior, crashes
- feature: New feature requests or improvement suggestions
- ux: UI/UX experience issues
- question: Usage questions
Output format:
{"type": "bug|feature|ux|question", "priority": "P1|P2|P3",
"summary": "One-line summary", "description": "Detailed description",
"components": ["affected modules"]}"""
def load_offset():
if OFFSET_FILE.exists():
return int(OFFSET_FILE.read_text().strip())
return None
def save_offset(offset):
OFFSET_FILE.write_text(str(offset))
def fetch_new_messages(token, offset=None):
url = f"https://api.telegram.org/bot{token}/getUpdates"
params = {"timeout": 10, "limit": 100}
if offset:
params["offset"] = offset
resp = requests.get(url, params=params).json()
if not resp.get("ok"):
print(f"[ERROR] getUpdates failed: {resp}", file=sys.stderr)
return [], offset
messages, new_offset = [], offset
for update in resp["result"]:
new_offset = update["update_id"] + 1
msg = update.get("message", {})
if msg.get("chat", {}).get("id") != TARGET_CHAT_ID:
continue
messages.append({
"from": msg.get("from", {}).get("first_name", "Unknown"),
"text": msg.get("text"),
"caption": msg.get("caption"),
"photo_file_id": msg["photo"][-1]["file_id"] if msg.get("photo") else None,
})
return messages, new_offset
def download_photo(token, file_id):
file_info = requests.get(
f"https://api.telegram.org/bot{token}/getFile",
params={"file_id": file_id}
).json()
file_path = file_info["result"]["file_path"]
image_data = requests.get(
f"https://api.telegram.org/file/bot{token}/{file_path}"
).content
return base64.b64encode(image_data).decode("utf-8")
def classify_feedback(image_base64=None, text=None):
content = []
if text:
content.append({"type": "text", "text": f"User description: {text}"})
if image_base64:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{image_base64}", "detail": "high"}
})
if not content:
return None
response = openai_client.chat.completions.create(
model="gpt-4o",
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": content}
],
max_tokens=500
)
return json.loads(response.choices[0].message.content)
def main():
offset = load_offset()
messages, new_offset = fetch_new_messages(BOT_TOKEN, offset)
save_offset(new_offset)
if not messages:
print("[INFO] No new messages.")
return
print(f"[INFO] Processing {len(messages)} new message(s)...")
for msg in messages:
image_b64 = None
if msg["photo_file_id"]:
image_b64 = download_photo(BOT_TOKEN, msg["photo_file_id"])
text = msg["text"] or msg["caption"]
result = classify_feedback(image_b64, text)
if result:
print(f"\n--- Feedback from {msg['from']} ---")
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
print(f"[SKIP] Empty message from {msg['from']}")
if __name__ == "__main__":
main()
Usage:
# Install dependencies
pip install requests python-dotenv openai
# Set environment variables (.env file)
TELEGRAM_BOT_TOKEN=your_token_here
TELEGRAM_CHAT_ID=your_chat_id_here
OPENAI_API_KEY=your_openai_key_here
# Run
python triage_bot.py
The script stores the offset in offset.txt, so each run only processes messages received since the last execution.
Step 7: Automate with Cron Jobs
Running the script manually is fine for testing, but real usage requires automation.
Why Cron Instead of Webhooks?
| Comparison | Cron + Polling | Webhook |
|---|---|---|
| Public server needed | No | Yes (HTTPS + valid SSL) |
| Setup difficulty | Low (one crontab line) | Medium (DNS + SSL + server) |
| Latency | Depends on cron interval | Real-time |
| Best for | Personal/small teams | High-traffic production |
Note: Polling (getUpdates) and Webhooks are mutually exclusive. If a Webhook is set, getUpdates will not work properly. To switch back to polling, call the
deleteWebhookAPI first.
Setting Up Crontab
# Open crontab editor
crontab -e
# Run every 2 hours (recommended interval, well under the 24-hour retention limit)
0 */2 * * * cd /path/to/your/project && /usr/bin/python3 triage_bot.py >> triage.log 2>&1
Key consideration for cron intervals: Since getUpdates only retains data for 24 hours, your cron interval must be well under that. A 2-4 hour interval is recommended, leaving enough buffer so that even if one execution fails, the next one can still recover the data before it expires.
macOS Users
Cron on macOS can be unreliable due to permission issues. Use launchd instead. Save the following plist as ~/Library/LaunchAgents/com.triage-bot.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.triage-bot</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/path/to/your/project/triage_bot.py</string>
</array>
<key>WorkingDirectory</key>
<string>/path/to/your/project</string>
<key>StartInterval</key>
<integer>7200</integer>
<key>StandardOutPath</key>
<string>/path/to/your/project/triage.log</string>
<key>StandardErrorPath</key>
<string>/path/to/your/project/triage-error.log</string>
</dict>
</plist>
Load the schedule:
launchctl load ~/Library/LaunchAgents/com.triage-bot.plist
Low-Code Alternative: n8n
If you prefer visual workflows, n8n offers a built-in Telegram Trigger node that you can configure with drag-and-drop, no code required.
Extension Ideas
The core of this system is "message ingestion + AI classification." Once you have structured classification results, you can connect various downstream actions:
- Auto-create Jira / Linear tickets: Call their REST APIs to convert classification results directly into tickets
- Send results back to Telegram: Use the sendMessage API to have the bot reply with a triage summary in the group for instant team visibility
- Store in Google Sheets / Notion: Write structured data via their APIs to build a searchable feedback history
- Add Webhook mode: When you have a public server, switch to Webhooks for real-time processing
Risks and Limitations
Token Security
A Bot Token grants full control over your bot. Store it in environment variables or a secrets manager. Never hardcode it. If you suspect a leak, immediately use /revoke in BotFather to regenerate.
24-Hour Data Retention
getUpdates data is only retained for 24 hours. If your cron job stops for any reason beyond a day (server restart, path errors, permission issues), feedback during that window is permanently lost. Add a simple health check mechanism: write a timestamp on each run, and have external monitoring check if the timestamp is stale.
LLM Classification Accuracy
Vision API classification isn't 100% accurate. In practice, it performs very well on clear bug screenshots with error messages, but can disagree with your judgment on ambiguous UX issues or feature request edge cases. Human review is recommended for high-priority feedback.
Cost at Scale
Low volume (10-20 screenshots/day) is nearly free. But costs scale linearly since each image requires one API call. Start with free credits or the lowest tier, observe actual usage, then choose your model accordingly. For current Vision API pricing, see OpenAI Pricing and Anthropic Pricing.
Privacy Considerations
User screenshots may contain personal information (names, emails, even passwords). These images are sent to third-party LLM APIs for analysis. If your team has strict data privacy requirements, evaluate whether this aligns with your data handling policies.
Telegram Rate Limits
Per the Telegram FAQ, rate limits have a tiered structure: ~30 msg/sec globally (across different chats), ~1 msg/sec per chat, and 20 msg/min per group. For getUpdates polling, you'll rarely hit these limits. But if your bot needs to send many replies, be aware of these constraints.
Conclusion
The beauty of this system is its simplicity: one Python file, one crontab line, one API key. No frameworks, no deployment, no always-on server.
Start with Steps 1-3: create the bot, get your chat ID, and confirm you can fetch messages. Once those three steps work, you have a functioning message ingestion pipeline. Then layer on LLM classification and cron jobs, and finally connect to Jira or whatever tool you already use.
Once you experience the convenience of "forward a screenshot, get automatic triage," you'll start thinking about what other workflows you can plug in.
FAQ
Can I use getUpdates and Webhooks at the same time?
No. In the Telegram Bot API, getUpdates (polling) and Webhooks are mutually exclusive. If a Webhook is set, getUpdates will not work properly. To switch back to polling, you must first call the deleteWebhook API to remove the Webhook.
What happens if I don't poll for more than 24 hours?
Messages are permanently lost. The Telegram getUpdates API only retains unconfirmed updates for 24 hours. Set your cron interval to 4 hours or less, and add error monitoring to ensure the schedule doesn't silently break.
How much does the LLM Vision API cost?
Google Gemini API offers a free tier (daily request limits vary by model — see the official pricing page for details), which is typically enough for personal use. OpenAI and Anthropic Vision APIs require payment, but costs are minimal at low volume (dozens of screenshots per day). Costs scale linearly since each image requires one API call. Start with Gemini's free tier to validate the workflow, then decide whether to switch.
Is this system suitable for production customer support?
It works well as an internal team triage tool, but isn't recommended for direct customer-facing use. Reasons include: no message queue reliability guarantees, inability to handle high concurrency, and potential LLM classification errors. For production customer support, pair it with a dedicated ticket system like Zendesk and add human review.



