Interactive Menus (menu)

Easily create interactive menus.

Introduction

An inline keyboard is an array of buttons underneath a message. grammY has a built-in plugin to create basic inline keyboards.

The menu plugin takes this idea further and lets you create rich menus right inside the chat. They can have interactive buttons, multiple pages with navigation between them, and more.

Here is a simple example that speaks for itself.

import { Bot } from "grammy";
import { Menu } from "@grammyjs/menu";

// Create a bot.
const bot = new Bot("token");

// Create a simple menu.
const menu = new Menu("my-menu-identifier")
  .text("A", (ctx) => ctx.reply("You pressed A!")).row()
  .text("B", (ctx) => ctx.reply("You pressed B!"));

// Make it interactive.
bot.use(menu);

bot.command("start", async (ctx) => {
  // Send the menu.
  await ctx.reply("Check out this menu:", { reply_markup: menu });
});

bot.start();
const { Bot } = require("grammy");
const { Menu } = require("@grammyjs/menu");

// Create a bot.
const bot = new Bot("token");

// Create a simple menu.
const menu = new Menu("my-menu-identifier")
  .text("A", (ctx) => ctx.reply("You pressed A!")).row()
  .text("B", (ctx) => ctx.reply("You pressed B!"));

// Make it interactive.
bot.use(menu);

bot.command("start", async (ctx) => {
  // Send the menu.
  await ctx.reply("Check out this menu:", { reply_markup: menu });
});

bot.start();
import { Bot } from "https://deno.land/x/grammy@v1.11.2/mod.ts";
import { Menu } from "https://deno.land/x/grammy_menu@v1.1.2/mod.ts";

// Create a bot.
const bot = new Bot("token");

// Create a simple menu.
const menu = new Menu("my-menu-identifier")
  .text("A", (ctx) => ctx.reply("You pressed A!")).row()
  .text("B", (ctx) => ctx.reply("You pressed B!"));

// Make it interactive.
bot.use(menu);

bot.command("start", async (ctx) => {
  // Send the menu.
  await ctx.reply("Check out this menu:", { reply_markup: menu });
});

bot.start();

Make sure that you install all menus before other middleware, especially before middleware that uses callback query data.

Naturally, if you are using a custom context type, you can pass it to Menu too.

const menu = new Menu<MyContext>("id");

Adding Buttons

The menu plugin lays out your keyboards exactly like the plugin for inline keyboards does. The class Menu replaces the class InlineKeyboard.

Here is an example for a menu that has four buttons in a 1-2-1 row shape.

const menu = new Menu("movements")
  .text("^", (ctx) => ctx.reply("Forward!")).row()
  .text("<", (ctx) => ctx.reply("Left!"))
  .text(">", (ctx) => ctx.reply("Right!")).row()
  .text("v", (ctx) => ctx.reply("Backwards!"));

Use text to add new text buttons. You can pass a label and a handler function.

Use row to end the current row, and add all subsequent buttons to a new one.

There are many more button types available, e.g. for opening URLs. Check out this plugin’s API Referenceopen in new window for MenuRange, as well as the Telegram Bot API Referenceopen in new window for InlineKeyboardButton.

Sending a Menu

You must first install a menu. This makes it interactive.

bot.use(menu);

You can now simply pass the menu as reply_markup when sending a message.

bot.command("menu", async (ctx) => {
  await ctx.reply("Here is your menu", { reply_markup: menu });
});

Dynamic Labels

Whenever you put a label string on a button, you can also pass a function (ctx: Context) => string to get a dynamic label on the button. This function may or may not be async.

// Create a button with the user's name, which will greet them when pressed.
const menu = new Menu("greet-me")
  .text(
    (ctx) => `Greet ${ctx.from?.first_name ?? "me"}!`, // dynamic label
    (ctx) => ctx.reply(`Hello ${ctx.from.first_name}!`), // handler
  );

A string that is generated by such a function is called a dynamic string. Dynamic strings are ideal for things like toggle buttons.

// Set of user identifiers that have notifications enabled.
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(); // update the menu!
    },
  );

Note that you must update a menu whenever you want your buttons to change. Call ctx.menu.update() to make sure that your menu will be re-rendered.

