contexts killer feature
gramio-style per-update context classes — except the shortcut methods aren't hand-written. they're generated from the Bot API schema, so they're always complete and never lag a version.
how to get them
rich contexts aren't automatic on a bare @yaebal/core Bot — they're
wired in by a contextFactory. the batteries-included yaebal meta
package's createBot() sets that factory for you; new Bot() from
plain @yaebal/core gives you the small base Context with no ctx.react/ctx.editText/etc. every example on this page uses createBot().
// 1. createBot() (the "yaebal" meta package) — the rich contexts by default
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.on("message:text", (ctx) => ctx.react("🔥")); // ✅ typed and present at runtime
// 2. bare @yaebal/core Bot — only the small base Context, no autogen shortcuts
import { Bot as CoreBot } from "@yaebal/core";
const bare = new CoreBot(process.env.BOT_TOKEN!);
// bare.on("message:text", (ctx) => ctx.react("🔥"));
// ^^^^^ Property 'react' does not exist
// 3. yaebal's Bot with your own contextFactory — override what createBot() wires
// by default (e.g. to wrap richContext with your own instrumentation), and stay
// fully typed since it's still yaebal's Bot subclass underneath
import { Bot, richContext } from "yaebal";
const wired = new Bot(process.env.BOT_TOKEN!, {
contextFactory: (api, update, updateType, me) => richContext(api, update, updateType, me),
});
wired.on("message:text", (ctx) => ctx.react("🔥")); // ✅ same as createBot()how it's built
contexts are a pure function of the schema. there's no per-method, per-context hand-coding — the generator derives everything.
Telegram Bot API (HTML)
│ our own parser (scripts/lib/parse-schema.mjs) → machine-readable JSON
▼
packages/types/schema.json ← single source of truth
│
├──────────► packages/types/scripts/generate.mjs → telegram.ts (types)
│
└──────────► packages/contexts/scripts/generate.mjs
├─ Update.props → 25 context types
├─ payload fields → providers (which ids it carries)
└─ each API method → matched shortcut
▼
src/generated/*.ts (one file per context)detection, in two steps
1. providers — from a payload's fields, the generator works out which ids that context can supply:
// payload field → id this context can fill
chat → chat_id = this.chat.id
message_id → message_id = this.message_id
from → user_id = this.from.id
CallbackQuery → callback_query_id = this.id
chat_id / message_id from this.message2. matching — for each of the Bot API's methods (180 as of this Bot API
version), it collects the id-arguments (chat_id, message_id, user_id, query ids). if the context's providers cover the required ones, it emits
a shortcut with those keys Omit-ted from the params:
// generated/message.ts — one shortcut per method whose id-arguments this
// context's providers cover; the omitted keys are filled in for you:
react(params: Omit<SetMessageReactionParams, "chat_id" | "message_id">) {
return this.api.call<boolean>("setMessageReaction", {
chat_id: this.chat.id,
message_id: this.message_id,
...params,
});
}adding a feature is free
because the contexts derive from the schema, a new Bot API method shows up on every context that has the right ids — automatically. take reactions, added in Bot API 7.0:
# Bot API 7.0 (Dec 2023) added setMessageReaction.
# nothing in the generator changed — only the schema did:
+ { "name": "setMessageReaction",
+ "arguments": [ {chat_id, required}, {message_id, required},
+ {reaction?}, {is_big?} ] }
# pnpm --filter @yaebal/contexts generate
# → ctx.react() now exists on every Message-based context.
# gramio would need a maintainer to hand-write it.ctx.react() lands on MessageContext, ChannelPostContext, BusinessMessageContext — every Message-based
context — with the right Omit signature. zero hand-written code.positional overloads (generated too)
for methods with an obvious "main" argument, the generator emits a positional overload next to
the params-object form: the media senders (sendPhoto, sendVideo, sendDocument, sendAudio, sendVoice, sendAnimation, sendSticker, sendVideoNote) take the file
first, forward / copy take the target chat, sendPoll(question, options) maps plain strings to options, sendLocation(lat, lon), sendDice(emoji?), and editReplyMarkup accepts a raw markup or an @yaebal/keyboard builder
directly (no argument removes the keyboard). all of it derived from the schema — a POSITIONAL table in the generator, not hand-written methods.
import { createBot } from "yaebal";
import { InlineKeyboard } from "@yaebal/keyboard";
const bot = createBot(process.env.BOT_TOKEN!);
// generated positional overloads — the params-object form always still works
bot.on("message", async (ctx) => {
await ctx.sendPhoto("https://cataas.com/cat", { caption: "мяу" });
await ctx.forward(123456789); // target chat, positional
await ctx.copy("@archive", { disable_notification: true });
await ctx.sendPoll("tabs or spaces?", ["tabs", "spaces"], { is_anonymous: false });
await ctx.sendLocation(55.7558, 37.6173);
await ctx.sendDice("🎰");
});
bot.on("callback_query", async (ctx) => {
await ctx.answer("saved");
await ctx.editReplyMarkup(new InlineKeyboard().text("back", "back")); // builder ok
await ctx.editReplyMarkup(); // no arg = remove keyboard
});the sugar layer (mixins)
autogen gives breadth; a thin hand-written layer gives ergonomics where the schema can't. the
shared MessageSugar mixin (src/sugar/message-mixin.ts) is applied to
every message-based context — message, channel_post, the edited_*, business_* and guest_message ones — because a
TS class can't inherit from two bases: the generated *Base class provides the api
surface, the mixin function wraps it with overloads. it adds positional-string send/reply/editText/editCaption, a
five-shape react, and:
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.on("message:text", (ctx) => {
ctx.send("hi"); // positional sugar
ctx.reply("yo", { parse_mode: "HTML" });
ctx.react("🔥"); // auto-generated, no chat_id/message_id
ctx.editText("edited");
});import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.on("message", async (ctx) => {
await ctx.typing(); // sendChatAction "typing" (any action: ctx.typing("upload_photo"))
await ctx.quote("deal", "noted"); // reply quoting a piece of this message
// moderation — target defaults to the sender of this message
await ctx.ban(); // banChatMember(ctx.from.id)
await ctx.unban(123456789);
await ctx.mute(3600); // restrict all sending for an hour
await ctx.restrict({ can_send_messages: false }, { user_id: 123456789 }); // explicit target
});import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("start", (ctx) =>
ctx.reply("send me anything — I'll quote it, react to it, and forward it back."),
);
bot.on("message:text", async (ctx) => {
// reply_parameters.quote — renders as a real reply-quote block, not just a debug annotation
await ctx.quote(ctx.text, "got it — quoting your message back.");
// setMessageReaction — renders as a reaction pill under the message
await ctx.react("🔥");
// forwardMessage — renders as a "Forwarded from …" header
await ctx.api.call("forwardMessage", {
chat_id: ctx.chat.id,
from_chat_id: ctx.chat.id,
message_id: ctx.message_id,
});
});
bot.start();typing(action?)—sendChatAction, defaults to"typing";quote(quoteText, text)— reply quoting a piece of this message;- moderation with the sender as the default target:
ban(userId?),unban(userId?),restrict(permissions, params?),mute(seconds?)—params.user_idoverrides the target; - business/topic routing on every one of them —
business_connection_id,message_thread_idand the direct-messages topic are carried automatically.
convenience getters
the generator also emits camel-case getters (the gramio / puregram idea) on every context that carries the field — senderId, chatId, firstName, isPM, isGroup, messageId — so you
never reach into the raw payload for the common things.
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.on("message:text", (ctx) => {
ctx.senderId; // number | undefined
ctx.chatId; // number
ctx.firstName; // string | undefined
ctx.isPM; // boolean (also isGroup, messageId)
});query contexts get their own shortcuts too
the mixin above is message-only; other update kinds get a smaller, matching set — a positional answer on every query context, approve/decline on join
requests:
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.on("message", async (ctx) => {
await ctx.react("🔥"); // emoji
await ctx.react("🔥", "<custom_emoji_id>"); // custom emoji (+ fallback)
await ctx.react([{ emoji: "👍" }, { custom_emoji_id: "1" }]); // many
await ctx.react(); // clear all
});
bot.on("callback_query", (callbackCtx) => callbackCtx.answer("saved"));
bot.on("inline_query", (inlineCtx) => inlineCtx.answer([]));
bot.on("chat_join_request", (joinCtx) => joinCtx.approve()); // or joinCtx.decline()
bot.on("shipping_query", (shippingCtx) => shippingCtx.answer(true));all 25 contexts
one class per Update field — on(query) types the handler to the matching one:
| filter query head | context class |
|---|---|
message | MessageContext |
edited_message | EditedMessageContext |
channel_post | ChannelPostContext |
edited_channel_post | EditedChannelPostContext |
business_connection | BusinessConnectionContext |
business_message | BusinessMessageContext |
edited_business_message | EditedBusinessMessageContext |
deleted_business_messages | DeletedBusinessMessagesContext |
guest_message | GuestMessageContext |
message_reaction | MessageReactionContext |
message_reaction_count | MessageReactionCountContext |
inline_query | InlineQueryContext |
chosen_inline_result | ChosenInlineResultContext |
callback_query | CallbackQueryContext |
shipping_query | ShippingQueryContext |
pre_checkout_query | PreCheckoutQueryContext |
purchased_paid_media | PurchasedPaidMediaContext |
poll | PollContext |
poll_answer | PollAnswerContext |
my_chat_member | MyChatMemberContext |
chat_member | ChatMemberContext |
chat_join_request | ChatJoinRequestContext |
chat_boost | ChatBoostContext |
removed_chat_boost | RemovedChatBoostContext |
managed_bot | ManagedBotContext |
| gramio / puregram | yaebal | |
|---|---|---|
| shortcuts | hand-written | generated from schema |
| coverage | what a maintainer wrapped | everything fillable |
| new api method | wait for a pr | pnpm generate |
| version lag | contexts trail the api | contexts == schema version |
| ergonomics | hand-tuned | autogen + thin sugar layer |