telebot / DEVELOPER_GUIDE.md
Esmaill1
Add AI Quiz Bot files
92d6323

๐Ÿ‘จโ€๐Ÿ’ป Developer Guide - AI Quiz Bot

Comprehensive guide for developers to understand, modify, and extend the bot code.

๐Ÿ“‘ Table of Contents

๐Ÿ—๏ธ Code Overview

File Structure

bot.py - Single file containing all bot logic (~400 lines)

The bot is organized into logical sections:

  1. Imports & Configuration - Load dependencies and environment
  2. API Functions - Communication with Ollama Cloud
  3. Message Handlers - Process user input
  4. Quiz Generation - Create questions
  5. Bot Initialization - Setup and start bot

Technology Stack

  • Framework: python-telegram-bot (async)
  • HTTP Client: httpx (async HTTP requests)
  • PDF Processing: PyPDF2
  • API: Ollama Cloud (REST API)
  • Configuration: python-dotenv

๐Ÿ”ง Main Components

1. Configuration Section (Lines 1-30)

import os
import json
import asyncio
import logging
from io import BytesIO
from dotenv import load_dotenv
from telegram import Update
from telegram.ext import Application, MessageHandler, filters, ContextTypes
import PyPDF2
import base64
import httpx

# Load .env file
load_dotenv()

# Configure logging
logging.basicConfig(
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    level=logging.INFO
)
logger = logging.getLogger(__name__)

# Environment variables
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
OLLAMA_API_KEY = os.getenv("OLLAMA_API_KEY", "")
VISION_MODEL = os.getenv("VISION_MODEL", "llava")
CHAT_MODEL = os.getenv("CHAT_MODEL", "mistral")

Key Points:

  • All configuration comes from .env file
  • Logging is configured at INFO level
  • Default values provided for optional settings
  • httpx and base64 used for API communication

2. Image Extraction Function (Lines 32-60)

async def extract_text_from_image(image_bytes: bytes) -> str:
    """Extract text from an image using Ollama vision API."""
    try:
        # Convert image to base64 for API transmission
        image_base64 = base64.b64encode(image_bytes).decode('utf-8')
        
        # Build request payload
        payload = {
            "model": VISION_MODEL,
            "messages": [{
                "role": "user",
                "content": "Just transcribe all the text...",
                "images": [image_base64]
            }],
            "stream": False
        }
        
        # Add auth header
        headers = {}
        if OLLAMA_API_KEY:
            headers["Authorization"] = f"Bearer {OLLAMA_API_KEY}"
        
        # Make async HTTP request
        async with httpx.AsyncClient(timeout=120.0) as client:
            response = await client.post(
                f"{OLLAMA_HOST}/api/chat",
                json=payload,
                headers=headers
            )
            response.raise_for_status()
            result = response.json()
            return result['message']['content']
            
    except Exception as e:
        logger.error(f"Error extracting text from image: {e}")
        raise

What it does:

  1. Encodes image to base64 (required by Ollama API)
  2. Prepares request with model and prompt
  3. Adds authentication header if API key exists
  4. Makes async POST request to Ollama
  5. Extracts and returns text from response

Customization:

  • Change prompt in "content" field for different text extraction
  • Modify timeout (120.0 seconds) if needed
  • Add image preprocessing before base64 encoding

3. PDF Extraction Function (Lines 63-73)

def extract_text_from_pdf(pdf_bytes: bytes) -> str:
    """Extract text from a PDF file."""
    try:
        pdf_reader = PyPDF2.PdfReader(BytesIO(pdf_bytes))
        text = ""
        for page in pdf_reader.pages:
            text += page.extract_text() + "\n"
        return text.strip()
    except Exception as e:
        logger.error(f"Error extracting text from PDF: {e}")
        raise

What it does:

  1. Creates BytesIO stream from PDF bytes
  2. Reads all pages sequentially
  3. Extracts text from each page
  4. Combines into single string
  5. Returns trimmed result

Limitations:

  • Only works with text-based PDFs (not scanned images)
  • Scanned PDFs need OCR (use image extraction instead)

4. Quiz Generation Function (Lines 76-138)

