Skip to content

UX-паттерны

Этот гайд про продуктовые решения, а не про API. Страницы Клавиатуры и Форматирование объясняют, как собрать клавиатуру или сущность; здесь же — что строить и почему.

Главное правило, из которого следует всё остальное

Пользователи Telegram предпочитают нажимать кнопки, а не печатать команды. Команды нужны для обнаружения (меню BotFather, deep-ссылки) — навигация принадлежит кнопкам.

Все паттерны ниже следуют из этого: если действие можно сделать кнопкой — делай кнопкой. Если та же кнопка может отредактировать текущее сообщение вместо отправки нового — редактируй. Если пользователь может зайти в тупик — добавь кнопку «назад».


1. Навигация на кнопках

Плохо: /start отправляет стену текста, заканчивающуюся словами «напишите /help для помощи, /settings для настройки, /delete чтобы удалить аккаунт».

Хорошо: /start отправляет короткий герой-блок + инлайн-клавиатуру, кнопки которой ведут пользователя ровно туда, куда вели бы те команды.

Почему:

  • Обнаруживаемость. Пользователи не читают /help — они нажимают плитки.
  • Меньше опечаток, меньше коллизий @botname в группах.
  • Одинаково работает на мобильном (где печатать дорого) и десктопе.

Команды всё равно существуют — вы регистрируете их через setMyCommands, чтобы кнопка меню Telegram показывала их как ярлыки (см. §10). Но основной интерфейс — кнопки.


2. Анатомия хорошего /start

Хорошо собранный /start — это три блока стопкой:

text
<ЖИРНЫЙ ЗАГОЛОВОК-ГЕРОЙ>          ← 1 строка, что это за бот
<цитата с коротким питчем>       ← 2-3 строки ценности простым языком
<курсивный совет или мета>       ← 1 строка, опционально (напр. "v2.1 · beta")

[ основное действие ] [ второстеп. ]
[      широкая третья кнопка     ]

Конкретно:

typescript
import { bold, italic, blockquote, format, InlineKeyboard } from "gramio";

const hero = format`${bold`🤖 GramIO Demo Bot`}

${blockquote`Бот на кнопках — каждый экран помещается в одно редактируемое сообщение. Нажмите плитку ниже, чтобы перейти; сообщение перепишется на месте.`}

${italic`Совет: здесь ничего не печатает команды — всё кликается.`}`;

const kb = new InlineKeyboard()
    .text("⚙️ Настройки", nav.pack({ to: "settings" }))
    .text("📊 Статистика", nav.pack({ to: "stats" }))
    .row()
    .text("❓ Помощь",     nav.pack({ to: "help" }));

bot.command("start", (ctx) => ctx.send(hero, { reply_markup: kb }));

Правила:

  • Никаких стен текста. Если на мобильном приходится скроллить — переписывайте.
  • Никакого «Welcome!» отдельной строкой. Заголовок уже приветствует.
  • Держите основные действия над сгибом. На мобильном это ≤ 4 рядов кнопок до начала прокрутки.
  • Не перечисляйте все фичи. /start — это главная страница, а не карта сайта.

3. Редактируйте на месте, не спамьте новыми сообщениями

Когда пользователь перемещается внутри бота, живёт одно сообщение и переписывается через editMessageText / editMessageCaption / editMessageMedia. Новые сообщения — для событий (уведомления, результаты, ошибки), а не для навигации.

typescript
bot.callbackQuery(nav, async (ctx) => {
    await ctx.answer();
    return ctx.editText("📜 История", { reply_markup: backKb });
    // НЕ ctx.send(...) — это вытолкнет старое меню вверх и засорит чат
});

Почему:

  • Чат остаётся чистым — бот ощущается как приложение, а не чат-бот.
  • Позиция скролла пользователя сохраняется.
  • «Где я» — всегда внизу чата.

