На проектах с Telegram-ботами разбор callback data - то место, где новички обычно упираются в стену: кнопки шлют обратно строки, а не готовые объекты, и без нормального фильтра хендлер обрастает вложенными if на пять экранов. Фильтр F.data aiogram 3 - магический фильтр (magic filter), который решает эту задачу в одну строку декоратора: сравнение, маска, вхождение подстроки - всё это можно описать прямо в аргументах хендлера, не трогая тело функции. Ниже - как я строю такие фильтры на практике, с примерами из ботов для записи на консультацию, уведомлений о статусе доставки СДЭК и интеграций с CRM.
Зачем нужен фильтр F.data в aiogram 3
Когда пользователь нажимает inline-кнопку, Telegram присылает боту объект CallbackQuery, а внутри него - поле data, обычная строка длиной до 64 байт. Никакой типизации, никакой структуры по умолчанию - что записали в callback_data при создании кнопки, то и придёт назад. В aiogram 2 это разбирали через callback_data.split(“:”) и кучу ручных проверок. В aiogram 3 для этого есть объект F - обёртка над magic filter, которая позволяет описывать условие декларативно, не открывая тело хендлера.
Самый простой случай - точное совпадение:
from aiogram import F, Router
from aiogram.types import CallbackQuery
router = Router()
@router.callback_query(F.data == "confirm_order")
async def confirm_order(callback: CallbackQuery):
await callback.answer("Заказ подтверждён")
await callback.message.edit_text("Спасибо, заказ в обработке")
Важный момент, о который спотыкаются почти все: callback.answer() нужно вызывать всегда, даже если не собираетесь показывать всплывающее уведомление. Без него у пользователя кнопка «крутится» до таймаута - Telegram ждёт подтверждения от бота, что колбэк обработан.
Разбор callback_data: от точного совпадения до масок
Живой бот редко ограничивается одной кнопкой с одним значением. На практике у меня почти в каждом проекте есть пагинация, категории, фильтры - и на каждый вариант заводить отдельный F.data == “…” неудобно.
Точное совпадение и списки значений
Если вариантов немного и они известны заранее, удобно сравнивать с множеством через in_:
@router.callback_query(F.data.in_({"yes", "no"}))
async def answer_yes_no(callback: CallbackQuery):
if callback.data == "yes":
await callback.message.answer("Записал вас на приём")
else:
await callback.message.answer("Хорошо, отменил запись")
Проверка вхождения и префиксов
Для пагинации и категорий я почти всегда использую префикс с разделителем и startswith:
@router.callback_query(F.data.startswith("page_"))
async def paginate(callback: CallbackQuery):
page = int(callback.data.split("_")[1])
await show_page(callback, page)
Есть ещё F.data.contains(“…”) и F.data.endswith(“…”) - они реже нужны, но выручают, когда в одной строке смешаны идентификатор и статус, например order_42_paid.
Слабое место этого подхода одно и то же везде: строковый разбор ломается тихо. Опечатался в разделителе, добавил в id заказа символ подчёркивания - и split даёт не тот индекс, а исключение вылетает не в фильтре, а глубоко в хендлере, когда его уже сложно связать с причиной.
Бесплатный материал
🎁 Полезный скрипт в подарок
Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.
Без спама. Отписка в 1 клик.
CallbackData фабрика: структурированный разбор данных кнопки
Когда в кнопке нужно передать больше одного значения - id заказа, действие, страницу - я перехожу на CallbackData из aiogram.filters.callback_data. Это класс на pydantic, который сам сериализует поля в строку с разделителем и разбирает её обратно, плюс следит за лимитом в 64 байта.
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
class OrderCallback(CallbackData, prefix="order"):
action: str
order_id: int
def order_keyboard(order_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(
text="Подтвердить",
callback_data=OrderCallback(action="confirm", order_id=order_id).pack()
),
InlineKeyboardButton(
text="Отменить",
callback_data=OrderCallback(action="cancel", order_id=order_id).pack()
),
]])
Фильтр на такую кнопку строится через .filter(), а в аргументы хендлера aiogram сам прокинет уже распакованный объект:
@router.callback_query(OrderCallback.filter(F.action == "confirm"))
async def confirm_handler(callback: CallbackQuery, callback_data: OrderCallback):
order_id = callback_data.order_id
await mark_order_paid(order_id)
await callback.answer("Оплата подтверждена")
На боте для приёма заявок, который я связывал с CRM и приёмом оплат через T‑Bank, именно эта конструкция сняла добрую половину багов: order_id всегда int, action всегда одно из объявленных значений, а если менеджер случайно передаст в кнопку строку длиннее лимита, pydantic упадёт на этапе создания клавиатуры, а не через неделю на проде при нажатии кнопки живым клиентом.
Если не хочется писать фабрики с нуля под каждый типовой сценарий - подтверждение заказа, пагинацию, выбор даты для записи - у меня в библиотеке готовых скриптов есть рабочие заготовки под aiogram, которые можно адаптировать под свою модель данных за пару часов вместо дня отладки.
Комбинирование F.data с другими фильтрами
Один F.data редко покрывает всю логику хендлера. На практике его комбинируют со StateFilter (текущий шаг FSM) и с проверкой отправителя:
@router.callback_query(
OrderCallback.filter(F.action == "cancel"),
StateFilter(OrderStates.waiting_confirm),
)
async def cancel_in_progress(callback: CallbackQuery, callback_data: OrderCallback, state: FSMContext):
await state.clear()
await callback.message.edit_text("Заявка отменена")
Перечисленные через запятую фильтры в aiogram работают как логическое И - сработают все сразу. Через операторы & и | можно строить более сложные условия прямо внутри F.data, например F.data.startswith(“admin_”) & ~F.from_user.id.in_(admin_ids), чтобы отсечь чужие нажатия на административные кнопки в общем чате.
Вот сводка, какой вариант когда выбираю:
| Способ | Когда использую | Плюсы | Минусы |
|---|---|---|---|
| F.data == “…” | Одна фиксированная кнопка (подтвердить, отмена, назад) | Максимально просто | Не масштабируется на параметры |
| F.data.startswith / contains | Пагинация, простые категории | Не нужен отдельный класс | Хрупкий парсинг строки вручную |
| F.data.in_({…}) | Небольшой фиксированный набор значений | Читаемо, без опечаток в проверках | Неудобно при десятках вариантов |
| CallbackData фабрика | Несколько параметров в кнопке (action + id + …) | Валидация, автоматическая упаковка/распаковка | Чуть больше кода на старте |
Частые ошибки при работе с фильтрами колбэков в aiogram 3
За несколько лет разработки ботов на aiogram эти грабли повторяются почти у каждого второго заказчика, который приходит с готовым, но глючным ботом на доработку.
Первое - лимит в 64 байта на callback_data. Если пытаетесь запихнуть в кнопку JSON с несколькими полями и длинными названиями через обычную f‑строку, легко вылезти за лимит на реальных данных, хотя в тестах с короткими id всё работало. CallbackData фабрика с короткими именами полей и prefix решает это надёжнее, чем ручная сериализация.
Второе - порядок регистрации хендлеров. aiogram проверяет фильтры сверху вниз и выполняет первый подошедший хендлер. Если сначала стоит общий F.data.startswith(“order_”), а ниже - более узкий F.data == “order_cancel”, второй хендлер никогда не сработает, потому что первый уже перехватил колбэк. На одном боте для записи клиентов я потратил час, разбираясь, почему кнопка отмены не работает - а причина была именно в порядке роутеров.
Третье - отсутствие проверки автора нажатия. Если сообщение с кнопками видно нескольким пользователям (например, в группе или пересланное сообщение), любой человек может нажать чужую inline-кнопку. Для действий вроде подтверждения оплаты или отмены записи я всегда добавляю сверку callback.from_user.id с id, для которого создавалась клавиатура - либо сохраняю его в CallbackData, либо в FSM-контексте.
Четвёртое - путаница между callback.data и callback.message.text. Иногда в хендлере по ошибке лезут в текст сообщения вместо данных кнопки, особенно когда переносят логику из обработчика обычных сообщений. Это ловится сразу при первом тесте, но на код-ревью встречается регулярно.
Автоматизация в мессенджере
Telegram-бот / Mini App
от 30 000 ₽
Подробнее →Частые вопросы
В чём разница между F.data и CallbackData фабрикой в aiogram 3?
F.data - это прямая работа со строкой callback_data: сравнение, startswith, contains и подобные операции. CallbackData фабрика - надстройка на pydantic, которая сама упаковывает несколько полей в строку по заданному префиксу и разбирает её обратно в типизированный объект. Для одной фиксированной кнопки хватает F.data, для кнопок с параметрами (id, действие, страница) удобнее фабрика.
Можно ли передать в callback_data сложный объект вроде JSON?
Технически строку можно набить произвольным содержимым, но лимит в 64 байта делает JSON рискованным - он быстро съедает лимит на скобках, кавычках и именах полей. Для нескольких параметров лучше CallbackData фабрика с короткими именами полей, а для действительно объёмных данных - хранить их на своей стороне (в базе или FSM-контексте) и передавать в кнопке только короткий идентификатор записи.
Как ограничить обработку колбэка только автором исходного сообщения?
Сохраняю id пользователя либо прямо в CallbackData (отдельным полем), либо в состоянии FSM при показе клавиатуры, а в хендлере сверяю его с callback.from_user.id перед выполнением действия. Если id не совпадает, отвечаю через callback.answer с коротким текстом вроде “Эта кнопка не для вас” и show_alert=True, ничего не выполняя.
Что делать, если callback_data превышает разрешённые 64 байта?
aiogram и сама CallbackData фабрика поднимут ошибку при попытке упаковать слишком длинные данные - это надёжнее, чем ловить обрезанную строку в проде. Решение обычно одно: сократить имена полей и хранимые значения (например, использовать числовой id вместо строкового slug), а всё, что не помещается, вынести в базу и передавать в кнопке только ссылку на запись.