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().

setup.ts
// 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.

pipeline
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:

providers
// 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.message

2. 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
// 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:

schema diff
# 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.
one regen, and 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.

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

handler.ts
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");
});
mixin-extra.ts
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
});
try it — quote, react, and forward
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_id overrides the target;
  • business/topic routing on every one of them — business_connection_id, message_thread_id and 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.

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

shortcuts.ts
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 headcontext class
messageMessageContext
edited_messageEditedMessageContext
channel_postChannelPostContext
edited_channel_postEditedChannelPostContext
business_connectionBusinessConnectionContext
business_messageBusinessMessageContext
edited_business_messageEditedBusinessMessageContext
deleted_business_messagesDeletedBusinessMessagesContext
guest_messageGuestMessageContext
message_reactionMessageReactionContext
message_reaction_countMessageReactionCountContext
inline_queryInlineQueryContext
chosen_inline_resultChosenInlineResultContext
callback_queryCallbackQueryContext
shipping_queryShippingQueryContext
pre_checkout_queryPreCheckoutQueryContext
purchased_paid_mediaPurchasedPaidMediaContext
pollPollContext
poll_answerPollAnswerContext
my_chat_memberMyChatMemberContext
chat_memberChatMemberContext
chat_join_requestChatJoinRequestContext
chat_boostChatBoostContext
removed_chat_boostRemovedChatBoostContext
managed_botManagedBotContext
gramio / puregramyaebal
shortcutshand-writtengenerated from schema
coveragewhat a maintainer wrappedeverything fillable
new api methodwait for a prpnpm generate
version lagcontexts trail the apicontexts == schema version
ergonomicshand-tunedautogen + thin sugar layer