Commands (commands
)
Command handling on steroids.
This plugin provides various features related to command handling that are not contained in the command handling done by the core library. 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
- Ability to scope command reach, e.g: only accessible to group admins or channels, etc
- Defining command translations
Did you mean
feature that finds the nearest existing command to a given user miss-input.. .? - Case-insensitive command matching
- Setting custom behavior for commands that explicitly mention your bot’s user, like:
/start@your
_bot - Custom command prefixes, e.g:
+
,?
or any symbol instead of/
- Support for commands that are not in the beginning of the message
- RegExp Commands!
All of these features are made possible because you will define one or more central command structures that define your bot’s commands.
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 that 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 the plugin provides.
import {
CommandGroup,
commandNotFound,
commands,
type CommandsFlavor,
} from "@grammyjs/commands";
2
3
4
5
6
const { CommandGroup, commands, commandNotFound } = require(
"@grammyjs/commands",
);
2
3
import {
CommandGroup,
commandNotFound,
commands,
type CommandsFlavor,
} from "https://deno.land/x/grammy_commands@v1.0.0/mod.ts";
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 defined your commands with an instance of the Command
class, you can call the set
method, which will register all the defined commands to your bot.
const myCommands = new CommandGroup();
myCommands.command("hello", "Say hello", (ctx) => ctx.reply("Hi there!"));
myCommands.command("start", "Start the bot", (ctx) => ctx.reply("Starting..."));
bot.use(myCommands);
await myCommands.setCommands(bot);
2
3
4
5
6
7
8
This will make it so every command you registered is displayed on the menu of a private chat with your bot, or whenever users type /
on a chat your bot is a member of.
Context Shortcut
What if you want some commands to be 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. This is how you can do that with the commands plugin:
// 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>("token");
// 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
33
34
35
36
// 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
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 can only be form out of:
1-32 characters. Can contain only lowercase English letters, digits and underscores.
Therefore calling set
or set
with anything but lower_c4s3_commands will throw an exception. Commands not following this rules can still be registered, used and handled, but will never be displayed on the user menu as such.
Be aware that set
and set
only affects 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:
src/
├─ commands/
│ ├─ admin.ts
│ ├─ users/
│ │ ├─ group.ts
│ │ ├─ say-hi.ts
│ │ ├─ say-bye.ts
│ │ ├─ ...
├─ bot.ts
├─ types.ts
tsconfig.json
2
3
4
5
6
7
8
9
10
11
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 use in the admin
and group
file-tabs.
export type MyContext = Context & CommandsFlavor<MyContext>;
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>("MyBotToken");
bot.use(commands());
bot.use(userCommands);
bot.use(devCommands);
2
3
4
5
6
7
8
9
10
import { userCommands } from './users/group.ts'
import type { MyContext } from '../types.ts'
export const devCommands = new CommandGroup<MyContext>()
devCommands.command('devlogin', 'Greetings', async (ctx, next) => {
if (ctx.from?.id === ctx.env.DEVELOPER_ID) {
await ctx.reply('Hi to me')
await ctx.setMyCommands(userCommands, devCommands)
} else {
await next()
}
})
devCommands.command('usercount', 'Greetings', async (ctx, next) => {
if (ctx.from?.id === ctx.env.DEVELOPER_ID) {
await ctx.reply(
`Active users: ${/** Your business logic */}`
)
} else {
await next()
}
})
devCommands.command('devlogout', 'Greetings', async (ctx, next) => {
if (ctx.from?.id === ctx.env.DEVELOPER_ID) {
await ctx.reply('Bye to me')
await ctx.setMyCommands(userCommands)
} else {
await next()
}
})
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
import sayHi from "./say-hi.ts";
import sayBye from "./say-bye.ts";
import etc from "./another-command.ts";
import type { MyContext } from "../../types.ts";
export const userCommands = new CommandGroup<MyContext>()
.add([sayHi, sayBye]);
2
3
4
5
6
7
import type { MyContext } from "../../types.ts";
export default new Command<MyContext>("sayhi", "Greetings", async (ctx) => {
await ctx.reply("Hello little User!");
});
2
3
4
5
Did you notice it is possible to register single initialized Commands via the .add
method into the Command
instance or also directly through the .command(
method? This allows for a one-file-only structure, like in the admin
file, or a more distributed file structure like in the group
file.
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 enforce you to have the same Context-type for a given Command
and their respective Commands
so you avoid at first glance that kind of silly mistake!
Combining this knowledge with the following section will get your Command-game to the next level.
Scoped Commands
Did you know you can allow different commands to be shown on different chats depending on the chat type, the language, and even the user status in a chat group? That’s what Telegram calls Command Scopes.
Now, Command Scopes are a cool feature, but using it by hand can get really messy, since it’s hard to keep track of all the scopes and what commands they present. Plus, by using Command Scopes on their own, you have to do manual filtering inside each command to ensure they’ll only run for the correct scopes. Syncing those two things up can be a nightmare, and that’s why this plugin exists. Check how it’s done.
The Command
class returned by the command
method exposes a method called add
. This method takes in a Bot
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("start", "Initializes bot configuration")
.addToScope(
{ type: "all_private_chats" },
(ctx) => ctx.reply(`Hello, ${ctx.chat.first_name}!`),
)
.addToScope(
{ type: "all_group_chats" },
(ctx) => ctx.reply(`Hello, members of ${ctx.chat.title}!`),
);
2
3
4
5
6
7
8
9
10
11
12
The start
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 start
command will be registered to 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
myCommands
.command("fun", "Laugh")
.addToScope(
{ type: "all_group_chats" },
(ctx) => ctx.reply("Haha"),
);
2
3
4
5
6
Notice that when you call the command
method, it opens up a new command. 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 to that scope. Take a look at this example.
myCommands
.command(
"default",
"Default command",
// This will be called when not on a group chat, or when the user is not an admin
(ctx) => ctx.reply("Hello from default scope"),
)
.addToScope(
{ type: "all_group_chats" },
// This will only be called for non-admin users in a group
(ctx) => ctx.reply("Hello, group chat!"),
)
.addToScope(
{ type: "all_chat_administrators" },
// This will be called for group admins, when inside that group
(ctx) => ctx.reply("Hello, admin!"),
);
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 for the same command, and their respective descriptions 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 that you can use for a more idiomatic approach:
import { LanguageCodes } from "grammy/types";
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("grammy/types");
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 "https://deno.land/x/grammy@v1.32.0/types.ts";
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 bundle inside your .ftl
files, you could make use of the following idea:
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
Finding the Nearest Command
Even though Telegram is capable of auto completing the registered commands, sometimes users do type them manually and, in some cases, happen to make mistakes. The commands plugin helps you deal with that by allowing you to suggest a command that might be what the user wanted in the first place. It is compatible with custom prefixes, so you don’t have to worry about that, and its usage is quite straightforward:
// 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>("token");
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) {
await 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
// Use the new context to instantiate your bot
const bot = new Bot("token");
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) {
await 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
Behind the scenes, command
will use the get
context method which by default will prioritize commands that correspond to the user language. If you want to opt-out of this behavior, you can pass the ignore
flag set to true. It is possible to search across multiple CommandGroup instances, and ctx
will be the most similar command, if any, across them all. It also allows to set the ignore
flag, which will ignore casing while looking for a similar command and the similarity
flag, which controls how similar a command name has to be to the user input for it to be recommended.
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
/commands
.
The recommended commands will only come from the Command
instances you pass to the function. So you could defer the checks into multiple, separate filters.
Let’s use the previous knowledge to inspect 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");
// Register each language-specific command group
// Let's assume the user is French and typed /Papi
bot
// this filter will trigger for any command-like 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
If the ignore
was falsy instead we would have gotten “ctx
equals /pain
”. We could add more filters like the above, with different parameters or Command
to check against. There are a lot of possibilities!
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 will match the user input in a case-sensitive manner. Having this flag set, for example, in a command named /dandy
will match /DANDY
the same as /dandY
or any other case-only variation.
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 it you can choose between three different behaviors:
ignored
: Ignores commands that mention your bot’s useroptional
: Handles both commands that do and that don’t mention the bot’s userrequired
: Only handles commands that mention the bot’s user
prefix
Currently, only commands starting with /
are recognized by Telegram and, thus, 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
TIP
Commands with custom prefixes cannot be shown in the Commands Menu.
matchOnlyAtStart
When handling commands, the grammY core library will only recognize commands that start on 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! All you have to do is set the match
option to false
, and the rest will be done by the plugin.
RegExp Commands
This feature is for those who are really looking to go wild, it allows you to create command handlers based on regular expressions instead of static strings, a basic example would look like:
myCommands
.command(
/delete_([a-zA-Z]+)/,
(ctx) => ctx.reply(`Deleting ${ctx.msg?.text?.split("_")[1]}`),
);
2
3
4
5
This command handler will trigger on /delete
the same as in /delete
, and it will reply “Deleting me” in the first case and “Deleting you” in the later, but will not trigger on /delete
nor /delete
, passing through as if it wasn’t there.