async def generate_quiz_questions(text: str, num_questions: int = 5) -> list:
    """Generate quiz questions from text using Ollama API."""
    
    system_prompt = """You are a strict Quiz Generator.
Rules:
1. Output ONLY the questions and answers.
2. Do NOT provide any conversational text...
4. Follow the required format exactly."""

    user_prompt = f"""Based on the text I provide, generate {num_questions} multiple-choice quiz questions.

You must output valid JSON only. Do not wrap it in markdown blocks (like ```json).
The output format must be a strictly valid JSON array of objects, like this:

[
  {{
    "question": "Which command creates a branch?",
    "options": ["git merge", "git branch", "git pull", "git push"],
    "correct_option_id": 1
  }}
]

(Note: 'correct_option_id' is the index of the correct answer in the options array: 0 for A, 1 for B, 2 for C, 3 for D).

Text to analyze:
{text}"""

    try:
        payload = {
            "model": CHAT_MODEL,
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt}
            ],
            "stream": False,
            "format": "json"
        }
        
        headers = {}
        if OLLAMA_API_KEY:
            headers["Authorization"] = f"Bearer {OLLAMA_API_KEY}"
        
        async with httpx.AsyncClient(timeout=120.0) as client:
            response = await client.post(
                f"{OLLAMA_HOST}/api/chat",
                json=payload,
                headers=headers
            )
            response.raise_for_status()
            result = response.json()
            output = result['message']['content']
        
        # Clean up markdown if AI added it
        clean_json = output.replace('```json', '').replace('```', '').strip()
        
        # Parse JSON
        questions = json.loads(clean_json)
        return questions
        
    except json.JSONDecodeError as e:
        logger.error(f"JSON Parse failed: {e}")
        raise ValueError(f"Failed to parse quiz questions: {e}")
    except Exception as e:
        logger.error(f"Error generating quiz: {e}")
        raise

How it works:

  1. System prompt instructs AI to generate specific format
  2. User prompt requests X questions in JSON format
  3. Sends request to Ollama Chat API
  4. Strips markdown code blocks if AI added them
  5. Parses and returns JSON array

JSON Output Format:

[
  {
    "question": "Question text here?",
    "options": ["Option A", "Option B", "Option C", "Option D"],
    "correct_option_id": 0
  }
]

How to Modify:

  • Change number of options (currently 4)
  • Modify difficulty/style in system prompt
  • Add new fields (e.g., "explanation": "...")
  • Change question count validation (1-20)

5. Poll Sending Function (Lines 141-156)

async def send_quiz_poll(context: ContextTypes.DEFAULT_TYPE, 
                        chat_id: int, question_data: dict):
    """Send a quiz poll to Telegram."""
    try:
        await context.bot.send_poll(
            chat_id=chat_id,
            question=question_data['question'][:300],
            options=question_data['options'][:10],
            type='quiz',
            correct_option_id=question_data['correct_option_id'],
            is_anonymous=False
        )
    except Exception as e:
        logger.error(f"Error sending poll: {e}")
        raise

Parameters:

  • chat_id - Telegram chat ID
  • question - Max 300 chars (Telegram limit)
  • options - Max 10 options (Telegram limit)
  • type='quiz' - Makes it a quiz (reveals answer)
  • is_anonymous=False - Shows who answered
  • correct_option_id - Index of correct answer

6. Question Count Parser (Lines 159-175)

def parse_num_questions(caption: str) -> int:
    """Parse number of questions from caption."""
    if not caption:
        return 5  # Default
    
    import re
    numbers = re.findall(r'\d+', caption)
    if numbers:
        num = int(numbers[0])
        return min(max(num, 1), 20)  # Limit: 1-20
    return 5

Logic:

  1. If no caption, return default (5)
  2. Extract first number from caption using regex
  3. Validate it's between 1-20
  4. Return constrained value

How to Modify:

  • Change default: return 5 โ†’ return 10
  • Change limits: min(max(num, 1), 20) โ†’ min(max(num, 2), 50)

๐Ÿ“‹ Function Reference

Message Handlers

handle_file(update, context) (Lines 178-270)

Processes images and PDFs sent by users.

Flow:

