Telegram Bot Developer

0•

Builds secure, multi-language Telegram bots using modern frameworks like teloxide, aiogram, and grammY.

The Telegram Bot Developer skill provides a comprehensive framework for building production-grade Telegram bots across Rust, Python, and Node.js. It streamlines the development process by offering standardized patterns for command handlers, inline keyboards, and Finite State Machines (FSM) for complex user dialogues. Crucially, the skill prioritizes security, enforcing mandatory secret protection through automated git-leaks detection and environment variable management to prevent bot token exposure. Whether you are building simple notification scripts or complex interactive applications, this skill provides the boilerplate and best practices for polling, webhooks, and database integration.

주요 기능

01Pre-built patterns for complex Finite State Machine (FSM) dialog flows
02Mandatory security hardening with pre-commit hooks and secret protection
03Comprehensive testing and TDD workflows for reliable bot handlers
04Standardized templates for inline keyboards and callback query handling
050 GitHub stars
06Multi-language support for Rust (teloxide), Python (aiogram 3.x), and Node.js (grammY)

사용 사례

01Building real-time notification bots for server monitoring and alerts
02Developing interactive customer support chatbots with stateful registration flows
03Creating automated content delivery tools with inline keyboards and menus
name description
telegram-bot
Build Telegram bots with modern frameworks. Use when: creating Telegram bot, chatbot, notification bot. Triggers: "telegram", "tg bot", "телеграм бот", "teloxide", "aiogram".

Telegram Bot Development

Project Protection Setup

MANDATORY before writing any code:

# 1. Create .gitignore
cat >> .gitignore << 'EOF'
# Build
target/
node_modules/
__pycache__/
*.pyc
dist/

# Secrets - CRITICAL for bots!
.env
.env.*
!.env.example
*.key
bot_token.txt

# IDE
.idea/
.vscode/
.DS_Store
EOF

# 2. Setup pre-commit hooks
cat > .pre-commit-config.yaml << 'EOF'
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: detect-private-key
      - id: check-added-large-files
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks
EOF

pre-commit install

Why critical for bots: Bot tokens give FULL access to your bot. Leaked token = compromised bot.


Stack Options

Language Framework Best For
Rust teloxide Performance, type safety
Python aiogram 3.x Rapid development, async
Node grammY / telegraf JS ecosystem

Quick Start

Rust (teloxide)

# Cargo.toml
[dependencies]
teloxide = { version = "0.13", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
log = "0.4"
pretty_env_logger = "0.5"
use teloxide::prelude::*;

#[tokio::main]
async fn main() {
    pretty_env_logger::init();
    log::info!("Starting bot...");

    let bot = Bot::from_env();

    teloxide::repl(bot, |bot: Bot, msg: Message| async move {
        bot.send_message(msg.chat.id, "Hello!").await?;
        Ok(())
    })
    .await;
}

Python (aiogram 3.x)

# requirements.txt
aiogram>=3.0
import asyncio
from aiogram import Bot, Dispatcher, types
from aiogram.filters import Command

bot = Bot(token="BOT_TOKEN")
dp = Dispatcher()

@dp.message(Command("start"))
async def start(message: types.Message):
    await message.answer("Hello!")

@dp.message()
async def echo(message: types.Message):
    await message.answer(message.text)

async def main():
    await dp.start_polling(bot)

if __name__ == "__main__":
    asyncio.run(main())

Node (grammY)

// package.json: "grammy": "^1.21"
import { Bot } from "grammy";

const bot = new Bot(process.env.BOT_TOKEN!);

bot.command("start", (ctx) => ctx.reply("Hello!"));
bot.on("message", (ctx) => ctx.reply(ctx.message.text ?? ""));

bot.start();

Command Handlers

Rust

use teloxide::{prelude::*, utils::command::BotCommands};

#[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase")]
enum Command {
    #[command(description = "Start the bot")]
    Start,
    #[command(description = "Get help")]
    Help,
    #[command(description = "Echo text")]
    Echo(String),
}

async fn answer(bot: Bot, msg: Message, cmd: Command) -> ResponseResult<()> {
    match cmd {
        Command::Start => bot.send_message(msg.chat.id, "Welcome!").await?,
        Command::Help => bot.send_message(msg.chat.id, Command::descriptions().to_string()).await?,
        Command::Echo(text) => bot.send_message(msg.chat.id, text).await?,
    };
    Ok(())
}

Python

from aiogram.filters import Command

@dp.message(Command("start"))
async def cmd_start(message: types.Message):
    await message.answer("Welcome!")

@dp.message(Command("help"))
async def cmd_help(message: types.Message):
    await message.answer("Available commands:\n/start - Start\n/help - Help")

Inline Keyboards

Rust

use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup};

