context
one object wraps each update, exposes typed accessors, and grows new properties as the chain enriches it.
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 message / edited_message / channel_post, and ctx.text falls back from text to caption.
bot.on("message:text", (ctx) => {
ctx.update; // the raw Update
ctx.updateType; // "message" | "callback_query" | …
ctx.message; // message ?? edited_message ?? channel_post
ctx.from; // User | undefined
ctx.chat; // Chat | undefined
ctx.text; // message.text ?? message.caption
ctx.is("callback_query"); // puregram-style narrowing check
});sending from the context
the context carries a handful of hand-written shortcuts that infer the chat from the current
update. send accepts a plain string or a format result; reply sets reply_parameters for you; the media shortcuts accept a MediaSource or a raw file_id/url string.
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.sendDocument(media.path("./a.pdf"));
});
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.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.
bot.on("message:text", (ctx) => ctx.text); // L1:L2
bot.on("message:caption", (ctx) => ctx.text); // caption also fills .text
bot.on("callback_query:data", (ctx) => ctx.callbackQuery);
bot.on("message:entities", (ctx) => ctx.entities);
bot.on(":photo", (ctx) => { /* any update with a message.photo */ });// matchQuery splits "message:text" into head + fields and checks each:
// head → must equal ctx.updateType
// text → ctx.text is a non-empty string
// data → ctx.callbackQuery?.data is set
// entities → ctx.message?.entities has length
// <other> → truthy on ctx.message[field] (e.g. photo, document, sticker)how 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.
// 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[] }
// anything else → C (unchanged)
bot.on("message:text", (ctx) => {
ctx.text; // string, not string | undefined
});
bot.on("callback_query:data", (ctx) => {
ctx.callbackQuery; // CallbackQuery, guaranteed present
});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.
bot
.derive(async (ctx) => ({ user: await db.find(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)