Кнопки в мессенджере — главный инструмент управления вниманием пользователя. Хорошо спроектированная клавиатура превращает 30-секундный диалог в 3 тапа, а плохая — заставляет писать «не понял» и звать оператора. В этой статье разберём, как правильно проектировать и реализовывать inline-кнопки и reply-клавиатуры в боте MAX от VK Tech: какие бывают типы, как устроен callback_query, как делать пагинацию и многоуровневые меню, как обрабатывать массовые нажатия и не словить rate limit, и какие ошибки повторяются у 90% команд на старте.
Какие бывают клавиатуры в боте MAX
Условно все клавиатуры мессенджера делятся на три класса:
- Reply-клавиатура — заменяет системную клавиатуру у пользователя. Кнопки выглядят как «системные», нажатие отправляет текст в чат. Хорошо для главного меню, частых команд, выбора одного из 2–6 пунктов.
- Inline-клавиатура — прикрепляется к конкретному сообщению бота. Нажатие генерирует callback_query без сообщения в чат. Идеальна для пагинации, фильтров, переключателей, действий «лайк/удалить/подтвердить».
- Удалённая (custom) — например, кнопка-ссылка
url, кнопкаweb_app(открывает мини-приложение), кнопка для логина через MAX ID.
В большинстве сценариев бот использует обе: reply для главного меню, inline для контекстных действий внутри карточек.
Когда что использовать
| Сценарий | Тип клавиатуры | Почему |
|---|---|---|
| Главное меню | Reply | Всегда под рукой, привычно |
| Каталог товаров с пагинацией | Inline | Не засоряет чат сообщениями |
| Подтверждение «Да/Нет» | Inline | Привязка к контексту |
| Выбор города/языка | Reply (одноразовая) | Простой ввод параметра |
| Запись на услугу (дата → время → подтверждение) | Inline | Сохраняет состояние в одном сообщении |
| Открытие мини-приложения | Inline (web_app) | Single-tap UX |
| Переход на сайт | Inline (url) | Прямая внешняя ссылка |
| Лайк/дизлайк под постом | Inline | Минимальный шум |
Структура callback_data
В боте MAX (как и в Telegram) у inline-кнопки есть поле callback_data — строка до 64 байт, которую бот получит при нажатии. Это не место для произвольных данных: 64 байта — это очень мало. Правильный паттерн:
# Плохо
callback_data = json.dumps({"action": "buy", "product_id": 12345, "qty": 2})
# может не влезть, ломается при добавлении полей
# Хорошо: компактный schema
callback_data = "buy:12345:2" # action:id:qty
# или с версией для совместимости
callback_data = "v2:buy:12345:2"
Если данных много — храните payload в БД/Redis, а в callback_data кладите только короткий ключ:
import secrets, json
import redis.asyncio as redis
r = redis.from_url("redis://localhost")
async def make_callback(payload: dict, ttl: int = 3600) -> str:
key = secrets.token_urlsafe(8) # ~11 символов
await r.set(f"cb:{key}", json.dumps(payload), ex=ttl)
return f"cb:{key}"
async def resolve_callback(data: str) -> dict | None:
raw = await r.get(data)
return json.loads(raw) if raw else None
Это решает сразу три проблемы: лимит 64 байта, обратная совместимость при изменении схемы и устаревание кнопок.
Минимальный пример inline-меню
from max_bot_api import Bot, InlineKeyboardMarkup, InlineKeyboardButton
bot = Bot(token=os.environ["MAX_BOT_TOKEN"])
def build_main_menu() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton("Каталог", callback_data="menu:catalog")],
[InlineKeyboardButton("Заказы", callback_data="menu:orders"),
InlineKeyboardButton("Профиль", callback_data="menu:profile")],
[InlineKeyboardButton("Поддержка", callback_data="menu:support")],
])
@bot.message_handler(commands=["start"])
async def on_start(msg):
await bot.send_message(
msg.chat.id,
"Здравствуйте! Чем помочь?",
reply_markup=build_main_menu(),
)
@bot.callback_query_handler(lambda c: c.data.startswith("menu:"))
async def on_menu(call):
section = call.data.split(":", 1)[1]
if section == "catalog":
await show_catalog(call)
elif section == "orders":
await show_orders(call)
# ...
await bot.answer_callback_query(call.id)
Ключевой момент — обязательно вызывайте answer_callback_query. Иначе у пользователя будет крутиться индикатор «загрузка» до 30 секунд.
Пагинация: единый паттерн на все случаи
Самый частый сценарий — каталог/список с пагинацией. Шаблон callback_data:
list:<entity>:<page>:<filter>
Реализация:
PAGE_SIZE = 5
async def show_catalog(call, page: int = 0, category: str = "all"):
items, total = await db.fetch_products(category, offset=page * PAGE_SIZE, limit=PAGE_SIZE)
text = format_products(items, page, total)
kb = build_catalog_kb(items, page, total, category)
await bot.edit_message_text(
chat_id=call.message.chat.id,
message_id=call.message.message_id,
text=text,
reply_markup=kb,
)
def build_catalog_kb(items, page, total, category):
rows = [[InlineKeyboardButton(it.title, callback_data=f"prod:{it.id}")] for it in items]
nav = []
if page > 0:
nav.append(InlineKeyboardButton("‹", callback_data=f"list:catalog:{page-1}:{category}"))
nav.append(InlineKeyboardButton(f"{page+1}/{(total - 1) // PAGE_SIZE + 1}", callback_data="noop"))
if (page + 1) * PAGE_SIZE < total:
nav.append(InlineKeyboardButton("›", callback_data=f"list:catalog:{page+1}:{category}"))
rows.append(nav)
rows.append([InlineKeyboardButton("‹ Назад", callback_data="menu:main")])
return InlineKeyboardMarkup(inline_keyboard=rows)
Кнопка noop (для индикатора страницы) нужна потому, что пустой callback_data запрещён, но обработчик просто отвечает answer_callback_query без действий.
Многоуровневые меню и breadcrumbs
Проблема многоуровневых меню — пользователь забывает, где он находится. Решение — заголовок-«хлебные крошки» в тексте сообщения:
Каталог › Электроника › Смартфоны (стр. 2 из 5)
И кнопка «‹ Назад» — обязательно с callback_data, ведущим на родительский уровень, а не «откатывающим состояние», иначе после deep link пользователь окажется в пустоте.
Reply-клавиатура для главного меню
from max_bot_api import ReplyKeyboardMarkup, KeyboardButton
def build_reply_menu() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton("🛍 Каталог"), KeyboardButton("📦 Мои заказы")],
[KeyboardButton("👤 Профиль"), KeyboardButton("💬 Поддержка")],
],
resize_keyboard=True,
is_persistent=True,
)
Кнопки reply-клавиатуры обрабатываются как обычный текст — поэтому делайте их уникальными и сравнивайте по точному совпадению, а не по «contains». Эмодзи в начале — отличный визуальный якорь и делает текст уникальным.
Динамические клавиатуры и состояние FSM
Если кнопка зависит от состояния пользователя (выбран товар → появляется «купить»), храните state в Redis и собирайте клавиатуру каждый раз заново:
async def render_cart(chat_id: int, user_id: int, message_id: int | None = None):
cart = await load_cart(user_id)
if not cart.items:
text, kb = "Корзина пуста", InlineKeyboardMarkup([[InlineKeyboardButton("К каталогу", callback_data="menu:catalog")]])
else:
text = format_cart(cart)
kb = InlineKeyboardMarkup([
*[[InlineKeyboardButton(f"− {it.title}", callback_data=f"cart:dec:{it.id}"),
InlineKeyboardButton(f"+ {it.title}", callback_data=f"cart:inc:{it.id}")]
for it in cart.items],
[InlineKeyboardButton(f"Оформить — {cart.total}₽", callback_data="cart:checkout")],
[InlineKeyboardButton("Очистить", callback_data="cart:clear")],
])
if message_id:
await bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=text, reply_markup=kb)
else:
await bot.send_message(chat_id, text, reply_markup=kb)
Web App кнопки и мини-приложения
В MAX, как и в Telegram, есть концепция мини-приложений. Inline-кнопка web_app открывает HTML-страницу прямо в мессенджере с авторизацией пользователя:
from max_bot_api import WebAppInfo
kb = InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(
"Записаться онлайн",
web_app=WebAppInfo(url="https://booking.example.ru/widget"),
),
]])
Web App получает initData с подписью HMAC-SHA256 от секрета бота. Обязательно валидируйте подпись на бэкенде:
import hmac, hashlib
from urllib.parse import parse_qsl
def validate_init_data(init_data: str, bot_token: str) -> bool:
parsed = dict(parse_qsl(init_data, keep_blank_values=True))
received_hash = parsed.pop("hash", "")
data_check = "\n".join(f"{k}={parsed[k]}" for k in sorted(parsed))
secret = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
expected = hmac.new(secret, data_check.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received_hash)
Без валидации мини-приложение можно обмануть — клиент пришлёт «чужой» user_id.
Deep links и стартовые параметры
Кнопка «Начать диалог» с параметром (реферальная ссылка, конкретный товар):
https://max.ru/<bot_username>?start=ref_abc123
В обработчике /start:
@bot.message_handler(commands=["start"])
async def on_start(msg):
parts = msg.text.split(maxsplit=1)
payload = parts[1] if len(parts) > 1 else ""
if payload.startswith("ref_"):
await register_referral(msg.from_user.id, code=payload[4:])
elif payload.startswith("prod_"):
await show_product(msg.chat.id, product_id=int(payload[5:]))
else:
await bot.send_message(msg.chat.id, "Привет!", reply_markup=build_main_menu())
Deep link хорошо подходит для рекламных каналов — каждая кампания получает свой start параметр и трекинг в UTM.
Лимиты и rate limit
Платформа мессенджера лимитирует частоту нажатий и редактирования сообщений. Реалистичные ограничения:
- ~30 callback_query в секунду на бота;
- 1 редактирование сообщения в секунду на чат;
- кнопка
web_app— только в приватных чатах (для групп —url); - максимум 100 кнопок на одно сообщение (по 8 в строке).
Если пользователь дёргает «+/−» в корзине 10 раз в секунду — debounce на стороне бота:
from collections import defaultdict
import time
last_action: dict[tuple[int, str], float] = defaultdict(float)
DEBOUNCE = 0.3 # сек
async def is_throttled(user_id: int, action: str) -> bool:
key = (user_id, action)
now = time.monotonic()
if now - last_action[key] < DEBOUNCE:
return True
last_action[key] = now
return False
Типичные ошибки
- Не вызывают answer_callback_query — у пользователя крутится индикатор.
- Длинный callback_data — обрезается, обработчик ловит мусор.
- Эмодзи разной ширины в reply-кнопках — на iOS/Android выглядит криво.
- Тяжёлые операции в callback handler — пользователь видит «загрузку» 5 секунд, бросает.
- Нет «‹ Назад» — пользователь уходит из меню в /start и теряет контекст.
- Кнопка живёт вечно — старое сообщение через месяц всё ещё показывает кнопку «Купить за 990₽», но цена уже 1290₽.
- callback_data без префикса — обработчики ловят чужие нажатия, сложно роутить.
- Нет идемпотентности — двойной клик на «Подтвердить заказ» создаёт два заказа.
Идемпотентность нажатий
Двойной клик — реальная проблема. Решение — флаг в Redis с коротким TTL:
async def once(user_id: int, action_id: str, ttl: int = 30) -> bool:
key = f"once:{user_id}:{action_id}"
return await r.set(key, "1", nx=True, ex=ttl) is True
@bot.callback_query_handler(lambda c: c.data == "cart:checkout")
async def on_checkout(call):
if not await once(call.from_user.id, "checkout"):
await bot.answer_callback_query(call.id, "Уже обрабатываем ваш заказ")
return
await create_order(call.from_user.id)
await bot.answer_callback_query(call.id, "Заказ создан")
A/B-тесты кнопок
Кнопки — отличное поле для A/B. Меняйте текст («Купить» vs «Заказать»), порядок («Оформить — 990₽» vs «990₽ — Оформить»), цвет эмодзи. Простейший роутер:
def variant(user_id: int) -> str:
return "A" if user_id % 2 == 0 else "B"
def cta_label(user_id: int, price: int) -> str:
return f"Купить — {price}₽" if variant(user_id) == "A" else f"Оформить заказ за {price}₽"
Логируйте variant, clicked, converted — за неделю выйдут статзначимые цифры на 5–10K пользователей.
Итого
Inline-кнопки и reply-клавиатуры — фундамент UX в боте MAX. Основные принципы: компактный callback_data с префиксами для роутинга, обязательный answer_callback_query, отдельные обработчики на «namespace», state в Redis вместо JSON в callback, breadcrumbs в заголовке многоуровневых меню, валидация Web App initData по HMAC, debounce и идемпотентность для двойных кликов, обновление сообщения через editMessageText вместо отправки нового. Грамотно сделанное меню сокращает диалог в 3–5 раз и снижает нагрузку на поддержку.
Частые вопросы
Чем inline-клавиатура отличается от reply в боте MAX?
Reply-клавиатура заменяет системную клавиатуру пользователя и отправляет нажатия в чат как обычные сообщения — она хороша для главного меню и часто-используемых команд. Inline-клавиатура прикреплена к конкретному сообщению бота, генерирует callback_query без видимого сообщения в чате, идеальна для пагинации, фильтров, переключателей и контекстных действий. В большинстве ботов используют обе: reply для верхнего уровня, inline — для всего остального.
Какие ограничения у callback_data?
Поле callback_data ограничено 64 байтами — это очень мало для произвольного JSON. Правильный паттерн — короткий schema вида action:id:param либо хранение payload в Redis с коротким ключом в callback_data. Так вы избегаете и лимита, и проблем обратной совместимости при изменении схемы. Дополнительно префиксы вида menu:, cart:, prod: помогают роутить обработчики и не цепляют чужие нажатия.
Как сделать пагинацию с inline-кнопками?
Стандартный шаблон: callback_data вида list:<entity>:<page>:<filter>. На каждый клик обработчик пересчитывает offset и вызывает editMessageText с новой клавиатурой. Снизу добавляйте навигационную строку «‹ <page>/<total> ›» — кнопка с номером страницы передаёт callback_data="noop", который просто отвечает answer_callback_query. Это даёт привычный UX каталога без засорения чата новыми сообщениями.
Как защитить мини-приложение Web App от подделки?
Web App получает строку initData с подписью HMAC-SHA256 от секрета WebAppData + bot_token. На бэкенде обязательно проверяйте подпись: разбираете query-string, удаляете поле hash, сортируете остальные поля, склеиваете через \n, считаете HMAC и сравниваете с received_hash через hmac.compare_digest. Без этой валидации клиент мини-приложения может прислать любой user_id и заполучить чужой профиль.
Что делать с двойными нажатиями кнопок?
Используйте идемпотентность через Redis: перед действием делаете SET NX EX 30 на ключ once:<user_id>:<action>. Если ключ уже занят — значит нажатие повторное, отвечаете в answer_callback_query «Уже обрабатываем». Дополнительно полезен debounce 0.3 секунды между нажатиями одной и той же кнопки — это срезает случайные дабл-клики и спам-клики на «+/−» в корзине без накладных расходов на БД.
Сколько кнопок можно разместить на одном сообщении?
В мессенджерах семейства MAX/Telegram-style ограничение около 100 кнопок на сообщение, до 8 в строке. На практике больше 5–7 строк по 2 кнопки делают интерфейс нечитаемым на мобильном экране. Если у вас длинный список — разбивайте на пагинацию по 5 пунктов. Reply-клавиатуру делайте 2–3 строки по 2 кнопки — это покрывает 95% сценариев главного меню без визуального шума.
Как избежать ситуации «кнопка ведёт на устаревшие данные»?
Два подхода: TTL на callback (хранение payload в Redis с ex=3600) и проверка актуальности перед действием. Например, перед добавлением товара в корзину перепроверяете цену в БД и, если изменилась, отправляете новое сообщение «Цена изменилась с 990₽ на 1290₽, подтвердить?». Старые сообщения с inline-кнопками можно либо удалять через deleteMessage по истечении TTL, либо обновлять через editMessageReplyMarkup, скрывая клавиатуру.