Storing Data

The example above demonstrates how to use the menu plugin. It is not a good idea to actually store user settings in a Set object, because then all data will be lost when you stop the server.

Instead, consider using a database or the session plugin if you want to store data.

Updating or Closing the Menu

When a button handler is called, a number of useful functions are available on ctx.menu.

If you want your menu to re-render, you can call ctx.menu.update(). This will only work inside the handlers that you install on your menu. It will not work when called from other bot middleware, as in such cases there is no way to know which menu should be updated.

const menu = new Menu("time", { onMenuOutdated: false })
  .text(
    () => new Date().toLocaleString(), // button label is current time
    (ctx) => ctx.menu.update(), // update time on button click
  );

The purpose of onMenuOutdated is explained below. You can ignore it for now.

You can also update the menu implicitly by editing the corresponding message.

const menu = new Menu("time")
  .text(
    "What's the time?",
    (ctx) => ctx.editMessageText("It is " + new Date().toLocaleString()),
  );

The menu will detect that you intend to edit the text of the message, and use the opportunity to update the buttons underneath too. As a result, you can often avoid having to call ctx.menu.update() explicitly.

Calling ctx.menu.update() does not update the menu immediately. Instead, it sets a flag and remembers to update it at some point during the execution of your middleware. This is called lazy updating. If you edit the message itself later on, the plugin can simply use the same API call to also update the buttons. This is very efficient, and ensures that both the message and the keyboard are updated at the same time.

Naturally, if you call ctx.menu.update() but you never request any edits to the message, the menu plugin will update the keyboard by itself, before your middleware completes.

You can force the menu to update immediately with await ctx.menu.update({ immediate: true }). Note that ctx.menu.update() will then return a promise, so you need to use await! Using the immediate flag also works for all other operations that you can call on ctx.menu. This should only be used when necessary.

If you want to close a menu, i.e. remove all buttons, you can call ctx.menu.close(). Again, this will be performed lazily.

You can easily create menus with several pages, and navigation between them. Every page has its own instance of Menu. The submenu button is a button that lets you navigate to other pages. Backwards navigation is done via the back button.

const main = new Menu("root-menu")
  .text("Welcome", (ctx) => ctx.reply("Hi!")).row()
  .submenu("Credits", "credits-menu");

const settings = new Menu("credits-menu")
  .text("Show Credits", (ctx) => ctx.reply("Powered by grammY"))
  .back("Go Back");

Both buttons optionally take middleware handlers so you can react to navigation events.

Instead of using submenu and back buttons to navigate between pages, you can also do this manually using ctx.menu.nav(). This function takes the menu identifier string, and will perform navigation lazily. Analogously, backwards navigation works via ctx.menu.back().

Next, you need to link the menus by registering them to one another. Registering a menu to another implies their hierarchy. The menu that is being registered to is the parent, and the registered menu is the child. Below, main is the parent of settings, unless a different parent is explicitly defined. The parent menu is used when backwards navigation is performed.

// Register settings menu at main menu.
main.register(settings);
// Optionally, set a different parent.
main.register(settings, "back-from-settings-menu");

You can register as many menus as you like, and nest them as deeply as you like. The menu identifiers let you jump easily to any page.

You only have to make a single menu of your nested menu structure interactive. For example, only pass the root menu to bot.use.

// If you have this:
main.register(settings);

// Do this:
bot.use(main);

// Don't do this:
bot.use(main);
bot.use(settings);

You can create multiple independent menus and make them all interactive. For example, if you create two unrelated menus and you never need to navigate between them, then you should install both of them independently.

// If you have independent menus like this:
const menuA = new Menu("menu-a");
const menuB = new Menu("menu-b");

// You can do this:
bot.use(menuA);
bot.use(menuB);

Payloads

You can store short text payloads along with all navigation and text buttons. When the respective handlers are invoked, the text payload will be available under ctx.match. This is useful because it lets you store a little bit of data in a menu.

Payloads cannot be used to actually store any significant amounts of data. The only thing you can store are short strings of typically less than 50 bytes, such as an index or an identifier. If you really want to store user data such as a file identifier, a URL, or anything else, you should use sessions.