Когда нарушать правило: результаты-действия, которые пользователь хочет сохранить в истории (подтверждение заказа, чек, успешная оплата). Это события — отправляйте новым сообщением.

Если вы дублируете одно и то же тело сообщения между вызовами ctx.send(...) и ctx.editText(...), переходите на @gramio/views — его context.render(view, params) сам определяет, отправить или отредактировать.


4. Вложенные меню — хлебные крошки, Назад, Домой

Любому меню глубже одного уровня нужны три вещи — в тексте, клавиатуре и обработчике соответственно:

  1. Хлебная крошка в тексте⚙️ Настройки · домой › настройки, чтобы пользователь знал, где он.
  2. Кнопка «◀ Назад» в последнем ряду — на уровень выше.
  3. Кнопка «🏠 Домой» на экранах глубже двух уровней — аварийный выход.
typescript
const text = format`${bold`⚙️ Настройки`} ${italic`· домой › настройки`}

${blockquote`Нажмите ряд, чтобы переключить значение.`}`;

const kb = new InlineKeyboard()
    // … ряды настроек …
    .row()
    .text("◀ Назад", nav.pack({ to: "home" }));

Антипаттерны:

  • Тупики. На каждом не-домашнем экране должен быть путь назад без ввода команды.
  • Непоследовательные цели «Назад». «◀ Назад» из /help и «◀ Назад» из settings/notifications означают разное — подпись одинаковая, но callback_data разный и конкретный.
  • Нет «Домой». Если пользователь зашёл на три уровня вглубь, не заставляйте его жать «Назад» трижды.

5. Кнопки-переключатели — подпись и есть состояние

Подпись кнопки-переключателя показывает, включена ли настройка. Одно нажатие переворачивает поле и перерисовывает тот же экран; видимое изменение подписи и есть обратная связь.

typescript
function toggleLabel(on: boolean, label: string) {
    return `${on ? "✅" : "⬜"} ${label}`;
}

// в клавиатуре:
.text(toggleLabel(session.notifications, "Уведомления"),
      toggle.pack({ key: "notifications" }))

Паттерн:

UIЗначение
✅ УведомленияВключено — нажмите, чтобы выключить
⬜ УведомленияВыключено — нажмите, чтобы включить
🔒 Только premiumНедоступно — нажатие отвечает пояснением

Один обработчик на схему, а не на поле. Используйте общий CallbackData("toggle").enum("key", […]) для всех переключателей экрана; обработчик переворачивает session[key] и перерисовывает.

Не смешивайте переключатели с кнопками-переходами в одном ряду. Визуально путает — пользователь не отличит «это переход» от «это переключение настройки» с первого взгляда.


6. Деструктивные действия — всегда подтверждайте

Никогда не вешайте «Удалить аккаунт», «Очистить данные», «Покинуть канал» на одно нажатие. Паттерн:

  1. Настройки показывают красную кнопку «🗑 Удалить аккаунт» (style: "danger").
  2. Нажатие на неё редактирует текущий экран в вид подтверждения.
  3. На экране подтверждения две кнопки: сначала безопасный вариант по умолчанию, затем деструктивный.
typescript
const kb = new InlineKeyboard()
    .text("🗑 Удалить аккаунт", nav.pack({ to: "account.confirmDelete" }),
          { style: "danger" });

// экран подтверждения:
const confirmKb = new InlineKeyboard()
    .text("❌ Отмена",        nav.pack({ to: "settings" }))
    .text("💥 Да, удалить",   nav.pack({ to: "account.doDelete" }),
          { style: "danger" });

Правила:

  • Безопасное действие первым (слева) — позиция по умолчанию для случайных нажатий.
  • Деструктивное действие со стилем (style: "danger", требует @gramio/keyboards ≥ 1.3.0).
  • Повторите последствия в тексте подтверждения: «Все данные будут стёрты. Это нельзя отменить».
  • Никогда не подтверждайте через alert — алерты блокируют весь клиент, ограничены 200 символами и не показывают кнопки. Используйте отредактированный экран.

