@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

terminal
pnpm add @yaebal/commands

usage

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.

bot.ts
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 fieldtypedescription
ctx.commandstringthe matched command name (what the user typed, minus / and @botname)
ctx.argsstring[]whitespace-split words after the command
ctx.payloadstringthe raw trimmed text after the command — deep-link parameters arrive intact
typed.ts
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.

localized.ts
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.

scoped.ts
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 /unban

shadowing 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.

shadow.ts
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)
since 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

extras.ts
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:

ephemeral.ts
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 menu

inspecting the registry

inspect.ts
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

lifecycle.ts
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 menu

api

exportsignaturedescription
commands<C extends Context>() => Commands<C>create a registry typed to your bot's accumulated context
Commandsclassthe registry — see the method table below
ScopedCommandsinterfacethe view returned by scoped()add() only
CommandContextinterface{ command: string; args: string[] } — what handlers see on ctx
CommandDescriptiontypestring or { default: string; [locale]: string }
CommandMenuinterfaceone setMyCommands payload: { scope?, languageCode?, commands }
SyncResultinterface{ pushed: CommandMenu[]; skipped: CommandMenu[] }
BotCommand, BotCommandScopetype re-exportsthe generated telegram types from @yaebal/types

Commands<C>

methodsignaturedescription
add(name | [name, ...aliases], description, ...handlers) => thisdefine a command; no handlers = menu-only entry — chainable
hidden(name | [name, ...aliases], ...handlers) => thisdefine a command with handlers but no menu entry
ephemeral(name | [name, ...aliases], description, ...handlers) => thislike 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:

commands.test.ts
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");
run 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.