context & filters
one object wraps each update, exposes typed accessors, and grows new properties as the chain enriches it — while filter queries and composable filters route on the raw update and narrow that object's type.
the base context
every update is wrapped in a Context. it holds the raw update and a
detected updateType, and derives the common shapes through getters — so ctx.message already resolves across every message-carrying update kind, and ctx.text/ctx.entities fall back from the text fields to the caption
ones.
import type { Context } from "@yaebal/core";
declare const ctx: Context;
ctx.update; // the raw Update
ctx.updateType; // "message" | "callback_query" | …
ctx.message; // message ?? edited_message ?? channel_post ??
// edited_channel_post ?? business_message ?? edited_business_message
ctx.from; // User | undefined — sender, from any of ~13 update kinds
ctx.chat; // Chat | undefined — chat, from any of ~10 update kinds
ctx.text; // message.text ?? message.caption
ctx.entities; // message.entities ?? message.caption_entities
ctx.senderChat; // Chat | undefined — set for anonymous admins / linked-channel posts
ctx.me; // this bot's own account, once known (see /docs/webhooks)
ctx.is("callback_query"); // puregram-style narrowing checkctx.from and ctx.chat each read from around a dozen different update
shapes (messages, callback queries, chat member updates, join requests, reactions, …), so a
handler that only cares "who/where" never has to know which kind of update it's looking at. ctx.senderChat is set when the message was posted by an anonymous admin/owner or
forwarded automatically from a linked channel — @yaebal/guards' isAnonymousAdmin/fromLinkedChannel key off exactly this.
sending from the context
the context carries a handful of hand-written shortcuts that infer the chat from the current
update. send/reply accept a plain string plus an extra-params object, or a single params object ({ text, ...sendMessageParams }); the media
shortcuts accept a MediaSource or a raw file_id/url string, and their caption accepts a plain string or a fmt result the
same way send's text does.
import { Bot } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
bot.on("message:text", async (ctx) => {
await ctx.send("hi"); // to the current chat
await ctx.reply("yo"); // reply_parameters set for you
await ctx.sendPhoto("AgAC…"); // file_id or url string
await ctx.send({ text: "hi", disable_notification: true }); // object form
});
bot.on("callback_query", (ctx) =>
ctx.answerCallbackQuery({ text: "got it" }), // no-op if no query
);send/sendPhoto/sendDocument reject if the update has no chat, and answerCallbackQuery resolves to false when there is no callback query — so they're safe to call unconditionally.where a reply goes
send/reply/sendPhoto/sendDocument don't just
target the current chat — they carry the update's own routing along automatically, so a reply
inside a forum topic or a business chat doesn't fall back to General or leak out of the
connected account.
import { Bot } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
// send/reply thread the update's own routing through automatically:
bot.on("message", async (ctx) => {
await ctx.reply("noted");
// - inside a forum topic → stays in that topic (message_thread_id)
// - in a channel's DM topic → stays in that topic (direct_messages_topic_id)
// - via a connected business account → sent AS that account (business_connection_id),
// not from the bot's own chat with the user
});
// routing()/businessRouting() are exposed so plugins building their own
// api.call(...) params (editMessageText, deleteMessage, …) get the same
// behavior instead of re-deriving it by hand:
bot.command("pin", async (ctx) => {
await ctx.api.call("pinChatMessage", {
chat_id: ctx.chat!.id,
message_id: ctx.message!.message_id,
...ctx.businessRouting(),
});
});filter queries
grammY-style L1:L2:L3 queries route on the update. the first segment must match ctx.updateType; each following segment is a field that must be present.
import { Bot } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
bot.on("message:text", (ctx) => ctx.text); // string
bot.on("message:caption", (ctx) => ctx.text); // caption also fills .text
bot.on("message:entities", (ctx) => ctx.entities); // MessageEntity[]
bot.on("callback_query:data", (ctx) => ctx.callbackQuery);
bot.on("message:photo", (ctx) => ctx.message.photo); // PhotoSize[], non-optional
bot.on(":photo", (ctx) => { /* any update with a message.photo, not just "message" */ });// matchQuery splits "message:text" into head + fields and checks each
// (always against the raw update — enrichment can't change what matches):
// head → must equal ctx.updateType
// text → the update's message.text is a non-empty string
// caption → the update's message.caption is a non-empty string (distinct from :text)
// data → update.callback_query.data is set
// entities → the message's entities (or caption_entities) have length
// <other> → truthy on the message's [field] (e.g. photo, document, sticker, …)the L2 field can be any of the recognized names, plus any message content field:
photo | video | sticker | audio | voice | document | animation | contact |
location | poll | dice | venue | video_note | game | invoice | successful_payment |
web_app_datahow Filtered narrows
the same query that routes also narrows the context type. Filtered<C,
Q> is a conditional type: for the queries it knows, it intersects the matching field onto
the context so your handler sees it as non-optional — including the message content fields
above, not just text/data/entities.
// Filtered<C, Q> — how a query narrows the context type:
// "…:text" | "…:caption" → C & { text: string }
// "…:data" | "callback_query" → C & { callbackQuery: CallbackQuery }
// "…:entities…" → C & { entities: MessageEntity[] }
// "…:photo" | "…:video" | … → C & { message: Message & { photo: PhotoSize[] } } (etc.)
// anything else → C (unchanged)
import { Bot } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
bot.on("message:text", (ctx) => {
ctx.text; // string, not string | undefined
});
bot.on("callback_query:data", (ctx) => {
ctx.callbackQuery; // CallbackQuery, guaranteed present
});
bot.on("message:photo", (ctx) => {
ctx.message.photo; // PhotoSize[], guaranteed present — no "?." needed
});composable filters
filter queries cover routing on the update's shape; @yaebal/filters covers routing on its content. Composer.filter(filter, ...handlers) runs a
predicate — sync or async, optionally staging typed data (a matched regex becomes ctx.match) — and only commits that data onto the context once the whole filter
tree matches, so a rejected and branch can never leak partial state. combine
filters with and/or/not; any bare (ctx) => boolean is already a valid filter.
import { Composer } from "@yaebal/core";
import { and, filters } from "@yaebal/filters";
// a Filter may be async and *stage* fields (regex stages ctx.match) — nothing
// touches the context until the whole tree matches, so a rejected branch never
// leaks partial data. combine any filters with and()/or()/not().
new Composer()
.filter(filters.regex(/^ticket (.+)$/i), (ctx) =>
ctx.reply(`ticket created: ${ctx.match[1]}`),
)
.filter(and(filters.isPrivate, filters.text), (ctx) =>
ctx.reply(`private text from ${ctx.from?.first_name}: ${ctx.text}`),
);import { Composer, createBot, filters } from "yaebal";
const support = new Composer()
.filter(filters.regex(/^ticket (.+)$/i), (ctx) =>
ctx.reply(`ticket created: ${ctx.match[1]}`),
);
const bot = createBot(process.env.BOT_TOKEN!)
.extend(support)
.command("help", (ctx) => ctx.reply("send: ticket <subject>"));
bot.start();derive / decorate accumulation
on top of filter-query narrowing, derive and decorate add their own
properties to the context type, and those carry downstream to every handler after them in the
chain — see core concepts for the full derive-vs-decorate rules.
import { Bot } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!)
.derive(async (ctx) => ({ user: await Promise.resolve({ id: ctx.from!.id }) })) // per-request
.decorate({ appVersion: "1.0.0" }) // static
.on("message:text", (ctx) => {
ctx.user; // ✅ from derive
ctx.appVersion; // ✅ from decorate
ctx.text; // ✅ from the filter query
});generated shortcuts
the base Context shown here is intentionally small. the much larger set of
per-update context classes — with API-method shortcuts generated from the Bot API schema — is the
autogen layer.
- contexts — the auto-generated context layer (the killer feature)