7. ctx.answer() на каждом callback — всегда

Telegram показывает крутящийся индикатор на любой инлайн-кнопке, пока бот не вызовет answerCallbackQuery. Если забыть, индикатор висит ~15 секунд. Хуже — пользователи думают, что кнопка сломана.

typescript
bot.callbackQuery(nav, async (ctx) => {
    await ctx.answer();        // ← первым делом. Пустой ответ — это нормально.
    // … делаем работу, редактируем сообщение и т.д.
});

Три формы:

ФормаUIДля чего
ctx.answer()Индикатор гаснет, ничего не видноНавигация (отредактированное сообщение и есть обратная связь)
ctx.answer("Сохранено")Маленький тост вверху экрана (~5с)Быстрые подтверждения, обратная связь по полю
ctx.answer({ text: "…", show_alert: true })Модальное окно с кнопкой OKОшибки, предупреждения, обязательное подтверждение

Не используйте show_alert для рутинной обратной связи — он прерывает. Не используйте тост для ошибок, на которые пользователь должен отреагировать — он его пропустит.

Для обработчиков, которым точно нужен тост, предпочтите @gramio/auto-answer-callback-query — он отвечает пустым за вас, так что вы никогда не забудете.


8. Загрузка / прогресс / оптимистичный UI

Если действие занимает больше ~300мс (запрос к API, запись в БД, ответ LLM), дайте обратную связь немедленно:

typescript
bot.callbackQuery(search, async (ctx) => {
    await ctx.answer();
    await ctx.editText("⏳ Ищу…");                // мгновенный плейсхолдер
    const results = await doExpensiveSearch();    // может занять секунды
    return ctx.editText(formatResults(results)); // финальное состояние
});

Для фоновой работы, растянутой на много секунд, редактируйте плейсхолдер с прогрессом (⏳ 3 / 10 страниц). Telegram ограничивает редактирования примерно 1/сек на сообщение — делайте грубые обновления (раз в ~2с), а не спам на каждое событие.


9. Пустые состояния

Пустой список — антипаттерн. Замените его пустым состоянием: дружелюбное пояснение + понятный призыв к действию.

typescript
const text = format`${bold`📊 Статистика`}

${blockquote`У вас пока нет активности — здесь настоящий бот показал бы заполненный вид.`}`;

const kb = new InlineKeyboard()
    .text("➕ Создать первую запись", "stats.create")
    .row()
    .text("◀ Назад", nav.pack({ to: "home" }));

Правило:

  • Первое пустое состояние — объясните, что здесь появится, когда будут данные, предложите действие создать первый элемент.
  • Отфильтровано-в-пустоту — «Нет результатов по foo. [🔄 Сбросить фильтры]»
  • Удалили-последний-элемент — «Готово! [➕ Добавить ещё]»

10. Обнаружение команд — setMyCommands и кнопка меню

Зарегистрируйте команды один раз при старте, чтобы они появлялись в нативном меню / Telegram и в кнопке меню поля ввода:

typescript
bot.onStart(async ({ bot }) => {
    await bot.api.setMyCommands({
        commands: [
            { command: "start",    description: "Открыть главное меню" },
            { command: "settings", description: "Открыть настройки" },
            { command: "help",     description: "Как работает этот бот" },
        ],
    });
});

Рекомендации:

  • Держите список коротким (≤ 5 команд). Это ярлык, а не карта сайта.
  • Каждая команда из списка должна быть достижима и кнопками. Команды — резервная навигация, а не единственный путь.
  • Локализуйте команды через language_code, если бот многоязычный.
  • Ограничивайте область через scope: { type: "chat_administrators" } и т.п., чтобы админ-команды видели только админы.

Для Mini Apps замените кнопку меню по умолчанию на bot.api.setChatMenuButton({ menu_button: { type: "web_app", text: "…", web_app: { url } } }) — см. Mini Apps.


