Интерактивные меню (menu
)
Легко создавайте интерактивные меню.
Ведение
Встроенная клавиатура — это массив кнопок под сообщением. В grammY есть встроенный плагин для создания базовых встроенных клавиатур.
Плагин меню развивает эту идею и позволяет создавать технологичные меню прямо в чате. В них могут быть интерактивные кнопки, несколько страниц с навигацией между ними и многое другое.
Вот простой пример, который говорит сам за себя.
import { Bot } from "grammy";
import { Menu } from "@grammyjs/menu";
// Создайте бота.
const bot = new Bot("");
// Создайте простое меню.
const menu = new Menu("my-menu-identifier")
.text("A", (ctx) => ctx.reply("Вы нажали A!")).row()
.text("B", (ctx) => ctx.reply("Вы нажали B!"));
// Сделайте его интерактивным
bot.use(menu);
bot.command("start", async (ctx) => {
// Отправьте меню.
await ctx.reply("Посмотрите на это меню:", { reply_markup: menu });
});
bot.start();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const { Bot } = require("grammy");
const { Menu } = require("@grammyjs/menu");
// Создайте бота.
const bot = new Bot("");
// Создайте простое меню.
const menu = new Menu("my-menu-identifier")
.text("A", (ctx) => ctx.reply("Вы нажали A!")).row()
.text("B", (ctx) => ctx.reply("Вы нажали B!"));
// Сделайте его интерактивным
bot.use(menu);
bot.command("start", async (ctx) => {
// Отправьте меню.
await ctx.reply("Посмотрите на это меню:", { reply_markup: menu });
});
bot.start();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Bot } from "https://deno.land/x/grammy@v1.33.0/mod.ts";
import { Menu } from "https://deno.land/x/grammy_menu@v1.3.0/mod.ts";
// Создайте бота.
const bot = new Bot("");
// Создайте простое меню.
const menu = new Menu("my-menu-identifier")
.text("A", (ctx) => ctx.reply("Вы нажали A!")).row()
.text("B", (ctx) => ctx.reply("Вы нажали B!"));
// Сделайте его интерактивным
bot.use(menu);
bot.command("start", async (ctx) => {
// Отправьте меню.
await ctx.reply("Посмотрите на это меню:", { reply_markup: menu });
});
bot.start();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Убедитесь, что вы установили все меню перед другими middleware, особенно перед middleware, использующим данные
callback
. Также, если вы используете пользовательскую конфигурацию для_query allowed
, не забудьте включить обновления_updates callback
._query
Естественно, если вы используете пользовательский тип контекста, вы можете передать его и в Menu
.
const menu = new Menu<MyContext>("id");
Добавление кнопок
Плагин меню выстраивает клавиатуру точно так же, как это делает плагин для встроенных клавиатур. Класс Menu
заменяет класс Inline
.
Вот пример меню, состоящего из четырех кнопок в форме ряда 1-2-1.
const menu = new Menu("movements")
.text("^", (ctx) => ctx.reply("Вперед!")).row()
.text("<", (ctx) => ctx.reply("Налево!"))
.text(">", (ctx) => ctx.reply("Направо!")).row()
.text("v", (ctx) => ctx.reply("Назад!"));
2
3
4
5
Используйте text
для добавления новых текстовых кнопок. Вы можете передать название в функцию обработчик.
Используйте row
, чтобы завершить текущую строку и добавить все последующие кнопки в новую.
Существует множество других типов кнопок, например, для открытия URL. Посмотрите API этого плагина для Menu
, а также Telegram Bot API для Inline
.
Отправка меню
Сначала необходимо установить меню. Это сделает его интерактивным.
bot.use(menu);
Теперь вы можете просто передать меню в качестве reply
при отправке сообщения.
bot.command("menu", async (ctx) => {
await ctx.reply("Вот ваше меню", { reply_markup: menu });
});
2
3
Динамически названия
Когда вы называете кнопку, вы также можете передать функцию (ctx:
для получения динамического текста на кнопке. Эта функция может быть async
, а может и не быть.
// Создайте кнопку с именем пользователя, которая будет приветствовать его при нажатии.
const menu = new Menu("greet-me")
.text(
(ctx) => `Приветствуйте ${ctx.from?.first_name ?? "меня"}!`, // динамическое название кнопки
(ctx) => ctx.reply(`Привет, ${ctx.from.first_name}!`), // обработчик
);
2
3
4
5
6
Строка, сгенерированная такой функцией, называется динамической строкой. Динамические строки идеально подходят для таких вещей, как кнопки переключения.
// Набор идентификаторов пользователей, у которых включены уведомления.
const notifications = new Set<number>();
function toggleNotifications(id: number) {
if (!notifications.delete(id)) notifications.add(id);
}
const menu = new Menu("toggle")
.text(
(ctx) => ctx.from && notifications.has(ctx.from.id) ? "🔔" : "🔕",
(ctx) => {
toggleNotifications(ctx.from.id);
ctx.menu.update(); // обновите меню!
},
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Обратите внимание, что вы должны обновлять меню каждый раз, когда хотите, чтобы ваши кнопки менялись. Вызовите ctx
, чтобы убедиться, что ваше меню будет перерисовано.
Хранение данных В примере выше показано, как использовать плагин меню.
Не стоит хранить пользовательские настройки в объекте Set
, так как в этом случае все данные будут потеряны при остановке сервера.
Вместо этого лучше использовать базу данных или плагин сессия, если вы хотите хранить данные.
Обновление или закрытие меню
Когда вызывается обработчик кнопки, в ctx
появляется ряд полезных функций.
Если вы хотите, чтобы ваше меню перерисовалось, вы можете вызвать ctx
. Эта функция будет работать только внутри обработчиков, которые вы установили в своем меню. Она не будет работать при вызове из другого middleware бота, поскольку в таких случаях нет возможности узнать, какое меню должно быть обновлено.
const menu = new Menu("time", { onMenuOutdated: false })
.text(
() => new Date().toLocaleString(), // название кнопки - это текущее время
(ctx) => ctx.menu.update(), // обновить время по нажатию
);
2
3
4
5
Назначение
on
объясняется ниже. Пока что вы можете игнорировать его.Menu Outdated
Вы также можете обновить меню неявно, отредактировав соответствующее сообщение.
const menu = new Menu("time")
.text(
"Какое сейчас время?",
(ctx) => ctx.editMessageText("Сейчас " + new Date().toLocaleString()),
);
2
3
4
5
Меню определит, что вы собираетесь редактировать текст сообщения, и воспользуется этой возможностью, чтобы обновить и кнопки под ним. В результате часто можно избежать явного вызова ctx
.
Вызов ctx
не приводит к немедленному обновлению меню. Вместо этого он устанавливает флаг и запоминает, что нужно обновить его в какой-то момент во время выполнения вашего middleware. Это называется ленивым обновлением. Если вы позже отредактируете само сообщение, плагин может просто использовать тот же вызов API для обновления кнопок. Это очень эффективно, и гарантирует, что и сообщение, и клавиатура будут обновлены одновременно.
Естественно, если вы вызовете ctx
, но не запросите никаких изменений в сообщении, плагин меню сам обновит клавиатуру до завершения работы вашего middleware.
Вы можете заставить меню обновляться немедленно с помощью await ctx
. Обратите внимание, что ctx
вернет Promise
, поэтому вам нужно использовать await
! Использование параметр immediate
также работает для всех других операций, которые вы можете вызвать в ctx
. Его следует использовать только в случае необходимости.
Если вы хотите закрыть меню, то есть убрать все кнопки, вы можете вызвать ctx
. Опять же, это будет выполнено лениво.
Навигация между меню
Вы можете легко создавать меню с несколькими страницами и навигацией между ними. Каждая страница имеет свой собственный экземпляр Menu
. Кнопка submenu
— это кнопка, позволяющая переходить на другие страницы. Навигация назад осуществляется с помощью кнопки back
.
const main = new Menu("root-menu")
.text("Добро пожаловать", (ctx) => ctx.reply("Привет!")).row()
.submenu("Авторы", "credits-menu");
const settings = new Menu("credits-menu")
.text("Показать авторов", (ctx) => ctx.reply("Разработано grammY"))
.back("Назад");
2
3
4
5
6
7
Обе кнопки опционально берут обработчики middleware, чтобы вы могли реагировать на события навигации.
Вместо того чтобы использовать кнопки submenu
и back
для навигации между страницами, вы можете делать это вручную с помощью ctx
. Эта функция принимает строку идентификатора меню и лениво выполняет навигацию. Аналогично, обратная навигация осуществляется с помощью ctx
.
Далее необходимо связать меню, зарегистрировав их друг с другом. Регистрация одного меню другим подразумевает их иерархию. Меню, которое регистрируется, является родительским, а регистрируемое меню - дочерним. Ниже, main
является родителем settings
, если явно не определен другой родитель. Родительское меню используется при обратной навигации.
// Зарегистрируйте меню настроек в главном меню.
main.register(settings);
// По желанию установите другого родителя.
main.register(settings, "back-from-settings-menu");
2
3
4
Вы можете зарегистрировать столько меню, сколько захотите, и вложить их так глубоко, как вам захочется. Идентификаторы меню позволяют легко перейти на любую страницу.
Вы должны сделать интерактивным только одно меню вашей вложенной структуры меню. Например, передайте только корневое меню в bot
.
// Если у вас есть:
main.register(settings);
// Сделайте это:
bot.use(main);
// НЕ делайте это:
bot.use(main);
bot.use(settings);
2
3
4
5
6
7
8
9
Вы можете создать несколько независимых меню и сделать их все интерактивными. Например, если вы создадите два несвязанных меню и вам никогда не понадобится перемещаться между ними, то вам следует установить их независимо друг от друга.
// Если у вас есть независимое меню, как это:
const menuA = new Menu("menu-a");
const menuB = new Menu("menu-b");
// Вы можете сделать так:
bot.use(menuA);
bot.use(menuB);
2
3
4
5
6
7
Payload
Вы можете хранить короткие текстовые payloads вместе со всеми навигационными и текстовыми кнопками. Когда соответствующие обработчики будут вызваны, текстовый payload будет доступна в разделе ctx
. Это полезно, поскольку позволяет хранить в меню немного данных.
Вот пример меню, в котором в payload хранится текущее время. Другим вариантом использования может быть, например, хранение индекса в пагинационном меню.
function generatePayload() {
return Date.now().toString();
}
const menu = new Menu("store-current-time-in-payload")
.text(
{ text: "ОТМЕНА!", payload: generatePayload },
async (ctx) => {
// Дайте пользователю 5 секунд, чтобы отменить действие.
const text = Date.now() - Number(ctx.match) < 5000
? "Операция была успешно отменена."
: "Слишком поздно. Ваши видео с кошками уже стали вирусными в интернете.";
await ctx.reply(text);
},
);
bot.use(menu);
bot.command("publish", async (ctx) => {
await ctx.reply(
"Видео будет отправлено. У вас есть 5 секунд, чтобы отменить это.",
{
reply_markup: menu,
},
);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Лимиты Payload нельзя использовать для хранения значительных объемов
данных. Единственное, что вы можете хранить — это короткие строки, обычно не превышающие 50 байт, такие как индекс или идентификатор. Если вы действительно хотите хранить пользовательские данные, такие как идентификатор файла, URL или что-то еще, вам следует использовать сессии.
Также обратите внимание, что payload всегда генерируется на основе текущего контекстного объекта. Это означает, что имеет значение, откуда вы переходите к меню, что может привести к неожиданным результатам. Например, когда меню устарело, оно будет перерисовано на основе нажатия кнопки устаревшего меню.
Payload’ы также хорошо сочетаются с динамическими диапазонами.
Динамические диапазоны
До сих пор мы рассматривали только динамическое изменение текста на кнопке. Вы также можете динамически изменять структуру меню, чтобы добавлять и удалять кнопки на лету.
Изменение меню во время обработки сообщений
Вы не можете создавать или изменять меню во время обработки сообщений. Все меню должны быть полностью созданы и зарегистрированы до запуска вашего бота. Это означает, что вы не можете сделать new Menu(«id»)
в обработчике вашего бота. Вы не можете вызвать menu
или т.п. в обработчике вашего бота.
Добавление новых меню во время работы бота приведет к утечке памяти. Ваш бот будет все больше и больше замедляться и в конце концов упадет.
Однако вы можете воспользоваться динамическими диапазонами, описанными в этом разделе. Они позволяют произвольно изменять структуру существующего экземпляра меню, поэтому они не менее эффективны. Используйте динамические диапазоны!
Вы можете позволить генерировать часть кнопок меню на лету (или все кнопки, если хотите). Мы называем эту часть меню динамическим диапазоном. Другими словами, вместо того чтобы определять кнопки непосредственно в меню, вы можете передать функцию, которая создаст кнопки при рендеринге меню. Самый простой способ создать динамический диапазон в этой функции — использовать класс Menu
, который предоставляет этот плагин. Класс Menu
предоставляет вам точно такие же функции, как и меню, но у него нет идентификатора, и его нельзя зарегистрировать.
const menu = new Menu("dynamic");
menu
.url("О нас", "https://grammy.dev/plugins/menu").row()
.dynamic(() => {
// Создайте часть меню динамически!
const range = new MenuRange();
for (let i = 0; i < 3; i++) {
range
.text(i.toString(), (ctx) => ctx.reply(`Вы выбрали ${i}`))
.row();
}
return range;
})
.text("Отмена", (ctx) => ctx.deleteMessage());
2
3
4
5
6
7
8
9
10
11
12
13
14
Функция построения диапазона, которую вы передаете dynamic
, может быть async
, так что вы можете даже считывать данные из API или базы данных, прежде чем вернуть новый диапазон меню. Во многих случаях имеет смысл генерировать динамический диапазон на основе данных сессии..
Функция построения диапазона принимает в качестве первого аргумента объект контекста. (В приведенном примере он не указан). По желанию, в качестве второго аргумента после ctx
, вы можете получить свежий экземпляр Menu
. Вы можете изменить его вместо того, чтобы возвращать свой собственный экземпляр, если вам так больше нравится. Вот как можно использовать два параметра функции построения диапазона.
menu.dynamic((ctx, range) => {
for (const text of ctx.session.items) {
range // Нет необходимости в `new MenuRange()` или `return`.
.text(text, (ctx) => ctx.reply(text))
.row();
}
});
2
3
4
5
6
7
Важно, чтобы ваша функция работала определенным образом, иначе ваши меню могут показать странное поведение или даже выдать ошибку. Поскольку меню всегда отображается дважды (один раз при отправке меню и один раз при нажатии кнопки), вам нужно убедиться, что:
- У вас нет никаких побочных эффектов в функции, которая строит динамический диапазон. Не отправляйте сообщения. Не записывайте данные сессии. Не изменяйте переменные за пределами функции. Посмотрите Википедию о побочных эффектах.
- Ваша функция стабильна, т.е. не зависит от случайности, текущего времени или других быстро меняющихся источников данных. Она должна генерировать одни и те же кнопки при первом и втором рендеринге меню. В противном случае плагин меню не сможет сопоставить правильный обработчик с нажатой кнопкой. Вместо этого он определит, что ваше меню устарело, и откажется вызывать обработчики.
Ответы на callback запросы вручную
Плагин меню будет автоматически вызывать answer
для своих собственных кнопок. Вы можете установить значение auto
, если хотите отключить это.
const menu = new Menu("id", { autoAnswer: false });
Теперь вам придется самостоятельно вызывать answer
. Это позволит вам передавать пользовательские сообщения, которые будут отображаться пользователю.
Устаревшие меню и отпечатки
Допустим, у вас есть меню, в котором пользователь может включать и выключать уведомления, как в примере вверху. Если пользователь дважды отправит /settings
, он получит одно и то же меню дважды. Но изменение настроек уведомления в одном из двух сообщений не приведет к обновлению другого!
Очевидно, что мы не можем отслеживать все сообщения о настройках в чате и обновлять все старые меню по всей истории чата. Для этого пришлось бы использовать так много вызовов API, что Telegram ограничил бы скорость вашего бота. Кроме того, для запоминания всех идентификаторов сообщений каждого меню во всех чатах потребуется много места. Это непрактично.
Решение заключается в том, чтобы проверять, не устарело ли меню, до выполнения каких-либо действий. Таким образом, мы будем обновлять устаревшие меню только в том случае, если пользователь действительно начнет нажимать на кнопки в них. Плагин меню делает это автоматически, так что вам не нужно об этом беспокоиться.
Вы можете настроить, что именно произойдет при обнаружении устаревшего меню. По умолчанию пользователю будет показано сообщение “Меню устарело, попробуйте еще раз!”, и меню будет обновлено. Вы можете определить пользовательское поведение в конфигурации в разделе on
.
// Отображаемое пользовательское сообщение
const menu0 = new Menu("id", {
onMenuOutdated: "Обновлено, попробуйте теперь.",
});
// Пользовательская функция обработчика
const menu1 = new Menu("id", {
onMenuOutdated: async (ctx) => {
await ctx.answerCallbackQuery();
await ctx.reply("Вот ваше новое меню", { reply_markup: menu1 });
},
});
// Полностью отключите проверку на устаревание (могут запускаться неправильные обработчики кнопок).
const menu2 = new Menu("id", { onMenuOutdated: false });
2
3
4
5
6
7
8
9
10
11
12
13
У нас есть система для проверки того, устарело ли меню. Мы считаем его устаревшим, если:
- Изменилась форма меню (количество строк или количество кнопок в любой строке).
- Позиция строки/столбца нажатой кнопки вышла за пределы диапазона.
- Изменился ярлык нажатой кнопки.
- Нажатая кнопка не содержит обработчика.
Возможно, что ваше меню изменится, а все вышеперечисленное останется неизменным. Также возможно, что меню принципиально не меняется (т.е. поведение обработчиков не меняется), хотя вышеуказанная система указывает на то, что меню устарело. Оба сценария маловероятны для большинства ботов, но если вы создаете меню, в котором такое возможно, вам следует использовать функцию отпечатков.
function ident(ctx: Context): string {
// Возвращаем строку, которая будет меняться тогда и только тогда, когда ваше меню изменится
// настолько существенно, что его следует считать устаревшим.
return ctx.session.myStateIdentifier;
}
const menu = new Menu("id", { fingerprint: (ctx) => ident(ctx) });
2
3
4
5
6
Строка отпечатков заменит вышеупомянутую систему. Таким образом, вы можете быть уверены, что устаревшие меню всегда будут обнаружены.
Как это работает
Плагин меню работает полностью без хранения каких-либо данных. Это важно для больших ботов с миллионами пользователей. Сохранение состояния всех меню заняло бы слишком много памяти.
Когда вы создаете объекты меню и связываете их вместе с помощью вызова register
, никакие меню на самом деле не создаются. Вместо этого плагин меню запоминает, как собирать новые меню на основе ваших операций. При отправке меню он будет воспроизводить эти операции для визуализации вашего меню. Это включает в себя прокладку всех динамических диапазонов и генерацию всех динамических названий. После отправки меню отрисованный массив кнопок будет снова забыт.
При отправке меню каждая кнопка содержит callback запрос, который хранит:
- Идентификатор меню.
- Позиция кнопки в строке/столбце.
- Необязательный payload.
- Флаг отпечатка, который хранит информацию о том, был ли использован отпечаток в меню.
- 4-байтовый хэш, который кодирует либо отпечаток, либо схему меню и метку кнопки.
Таким образом, мы можем определить, какая именно кнопка меню была нажата. Меню будет обрабатывать нажатия кнопок только в том случае, если:
- Идентификаторы меню совпадают.
- Указана строка/колонка.
- Существует флаг отпечатка.
Когда пользователь нажимает кнопку меню, нам нужно найти обработчик, который был добавлен к этой кнопке во время рендеринга меню. Таким образом, мы просто снова отображаем старое меню. Однако на этот раз нам не нужен полный макет — нам нужна только общая структура и одна конкретная кнопка. Следовательно, плагин меню будет выполнять неглубокий рендеринг, чтобы быть более эффективным. Другими словами, меню будет отрисовываться только частично.
Как только нажатая кнопка снова становится известна (и мы проверили, что меню не является устаревшим), мы вызываем обработчик.
Внутри плагин меню активно использует трансформирующие API функции, например, для быстрого рендеринга исходящих меню на лету.
Когда вы регистрируете меню в большой навигационной иерархии, они фактически не будут хранить эти ссылки явно. Под капотом все меню одной структуры добавляются в один большой пул, и этот пул разделяется между всеми содержащимися экземплярами. Каждое меню отвечает за каждое другое в индексе, и они могут обрабатывать и отображать друг друга. (Чаще всего только корневое меню передается в bot
и получает любые обновления. В таких случаях один экземпляр будет обрабатывать весь пул.) В результате вы можете перемещаться между произвольными меню без ограничений, при этом обработка обновлений может происходить за O(1)
временной сложности, поскольку нет необходимости искать по всей иерархии нужное меню для обработки любого нажатия кнопки.