Here is an example menu that remembers current time in the payload. Other use cases could be, for example, to store the index in a paginated menu.

function generatePayload() {
  return Date.now().toString();
}

const menu = new Menu("store-current-time-in-payload")
  .text(
    { text: "ABORT!", payload: generatePayload },
    async (ctx) => {
      // Give the user 5 seconds to undo.
      const text = Date.now() - Number(ctx.match) < 5000
        ? "The operation was canceled successfully."
        : "Too late. Your cat videos have already gone viral on the internet.";
      await ctx.reply(text);
    },
  );

bot.use(menu);
bot.command("publish", async (ctx) => {
  await ctx.reply("The videos will be sent. You have 5 seconds to cancel it.", {
    reply_markup: menu,
  });
});

Payloads also work well together with dynamic ranges.

Dynamic Ranges

So far, we’ve only seen how to change the text on a button dynamically. You can also dynamically adjust the structure of a menu in order to add and remove buttons on the fly.

Changing a Menu During Message Handling

You cannot create or change your menus during message handling. All menus must be fully created and registered before your bot starts. This means that you cannot do new Menu("id") in a handler of your bot. You cannot call menu.text or the like in a handler of your bot.

Adding new menus while your bot is running would cause a memory leak. Your bot would slow down more and more, and eventually crash.

However, you can make use of the dynamic ranges described in this section. They allow you to arbitrarily change the structure of an existing menu instance, so they are equally powerful.

You can let a part of a menu’s buttons be generated on the fly (or all of them if you want). We call this part of the menu a dynamic range. In other words, instead of defining the buttons directly on the menu, you can pass a factory function that creates a the buttons when the menu is rendered. The easiest way to create a dynamic range in this function is by using the MenuRange class that this plugin provides. A MenuRange provides you with exactly the same functions as a menu, but it does not have an identifier, and it cannot be registered.

const menu = new Menu("dynamic");
menu
  .url("About", "https://grammy.dev/plugins/menu").row()
  .dynamic(() => {
    // Generate a part of the menu dynamically!
    const range = new MenuRange();
    for (let i = 0; i < 3; i++) {
      range
        .text(i.toString(), (ctx) => ctx.reply(`You chose ${i}`))
        .row();
    }
    return range;
  })
  .text("Cancel", (ctx) => ctx.deleteMessage());

The range builder function that you pass to dynamic may be async, so you can even read data from an API or a database before returning your new menu range. In many cases, it makes sense to generate a dynamic range based on session data.

The range builder function takes a context object as the first argument. (This is not specified in the example above.) Optionally, as a second argument after ctx, you can receive a fresh instance of MenuRange. You can modify it instead of returning your own instance if that’s what you prefer. Here is how you can use the two parameters of the range builder function.

menu.dynamic((ctx, range) => {
  for (const text of ctx.session.items) {
    range // no need for `new MenuRange()` or a `return`
      .text(text, (ctx) => ctx.reply(text))
      .row();
  }
});

It is important that your factory function works in a certain way, otherwise your menus may show strange behavior or even throw errors. As menus are always rendered twice (once when the menu is sent, and once when a button is pressed), you need to make sure that:

  1. You do not have any side-effects in the function that builds the dynamic range. Do not send messages. Do not write to the session data. Do not change any variables outside of the function. Check out Wikipedia on side-effectsopen in new window.
  2. Your function is stable, i.e. it does not depend on randomness, the current time, or other fast-changing data sources. It has to generate the same buttons the first and the second time the menu is rendered. Otherwise, the menu plugin cannot match the correct handler with the pressed button. Instead, it will detect that your menu is outdated, and refuse to call the handlers.

Answering Callback Queries Manually

The menu plugin will call answerCallbackQuery automatically for its own buttons. You can set autoAnswer: false if you want to disable this.

const menu = new Menu("id", { autoAnswer: false });

You will now have to call answerCallbackQuery yourself. This allows you to pass custom messages that are displayed to the user.

Outdated Menus and Fingerprints

Let’s say you have a menu where a user can toggle notifications on and off, such as in the example up here. Now, if a user sends /settings twice, they will get the same menu twice. But, changing the notification setting on one of the two messages will not update the other!