11. Reply-клавиатуры против инлайн-клавиатур

Reply-клавиатураИнлайн-клавиатура
Привязана кПолю ввода чатаКонкретному сообщению
СохраняетсяПока не заменена / убранаПока сообщение не удалено
НажатиеОтправляет подпись как новое сообщение пользователяВызывает callback_query
Лучше дляПостоянной главной навигации (2–4 плитки) в простых ботахВсего остального — меню, действия, переключатели, пагинация

Когда использовать reply-клавиатуры:

  • Простые утилитарные боты (калькулятор, валюта, поиск в один шаг).
  • Постоянная нижняя панель «Домой / Магазин / Профиль» (вместе с .persistent(true).resized(true)).
  • Не-текстовые запросы (requestContact, requestLocation, requestUsers, webApp) — для инлайн-клавиатур их нет в том же виде.

Когда использовать инлайн-клавиатуры:

  • Любое вложенное меню, любой редактируемый экран, любой поток из более чем одного шага.
  • Везде, где вы хотите позже отредактировать сообщение (reply-клавиатуры так не умеют).

Смешивать нормально: постоянная reply-клавиатура для главной навигации, инлайн-клавиатуры внутри каждого экрана. Только не заставляйте пользователя менять ментальную модель посреди потока.


12. Иерархия форматирования — жирный заголовок, цитата-контекст, курсив-мета

У каждого экрана максимум три текстовые зоны:

<жирный заголовок>           ← 1 строка, что это за экран
<цитата-описание>            ← 1-3 строки, зачем вы здесь / что делать
<курсивная мета>             ← опционально: состояние, время, ID
typescript
format`${bold`⚙️ Настройки`} ${italic`· домой › настройки`}

${blockquote`Нажмите ряд, чтобы переключить значение.`}`

Тактика:

  • bold только для заголовка. Если жирным выделено каждое второе слово — не выделено ничего.
  • blockquote для описательного контекста. Рендерится блоком с левой полосой — взгляд пользователя цепляется за него.
  • expandableBlockquote для длинных раскрытий (условия, детали ошибки), которые пользователь может захотеть прочитать.
  • italic для второстепенной меты — бейджи состояния, даты, «v2.1».
  • code / pre для буквальных значений (ID, токены, URL, которые пользователь может скопировать). Сочетайте с кнопкой .copy() для лучшего UX.
  • link только для внешних URL. Внутренняя навигация — кнопки, а не ссылки.
  • spoiler для намеренно скрытого контента (ответы на загадки, NSFW) — не как приём стилизации.

И грабли форматирования (полные правила в Форматировании):

  • Никогда parse_mode: "HTML" вместе с format. Никогда.
  • Никогда нативный Array.prototype.join() на Formattable — используйте хелпер join.
  • Никогда .toString() на FormattableString — передавайте его напрямую в send / editText.
  • Всегда оборачивайте переиспользуемые Formattable во внешний format `` — обычная интерполяция шаблона срезает сущности.

13. Дисциплина подписей кнопок

  • Коротко. ≤ ~20 символов. Мобильный обрезает длинные подписи посреди слова.
  • Единая система эмодзи. Выберите одно соглашение и держитесь его:
    • ⚙️ настройки · 📊 статистика · помощь · 🏠 домой · назад
    • / переключатели · 🗑 / 💥 деструктивные · отмена
    • создать · 🔄 обновить · 💾 сохранить · 📋 копировать
  • Sentence case или Title Case — выберите одно. «Открыть настройки» или «Открыть Настройки», но не «открыть настройки» и не «ОТКРЫТЬ НАСТРОЙКИ».
  • Глаголы, а не существительные для кнопок-действий: «Удалить аккаунт», а не «Аккаунт». Исключение: плитки навигации («Настройки», «Статистика»), где существительное и есть пункт назначения.

