telegram-bot/SKILL.md
Build Telegram bots with the complete aiogram 3.x ecosystem - FSM for conversation flows, aiogram-dialog for GUI-like windows and widgets, Scenes for isolated multi-step workflows, aiogram-i18n for internationalization, routers for modular design, middleware, inline keyboards, and production-ready patterns. Master the full aiogram stack for professional Telegram bot development.
npx skillsauth add snqb/my-skills telegram-botInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Build production-ready Telegram bots using the complete aiogram 3.x ecosystem with FSM, Dialog framework, Scenes, i18n, and modern async Python patterns.
This skill covers the complete aiogram ecosystem, not just the core framework:
Basic aiogram:
aiogram-dialog Extensions:
Scenes Features:
i18n Capabilities:
| Need | Use | Example | |------|-----|---------| | Simple command handlers | aiogram core | /start, /help commands | | Multi-step forms | FSM States | Registration, surveys | | GUI-like menus/dialogs | aiogram-dialog | Settings menu, product catalog | | Isolated workflows | Scenes | Quiz flow, onboarding wizard | | Multi-language bot | aiogram-i18n | International bots | | Complex filters | Magic Filter (F) | Conditional routing |
# Core aiogram
pip install aiogram
# With faster JSON parsing
pip install aiogram[fast]
# With Redis storage
pip install aiogram[redis]
# aiogram-dialog (GUI framework)
pip install aiogram-dialog
# aiogram-i18n (translations)
pip install aiogram-i18n
# Using uv
uv pip install aiogram aiogram-dialog aiogram-i18n
Requirements: Python 3.7+
import asyncio
from aiogram import Bot, Dispatcher
from aiogram.types import Message
from aiogram.filters import Command
BOT_TOKEN = "YOUR_BOT_TOKEN_HERE"
bot = Bot(token=BOT_TOKEN)
dp = Dispatcher()
@dp.message(Command("start"))
async def cmd_start(message: Message):
await message.answer(f"Hello, {message.from_user.full_name}!")
@dp.message()
async def echo_message(message: Message):
await message.answer(message.text)
async def main():
await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
telegram_bot/
├── bot.py # Main entry point
├── config.py # Configuration (tokens, settings)
├── handlers/ # Handler modules
│ ├── __init__.py
│ ├── start.py # Basic commands
│ ├── forms.py # FSM-based forms
│ └── dialogs/ # aiogram-dialog windows
│ ├── __init__.py
│ ├── menu.py # Main menu dialog
│ └── settings.py # Settings dialog
├── scenes/ # Scene definitions
│ ├── __init__.py
│ ├── quiz.py # Quiz scene
│ └── onboarding.py # Onboarding scene
├── middlewares/ # Custom middleware
│ ├── __init__.py
│ ├── auth.py # Authentication
│ └── i18n.py # Translation middleware
├── keyboards/ # Keyboard builders
│ ├── __init__.py
│ ├── inline.py # Inline keyboards
│ └── reply.py # Reply keyboards
├── states/ # FSM state groups
│ ├── __init__.py
│ └── forms.py # Form states
├── locales/ # Translation files (i18n)
│ ├── en/
│ │ └── LC_MESSAGES/
│ │ └── messages.ftl # English translations
│ └── ru/
│ └── LC_MESSAGES/
│ └── messages.ftl # Russian translations
└── utils/ # Utility functions
├── __init__.py
└── db.py # Database helpers
FSM manages conversation flows with multiple steps. Perfect for registration forms, surveys, order processes, multi-step wizards.
# states/forms.py
from aiogram.fsm.state import State, StatesGroup
class RegistrationForm(StatesGroup):
name = State()
age = State()
email = State()
confirm = State()
# handlers/forms.py
from aiogram import Router, F
from aiogram.types import Message
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from states.forms import RegistrationForm
router = Router()
@router.message(Command("register"))
async def cmd_register(message: Message, state: FSMContext):
await state.set_state(RegistrationForm.name)
await message.answer("Please enter your name:")
@router.message(RegistrationForm.name)
async def process_name(message: Message, state: FSMContext):
await state.update_data(name=message.text)
await state.set_state(RegistrationForm.age)
await message.answer("Thanks! Now enter your age:")
@router.message(RegistrationForm.age)
async def process_age(message: Message, state: FSMContext):
if not message.text.isdigit():
await message.answer("Please enter a valid number!")
return
await state.update_data(age=int(message.text))
await state.set_state(RegistrationForm.email)
await message.answer("Great! Now enter your email:")
@router.message(RegistrationForm.email)
async def process_email(message: Message, state: FSMContext):
await state.update_data(email=message.text)
data = await state.get_data()
confirmation_text = f"""
Please confirm your registration:
Name: {data['name']}
Age: {data['age']}
Email: {data['email']}
Type 'yes' to confirm or 'no' to cancel.
"""
await state.set_state(RegistrationForm.confirm)
await message.answer(confirmation_text)
@router.message(RegistrationForm.confirm, F.text.lower() == "yes")
async def process_confirm(message: Message, state: FSMContext):
data = await state.get_data()
# Save to database here
await message.answer("✅ Registration complete!")
await state.clear()
@router.message(Command("cancel"))
async def cmd_cancel(message: Message, state: FSMContext):
await state.clear()
await message.answer("Cancelled.")
from aiogram import Dispatcher
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.storage.redis import RedisStorage
from redis.asyncio import Redis
# In-memory (default, not persistent)
dp = Dispatcher(storage=MemoryStorage())
# Redis (persistent, production-ready)
redis = Redis(host='localhost', port=6379)
dp = Dispatcher(storage=RedisStorage(redis=redis))
aiogram-dialog transforms Telegram bots into GUI-like applications with windows, widgets, and declarative rendering.
Resources:
Window: UI component that displays content (text + keyboards) Dialog: Container grouping one or more windows DialogManager: Service for dialog navigation Widgets: Reusable UI components (buttons, calendars, selects, etc.)
Text Widgets:
Const - Static textFormat - Dynamic formatting with variablesMulti - Multiple text widgetsCase - Conditional textProgress - Progress barsList - Lists of itemsKeyboard Widgets:
Button - Click actionUrl - External linkSelect - List selectionRadio - Single choiceCheckbox - Multi-choiceCalendar - Date pickerCounter - Number increment/decrementInput Widgets:
MessageInput - Capture any messageTextInput - Text input with validationMedia Widgets:
StaticMedia - Fixed images/filesDynamicMedia - Dynamic media loadingfrom aiogram import Router
from aiogram.filters import Command
from aiogram.fsm.state import State, StatesGroup
from aiogram_dialog import Dialog, DialogManager, Window, StartMode, setup_dialogs
from aiogram_dialog.widgets.text import Const, Format
from aiogram_dialog.widgets.kbd import Button, SwitchTo, Cancel
# Define states
class MenuStates(StatesGroup):
main = State()
settings = State()
about = State()
# Create windows
main_window = Window(
Const("🏠 Main Menu"),
Format("Welcome, {user_name}!"),
SwitchTo(
text=Const("⚙️ Settings"),
id="to_settings",
state=MenuStates.settings
),
SwitchTo(
text=Const("ℹ️ About"),
id="to_about",
state=MenuStates.about
),
Cancel(Const("❌ Close")),
state=MenuStates.main,
getter=lambda dialog_manager, **kwargs: {
"user_name": dialog_manager.event.from_user.full_name
}
)
settings_window = Window(
Const("⚙️ Settings"),
Button(
text=Const("🔄 Reset"),
id="reset",
on_click=lambda c, b, m: m.event.answer("Settings reset!")
),
SwitchTo(
text=Const("🔙 Back"),
id="back",
state=MenuStates.main
),
state=MenuStates.settings
)
about_window = Window(
Const("ℹ️ About"),
Const("This is a demo bot built with aiogram-dialog."),
SwitchTo(
text=Const("🔙 Back"),
id="back",
state=MenuStates.main
),
state=MenuStates.about
)
# Create dialog
menu_dialog = Dialog(main_window, settings_window, about_window)
# Setup router
router = Router()
router.include_router(menu_dialog)
# Start dialog handler
@router.message(Command("menu"))
async def start_menu(message: Message, dialog_manager: DialogManager):
await dialog_manager.start(MenuStates.main, mode=StartMode.RESET_STACK)
# Setup dialogs in main
# dp.include_router(router)
# setup_dialogs(dp)
from aiogram_dialog.widgets.kbd import Calendar
from datetime import date
async def on_date_selected(callback, widget, manager: DialogManager, selected_date: date):
await callback.answer(f"Selected: {selected_date}")
await manager.done()
date_window = Window(
Const("📅 Select a date:"),
Calendar(id="calendar", on_click=on_date_selected),
Cancel(Const("❌ Cancel")),
state=DateStates.select
)
from aiogram_dialog.widgets.kbd import Select
# Getter function provides data
async def get_products(**kwargs):
return {
"products": [
("product_1", "Laptop - $999"),
("product_2", "Mouse - $29"),
("product_3", "Keyboard - $79"),
]
}
async def on_product_selected(callback, widget, manager: DialogManager, item_id: str):
await callback.answer(f"Selected: {item_id}")
product_window = Window(
Const("🛒 Select a product:"),
Select(
Format("{item[1]}"), # Display text
id="product_select",
item_id_getter=lambda x: x[0], # ID
items="products", # From getter
on_click=on_product_selected
),
state=ShopStates.products,
getter=get_products
)
from aiogram import Bot, Dispatcher
from aiogram_dialog import setup_dialogs
bot = Bot(token=BOT_TOKEN)
dp = Dispatcher()
# Include dialog routers
dp.include_router(menu_dialog)
dp.include_router(shop_dialog)
# CRITICAL: Call setup_dialogs BEFORE start_polling
setup_dialogs(dp)
async def main():
await dp.start_polling(bot)
Scenes create isolated conversation contexts where global handlers are bypassed. Perfect for quizzes, guided workflows, multi-step onboarding.
Official Documentation:
Scene: Isolated namespace/room for focused interactions SceneRegistry: Container for all scenes ScenesManager: Manages user transitions between scenes SceneWizard: Enables scene navigation (enter, exit, goto, back, retake) SceneConfig: Scene configuration (state, handlers, lifecycle)
from aiogram.fsm.scene import Scene, on, ScenesManager
from aiogram.types import Message
from aiogram.filters import Command
class QuizScene(Scene, state="quiz"):
"""Quiz scene with isolated context."""
@on.message.enter()
async def on_enter(self, message: Message):
"""Called when user enters scene."""
await message.answer(
"📝 Quiz Started!\n\n"
"Question 1: What is 2+2?\n"
"Reply with the answer."
)
@on.message()
async def handle_answer(self, message: Message, wizard: SceneWizard):
"""Handle quiz answers."""
if message.text == "4":
await message.answer("✅ Correct!")
await wizard.exit() # Exit scene
else:
await message.answer("❌ Try again!")
await wizard.retake() # Re-enter scene
@on.message.exit()
async def on_exit(self, message: Message):
"""Called when user leaves scene."""
await message.answer("Quiz completed!")
# Setup scene registry
from aiogram import Router
router = Router()
scene_registry = SceneRegistry(router)
scene_registry.add(QuizScene)
# Entry point handler
@router.message(Command("quiz"))
async def start_quiz(message: Message, scenes: ScenesManager):
await scenes.enter(QuizScene)
class MyScene(Scene, state="my_scene"):
@on.message()
async def handler(self, message: Message, wizard: SceneWizard):
# Enter another scene
await wizard.enter(OtherScene)
# Exit current scene
await wizard.exit()
# Go to specific scene
await wizard.goto(TargetScene)
# Re-enter current scene (reset)
await wizard.retake()
# Go back to previous scene
await wizard.back()
from aiogram.fsm.state import State
class OnboardingWizard:
"""Multi-scene onboarding flow."""
class WelcomeScene(Scene, state="welcome"):
@on.message.enter()
async def on_enter(self, message: Message):
await message.answer(
"👋 Welcome to the bot!\n\n"
"Let's get you set up. What's your name?"
)
@on.message()
async def handle_name(self, message: Message, state: FSMContext, wizard: SceneWizard):
await state.update_data(name=message.text)
await wizard.goto(OnboardingWizard.AgeScene)
class AgeScene(Scene, state="age"):
@on.message.enter()
async def on_enter(self, message: Message):
await message.answer("How old are you?")
@on.message()
async def handle_age(self, message: Message, state: FSMContext, wizard: SceneWizard):
if not message.text.isdigit():
await message.answer("Please enter a number!")
await wizard.retake()
return
await state.update_data(age=int(message.text))
await wizard.goto(OnboardingWizard.ConfirmScene)
class ConfirmScene(Scene, state="confirm"):
@on.message.enter()
async def on_enter(self, message: Message, state: FSMContext):
data = await state.get_data()
await message.answer(
f"Confirm your details:\n"
f"Name: {data['name']}\n"
f"Age: {data['age']}\n\n"
f"Type 'yes' to confirm."
)
@on.message()
async def handle_confirm(self, message: Message, wizard: SceneWizard):
if message.text.lower() == "yes":
await message.answer("✅ Onboarding complete!")
await wizard.exit()
else:
await wizard.back() # Go back to previous scene
# Register all scenes
scene_registry.add(OnboardingWizard.WelcomeScene)
scene_registry.add(OnboardingWizard.AgeScene)
scene_registry.add(OnboardingWizard.ConfirmScene)
class MyScene(Scene, state="my_scene"):
@on.message.enter()
async def on_enter(self, message: Message):
"""Called when entering scene."""
print("Entering scene")
@on.message.exit()
async def on_exit(self, message: Message):
"""Called when exiting scene."""
print("Exiting scene")
@on.message()
async def handle_message(self, message: Message):
"""Regular message handler within scene."""
await message.answer("Handling message in scene")
aiogram-i18n provides multi-language support with lazy translation, FluentRuntimeCore, and middleware integration.
Resources:
pip install aiogram-i18n
locales/
├── en/
│ └── LC_MESSAGES/
│ └── messages.ftl # English translations
├── ru/
│ └── LC_MESSAGES/
│ └── messages.ftl # Russian translations
└── es/
└── LC_MESSAGES/
└── messages.ftl # Spanish translations
# locales/en/LC_MESSAGES/messages.ftl
welcome = Welcome, { $name }!
button-start = Start
button-settings = Settings
ask-name = What is your name?
registration-complete = Registration complete!
# locales/ru/LC_MESSAGES/messages.ftl
welcome = Добро пожаловать, { $name }!
button-start = Начать
button-settings = Настройки
ask-name = Как вас зовут?
registration-complete = Регистрация завершена!
from aiogram import Bot, Dispatcher
from aiogram_i18n import I18nMiddleware
from aiogram_i18n.cores import FluentRuntimeCore
# Create i18n middleware
i18n_middleware = I18nMiddleware(
core=FluentRuntimeCore(
path="locales/{locale}/LC_MESSAGES"
),
default_locale="en",
allowed_locales=["en", "ru", "es"]
)
# Register middleware
dp = Dispatcher()
i18n_middleware.setup(dispatcher=dp)
from aiogram_i18n import gettext as _
from aiogram_i18n import lazy_gettext as __
@router.message(Command("start"))
async def cmd_start(message: Message):
# Translate at runtime
await message.answer(
_("welcome", name=message.from_user.full_name)
)
@router.message(Command("register"))
async def cmd_register(message: Message):
await message.answer(_("ask-name"))
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram_i18n import lazy_gettext as __
def get_menu_keyboard():
"""Keyboard with lazy translations."""
buttons = [
[InlineKeyboardButton(
text=__("button-start"),
callback_data="start"
)],
[InlineKeyboardButton(
text=__("button-settings"),
callback_data="settings"
)],
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
from aiogram_i18n import I18nContext
@router.message(Command("language"))
async def cmd_language(message: Message, i18n: I18nContext):
# Get current locale
current = i18n.locale
# Set new locale
i18n.locale = "ru" # Switch to Russian
await message.answer(f"Language changed from {current} to {i18n.locale}")
from aiogram_i18n.middleware import I18nMiddleware
async def get_user_locale(event):
"""Get user's preferred locale from database."""
user_id = event.from_user.id
# Fetch from database
user_locale = await db.get_user_locale(user_id)
return user_locale or "en"
i18n_middleware = I18nMiddleware(
core=FluentRuntimeCore(path="locales/{locale}/LC_MESSAGES"),
manager=get_user_locale # Custom locale resolver
)
Magic Filter provides expressive syntax for filtering messages, callbacks, and events.
Documentation:
from aiogram import F
# Exact match
@router.message(F.text == "Hello")
# Contains
@router.message(F.text.contains("hello"))
# Starts with
@router.message(F.text.startswith("/"))
# Ends with
@router.message(F.text.endswith("!"))
# Case-insensitive
@router.message(F.text.lower() == "hello")
# Is digit
@router.message(F.text.isdigit())
# Regex match
@router.message(F.text.regexp(r"^\d{3}-\d{3}-\d{4}$")) # Phone number
# Photo message
@router.message(F.photo)
# Document with specific extension
@router.message(F.document.file_name.endswith(".pdf"))
# Video message
@router.message(F.video)
# Audio message
@router.message(F.audio)
# Voice message
@router.message(F.voice)
# Sticker
@router.message(F.sticker)
# Exact callback data
@router.callback_query(F.data == "button_click")
# Callback data starts with
@router.callback_query(F.data.startswith("page_"))
# Callback data contains
@router.callback_query(F.data.contains("product"))
from aiogram.filters import Command
# Combine with AND
@router.message(Command("admin") & F.from_user.id.in_([123, 456]))
# Combine with OR
@router.message(F.text.contains("help") | F.text.contains("support"))
# Negate with NOT
@router.message(~F.photo) # Messages without photos
# User ID
@router.message(F.from_user.id == 123456789)
# User IDs in list
@router.message(F.from_user.id.in_([123, 456, 789]))
# Username
@router.message(F.from_user.username == "john_doe")
# Is bot
@router.message(F.from_user.is_bot == False)
# handlers/admin.py
from aiogram import Router
admin_router = Router()
@admin_router.message(Command("admin"))
async def admin_panel(message: Message):
await message.answer("Admin panel")
# handlers/user.py
user_router = Router()
@user_router.message(Command("profile"))
async def user_profile(message: Message):
await message.answer("User profile")
# bot.py
dp.include_router(admin_router)
dp.include_router(user_router)
from aiogram import BaseMiddleware
from typing import Callable, Dict, Any, Awaitable
class AuthMiddleware(BaseMiddleware):
def __init__(self, admin_ids: list[int]):
self.admin_ids = admin_ids
super().__init__()
async def __call__(
self,
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
event: Message,
data: Dict[str, Any]
) -> Any:
if event.from_user.id not in self.admin_ids:
await event.answer("⛔ Access denied!")
return
data["is_admin"] = True
return await handler(event, data)
# Register middleware
admin_router.message.middleware(AuthMiddleware(admin_ids=[123456789]))
import asyncio
from aiogram import Bot, Dispatcher, Router
from aiogram.filters import Command
from aiogram.fsm.storage.redis import RedisStorage
from aiogram.fsm.scene import Scene, on, ScenesManager, SceneRegistry
from aiogram_dialog import Dialog, Window, DialogManager, setup_dialogs, StartMode
from aiogram_dialog.widgets.text import Const, Format
from aiogram_dialog.widgets.kbd import Button, Cancel, SwitchTo
from aiogram_i18n import I18nMiddleware
from aiogram_i18n.cores import FluentRuntimeCore
from redis.asyncio import Redis
# Configuration
BOT_TOKEN = "YOUR_TOKEN"
# Initialize bot with Redis storage
redis = Redis(host='localhost', port=6379)
storage = RedisStorage(redis=redis)
bot = Bot(token=BOT_TOKEN)
dp = Dispatcher(storage=storage)
# Setup i18n
i18n = I18nMiddleware(
core=FluentRuntimeCore(path="locales/{locale}/LC_MESSAGES"),
default_locale="en"
)
i18n.setup(dispatcher=dp)
# Scene example
class WelcomeScene(Scene, state="welcome"):
@on.message.enter()
async def on_enter(self, message: Message):
await message.answer("Welcome to the bot! Type /menu for options.")
@on.message()
async def handle(self, message: Message, wizard: SceneWizard):
await wizard.exit()
# Dialog example
class MenuStates(StatesGroup):
main = State()
menu_window = Window(
Const("🏠 Main Menu"),
Button(Const("ℹ️ Info"), id="info", on_click=lambda c, b, m: c.answer("Bot info!")),
Cancel(Const("❌ Close")),
state=MenuStates.main
)
menu_dialog = Dialog(menu_window)
# Router setup
main_router = Router()
main_router.include_router(menu_dialog)
scene_registry = SceneRegistry(main_router)
scene_registry.add(WelcomeScene)
@main_router.message(Command("start"))
async def cmd_start(message: Message, scenes: ScenesManager):
await scenes.enter(WelcomeScene)
@main_router.message(Command("menu"))
async def cmd_menu(message: Message, dialog_manager: DialogManager):
await dialog_manager.start(MenuStates.main, mode=StartMode.RESET_STACK)
# Include router and setup
dp.include_router(main_router)
setup_dialogs(dp)
# Run bot
async def main():
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
Use aiogram-dialog for Complex UIs
Use Scenes for Isolated Workflows
Use FSM for Simple Multi-Step Forms
Use i18n for Multi-Language Bots
Use Magic Filter (F) for Expressive Filtering
Don't Mix Dialog and FSM for Same Feature
Don't Forget to Call setup_dialogs(dp)
start_polling()Don't Ignore Scene Isolation
| Feature | FSM | aiogram-dialog | Scenes | |---------|-----|----------------|--------| | Best For | Linear forms | GUI menus | Isolated flows | | Complexity | Low | Medium | Medium | | UI Style | Custom keyboards | Widget-based | Custom | | Navigation | State transitions | Window switching | Scene jumps | | Data Storage | FSMContext | DialogManager | FSMContext | | Isolation | None | None | Full | | Reusability | Low | High (widgets) | Medium |
Testing is critical for production bots. There are two main approaches: unit testing with mocks and integration testing with real Telegram API.
Testing Philosophy:
aiogram-tests (GitHub) provides MockedBot for testing handlers without real Telegram API calls.
Installation:
pip install aiogram-tests pytest pytest-asyncio
# Using uv
uv pip install aiogram-tests pytest pytest-asyncio
# tests/test_handlers.py
import pytest
from aiogram_tests import MockedBot
from aiogram_tests.handler import MessageHandler
from aiogram_tests.types.dataset import MESSAGE
from handlers.start import cmd_start # Your handler
@pytest.mark.asyncio
async def test_start_command():
"""Test /start command returns welcome message."""
request = MockedBot(MessageHandler(cmd_start))
calls = await request.query(message=MESSAGE.as_object(text="/start"))
answer_message = calls.send_message.fetchone()
assert "Welcome" in answer_message.text
@pytest.mark.asyncio
async def test_echo_handler():
"""Test echo handler repeats user message."""
from handlers.echo import echo_handler
request = MockedBot(MessageHandler(echo_handler))
calls = await request.query(message=MESSAGE.as_object(text="Hello!"))
answer = calls.send_message.fetchone()
assert answer.text == "Hello!"
import pytest
from aiogram_tests import MockedBot
from aiogram_tests.handler import MessageHandler
from aiogram_tests.types.dataset import MESSAGE
from aiogram.fsm.context import FSMContext
from handlers.forms import cmd_register, process_name
from states.forms import RegistrationForm
@pytest.mark.asyncio
async def test_registration_start():
"""Test /register command starts form."""
request = MockedBot(MessageHandler(cmd_register))
calls = await request.query(message=MESSAGE.as_object(text="/register"))
answer = calls.send_message.fetchone()
assert "enter your name" in answer.text.lower()
@pytest.mark.asyncio
async def test_registration_name_step():
"""Test name processing in registration form."""
request = MockedBot(MessageHandler(process_name))
# Simulate FSM state
calls = await request.query(
message=MESSAGE.as_object(text="John"),
state=RegistrationForm.name
)
answer = calls.send_message.fetchone()
assert "age" in answer.text.lower()
from aiogram_tests.types.dataset import CALLBACK_QUERY
@pytest.mark.asyncio
async def test_menu_callback():
"""Test menu button callback."""
from handlers.menu import process_menu_settings
request = MockedBot(CallbackQueryHandler(process_menu_settings))
calls = await request.query(
callback_query=CALLBACK_QUERY.as_object(data="menu_settings")
)
# Verify callback was answered
assert calls.answer_callback_query.called
# Verify message was edited
edited = calls.edit_message_text.fetchone()
assert "Settings" in edited.text
# conftest.py
import pytest
from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.memory import MemoryStorage
@pytest.fixture
def bot():
"""Create bot instance for tests."""
return Bot(token="TEST_TOKEN")
@pytest.fixture
def dp():
"""Create dispatcher with memory storage."""
return Dispatcher(storage=MemoryStorage())
@pytest.fixture
async def setup_bot(dp):
"""Setup bot with all handlers."""
from handlers import start, forms, menu
dp.include_router(start.router)
dp.include_router(forms.router)
dp.include_router(menu.router)
yield dp
# Cleanup
await dp.storage.close()
@pytest.mark.asyncio
async def test_menu_dialog():
"""Test menu dialog window content."""
from dialogs.menu import main_window, MenuStates
# Mock dialog manager
dialog_manager = MockDialogManager()
# Render window
content = await main_window.render_text(
dialog_manager,
state=MenuStates.main
)
assert "Main Menu" in content
Integration tests use real Telegram API to test bot behavior as a user would experience it. This catches issues that unit tests miss.
Why Integration Testing?
Resources:
Requirements:
pip install telethon pytest pytest-asyncio
# Using uv
uv pip install telethon pytest pytest-asyncio
Get Telegram API Credentials:
api_id and api_hash# tests/conftest.py
import pytest
import asyncio
from telethon import TelegramClient
from telethon.sessions import StringSession
# Store session string in environment variable for CI/CD
SESSION_STRING = "YOUR_SESSION_STRING" # From first login
API_ID = 12345 # Your API ID
API_HASH = "your_api_hash" # Your API hash
BOT_USERNAME = "@your_bot"
@pytest.fixture(scope="session")
def event_loop():
"""Create event loop for async tests."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
async def client():
"""Create Telethon client (acts as user)."""
client = TelegramClient(
StringSession(SESSION_STRING),
API_ID,
API_HASH
)
await client.start()
yield client
await client.disconnect()
@pytest.fixture(scope="session")
async def bot_process():
"""Start bot in background for tests."""
import subprocess
import time
# Start bot
process = subprocess.Popen(["python", "bot.py"])
time.sleep(2) # Wait for bot to start
yield process
# Stop bot
process.terminate()
process.wait()
@pytest.fixture
async def conversation(client):
"""Create conversation with bot."""
async with client.conversation(BOT_USERNAME) as conv:
# Send /start to initialize
await conv.send_message("/start")
await conv.get_response()
yield conv
# tests/test_integration.py
import pytest
from telethon.tl.custom import Conversation
@pytest.mark.asyncio
async def test_start_command(conversation: Conversation):
"""Test /start command flow."""
await conversation.send_message("/start")
response = await conversation.get_response()
assert "Welcome" in response.text
assert response.buttons # Has keyboard
@pytest.mark.asyncio
async def test_registration_flow(conversation: Conversation):
"""Test complete registration flow."""
# Start registration
await conversation.send_message("/register")
response = await conversation.get_response()
assert "name" in response.text.lower()
# Enter name
await conversation.send_message("John Doe")
response = await conversation.get_response()
assert "age" in response.text.lower()
# Enter age
await conversation.send_message("25")
response = await conversation.get_response()
assert "email" in response.text.lower()
# Enter email
await conversation.send_message("[email protected]")
response = await conversation.get_response()
assert "confirm" in response.text.lower()
# Confirm
await conversation.send_message("yes")
response = await conversation.get_response()
assert "complete" in response.text.lower()
@pytest.mark.asyncio
async def test_inline_keyboard(conversation: Conversation):
"""Test inline keyboard interaction."""
await conversation.send_message("/menu")
response = await conversation.get_response()
# Find "Settings" button
settings_button = None
for row in response.buttons:
for button in row:
if "Settings" in button.text:
settings_button = button
break
assert settings_button is not None
# Click button
await settings_button.click()
response = await conversation.get_edit() # Wait for message edit
assert "Settings" in response.text
@pytest.mark.asyncio
async def test_product_selection(conversation: Conversation):
"""Test aiogram-dialog product selection."""
await conversation.send_message("/shop")
response = await conversation.get_response()
# Find product button
laptop_button = None
for row in response.buttons:
for button in row:
if "Laptop" in button.text:
laptop_button = button
break
assert laptop_button is not None
# Select product
await laptop_button.click()
# Wait a bit for processing (important for CI/CD)
import asyncio
await asyncio.sleep(0.5)
response = await conversation.get_edit()
assert "added to cart" in response.text.lower()
# tests/helpers.py
import asyncio
from telethon.tl.custom import Message
async def find_button(message: Message, text: str):
"""Find button by text."""
if not message.buttons:
return None
for row in message.buttons:
for button in row:
if text in button.text:
return button
return None
async def click_and_wait(button, conv, delay=0.5):
"""Click button and wait for response."""
await button.click()
await asyncio.sleep(delay)
return await conv.get_edit()
# Usage in tests
@pytest.mark.asyncio
async def test_with_helpers(conversation):
await conversation.send_message("/menu")
response = await conversation.get_response()
settings = await find_button(response, "Settings")
assert settings is not None
response = await click_and_wait(settings, conversation)
assert "Settings" in response.text
1. Add Delays for Stability
import asyncio
# After bot actions, especially in CI/CD
await asyncio.sleep(0.5)
2. Use Random Data
import uuid
# Avoid database cleanup
username = f"test_user_{uuid.uuid4().hex[:8]}"
3. Isolate Tests
# Clear state between tests
@pytest.fixture(autouse=True)
async def reset_state(conversation):
await conversation.send_message("/cancel")
yield
4. Test Realistic Scenarios
@pytest.mark.asyncio
async def test_error_recovery(conversation):
"""Test bot handles invalid input gracefully."""
await conversation.send_message("/register")
await conversation.get_response()
# Send invalid age
await conversation.send_message("abc")
response = await conversation.get_response()
assert "valid number" in response.text.lower()
# Retry with valid age
await conversation.send_message("25")
response = await conversation.get_response()
assert "email" in response.text.lower()
What to Test:
Tools:
Example:
# Unit test - fast, no API
test_registration_validates_age()
test_menu_returns_correct_keyboard()
What to Test:
Tools:
Example:
# Integration test - slow, real API
test_complete_registration_flow()
test_product_purchase_with_payment()
/\
/ \ Integration Tests (Few, Slow, Realistic)
/____\
/ \
/ \ Unit Tests (Many, Fast, Isolated)
/__________\
Ratio: ~80% unit tests, ~20% integration tests
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest pytest-asyncio aiogram-tests
- name: Run unit tests
run: pytest tests/unit/
- name: Run integration tests
env:
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
TELETHON_API_ID: ${{ secrets.TELETHON_API_ID }}
TELETHON_API_HASH: ${{ secrets.TELETHON_API_HASH }}
TELETHON_SESSION: ${{ secrets.TELETHON_SESSION }}
run: pytest tests/integration/
# Install coverage
pip install pytest-cov
# Run with coverage
pytest --cov=handlers --cov=dialogs --cov-report=html
# View report
open htmlcov/index.html
★ Insight ─────────────────────────────────────
1. The aiogram Ecosystem is Modular
2. Choose the Right Tool
3. aiogram-dialog Transforms UX
4. Scenes Enable Context Isolation
5. Testing is Essential
6. Production-Ready Stack
documentation
Enrich Markdown articles with inline Wikipedia links. First mention of each notable entity gets a hyperlink. Use when asked to add wiki links, enrich, or add references to .md files.
development
Structured visual QA: screenshot → batch issues → fix all → verify. Replaces the 300-cycle screenshot→edit death spiral. Optional bishkek review as exit gate. Use when building/polishing UI with browser testing, or when user asks for N iterations/reviews.
development
Find complex code, analyze intent, recommend battle-tested library replacements. Uses radon/eslint for detection, GitHub quality search for alternatives.
research
Research real-world UI patterns from curated galleries (Collect UI, Component Gallery, Mobbin). Use when exploring what exists: dropdowns, accordions, inputs, navigation, cards, modals, etc.