Інтерактивні меню (menu
)
Легко створюйте інтерактивні меню.
Вступ
Вбудована клавіатура — це масив кнопок під повідомленням. grammY має вбудований плагін для створення базових вбудованих клавіатур.
Плагін меню розвиває цю ідею та дозволяє створювати повноцінні меню прямо у чаті. Вони можуть містити інтерактивні кнопки, кілька сторінок з навігацією між ними тощо.
Ось простий приклад, який говорить сам за себе.
import { Bot } from "grammy";
import { Menu } from "@grammyjs/menu";
// Створюємо бота.
const bot = new Bot("");
// Створюємо просте меню.
const menu = new Menu("ідентифікатор")
.text("A", (ctx) => ctx.reply("Ви натиснули A!")).row()
.text("Б", (ctx) => ctx.reply("Ви натиснули Б!"));
// Робимо його інтерактивним.
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("ідентифікатор")
.text("A", (ctx) => ctx.reply("Ви натиснули A!")).row()
.text("Б", (ctx) => ctx.reply("Ви натиснули Б!"));
// Робимо його інтерактивним.
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("ідентифікатор")
.text("A", (ctx) => ctx.reply("Ви натиснули A!")).row()
.text("Б", (ctx) => ctx.reply("Ви натиснули Б!"));
// Робимо його інтерактивним.
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
Переконайтеся, що ви встановили всі меню перед іншими проміжними обробниками, особливо перед проміжними обробниками, які використовують дані запиту зворотного виклику. Крім того, якщо ви використовуєте власну конфігурацію для
allowed
, не забудьте включити оновлення_updates callback
._query
Звісно, якщо ви використовуєте власний тип контексту, ви можете передати його до Menu
.
const menu = new Menu<MyContext>("ідентифікатор");
Додавання кнопок
Плагін меню розташовує ваші кнопки так само, як це робить плагін для вбудованих клавіатур. Клас Menu
замінює клас Inline
.
Ось приклад для меню з чотирма кнопками, які розташовується на 3-х рядках у кількості 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
. Це спрацює лише всередині обробників, які ви встановили у вашому меню. Він не працюватиме при виклику з іншого проміжного обробника бота, оскільки в таких випадках немає способу дізнатися, яке меню слід оновити.
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
не оновлює меню негайно. Замість цього він встановлює прапорець, щоб не забути оновити меню у певний момент під час виконання вашого проміжного обробника. Це називається лінивим оновленням. Якщо ви пізніше відредагуєте саме повідомлення, плагін може просто використати той самий виклик API для оновлення кнопок. Це дуже ефективно та гарантує, що повідомлення і клавіатура будуть оновлені одночасно.
Звичайно, якщо ви викликаєте ctx
, але ніколи не запитуєте жодних змін у повідомленні, плагін меню оновить клавіатуру самостійно, до завершення роботи вашого проміжного обробника.
Ви можете змусити меню оновлюватися негайно за допомогою 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
Обидві кнопки за бажанням приймають проміжний обробник, щоб ви могли реагувати на події навігації.
Замість того, щоб використовувати кнопки 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)
Ви можете зберігати короткі текстові дані у всіх навігаційних та текстових кнопках. Коли відповідні обробники буде викликано, ці дані будуть доступні у 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
Обмеження
Дані кнопок не можна використовувати для зберігання значних обсягів даних. Єдине, що ви можете зберігати, це короткі рядки, зазвичай менше 50 байт, як-от індекс або ідентифікатор. Якщо ви дійсно хочете зберігати дані користувача, як-от ідентифікатор файлу, URL-адреса або щось інше, вам слід використовувати sessions.
Також зауважте, що дані кнопок завжди генеруються на основі поточного обʼєкта контексту. Це означає, що звідки ви переходите до меню має значення, що може призвести до несподіваних результатів. Наприклад, якщо меню є застарілим, воно буде перерендерено на основі натискання кнопки застарілого меню.
Дані кнопок також добре поєднується з динамічними діапазонами.
Динамічні діапазони (dynamic ranges)
Досі ми бачили лише те, як динамічно змінювати напис на кнопці. Ви також можете динамічно налаштовувати структуру меню, щоб на льоту додавати та видаляти кнопки.
Зміна меню під час обробки повідомлення
Ви не можете створювати або змінювати меню під час обробки повідомлення. Всі меню повинні бути повністю створені та зареєстровані до запуску бота. Це означає, що ви не можете зробити new Menu("id")
в обробнику вашого бота. Ви не можете викликати menu
або щось подібне в обробнику вашого бота.
Додавання нових меню під час роботи бота призведе до витоку памʼяті. Ваш бот буде сповільнюватися все більше і більше, що врешті-решт призведе до аварійного завершення роботи.
Однак ви можете скористатися динамічними діапазонами, описаними в цьому розділі. Вони дозволяють вам довільно змінювати структуру існуючого екземпляра меню, тому вони не менш потужні. Використовуйте динамічні діапазони!
Ви можете дозволити частині кнопок або всім кнопкам, якщо хочете, меню генеруватися на льоту. Ми називаємо цю частину меню динамічним діапазоном. Інакше кажучи, замість того, щоб визначати кнопки безпосередньо в меню, ви можете передати фабричну функцію, яка створить кнопки під час рендерингу меню. Найпростіший спосіб створити динамічний діапазон у цій функції — використати клас Menu
, який надає цей плагін. Клас Menu
надає вам ті самі функції, що й меню, але він не має ідентифікатора, тому його не можна зареєструвати.
const menu = new Menu("dynamic");
menu
.url("Докладніше", "https://grammy.dev/uk/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 або бази даних, перш ніж повертати новий діапазон меню. У багатьох випадках має сенс створювати динамічний діапазон на основі даних сесії.
Функція побудови діапазону приймає обʼєкт контексту як перший аргумент. Проте у наведеному вище прикладі це не вказано. За необхідності ви можете отримати свіжий екземпляр 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
Важливо, щоб ваша фабрична функція працювала певним чином, інакше ваші меню можуть показувати дивну поведінку або навіть буди причиною виникнення помилок. Оскільки меню завжди рендериться двічі: вперше, коли меню надсилається, а вдруге, коли натискається кнопка, вам потрібно переконатися, що:
- У вас немає побічних ефектів у функції, яка будує динамічний діапазон. Не надсилайте повідомлень. Не записуйте дані у сесію. Не змінюйте жодних змінних за межами функції. Перегляньте статтю про побічні ефекти на Вікіпедії.
- Ваша функція є стабільною, тобто вона не залежить від випадковості, поточного часу або інших швидкозмінних джерел даних. Вона повинна генерувати ті самі кнопки під час першого та другого рендерингу меню. Інакше плагін меню не зможе зіставити правильний обробник з натиснутою кнопкою. Натомість він виявить, що ваше меню застаріле, й відмовиться викликати обробники.
Відповіді на запити зворотного виклику вручну
Плагін меню буде автоматично викликати answer
для своїх кнопок. Ви можете встановити auto
, якщо хочете вимкнути цю функцію.
const menu = new Menu("id", { autoAnswer: false });
Тепер вам доведеться викликати answer
самостійно. Це дозволить вам передавати власні повідомлення, які відображатимуться користувачеві.
Застарілі меню та відбиток меню (fingerprint)
Скажімо, у вас є меню, в якому користувач може вмикати та вимикати сповіщення, як у цьому прикладі. Тепер, якщо користувач двічі надішле /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
Ми маємо евристичний алгоритм для перевірки застарілості меню. Ми вважаємо його застарілим, якщо:
- Змінилася форма меню: кількість рядків або кількість кнопок у будь-якому рядку.
- Позиція рядка/стовпчика натиснутої кнопки знаходиться поза діапазоном.
- Змінився напис на натиснутій кнопці.
- Натиснута кнопка не містить обробника.
Можливо, що ваше меню змінюється, а всі перераховані вище речі залишаються незмінними. Також можливо, що ваше меню не змінюється принципово, тобто поведінка обробників не змінюється, хоча наведені вище перевірки вказують на те, що меню застаріло. Обидва сценарії малоймовірні для більшості ботів, але якщо ви створюєте меню, де це трапляється, вам слід використовувати власну функцію відбитку.
function ident(ctx: Context): string {
// Повертаємо рядок, який буде змінено, якщо і тільки якщо ваше меню зміниться настільки суттєво,
// що його слід вважати застарілим.
return ctx.session.myStateIdentifier;
}
const menu = new Menu("id", { fingerprint: (ctx) => ident(ctx) });
2
3
4
5
6
Відбиток замінить наведені вище евристичні перевірки. Отже, ви можете бути впевнені, що застарілі меню завжди буде виявлено.
Як це працює
Плагін меню працює повністю без збереження будь-яких даних. Це важливо для великих ботів з мільйонами користувачів. Збереження стану всіх меню зайняло б занадто багато памʼяті.
Коли ви створюєте обʼєкти меню і повʼязуєте їх між собою за допомогою викликів register
, меню насправді не створюється. Натомість плагін меню запамʼятовує, як збирати нові меню на основі ваших дій. Щоразу, коли буде надіслано меню, він відтворить ці операції, щоб відрендерити ваше меню. Це включає створення всіх динамічних діапазонів і генерування всіх динамічних написів. Після надсилання меню, відрендерений масив кнопок знову буде забуто.
Коли меню буде надіслано, кожна кнопка міститиме запит зворотного виклику, який зберігає:
- Ідентифікатор меню.
- Позицію кнопки у рядку/стовпчику.
- Необовʼязкові дані (payload).
- Прапорець відбитку, який повідомляє, чи був використаний відбиток в меню, чи ні.
- 4-х байтовий хеш, який кодує або відбиток, або макет меню та напис кнопки.
Тому ми можемо точно визначити, яка кнопка якого меню була натиснута. Меню буде обробляти натискання кнопок, тільки якщо:
- Ідентифікатори меню співпадають.
- Вказано рядок/стовпець.
- Існує прапорець відбитку.
Коли користувач натискає кнопку меню, нам потрібно знайти обробник, який було додано до цієї кнопки під час рендерингу меню. Отже, ми просто відрендеримо старе меню ще раз. Однак цього разу нам не потрібен повний макет, тому що все, що нам потрібно, це загальна структура й одна конкретна кнопка. Тож плагін меню виконає поверхневий рендеринг, щоб бути більш ефективним. Інакше кажучи, меню буде відрендерено лише частково.
Після того, як натиснута кнопка знову стала відомою і ми перевірили, що меню не є застарілим, ми викликаємо обробник.
Усередині плагін меню активно використовує перетворювачі API, наприклад, для швидкого рендерингу вихідних меню на льоту.
Коли ви реєструєте меню у великій навігаційній ієрархії, вони фактично не будуть зберігати ці посилання в явному вигляді. По суті, всі меню однієї структури додаються до одного великого пулу, і цей пул є спільним для всіх екземплярів, що в ньому містяться. Кожне меню відповідає за кожне інше в індексі, і вони можуть обробляти й рендерити одне одного. Найчастіше до bot
передається лише кореневе меню, яке отримує всі оновлення. У таких випадках один екземпляр оброблятиме весь пул. У результаті ви можете переміщатися між довільними меню без обмежень, при цьому обробка оновлень може відбуватися за O(1)
часової складності, оскільки не потрібно перебирати всю ієрархію, щоб знайти потрібне меню для обробки будь-якого натискання кнопки.