Раскладка:

  • Максимум 2–3 кнопки в ряд для подписей > 10 символов или 4 для коротких («◀», «▶», номера страниц).
  • Основное над второстепенным. Кнопка, на которую вы ожидаете нажатие, идёт в ряд 1.
  • .columns(N) для сеток, .pattern([1, 2, 1]) для асимметричных раскладок — никогда не считайте ряды вручную спамом .row().

14. Deep-ссылки — онбординг и непрерывность между чатами

/start <param> — это способ сослаться напрямую на внутренний экран бота снаружи Telegram, но это лишь одно из восьми семейств бот-deep-ссылок. ?startgroup= приходит как my_chat_member (не /start); ?startapp= приходит внутрь WebAppInitData на фронтенде Mini App (тоже не /start). Путаница между ними — причина №1 «пейлоад пропал».

typescript
bot.command("start", (ctx) => {
    if (ctx.args === "login-inline") return handleAuthRedirect(ctx);
    if (ctx.args?.startsWith("ref_")) return handleReferral(ctx, ctx.args.slice(4));
    return ctx.send(heroText(), { reply_markup: mainKeyboard() });
});

Полную карту маршрутизации (паттерн ссылки → обработчик → поле контекста), правила кодирования пейлоада, токены прав admin= и разобранный пример с /start плюс проверкой админ-прав через my_chat_member — см. Deep-ссылки.


15. Тихая отправка и поведение ответа

  • disable_notification: true — отправить тихо (без пуша). Подходит для периодических обновлений, ежедневных дайджестов, «вот ваш номер».
  • protect_content: true — запрещает пересылку и копирование. Подходит для чувствительного контента (OTP, чеки).
  • reply_parameters: { message_id } — ответить на конкретное сообщение. Используйте, когда ответ не имеет смысла без сообщения-контекста прямо над ним; не используйте «для красоты» в личных чатах 1-на-1.
  • parse_modeне надо. Вы используете format. См. §12.

16. Группы против личных чатов

Если бот работает в группах, несколько паттернов меняются:

  • Упоминайте себя в ответах или используйте reply_parameters — иначе пользователи не понимают, на какое сообщение ответил бот.
  • Учитывайте privacy mode. По умолчанию боты видят в группах только команды и @упоминания. Если бот должен видеть все сообщения, отключите приватность через /setprivacy в BotFather — но только если это реально нужно.
  • Не редактируйте сообщения, с которыми пользователь не взаимодействовал. Правки в группах видны всем; редактируйте только сообщение, привязанное к callback текущего пользователя.
  • Будьте тише. Используйте disable_notification щедро в группах.
  • selective: true на reply-клавиатурах / RemoveKeyboard, чтобы нацелиться на одного пользователя.

Чек-лист — перед релизом

Прежде чем объявить бота готовым, пройдите этот список:

  • [ ] /start — это герой + кнопки, а не стена текста.
  • [ ] У каждого не-домашнего экрана есть кнопка Назад.
  • [ ] У каждого экрана глубже 2 уровней есть кнопка Домой.
  • [ ] Каждый callback-обработчик вызывает ctx.answer() первой строкой.
  • [ ] Каждая навигация редактирует сообщение; никаких новых отправок для навигации.
  • [ ] Кнопки-переключатели показывают / в подписи.
  • [ ] Деструктивные действия проходят через экран подтверждения с безопасным значением по умолчанию.
  • [ ] У пустых состояний есть дружелюбное сообщение и призыв к действию.
  • [ ] Долгие операции сразу показывают плейсхолдер .
  • [ ] setMyCommands вызывается при старте; список короткий и каждая команда достижима кнопками.
  • [ ] Никакого parse_mode. Никакого нативного .join() на Formattable. Никакого .toString() на FormattableString.
  • [ ] Подписи кнопок ≤ 20 символов и используют единую систему эмодзи.
  • [ ] Для групповых ботов: privacy mode выставлен верно; ответы используют reply_parameters, где нужно.

Дальше