let keyboard = InlineKeyboardMarkup::new(vec![
    vec![
        InlineKeyboardButton::callback("Option 1", "opt_1"),
        InlineKeyboardButton::callback("Option 2", "opt_2"),
    ],
    vec![
        InlineKeyboardButton::url("Website", "https://example.com".parse().unwrap()),
    ],
]);

bot.send_message(chat_id, "Choose:")
    .reply_markup(keyboard)
    .await?;

Python

from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton

keyboard = InlineKeyboardMarkup(inline_keyboard=[
    [
        InlineKeyboardButton(text="Option 1", callback_data="opt_1"),
        InlineKeyboardButton(text="Option 2", callback_data="opt_2"),
    ],
    [
        InlineKeyboardButton(text="Website", url="https://example.com"),
    ]
])

await message.answer("Choose:", reply_markup=keyboard)

Callback Handling

Rust

#[derive(Clone)]
enum CallbackAction {
    Option1,
    Option2,
}

async fn callback_handler(
    bot: Bot,
    q: CallbackQuery,
) -> ResponseResult<()> {
    if let Some(data) = &q.data {
        let text = match data.as_str() {
            "opt_1" => "You chose Option 1",
            "opt_2" => "You chose Option 2",
            _ => "Unknown",
        };

        bot.answer_callback_query(&q.id).await?;

        if let Some(msg) = q.message {
            bot.edit_message_text(msg.chat.id, msg.id, text).await?;
        }
    }
    Ok(())
}

Python

@dp.callback_query(lambda c: c.data.startswith("opt_"))
async def process_callback(callback: types.CallbackQuery):
    if callback.data == "opt_1":
        text = "You chose Option 1"
    else:
        text = "You chose Option 2"

    await callback.answer()  # Remove loading state
    await callback.message.edit_text(text)

FSM (Finite State Machine) for Dialogs

Python (aiogram)

from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup

class Form(StatesGroup):
    name = State()
    email = State()
    confirm = State()

@dp.message(Command("register"))
async def start_registration(message: types.Message, state: FSMContext):
    await state.set_state(Form.name)
    await message.answer("Enter your name:")

@dp.message(Form.name)
async def process_name(message: types.Message, state: FSMContext):
    await state.update_data(name=message.text)
    await state.set_state(Form.email)
    await message.answer("Enter your email:")

@dp.message(Form.email)
async def process_email(message: types.Message, state: FSMContext):
    await state.update_data(email=message.text)
    data = await state.get_data()
    await state.clear()
    await message.answer(f"Registered: {data['name']} ({data['email']})")

Rust (teloxide dialogue)

use teloxide::dispatching::dialogue::{InMemStorage, Dialogue};

type MyDialogue = Dialogue<State, InMemStorage<State>>;

#[derive(Clone, Default)]
pub enum State {
    #[default]
    Start,
    ReceiveName,
    ReceiveEmail { name: String },
}

async fn start(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    bot.send_message(msg.chat.id, "Enter your name:").await?;
    dialogue.update(State::ReceiveName).await?;
    Ok(())
}

async fn receive_name(bot: Bot, dialogue: MyDialogue, msg: Message) -> HandlerResult {
    let name = msg.text().unwrap_or_default().to_string();
    bot.send_message(msg.chat.id, "Enter your email:").await?;
    dialogue.update(State::ReceiveEmail { name }).await?;
    Ok(())
}

Webhooks vs Polling

Method Pros Cons Use When
Polling Simple setup, works locally Less efficient, delay Development, small bots
Webhooks Real-time, efficient Needs HTTPS, public URL Production