1. Get file (image or document)
2. Detect file type from extension
3. Download file bytes
4. Extract text (vision API for images, PyPDF2 for PDFs)
5. Parse number of questions from caption
6. Generate quiz questions
7. Send each question as poll
8. Send completion message

File Type Detection:

  • Images: .jpg, .jpeg, .png, .webp
  • PDFs: .pdf
  • Others: Rejected with error message

Status Messages:

  • "๐Ÿ“ธ ุฌุงุฑูŠ ุชุญู„ูŠู„ ุงู„ุตูˆุฑุฉ..." - Processing image
  • "๐Ÿ“„ ุฌุงุฑูŠ ุงุณุชุฎุฑุงุฌ ุงู„ู†ุต..." - Processing PDF
  • "๐Ÿง  ุฌุงุฑูŠ ุชูˆู„ูŠุฏ X ุงุณุฆู„ุฉ..." - Generating questions

handle_text(update, context) (Lines 273-320)

Processes text messages from users.

Flow:

1. Get text from message
2. Validate minimum length (20 chars)
3. Check if first line is a number
4. If number found, remove it and use as question count
5. Generate quiz questions
6. Send each question as poll
7. Send completion message

Validation:

  • Minimum text: 20 characters
  • Question count: 1-20 (parsed from first line)
  • Default: 5 questions

Command Handlers

start_command(update, context) (Lines 323-343)

Sends welcome message with usage instructions.

Content:

  • ๐Ÿ‘‹ Welcome message
  • ๐Ÿ–ผ๏ธ Image usage
  • ๐Ÿ“„ PDF usage
  • โœ๏ธ Text usage
  • โš™๏ธ Settings
  • ๐Ÿš€ Call to action

help_command(update, context) (Lines 346-365)

Sends FAQ and common questions.

FAQs Covered:

  • Question count limits (1-20)
  • Language support
  • Missing answers troubleshooting
  • Speed issues
  • How to start

๐Ÿ”„ Data Flow

Complete User Request Flow

User sends Image
    โ†“
Telegram receives message
    โ†“
handle_file() triggered
    โ†“
Get file ID from Telegram API
    โ†“
Download file bytes
    โ†“
Detect file type (image/PDF)
    โ†“
IF IMAGE:
  extract_text_from_image()
  โ”œโ”€ Convert to base64
  โ”œโ”€ Call Ollama Vision API
  โ””โ”€ Get extracted text
  
ELSE IF PDF:
  extract_text_from_pdf()
  โ”œโ”€ Read PDF pages
  โ”œโ”€ Extract text per page
  โ””โ”€ Combine text
    โ†“
Parse caption for question count
    โ†“
Call generate_quiz_questions()
โ”œโ”€ Build prompt with text and count
โ”œโ”€ Call Ollama Chat API
โ”œโ”€ Parse JSON response
โ””โ”€ Get list of questions
    โ†“
For each question:
  send_quiz_poll()
  โ”œโ”€ Call Telegram send_poll API
  โ””โ”€ Add 1 second delay
    โ†“
Send completion message
    โ†“
Operation complete

๐Ÿ› ๏ธ How to Modify

Changing Models

Edit .env:

VISION_MODEL=different-model-name
CHAT_MODEL=different-model-name

Or change defaults in bot.py:

VISION_MODEL = os.getenv("VISION_MODEL", "new-default-model")
CHAT_MODEL = os.getenv("CHAT_MODEL", "new-default-model")

Changing Question Format

In generate_quiz_questions(), modify the prompt:

user_prompt = f"""...
The output format must be a strictly valid JSON array of objects:
[
  {{
    "question": "Question text?",
    "options": ["A", "B", "C", "D"],
    "correct_option_id": 0,
    "explanation": "Why is this correct?"  # NEW FIELD
  }}
]
..."""

Then update parsing:

questions = json.loads(clean_json)
# Now questions contain explanation field too

Changing Response Language

Modify prompts in both functions:

system_prompt = """ุฃู†ุช ู…ูˆู„ุฏ ุฃุณุฆู„ุฉ ุฐูƒูŠ.
ุงู„ู‚ูˆุงู†ูŠู†:
1. ุฃุทู„ู‚ ูู‚ุท ุงู„ุฃุณุฆู„ุฉ ูˆุงู„ุฅุฌุงุจุงุช..."""

