@yaebal/commands
one registry for command handlers and the telegram / command menu — define each
command once (name, menu description, handlers), wire the handlers with plugin(),
and push the menu with register() or the diff-aware sync(). supports
localized descriptions, menu scopes, aliases and hidden commands, and validates everything at add() time.
install
pnpm add @yaebal/commandsusage
create a registry with commands(), chain .add() calls, then bot.install(cmd.plugin()) to register the handlers and cmd.sync(bot.api) to push the menu to telegram.
import { Bot } from "@yaebal/core";
import { commands } from "@yaebal/commands";
const bot = new Bot(process.env.BOT_TOKEN!);
const cmd = commands()
.add("start", "start the bot", async (ctx) => {
await ctx.reply(`welcome! args: ${ctx.args.join(", ")}`);
})
.add("help", "show help", async (ctx) => {
await ctx.reply("available commands: /start, /help");
});
// wire the handlers onto the bot
bot.install(cmd.plugin());
// push the /command menu — only the menus that changed
await cmd.sync(bot.api);
bot.start();typed context
the registry is generic over your bot's accumulated context. pass the context type and handlers
see plugin-added properties with no casting; installing the plugin on a bot that doesn't
provide that context is a compile error. inside a handler the context also carries the fields Composer.command() adds:
| context field | type | description |
|---|---|---|
ctx.command | string | the matched command name (what the user typed, minus / and @botname) |
ctx.args | string[] | whitespace-split words after the command |
ctx.payload | string | the raw trimmed text after the command — deep-link parameters arrive intact |
import type { Context } from "@yaebal/core";
import { session } from "@yaebal/session";
type MyContext = Context & { session: { count: number } };
const cmd = commands<MyContext>().add("count", "count up", async (ctx) => {
// ctx.session (from the session plugin) and ctx.args are fully typed
await ctx.reply(`count: ${++ctx.session.count}, args: ${ctx.args.length}`);
});
// the bot must provide MyContext before install — checked by the compiler
bot.install(session({ initial: () => ({ count: 0 }) })).install(cmd.plugin());localized descriptions
a description is a plain string or per-locale strings with a required default. register() / sync() push the default menu plus one menu per locale
seen in the registry — commands missing a locale fall back to their default text.
const cmd = commands()
.add("start", { default: "start the bot", ru: "запустить бота" }, handler)
.add("help", { default: "show help", ru: "показать помощь" }, handler);
// pushes the default menu + one menu per locale;
// commands missing a locale fall back to their default text
await cmd.register(bot.api);scopes
scoped(scope) returns a view whose commands only show in that scope's menu. scoped
menus repeat the unscoped commands, because telegram replaces (not merges) the command
list for users a more specific scope matches.
const cmd = commands().add("start", "start the bot", handler);
cmd.scoped({ type: "all_chat_administrators" })
.add("ban", "ban a user", banHandler)
.add("unban", "unban a user", unbanHandler);
await cmd.register(bot.api);
// everyone's menu: /start
// admins' menu: /start /ban /unbanshadowing an unscoped command in one explicit scope
a name may be defined both unscoped and in one explicit scope — the explicit def
shadows the unscoped one. that's the escape hatch for targeting the base command's own menu at
a scope other than the default (e.g. all_private_chats instead of BotCommandScopeDefault) without losing the auto-repeat into other explicit scopes
shown above.
const cmd = commands().add("start", "start the bot", handler);
cmd.scoped({ type: "all_private_chats" })
.add("start", "start the bot (dm)", dmHandler);
await cmd.register(bot.api);
// default menu: /start (generic text)
// private-chats menu: /start (dm text)plugin() wires command() by name alone (no scope awareness at
runtime), the explicit def's handler wins globally, not just inside its scope — a menu-only
shadow (no handlers) falls back to the unscoped handler instead. two explicit scopes
can never share a name: nothing at runtime could pick between two differing handlers, so that
stays a duplicate command name error.aliases, hidden and menu-only commands
const cmd = commands()
// ["name", ...aliases] — every name is handled, only the first shows in the menu
.add(["settings", "prefs"], "open settings", handler)
// handled but never shown in any menu (debug/admin commands)
.hidden("debug", async (ctx) => ctx.reply("debug info"))
// menu-only: no handlers — the command is handled elsewhere (a router, a scene)
.add("report", "file a report");ephemeral commands
ephemeral() is add() plus is_ephemeral: true on the menu
entry — pair it with @yaebal/ephemeral's ctx.replyEphemeral() so the answer is private too:
import { ephemeral } from "@yaebal/ephemeral";
// is_ephemeral in the menu (bot api 10.2+): telegram shows the /stats invocation
// only to its sender and expects an answer within ~15 seconds — answer it
// ephemerally too, so nothing lands in the group's history.
const cmd = commands().ephemeral("stats", "your personal stats", async (ctx) => {
await ctx.replyEphemeral(`you: ${await stats(ctx.from.id)}`);
});
bot.install(ephemeral()).install(cmd.plugin());
await cmd.sync(bot.api); // the flag is diffed — flipping it repushes the menuinspecting the registry
cmd.list(); // default menu: { command, description }[]
cmd.list({ languageCode: "ru" }); // ru menu, falling back to default text
cmd.list({ scope: { type: "all_chat_administrators" } });
cmd.menus(); // every (scope, language) menu register() would push
// a /help text from the same source of truth
bot.command("help", (ctx) =>
ctx.reply(cmd.list().map((c) => `/${c.command} — ${c.description}`).join("\n")),
);register, sync, unregister
await cmd.register(bot.api); // push every menu
await cmd.register(bot.api, { languageCode: "ru" }); // push a single menu
const { pushed, skipped } = await cmd.sync(bot.api); // push only what changed
await cmd.unregister(bot.api); // deleteMyCommands for every menuapi
| export | signature | description |
|---|---|---|
commands | <C extends Context>() => Commands<C> | create a registry typed to your bot's accumulated context |
Commands | class | the registry — see the method table below |
ScopedCommands | interface | the view returned by scoped() — add() only |
CommandContext | interface | { command: string; args: string[] } — what handlers see on ctx |
CommandDescription | type | string or { default: string; [locale]: string } |
CommandMenu | interface | one setMyCommands payload: { scope?, languageCode?, commands } |
SyncResult | interface | { pushed: CommandMenu[]; skipped: CommandMenu[] } |
BotCommand, BotCommandScope | type re-exports | the generated telegram types from @yaebal/types |
Commands<C>
| method | signature | description |
|---|---|---|
add | (name | [name, ...aliases], description, ...handlers) => this | define a command; no handlers = menu-only entry — chainable |
hidden | (name | [name, ...aliases], ...handlers) => this | define a command with handlers but no menu entry |
ephemeral | (name | [name, ...aliases], description, ...handlers) => this | like add, but the menu entry carries is_ephemeral: true — also on scoped() views |
scoped | (scope: BotCommandScope) => ScopedCommands<C> | a view whose add()s attach commands to a specific menu scope |
list | (options?: { languageCode?, scope? }) => BotCommand[] | one menu's { command, description }[]; unknown scopes fall back to default |
menus | () => CommandMenu[] | every (scope, language) menu register() would push |
plugin | () => Plugin<C, Record<never, never>> | wires every command's (and alias's) handlers — pass to bot.install() |
register | (api, options?: { languageCode?, scope? }) => Promise<CommandMenu[]> | push every menu via setMyCommands, or a single one via options |
sync | (api) => Promise<SyncResult> | diff each menu against getMyCommands and push only what changed |
unregister | (api) => Promise<CommandMenu[]> | clear every managed menu via deleteMyCommands |
validation
add() / hidden() / ephemeral() throw early — instead of a late bot api 400 — on:
names not matching [a-z0-9_]{1,32}, duplicate names or aliases, empty or
>256-char descriptions, locale keys that aren't two-letter iso 639-1 codes, and menus over
100 commands (scoped menus count the repeated unscoped commands).
testing
with @yaebal/test the plugin's handlers are driven
by actors, and menu pushes through env.api are recorded like any other api call:
import { createTestEnv } from "@yaebal/test";
const env = createTestEnv(new Composer<Context>().install(cmd.plugin()));
await env.createUser().sendCommand("start");
env.lastApiCall("sendMessage"); // assert the reply
await cmd.register(env.api); // menu pushes are recorded too
env.callsTo("setMyCommands");sync() on deploy, not register(). sync() reads each menu first and skips unchanged ones, so redeploys don't hammer setMyCommands. skip both during local development against a production token to
avoid overwriting the live menu. scope affects the menu, not the handlers. a command scoped to
all_chat_administrators is still executable by anyone who types it — guard the
handler itself (for example with @yaebal/filters). bot.install(), not bot.use(). the plugin is installed
via bot.install(cmd.plugin()) — install is the method that threads the
context type through. registration order is insertion order. commands appear in each menu in the order they were added, unscoped and scoped interleaved as written.
see the commands
example for a focused tour of every feature, the commerce-suite
example for the registry driving a real bot's localized menu, and @yaebal/router / @yaebal/scenes for the handlers menu-only
entries typically point at.