Webhook Setup (Rust)

use teloxide::dispatching::update_listeners::webhooks;

let addr = ([0, 0, 0, 0], 8443).into();
let url = "https://your-domain.com/webhook".parse().unwrap();

let listener = webhooks::axum(bot.clone(), webhooks::Options::new(addr, url))
    .await
    .unwrap();

teloxide::repl_with_listener(bot, handler, listener).await;

Database Integration

SQLite with SQLx (Rust)

use sqlx::SqlitePool;

struct AppState {
    db: SqlitePool,
}

async fn save_user(pool: &SqlitePool, user_id: i64, username: &str) -> Result<()> {
    sqlx::query!(
        "INSERT OR REPLACE INTO users (id, username) VALUES (?, ?)",
        user_id, username
    )
    .execute(pool)
    .await?;
    Ok(())
}

Environment & Security

# .env (NEVER commit!)
TELOXIDE_TOKEN=123456:ABC-DEF...
DATABASE_URL=sqlite:bot.db

# .gitignore
.env
*.db

Common Pitfalls

Pitfall Solution
Token in code Use environment variables
No error handling Wrap handlers in try/catch
Blocking in async Use tokio::spawn for heavy work
No rate limiting Respect Telegram limits (30 msg/sec)
Large media sync Use file_id, not re-uploading

Testing

Rust (teloxide)

#[cfg(test)]
mod tests {
    use super::*;

    // Unit test for message processing logic
    #[test]
    fn test_parse_command() {
        let result = parse_expense("/add 100 food");
        assert_eq!(result.amount, 100.0);
        assert_eq!(result.category, "food");
    }

    // Integration test with mock bot
    #[tokio::test]
    async fn test_start_command_returns_welcome() {
        // Use teloxide_tests or mock the bot
        let response = handle_start_command().await;
        assert!(response.contains("Welcome"));
    }
}

Python (aiogram)

import pytest
from unittest.mock import AsyncMock, MagicMock

@pytest.fixture
def mock_message():
    message = MagicMock()
    message.answer = AsyncMock()
    message.text = "/start"
    message.from_user.id = 123
    return message

@pytest.mark.asyncio
async def test_start_handler(mock_message):
    await cmd_start(mock_message)
    mock_message.answer.assert_called_once()
    assert "Welcome" in mock_message.answer.call_args[0][0]

@pytest.mark.asyncio
async def test_fsm_state_transition(mock_message, state):
    await start_registration(mock_message, state)
    current_state = await state.get_state()
    assert current_state == Form.name

Node (grammY)

import { describe, it, expect, vi } from 'vitest';

describe('Bot handlers', () => {
  it('responds to /start', async () => {
    const ctx = {
      reply: vi.fn(),
      message: { text: '/start' },
    };

    await startHandler(ctx);
    expect(ctx.reply).toHaveBeenCalledWith(expect.stringContaining('Welcome'));
  });
});

TDD Workflow

1. Task[tdd-test-writer]: "Create /start command handler"
   → Writes test that expects welcome message
   → cargo test / pytest / npm test → FAILS (RED)

2. Task[rust-developer]: "Implement /start handler"
   → Implements minimal code
   → cargo test → PASSES (GREEN)

3. Repeat for each command/feature

4. Task[code-reviewer]: "Review bot implementation"
   → Checks security, error handling, patterns

Security Checklist

  • Token in environment variable (never hardcoded)
  • .env in .gitignore
  • pre-commit hooks with gitleaks
  • Input validation (no command injection)
  • Rate limiting for user requests
  • Webhook URL uses HTTPS
  • No sensitive data in logs
  • User data encrypted if stored

Project Structure

telegram-bot/
├── src/
│   ├── main.rs
│   ├── handlers/
│   │   ├── mod.rs
│   │   ├── commands.rs
│   ���   └── callbacks.rs
│   ├── state.rs        # FSM states
│   └── db.rs           # Database
├── tests/
│   └── integration.rs  # Integration tests
├── Cargo.toml
├── .env.example        # Template (committed)
├── .env                # Real secrets (NOT committed)
└── .gitignore