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.

handler.ts
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 check

ctx.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.

send.ts
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
);
guard rails. 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.

routing.ts
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.

filters.ts
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
// 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:

message content fields
photo | video | sticker | audio | voice | document | animation | contact |
location | poll | dice | venue | video_note | game | invoice | successful_payment |
web_app_data

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 — including the message content fields above, not just text/data/entities.

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

composable-filters.ts
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}`),
  );
try it — feature routes
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.

enrich.ts
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)