Commands (commands)
Command handling on steroids.
This plugin offers advanced command-handling features beyond the core library’s command handling. Here is a quick overview of what you get with this plugin:
- Better code readability by encapsulating middleware with command definitions.
- User command menu synchronization via
set.MyCommands - Improved command grouping and organization.
- Command reach scoping, e.g. limiting access to group admins or specific channels.
- Support for command translations.
Did you meanfeature to suggest the closest command when a user makes a typo... .? - Case-insensitive command matching.
- Setting custom behavior for commands that explicitly mention your bot’s username, such as
/start@your._bot - Custom command prefixes, e.g.
+,?, or any symbol instead of/. - Support for commands not located at the start of a message.
- RegExp commands!
All of these features are powered by central command structures that you define for your bot.
Basic Usage
Before we dive in, take a look at how you can register and handle a command with the plugin:
const myCommands = new CommandGroup();
myCommands.command("hello", "Say hello", (ctx) => ctx.reply(`Hello, world!`));
bot.use(myCommands);2
3
4
5
This registers a new /hello command to your bot, which will be handled by the given middleware.
Now, let’s get into some of the extra tools this plugin has to offer.
Importing
First of all, here’s how you can import all the necessary types and classes provided by the plugin.
import {
CommandGroup,
commandNotFound,
commands,
type CommandsFlavor,
} from "@grammyjs/commands";2
3
4
5
6
const { CommandGroup, commandNotFound, commands } = require(
"@grammyjs/commands",
);2
3
import {
CommandGroup,
commandNotFound,
commands,
type CommandsFlavor,
} from "npm:@grammyjs/commands";2
3
4
5
6
Now that the imports are settled, let’s see how we can make our commands visible to our users.
User Command Menu Setting
Once you have defined your commands using the Command class, you can call the set method to add all the defined commands to the user command menu.
const myCommands = new CommandGroup();
myCommands.command("hello", "Say hello", (ctx) => ctx.reply(`Hello, world!`));
bot.use(myCommands);
// Update the user command menu
await myCommands.setCommands(bot); 2
3
4
5
6
7
8
This ensures that each registered command appears in the menu of a private chat with your bot or when users type / in a chat where your bot is a member.
Context Shortcut
What if you want some commands displayed only to certain users? For example, imagine you have a login and a logout command. The login command should only appear for logged-out users, and vice versa. Here’s how to do that with the commands plugin:
// Use the flavor to create a custom context
type MyContext = CommandsFlavor<Context>;
// Use the new context to instantiate your bot
const bot = new Bot<MyContext>("<YOUR_BOT_TOKEN>"); // check https://t.me/BotFather
// Register the context shortcut
bot.use(commands());
const loggedOutCommands = new CommandGroup<MyContext>();
const loggedInCommands = new CommandGroup<MyContext>();
loggedOutCommands.command(
"login",
"Start your session with the bot",
async (ctx) => {
await ctx.setMyCommands(loggedInCommands);
await ctx.reply("Welcome! Session started!");
},
);
loggedInCommands.command(
"logout",
"End your session with the bot",
async (ctx) => {
await ctx.setMyCommands(loggedOutCommands);
await ctx.reply("Goodbye :)");
},
);
bot.use(loggedInCommands);
bot.use(loggedOutCommands);
// By default, users are not logged in,
// so you can set the logged-out commands for everyone
await loggedOutCommands.setCommands(bot);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const bot = new Bot("<YOUR_BOT_TOKEN>"); // check https://t.me/BotFather
// Register the context shortcut
bot.use(commands());
const loggedOutCommands = new CommandGroup();
const loggedInCommands = new CommandGroup();
loggedOutCommands.command(
"login",
"Start your session with the bot",
async (ctx) => {
await ctx.setMyCommands(loggedInCommands);
await ctx.reply("Welcome! Session started!");
},
);
loggedInCommands.command(
"logout",
"End your session with the bot",
async (ctx) => {
await ctx.setMyCommands(loggedOutCommands);
await ctx.reply("Goodbye :)");
},
);
bot.use(loggedInCommands);
bot.use(loggedOutCommands);
// By default, users are not logged in,
// so you can set the logged-out commands for everyone
await loggedOutCommands.setCommands(bot);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
This way, when a user calls /login, they’ll have their command list changed to contain only the logout command. Neat, right?
Command Name Restrictions
As stated in the Telegram Bot API documentation, command names must consist of:
- Between 1 and 32 characters.
- Only lowercase English letters (a-z), digits (0-9), and underscores (_).
Therefore, calling set or set with invalid command names will throw an exception. Commands that don’t follow these rules can still be registered and handled, but won’t appear in the user command menu.
Be aware that set and set only affect the commands displayed in the user’s commands menu, and not the actual access to them. You will learn how to implement restricted command access in the Scoped Commands section.
Grouping Commands
Since we can split and group our commands into different instances, it allows for a much more idiomatic command file organization.
Let’s say we want to have developer-only commands. We can achieve that with the following code structure:
.
├── bot.ts
├── types.ts
└── commands/
├── admin.ts
└── users/
├── group.ts
├── say-hello.ts
└── say-bye.ts2
3
4
5
6
7
8
9
The following code group exemplifies how we could implement a developer only command group, and update the Telegram client command menu accordingly. Make sure you take notice of the different patterns being used in the admin and group file-tabs.
import { devCommands } from "./commands/admin.ts";
import { userCommands } from "./commands/users/group.ts";
import type { MyContext } from "./types.ts";
export const bot = new Bot<MyContext>("<YOUR_BOT_TOKEN>"); // check https://t.me/BotFather
bot.use(commands());
bot.use(userCommands);
bot.filter((ctx) => ctx.from?.id == /** Put your ID here **/)
.use(devCommands);2
3
4
5
6
7
8
9
10
11
import type { Context } from "grammy";
export type MyContext = CommandsFlavor<Context>;2
3
import { userCommands } from './users/group.ts';
import type { MyContext } from '../types.ts';
export const devCommands = new CommandGroup<MyContext>();
devCommands.command("devlogin", "Set command menu to dev mode", async (ctx, next) => {
await ctx.reply("Hello, fellow developer! Are we having coffee today too?");
await ctx.setMyCommands(userCommands, devCommands);
});
devCommands.command("usercount", "Display user count", async (ctx, next) => {
await ctx.reply(`Total users: ${/** Your business logic */}`);
});
devCommands.command("devlogout", "Reset command menu to user-mode", async (ctx, next) => {
await ctx.reply("Until next commit!");
await ctx.setMyCommands(userCommands);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import sayHello from "./say-hello.ts";
import sayBye from "./say-bye.ts";
import type { MyContext } from "../../types.ts";
export const userCommands = new CommandGroup<MyContext>()
.add([sayHello, sayBye]);2
3
4
5
6
import type { MyContext } from "../../types.ts";
export default new Command<MyContext>("hello", "Say hello", async (ctx) => {
await ctx.reply("Hello, little user!");
});2
3
4
5
import type { MyContext } from "../../types.ts";
export default new Command<MyContext>("bye", "Say bye", async (ctx) => {
await ctx.reply("Goodbye :)");
});2
3
4
5
Did you know that, as shown in the example above, you can create commands either by using the .command( method directly or by registering initialized Commands into a Command instance with the .add method? This approach lets you keep everything in a single file, like in admin, or organize your commands across multiple files, like in group.
Always Use Command Groups
When creating and exporting commands using the Command constructor, it’s mandatory to register them onto a Command instance via the .add method. On their own they are useless, so make sure you do that at some point.
The plugin also ensures that a Command and its Commands share the same Context type, so you can avoid that kind of silly mistake at first glance! Combining this knowledge with the following section will get your command-game to the next level.
Scoped Commands
Did you know you can show different commands in various chats based on the chat type, language, and even user status within a chat group? That’s what Telegram refers to as Command Scopes.
Now, command scopes are a cool feature, but using them by hand can get really messy since it’s hard to keep track of all the scopes and the commands they present. Plus, by using command scopes on their own, you have to do manual filtering inside each command to ensure they run only for the correct scopes. Syncing those two things up can be a nightmare, which is why this plugin exists. Let’s check how it’s done.
The Command class returned by the command method exposes a method called add. This method takes in a Bot together with one or more handlers, and registers those handlers to be run at that specific scope.
You don’t even need to worry about calling filter, the add method will guarantee that your handler only gets called if the context is right.
Here’s an example of a scoped command:
const myCommands = new CommandGroup();
myCommands
.command("hello", "Say hello")
.addToScope(
{ type: "all_group_chats" },
(ctx) => ctx.reply(`Hello, members of ${ctx.chat.title}!`),
)
.addToScope(
{ type: "all_private_chats" },
(ctx) => ctx.reply(`Hello, ${ctx.chat.first_name}!`),
);2
3
4
5
6
7
8
9
10
11
12
The hello command can now be called from both private and group chats, and it will give a different response depending on where it gets called from. Now, if you call my, the hello command menu will be displayed in both private and group chats.
Here’s an example of a command that’s only accessible to group admins.
adminCommands
.command("secret", "Admin only")
.addToScope(
{ type: "all_chat_administrators" },
(ctx) => ctx.reply("Free cake!"),
);2
3
4
5
6
And here is an example of a command that’s only accessible in groups.
groupCommands
.command("fun", "Laugh")
.addToScope(
{ type: "all_group_chats" },
(ctx) => ctx.reply("Haha"),
);2
3
4
5
6
Notice that the command method could receive the handler too. If you give it a handler, that handler will apply to the default scope of that command. Calling add on that command will then add a new handler, which will be filtered for that scope. Take a look at this example.
myCommands
.command(
"default",
"Default command",
// This will be called when not in a group chat
(ctx) => ctx.reply("Hello from default scope"),
)
.addToScope(
{ type: "all_chat_administrators" },
// This will be called for group admins, when inside that group
(ctx) => ctx.reply("Hello, admin!"),
)
.addToScope(
{ type: "all_group_chats" },
// This will only be called for non-admin users in a group
(ctx) => ctx.reply("Hello, group chat!"),
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Command Translations
Another powerful feature is the ability to set different names and their respective descriptions for the same command based on the user language. The commands plugin makes that easy by providing the localize method. Check it out:
myCommands
// You need to set a default name and description
.command("hello", "Say hello")
// And then you can set the localized ones
.localize("pt", "ola", "Dizer olá");2
3
4
5
Add as many as you want! The plugin will take care of registering them for you when you call my.
For convenience, grammY exports a Language enum-like object, which you can use to create a more idiomatic approach.
import { LanguageCodes } from "@grammyjs/commands";
myCommands.command(
"chef",
"Steak delivery",
(ctx) => ctx.reply("Steak on the plate!"),
)
.localize(
LanguageCodes.Spanish,
"cocinero",
"Bife a domicilio",
);2
3
4
5
6
7
8
9
10
11
12
const { LanguageCodes } = require("@grammyjs/commands");
myCommands.command(
"chef",
"Steak delivery",
(ctx) => ctx.reply("Steak on the plate!"),
)
.localize(
LanguageCodes.Spanish,
"cocinero",
"Bife a domicilio",
);2
3
4
5
6
7
8
9
10
11
12
import { LanguageCodes } from "npm:@grammyjs/commands";
myCommands.command(
"chef",
"Steak delivery",
(ctx) => ctx.reply("Steak on the plate!"),
)
.localize(
LanguageCodes.Spanish,
"cocinero",
"Bife a domicilio",
);2
3
4
5
6
7
8
9
10
11
12
Localizing Commands with the Internationalization Plugin
If you are looking to have your localized command names and descriptions bundled inside your .ftl files, you could make use of the following approach:
function addLocalizations(command: Command) {
i18n.locales.forEach((locale) => {
command.localize(
locale,
i18n.t(locale, `${command.name}.command`),
i18n.t(locale, `${command.name}.description`),
);
});
return command;
}
myCommands.commands.forEach(addLocalizations);2
3
4
5
6
7
8
9
10
11
12
Ephemeral Commands
Now that you have got used to the ins and outs of the Command Menu, it is just the right time to learn about Ephemeral Commands.
Since Telegram Bot API 10.2, bots can now send private responses directly inside group chats, those are called Ephemeral Messages. As a paired feature, there were the new born Ephemeral Commands. This are special commands in which the invocation message stays invisible to the other group members. The Telegram client also highlights them with a special icon in the command menu.
You can tag a command as ephemeral by using the .ephemeral() method. However, Ephemeral Commands are strictly about the invisible invocation of that command, the “receiving end” from our point of view. It is up to your handler logic to define however you want to respond to that message:
myCommands
.command("invisible", "Boo something")
.ephemeral()
.addToScope(
{ type: "all_group_chats" },
(ctx) => {/** Do something ephemerally triggered */},
);
// Update the Command Menu with the "invisible" command shown as ephemeral
await myCommands.setCommands(bot);2
3
4
5
6
7
8
9
Strictly Ephemeral: The Story of an Edge Case
By default, a command registered as ephemeral will only be triggered if the incoming command message was indeed ephemeral. “What?” —would you ask. Seems obvious, but, there is an edge case.
We internally register Ephemeral Commands as strict. This means that, if the user manually types the command, for example, /invisible@your, and their client does not recognize it as an Ephemeral Command, the message can be sent as a normal message, a normal command. In this case, by default, we’ll ignore that command call.
“Why that could happen?” —I hear you saying… If the user client is old or working with an outdated (likely cached) Command Menu in which our command was not marked as ephemeral, their client can send it as normal command.
You can deactivate this at registration time with .ephemeral({ strict :. Which makes the command to trigger even if the command call wasn’t properly ephemerically sent.
Ephemeral Responses
That is where the receiving end of Ephemeral Commands stops.
However, since most of the time you’ll also want to respond ephemerally to Ephemeral Commands, we’ll take a look at that. See Reply Targets and Conditions section first.
To send an Ephemeral Message you’ll need to specify the target with receiver. In this case we’ll reply to the user which called our /whisp command:
myCommands
.command("whisp_n_shout", "Reply with an ephemeral and a normal message")
.ephemeral()
.addToScope(
{ type: "all_group_chats" },
async (ctx) => {
await ctx.reply("This is only visible to you", {
receiver_user_id: ctx.from.id,
});
await ctx.reply("This is be visible to everyone in the group chat");
},
);2
3
4
5
6
7
8
9
10
11
12
At first it looks alright, but the first reply, which is an ephemeral one, will fail if your bot has no admin rights in the chat you’re trying to reply in.
Being that case, you’ll need to specify to which ephemeral message the bot is exactly replying. This will trigger the “reply” client UI.
myCommands
.command("whisper", "Replies with a message only you can see")
.ephemeral()
.addToScope(
{ type: "all_group_chats" },
(ctx ) =>
ctx.reply("This is only visible to you", {
receiver_user_id: ctx.from.id,
reply_parameters: {
ephemeral_message_id: ctx.msg.ephemeral_message_id,
}
});
);2
3
4
5
6
7
8
9
10
11
12
13
Since your bot may have admin rights in one chat but not another, just to give you an idea, you could add a chat-specific check.
myCommands
.command("whisper", "Replies with a message only you can see")
.ephemeral()
.addToScope(
{ type: "all_group_chats" },
async (ctx) => {
const botMember = await ctx.api.getChatMember(ctx.chat.id, ctx.me.id);
const isBotChatAdmin = ["administrator", "creator"].includes(
botMember.status,
);
ctx.reply("This is only visible to you", {
receiver_user_id: ctx.from.id,
...(!isBotChatAdmin
? {
reply_parameters: {
ephemeral_message_id: ctx.msg.ephemeral_message_id,
},
}
: {}),
});
},
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
That will make the ephemeral reply to happen, at least on our end, since Ephemeral Message are not guaranteed to be delivered, on both chats, the ones were the bot is and not is admin. Besides not forcing to reply with the client reply UI. The downside being having an api call each time that command is called.
DANGER
Double check Reply Targets and Conditions in regards of the volatile nature of sending Ephemeral Messages.
Finding the Nearest Command
Telegram autocompletes registered commands while typing. However, sometimes users still type these commands completely by hand and may make mistakes.
To help with this, the commands plugin suggests a command that the user might have intended to use.
This functionality works with custom prefixes, so you don’t need to worry about compatibility. Plus, it’s easy to use.
// Use the flavor to create a custom context
type MyContext = Context & CommandsFlavor;
// Use the new context to instantiate your bot
const bot = new Bot<MyContext>("<YOUR_BOT_TOKEN>"); // check https://t.me/BotFather
const myCommands = new CommandGroup<MyContext>();
// ... Register the commands
bot
// Check if there is a command
.filter(commandNotFound(myCommands))
// If so, that means it wasn't handled by any of our commands
.use(async (ctx) => {
// We found a potential match
if (ctx.commandSuggestion) {
return ctx.reply(
`Hmm... I don't know that command. Did you mean ${ctx.commandSuggestion}?`,
);
}
// Nothing seems to come close to what the user typed
await ctx.reply("Oops... I don't know that command :/");
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const bot = new Bot("<YOUR_BOT_TOKEN>"); // check https://t.me/BotFather
const myCommands = new CommandGroup();
// ... Register the commands
bot
// Check if there is a command
.filter(commandNotFound(myCommands))
// If so, that means it wasn't handled by any of our commands
.use(async (ctx) => {
// We found a potential match
if (ctx.commandSuggestion) {
return ctx.reply(
`Hmm... I don't know that command. Did you mean ${ctx.commandSuggestion}?`,
);
}
// Nothing seems to come close to what the user typed
await ctx.reply("Oops... I don't know that command :/");
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
The command predicate takes in some options to customize its behavior:
ignore: Do not prioritize commands that match the user language.Localization ignore: Allows the plugin to ignore letter casing when searching for similar commands.Case similarity: Determines how similar a command name must be to the user input in order to be suggested.Threshold
Additionally, you can search across multiple Command instances by providing an array of Command instead of just one instance.
The command function will only trigger for updates which contain command-like text similar to your registered commands. For example, if you only have registered commands with a custom prefix like ?, it will trigger the handler for anything that looks like your commands, e.g. ?sayhi but not /definitely.
Same goes the other way, if you only have commands with the default prefix, it will only trigger on updates that look like /regular and /commands.
The recommended commands will only come from the Command instances you pass to the function. This means you can separate the checks into multiple, separate filters.
Now, let’s apply this understanding to the next example.
const myCommands = new CommandGroup();
myCommands.command("dad", "calls dad", () => {}, { prefix: "?" })
.localize("es", "papa", "llama a papa")
.localize("fr", "pere", "appelle papa");
const otherCommands = new CommandGroup();
otherCommands.command("bread", "eat a toast", () => {})
.localize("es", "pan", "come un pan")
.localize("fr", "pain", "manger du pain");
bot.use(myCommands);
bot.use(otherCommands);
// Let's assume the user is French and typed '/Papi'
bot
// This filter will trigger for any command-like text, such as '/regular' or '?custom'
.filter(commandNotFound([myCommands, otherCommands], {
ignoreLocalization: true,
ignoreCase: true,
}))
.use(async (ctx) => {
ctx.commandSuggestion === "?papa"; // Evaluates to true
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
If the ignore were set to false, then ctx would equal /pain.
We could also add more filters similar to the one mentioned earlier by using different parameters or Commands to check against.
There are many possibilities for how we can customize this!
Command Options
There are a few options that can be specified per command, per scope, or globally for a Command instance. These options allow you to further customize how your bot handles commands, giving you more flexibility.
ignoreCase
By default, commands match user input in a case-sensitive manner. When this flag is set, a command like /dandy will match variations such as /DANDY or /dandY, regardless of case.
targetedCommands
When users invoke a command, they can optionally tag your bot, like so: /command@bot. You can decide what to do with these commands by using the targeted config option. With this option, you can choose between three different behaviors:
ignored: Ignores commands that mention your bot’s username.optional: Handles both commands that mention the bot’s username and ones that don’t.required: Only handles commands that mention the bot’s username.
prefix
Currently, only commands starting with / are recognized by Telegram and, consequently, by the command handling done by the grammY core library. In some occasions, you might want to change that and use a custom prefix for your bot. That is made possible by the prefix option, which will tell the commands plugin to look for that prefix when trying to identify a command.
If you ever need to retrieve bot entities from an update and need them to be hydrated with the custom prefix you have registered, there is a method specifically tailored for that, called ctx, which returns the same interface as ctx
WARNING
Commands with custom prefixes cannot be registered in the Commands Menu, and are therefore incompatible with Ephemeral Commands.
matchOnlyAtStart
When handling commands, the grammY core library recognizes commands only if they start at the first character of a message. The commands plugin, however, allows you to listen for commands in the middle of the message text, or in the end, it doesn’t matter! Simply set the match option to false, and the plugin will handle the rest.
RegExp Commands
This feature is for those who want to go wild. It allows you to create command handlers based on regular expressions instead of static strings. A basic example would look like this:
myCommands
.command(
/delete_([a-zA-Z]+)/,
"Delete this",
(ctx) => ctx.reply(`Deleting ${ctx.msg?.text?.split("_")[1]}`),
);2
3
4
5
6
This command handler will trigger on /delete the same as on /delete, and it will reply Deleting me in the first case and Deleting you in the second, but will not trigger on /delete nor /delete, passing through as if it wasn’t there.
WARNING
Due to the nature of RegExp Commands, they cannot be registered in the Commands Menu and are therefore incompatible with Ephemeral Commands.