It is clear that we cannot keep track of all settings messages in a chat, and update all old menus across the entire chat history. You would have to use so many API calls for this that Telegram would rate-limit your bot. You would also require a lot of storage to remember all of the message identifiers of every menu, across all chats. This is not practical.

The solution, is to check if a menu is outdated before performing any actions. This way, we will only update old menus if a user actually starts clicking the buttons on them. The menu plugin handles this automatically for you, so you don’t need to worry about it.

You can configure exactly what happens when an outdated menu is detected. By default, the message “Menu was outdated, try again!” will be displayed to the user, and the menu will be updated. You can define custom behavior in the config under onMenuOutdated.

// Custom message to be displayed
const menu0 = new Menu("id", { onMenuOutdated: "Updated, try now." });
// Custom handler function
const menu1 = new Menu("id", {
  onMenuOutdated: async (ctx) => {
    await ctx.answerCallbackQuery();
    await ctx.reply("Here is a fresh menu", { reply_markup: menu1 });
  },
});
// Completely disable outdated check (may run wrong button handlers).
const menu2 = new Menu("id", { onMenuOutdated: false });

We have a heuristic to check if the menu is outdated. We consider it outdated if:

  • The shape of the menu changed (number of rows, or number of buttons in any row).
  • The row/column position of the pressed button is out of range.
  • The label on the pressed button changed.
  • The pressed button does not contain a handler.

It is possible that your menu changes, while all of the above things stay the same. It is also possible that your menu does not change fundamentally (i.e. the behavior of the handlers does not change), even though the above heuristic indicates that the menu is outdates. Both scenarios are unlikely to happen for most bots, but if you are creating a menu where this is the case, you should use a fingerprint function.

function ident(ctx: Context): string {
  // Return a string that would change if and only if your menu changes
  // so significantly that it should be considered outdated.
  return ctx.session.myStateIdentifier;
}
const menu = new Menu("id", { fingerprint: (ctx) => ident(ctx) });

The fingerprint string will replace the above heuristic. This way, you can be sure that outdated menus are always detected.

How Does It Work

The menu plugin works completely without storing any data. This is important for large bots with millions of users. Saving the state of all menus would consume too much memory.

When you create your menu objects and link them together via register calls, no menus are actually built. Instead, the menu plugin will remember how to assemble new menus based on your operations. Whenever a menu is sent, it will replay these operations to render your menu. This includes laying out all dynamic ranges and generating all dynamic labels. Once the menu is sent, the rendered button array will be forgotten again.

When a menu is sent, every button contains callback query that stores:

  • The menu identifier.
  • The row/column position of the button.
  • An optional payload.
  • A fingerprint flag that stores whether or not a fingerprint was used in the menu.
  • A 4-byte hash that encodes either the fingerprint, or the menu layout and the button label.

That way, we can identify exactly which button of which menu was pressed. A menu will only handle button presses if:

  • The menu identifiers match.
  • The row/column is specified.
  • The fingerprint flag exists.

When a user presses a menu’s button, we need to find the handler that was added to that button at the time the menu was rendered. Hence, we simply render the old menu again. However, this time, we don’t actually need the full layout—all we need is the overall structure, and that one specific button. Consequently, the menu plugin will perform a shallow rendering in order to be more efficient. In other words, the menu will only be rendered partially.

Once the pressed button is known again (and we have checked that the menu is not outdated), we invoke the handler.

Internally, the menu plugin makes heavy use of API Transformer Functions, for example, to quickly render outgoing menus on the fly.

When you register the menus in a large navigation hierarchy, they will in fact not store these references explicitly. Under the hood, all menus of that one structure are added to the same large pool, and that pool is shared across all contained instances. Every menu is responsible for every other one in the index, and they can handle and render each other. (Most often, it is only the root menu that is actually passed to bot.use and that receives any updates. In such cases, this one instance will handle the complete pool.) As a result, you are able to navigate between arbitrary menus without limit, all while the update handling can happen in O(1) time complexityopen in new window because there is no need to search through entire hierarchies to find the right menu to handle any given button click.

Plugin Summary