Aiogram 3: туториал по созданию Telegram-бота на Python
Соберите бота на Aiogram 3: безопасно настройте токен, подключите Router, inline-кнопки и FSM, проверьте сценарий и запустите через systemd.
В этом туториале соберём запускаемого Telegram-бота на Aiogram 3: он ответит на /start, покажет inline-кнопки, обработает callback и примет короткую заявку через FSM. Токен читается из переменной окружения, а все обработчики подключены через Router.
Если удобнее идти по шагам с заданиями, начните с бесплатного практикума Aiogram 3: там шесть уроков от первого ответа до FSM и публикации на VPS.
Подготовка проекта
Нужен Python 3.10 или новее. Создайте бота через @BotFather, затем подготовьте виртуальное окружение:
mkdir aiogram-demo
cd aiogram-demo
python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install "aiogram==3.31.0"
В Windows PowerShell активируйте окружение командой \.venv\Scripts\Activate.ps1. Не добавляйте токен в исходный код или Git. Передайте его процессу через окружение:
# macOS / Linux
read -rsp "Токен бота: " BOT_TOKEN; echo
export BOT_TOKEN
python bot.py
# Windows PowerShell
$secure = Read-Host "Токен бота" -AsSecureString
$env:BOT_TOKEN = [System.Net.NetworkCredential]::new("", $secure).Password
python bot.py
Полный пример: бот, кнопки и FSM
Создайте bot.py. Здесь один Router содержит обработчики запуска, callback и формы. В реальном проекте его можно разнести по модулям, сохранив явное подключение роутеров к Dispatcher.
import asyncio
import os
from aiogram import Bot, Dispatcher, F, Router
from aiogram.filters import Command, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
class OrderForm(StatesGroup):
name = State()
contact = State()
router = Router(name=__name__)
def main_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="Показать каталог", callback_data="catalog")
builder.button(text="Оставить заявку", callback_data="order:start")
builder.adjust(1)
return builder.as_markup()
@router.message(CommandStart())
async def start(message: Message, state: FSMContext) -> None:
await state.clear()
name = message.from_user.full_name if message.from_user else "друг"
await message.answer(f"Привет, {name}! Выберите действие:", reply_markup=main_keyboard())
@router.callback_query(F.data == "catalog")
async def show_catalog(callback: CallbackQuery) -> None:
if not isinstance(callback.message, Message):
await callback.answer("Откройте бота в личном чате", show_alert=True)
return
await callback.answer()
await callback.message.edit_text("Демо-каталог: товар A, товар B", reply_markup=main_keyboard())
@router.callback_query(F.data == "order:start")
async def start_order(callback: CallbackQuery, state: FSMContext) -> None:
if not isinstance(callback.message, Message):
await callback.answer("Откройте бота в личном чате", show_alert=True)
return
await callback.answer()
await state.set_state(OrderForm.name)
await callback.message.answer("Как к вам обращаться? Для отмены отправьте /cancel.")
@router.message(Command("cancel"))
async def cancel_order(message: Message, state: FSMContext) -> None:
await state.clear()
await message.answer("Форма отменена. Начать заново можно кнопкой «Оставить заявку».")
@router.message(OrderForm.name, F.text)
async def get_name(message: Message, state: FSMContext) -> None:
name = message.text.strip()
if not name:
await message.answer("Имя не должно быть пустым. Попробуйте ещё раз.")
return
await state.update_data(name=name)
await state.set_state(OrderForm.contact)
await message.answer("Для теста введите пример контакта, например @demo. Не отправляйте реальные персональные данные.")
@router.message(OrderForm.name)
async def reject_non_text_name(message: Message) -> None:
await message.answer("Отправьте имя обычным текстовым сообщением.")
@router.message(OrderForm.contact, F.text)
async def get_contact(message: Message, state: FSMContext) -> None:
contact = message.text.strip()
if not contact:
await message.answer("Контакт не должен быть пустым. Попробуйте ещё раз.")
return
await state.update_data(contact=contact)
data = await state.get_data()
await state.clear()
# В рабочем сервисе сохраните заявку в базе до отправки уведомления.
# Не пишите контактные данные в логи и не оставляйте их в тестовых чатах.
await message.answer(f"Спасибо, {data['name']}! Демоформа пройдена; данные не сохранены.")
@router.message(OrderForm.contact)
async def reject_non_text_contact(message: Message) -> None:
await message.answer("Отправьте пример контакта обычным текстовым сообщением.")
async def main() -> None:
token = os.getenv("BOT_TOKEN")
if not token:
raise RuntimeError("Задайте BOT_TOKEN в окружении перед запуском")
bot = Bot(token=token)
dispatcher = Dispatcher()
dispatcher.include_router(router)
await dispatcher.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
Откройте своего бота и проверьте /start, обе кнопки, два шага формы и /cancel. У callback есть отдельный ответ через callback.answer(): он подтверждает нажатие Telegram-клиенту; если обработчик завершился ошибкой раньше, интерфейс кнопки может продолжать показывать ожидание.
Что важно в примере
Router и фильтры
Router группирует обработчики, а Dispatcher получает обновления и подключает один или несколько роутеров. Фильтры вроде CommandStart() и F.data == "catalog" выбирают подходящий обработчик до выполнения его логики. Не добавляйте «поймать всё» выше обработчиков FSM: общий обработчик может перехватить текст ответа.
FSM и хранилище
FSMContext передаётся обработчику автоматически. В Dispatcher() без параметра используется MemoryStorage: состояния и данные исчезнут при остановке процесса. Для сценариев, которые должны переживать перезапуск, подключайте внешнее хранилище. Например, Aiogram предоставляет RedisStorage, которому нужен пакет redis и работающий Redis-сервер. Само FSM-хранилище не заменяет базу данных заявок или заказов.
Персональные данные
Пример формы демонстрационный: перед сбором реальных контактов сообщите пользователю цель и срок хранения, ограничьте доступ и сохраняйте данные в защищённом хранилище. Не печатайте весь объект пользователя или текст сообщений в обычные логи.
Middleware без предположения о типе Update
Update может быть не только сообщением от пользователя. Middleware на уровне dispatcher.update не должен без проверки обращаться к event.from_user. Если нужно измерять обработку именно сообщений, используйте message middleware и не логируйте содержимое:
import logging
from time import perf_counter
from typing import Any, Awaitable, Callable
from aiogram import BaseMiddleware
from aiogram.types import Message
logger = logging.getLogger(__name__)
class MessageTimingMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[Message, dict[str, Any]], Awaitable[Any]],
event: Message,
data: dict[str, Any],
) -> Any:
started = perf_counter()
try:
return await handler(event, data)
finally:
logger.info(
"message_handled",
extra={"duration_ms": round((perf_counter() - started) * 1000)},
)
router.message.outer_middleware(MessageTimingMiddleware())
Настройте logging в точке входа, если хотите видеть это сообщение в консоли. Middleware должен вызывать следующий обработчик, а блок finally позволит измерить и неуспешную обработку.
Запуск через systemd
Для постоянного процесса создайте отдельного системного пользователя. Код и виртуальное окружение в /opt/mybot должны быть доступны ему на чтение и выполнение. Файл с токеном /etc/mybot/mybot.env сделайте доступным только root и группе сервиса: владелец root:mybot, права 0640. Не записывайте токен в unit-файл или репозиторий.
sudo useradd --system --user-group --no-create-home --shell /usr/sbin/nologin mybot
sudo install -d -o root -g mybot -m 0750 /etc/mybot
sudo install -o root -g mybot -m 0640 /dev/null /etc/mybot/mybot.env
sudoedit /etc/mybot/mybot.env
Добавьте в env-файл строку BOT_TOKEN=... и сохраните его. Подготовьте /opt/mybot и .venv так, чтобы группа mybot могла читать файлы и проходить по каталогам; не выдавайте сервису право менять код.
# /etc/systemd/system/mybot.service
[Unit]
Description=Aiogram bot
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=mybot
Group=mybot
WorkingDirectory=/opt/mybot
EnvironmentFile=/etc/mybot/mybot.env
ExecStart=/opt/mybot/.venv/bin/python /opt/mybot/bot.py
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now mybot
sudo systemctl status mybot
sudo journalctl -u mybot -f
Проверьте логи после запуска и отправьте боту /start. Для обновления сначала остановите сервис, обновите код и зависимости в виртуальном окружении, затем запустите его снова. Если токен случайно попал в Git или логи, отзовите его через BotFather и выпустите новый.
Что дальше
Для первого законченного проекта пройдите бесплатный практикум Aiogram 3. Когда понадобятся PostgreSQL, платежи, Mini App, webhook, Docker и тесты, переходите к полному курсу по Telegram-ботам.
Пример закреплён на Aiogram 3.31.0; перед обновлением сверяйте изменения в release notes. API можно проверить в документации по Router, FSM storage и middleware. Практические проекты и исходники связаны с кейсом Telegram-магазина FazerCards.
по теме
следующий шаг
Собрать первого Telegram-бота
Бесплатный практикум Aiogram 3 помогает пройти путь от первого ответа до FSM и деплоя на VPS.
поделиться
Нужна разработка Telegram-бота?
Разрабатываю ботов, Mini Apps, платежи и автоматизацию под ключ.