Adding Database Support

Import database library:

import sqlite3  # or sqlalchemy, mongodb, etc.

Add in handle_file() after quiz generation:

# Save quiz to database
db.save_quiz(
    user_id=message.from_user.id,
    questions=questions,
    source_type='image',
    timestamp=datetime.now()
)

Adding User Analytics

Track in message handlers:

# Count API calls
STATS = {
    "total_images": 0,
    "total_pdfs": 0,
    "total_texts": 0,
    "total_questions": 0
}

# In handle_file():
STATS["total_images"] += 1
STATS["total_questions"] += len(questions)

๐Ÿš€ Adding Features

Feature 1: Question Difficulty Levels

# Modify prompt
user_prompt = f"""...
Difficulty level: {difficulty}  # easy, medium, hard
Generate questions appropriate for {difficulty} level...
"""

# Parse difficulty from caption
def parse_difficulty(caption: str) -> str:
    if "easy" in caption.lower():
        return "easy"
    elif "hard" in caption.lower():
        return "hard"
    return "medium"

Feature 2: Export Questions as PDF

from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas

def export_questions_to_pdf(questions: list) -> bytes:
    buffer = BytesIO()
    c = canvas.Canvas(buffer, pagesize=letter)
    
    for i, q in enumerate(questions, 1):
        c.drawString(50, 700 - i*20, f"{i}. {q['question']}")
        for opt_idx, opt in enumerate(q['options']):
            marker = "โœ“" if opt_idx == q['correct_option_id'] else " "
            c.drawString(70, 680 - i*20, f"{marker} {opt}")
    
    c.save()
    buffer.seek(0)
    return buffer.getvalue()

Feature 3: User Statistics

class UserStats:
    def __init__(self):
        self.stats = {}
    
    def record_quiz(self, user_id: int, num_questions: int):
        if user_id not in self.stats:
            self.stats[user_id] = {"quizzes": 0, "questions": 0}
        
        self.stats[user_id]["quizzes"] += 1
        self.stats[user_id]["questions"] += num_questions
    
    def get_user_stats(self, user_id: int) -> dict:
        return self.stats.get(user_id, {})

stats = UserStats()

# In handle_file():
stats.record_quiz(message.from_user.id, len(questions))

๐Ÿ“ Best Practices

1. Error Handling

Always use try-except:

try:
    result = await some_api_call()
except httpx.TimeoutException:
    logger.error("API timeout")
    # Handle gracefully
except Exception as e:
    logger.error(f"Unexpected error: {e}")
    # Notify user

2. Async/Await

Use async for all API calls:

# โœ… Good
async def my_function():
    async with httpx.AsyncClient() as client:
        response = await client.get(url)

# โŒ Avoid blocking calls in handlers
requests.get(url)  # This blocks the bot!

3. Logging

Log important events:

logger.info(f"Processing image for user {user_id}")
logger.error(f"Failed to extract text: {e}")
logger.debug(f"Generated questions: {questions}")

4. Input Validation

Always validate user input:

if not text or len(text) < 20:
    return None  # Invalid
    
num = parse_num_questions(caption)
if not 1 <= num <= 20:
    num = 5  # Reset to default

5. Resource Cleanup

Close resources properly:

async with httpx.AsyncClient() as client:
    response = await client.post(url, json=payload)
    # Automatically closed when exiting context

6. Environment Variables

Never hardcode credentials:

# โŒ Bad
API_KEY = "secret123"

# โœ… Good
API_KEY = os.getenv("API_KEY")
if not API_KEY:
    raise ValueError("API_KEY not set in .env")

๐Ÿ”— Related Files

  • README.md - User documentation
  • ARCHITECTURE.md - System design and API details
  • .env - Configuration (keep private!)
  • requirements.txt - Dependencies

๐Ÿ“ž Support

For questions or issues:

  1. Check error logs
  2. Review function docstrings
  3. Consult this guide
  4. Test with simple examples first

Last Updated: February 2026
Code Version: 1.0