UX-паттерны
Этот гайд про продуктовые решения, а не про API. Страницы Клавиатуры и Форматирование объясняют, как собрать клавиатуру или сущность; здесь же — что строить и почему.
Главное правило, из которого следует всё остальное
Пользователи Telegram предпочитают нажимать кнопки, а не печатать команды. Команды нужны для обнаружения (меню BotFather, deep-ссылки) — навигация принадлежит кнопкам.
Все паттерны ниже следуют из этого: если действие можно сделать кнопкой — делай кнопкой. Если та же кнопка может отредактировать текущее сообщение вместо отправки нового — редактируй. Если пользователь может зайти в тупик — добавь кнопку «назад».
1. Навигация на кнопках
Плохо: /start отправляет стену текста, заканчивающуюся словами «напишите /help для помощи, /settings для настройки, /delete чтобы удалить аккаунт».
Хорошо: /start отправляет короткий герой-блок + инлайн-клавиатуру, кнопки которой ведут пользователя ровно туда, куда вели бы те команды.
Почему:
- Обнаруживаемость. Пользователи не читают
/help— они нажимают плитки. - Меньше опечаток, меньше коллизий
@botnameв группах. - Одинаково работает на мобильном (где печатать дорого) и десктопе.
Команды всё равно существуют — вы регистрируете их через setMyCommands, чтобы кнопка меню Telegram показывала их как ярлыки (см. §10). Но основной интерфейс — кнопки.
2. Анатомия хорошего /start
Хорошо собранный /start — это три блока стопкой:
<ЖИРНЫЙ ЗАГОЛОВОК-ГЕРОЙ> ← 1 строка, что это за бот
<цитата с коротким питчем> ← 2-3 строки ценности простым языком
<курсивный совет или мета> ← 1 строка, опционально (напр. "v2.1 · beta")
[ основное действие ] [ второстеп. ]
[ широкая третья кнопка ]Конкретно:
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. Новые сообщения — для событий (уведомления, результаты, ошибки), а не для навигации.
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. Вложенные меню — хлебные крошки, Назад, Домой
Любому меню глубже одного уровня нужны три вещи — в тексте, клавиатуре и обработчике соответственно:
- Хлебная крошка в тексте —
⚙️ Настройки · домой › настройки, чтобы пользователь знал, где он. - Кнопка «◀ Назад» в последнем ряду — на уровень выше.
- Кнопка «🏠 Домой» на экранах глубже двух уровней — аварийный выход.
const text = format`${bold`⚙️ Настройки`} ${italic`· домой › настройки`}
${blockquote`Нажмите ряд, чтобы переключить значение.`}`;
const kb = new InlineKeyboard()
// … ряды настроек …
.row()
.text("◀ Назад", nav.pack({ to: "home" }));Антипаттерны:
- Тупики. На каждом не-домашнем экране должен быть путь назад без ввода команды.
- Непоследовательные цели «Назад». «◀ Назад» из
/helpи «◀ Назад» изsettings/notificationsозначают разное — подпись одинаковая, ноcallback_dataразный и конкретный. - Нет «Домой». Если пользователь зашёл на три уровня вглубь, не заставляйте его жать «Назад» трижды.
5. Кнопки-переключатели — подпись и есть состояние
Подпись кнопки-переключателя показывает, включена ли настройка. Одно нажатие переворачивает поле и перерисовывает тот же экран; видимое изменение подписи и есть обратная связь.
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. Деструктивные действия — всегда подтверждайте
Никогда не вешайте «Удалить аккаунт», «Очистить данные», «Покинуть канал» на одно нажатие. Паттерн:
- Настройки показывают красную кнопку «🗑 Удалить аккаунт» (
style: "danger"). - Нажатие на неё редактирует текущий экран в вид подтверждения.
- На экране подтверждения две кнопки: сначала безопасный вариант по умолчанию, затем деструктивный.
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 секунд. Хуже — пользователи думают, что кнопка сломана.
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), дайте обратную связь немедленно:
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. Пустые состояния
Пустой список — антипаттерн. Замените его пустым состоянием: дружелюбное пояснение + понятный призыв к действию.
const text = format`${bold`📊 Статистика`}
${blockquote`У вас пока нет активности — здесь настоящий бот показал бы заполненный вид.`}`;
const kb = new InlineKeyboard()
.text("➕ Создать первую запись", "stats.create")
.row()
.text("◀ Назад", nav.pack({ to: "home" }));Правило:
- Первое пустое состояние — объясните, что здесь появится, когда будут данные, предложите действие создать первый элемент.
- Отфильтровано-в-пустоту — «Нет результатов по
foo. [🔄 Сбросить фильтры]» - Удалили-последний-элемент — «Готово! [➕ Добавить ещё]»
10. Обнаружение команд — setMyCommands и кнопка меню
Зарегистрируйте команды один раз при старте, чтобы они появлялись в нативном меню / Telegram и в кнопке меню поля ввода:
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 строки, зачем вы здесь / что делать
<курсивная мета> ← опционально: состояние, время, IDformat`${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 «пейлоад пропал».
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, где нужно.
Дальше
- Клавиатуры → — как собрать каждый тип клавиатуры
- Форматирование → — сущности,
format, хелперjoin - Deep-ссылки → — все семейства
t.me/<bot>?... - Плагин Views → — переиспользуемые шаблоны экранов с авто send/edit