Legan Studio
Все статьи
~ 8 мин чтения

Inline-кнопки и клавиатуры в боте MAX: дизайн и реализация

Как проектировать inline и reply-клавиатуры в боте MAX: callback_data, deep links, динамические меню, ограничения, типичные ошибки и реальные паттерны.

  • MAX
  • UX
  • разработка

Кнопки в мессенджере — главный инструмент управления вниманием пользователя. Хорошо спроектированная клавиатура превращает 30-секундный диалог в 3 тапа, а плохая — заставляет писать «не понял» и звать оператора. В этой статье разберём, как правильно проектировать и реализовывать inline-кнопки и reply-клавиатуры в боте MAX от VK Tech: какие бывают типы, как устроен callback_query, как делать пагинацию и многоуровневые меню, как обрабатывать массовые нажатия и не словить rate limit, и какие ошибки повторяются у 90% команд на старте.

Какие бывают клавиатуры в боте MAX

Условно все клавиатуры мессенджера делятся на три класса:

  1. Reply-клавиатура — заменяет системную клавиатуру у пользователя. Кнопки выглядят как «системные», нажатие отправляет текст в чат. Хорошо для главного меню, частых команд, выбора одного из 2–6 пунктов.
  2. Inline-клавиатура — прикрепляется к конкретному сообщению бота. Нажатие генерирует callback_query без сообщения в чат. Идеальна для пагинации, фильтров, переключателей, действий «лайк/удалить/подтвердить».
  3. Удалённая (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.

Кнопка «Начать диалог» с параметром (реферальная ссылка, конкретный товар):

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

Типичные ошибки

  1. Не вызывают answer_callback_query — у пользователя крутится индикатор.
  2. Длинный callback_data — обрезается, обработчик ловит мусор.
  3. Эмодзи разной ширины в reply-кнопках — на iOS/Android выглядит криво.
  4. Тяжёлые операции в callback handler — пользователь видит «загрузку» 5 секунд, бросает.
  5. Нет «‹ Назад» — пользователь уходит из меню в /start и теряет контекст.
  6. Кнопка живёт вечно — старое сообщение через месяц всё ещё показывает кнопку «Купить за 990₽», но цена уже 1290₽.
  7. callback_data без префикса — обработчики ловят чужие нажатия, сложно роутить.
  8. Нет идемпотентности — двойной клик на «Подтвердить заказ» создаёт два заказа.

Идемпотентность нажатий

Двойной клик — реальная проблема. Решение — флаг в 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, скрывая клавиатуру.