@yaebal/commands
v0.2.0
Published
yaebal commands — one registry for command handlers + the telegram command menu: localized descriptions, scopes, aliases, hidden commands, diff-based sync.
Maintainers
Readme
@yaebal/commands
one registry for command handlers and the telegram / command menu — define each command once
(name, menu description, handlers), then wire the handlers with plugin() and push the menu with
register() / the diff-aware sync(). supports localized descriptions, menu scopes, aliases and
hidden commands, and validates names/descriptions at add() time.
install
pnpm add @yaebal/commandsusage
import { Bot } from "@yaebal/core";
import { commands } from "@yaebal/commands";
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");
});
const bot = new Bot(process.env.BOT_TOKEN!).install(cmd.plugin()); // wire the handlers
await cmd.sync(bot.api); // push the / menu — only if it changed
bot.start();typed context
the registry is generic over your bot's accumulated context — handlers see plugin-added
properties (and ctx.command / ctx.args) with no casting:
import type { Context } from "@yaebal/core";
type MyContext = Context & { session: { count: number } };
const cmd = commands<MyContext>().add("count", "count up", async (ctx) => {
await ctx.reply(`count: ${++ctx.session.count}`);
});
// bot must provide MyContext before install — checked by the compiler
bot.install(session({ initial: () => ({ count: 0 }) })).install(cmd.plugin());localized descriptions
pass per-locale strings with a required default; register() / sync() push the default menu
plus one menu per locale (missing locales fall back to default):
const cmd = commands()
.add("start", { default: "start the bot", ru: "запустить бота" }, handler)
.add("help", { default: "show help", ru: "показать помощь" }, handler);
await cmd.register(bot.api); // pushes the default menu + the ru menuscopes
scoped() returns a view whose commands only show in that scope's menu. scoped menus repeat the
unscoped commands, because telegram replaces (not merges) the list for users a 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);
// default menu: /start — admins' menu: /start /ban /unbannote: scope only affects the menu. handlers still run for anyone — guard them yourself (e.g.
with @yaebal/filters).
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 (like the admin menu 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)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 and hidden 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(inspect(ctx)));ephemeral commands
ephemeral() is add() plus is_ephemeral: true on the menu entry (bot api 10.2+): telegram
shows the command's invocation only to its sender, and expects an answer within ~15 seconds —
pair it with @yaebal/ephemeral's
ctx.replyEphemeral() so the answer is private too. sync() diffs the flag, so flipping a
command to ephemeral repushes its menu. also available on scoped(...) views.
const cmd = commands().ephemeral("stats", "your personal stats", async (ctx) => {
await ctx.replyEphemeral(`you: ${await stats(ctx.from.id)}`);
});menu-only entries
add() without handlers puts a command in the menu without registering middleware — useful when
the handler lives elsewhere (a router, a scene):
const cmd = commands().add("report", "file a report"); // handled by a scenes entry pointsync, register, unregister
register(api)pushes every(scope, language)menu unconditionally; pass{ scope, languageCode }to push a single one.sync(api)reads each menu withgetMyCommandsfirst and only pushes the ones that changed — safe to run on every deploy. returns{ pushed, skipped }.unregister(api)clears every managed menu viadeleteMyCommands.menus()returns everythingregister()would push;list({ languageCode?, scope? })returns one menu's{ command, description }[]— handy for a/helptext:
bot.command("help", (ctx) =>
ctx.reply(cmd.list().map((c) => `/${c.command} — ${c.description}`).join("\n")),
);validation
add() / hidden() / ephemeral() throw early (instead of a late bot api 400) on: names not matching
[a-z0-9_]{1,32}, duplicate names/aliases, empty or >256-char descriptions, non-ISO-639-1 locale
keys, and menus over 100 commands.
part of yaebal — a type-safe, runtime-agnostic Telegram Bot API framework. MIT.
