core concepts
one middleware engine, a context type that accumulates, and filter queries that narrow it.
the composer
Bot extends Composer. there is no separate router — the bot is the
middleware chain. each method that enriches the context returns a composer with an augmented
context type, so the chain stays type-safe end to end.
// Bot extends Composer — same middleware engine, no fork
bot
.install(session())
.derive((ctx) => ({ user: loadUser(ctx.from!.id) })) // async, per-request
.decorate({ version: "1.0.0" }) // static, zero cost
.on("message:text", (ctx) => {
ctx.user; // ✅ added by derive, fully typed
ctx.version; // ✅ added by decorate
});derive vs decorate
two ways to add to the context, kept deliberately distinct:
| method | when | cost | use for |
|---|---|---|---|
derive | async, per request | runs every update | db lookups, computed state |
decorate | static, once | zero per-request | constants, helpers, services |
bot.derive(async (ctx) => {
const user = await db.users.find(ctx.from!.id);
return { user };
});
// every downstream handler now sees ctx.user: Userfilter queries
grammY-style L1:L2:L3 queries. they don't just route — they narrow the
context type, so the field you filtered on is guaranteed present and typed.
bot.on("message:text", (ctx) => ctx.text); // ctx.text: string
bot.on("message:photo", (ctx) => ctx.photo); // ctx.photo: PhotoSize[]
bot.on("callback_query:data", (ctx) => ctx.data); // ctx.data: string
bot.on("message:entities:url", (ctx) => { /* ... */ });writing a plugin
a plugin is a function (composer) => composer with an explicit input and output
context. dependencies are type-checked — you can't install a plugin whose required context
isn't there yet.
import type { Plugin } from "@yaebal/core";
const timer: Plugin<Context, { startedAt: number }> = (composer) =>
composer.derive(() => ({ startedAt: Date.now() }));
bot.install(timer); // ctx.startedAt is now typed downstreamany. that's what keeps the chain honest.scoped derive
pass one or more update-type strings as the first arguments to derive and the
function runs only for those update types. updates that don't match skip the
derive entirely, saving the work of the async callback on every irrelevant event.
// pass a single update type, or an array of them
bot.derive(["message", "edited_message"], async (ctx) => {
const user = await db.users.find(ctx.from!.id);
return { user }; // typed as Partial — only present on those updates
});
// unscoped derive runs for every update type
bot.derive(async (ctx) => ({ ts: Date.now() }));composer.filter
filter(predicate, ...handlers) creates a type-narrowing branch. any handler passed
to it receives the context already narrowed to the subset that satisfies the predicate — the
same guarantee as on(), but composable with predicates from @yaebal/filters or your own boolean functions.
import { text, command, and, isPrivate } from "@yaebal/filters";
// filter() runs handlers only when the filter matches, and narrows
// the context — filters can also attach data (regex → ctx.match)
bot.filter(text, (ctx) => ctx.text); // ctx.text: string
bot.filter(command("buy"), (ctx) => ctx.args); // ctx.command, ctx.args
// compose with and / or / not
bot.filter(and(isPrivate, command("pay")), (ctx) => ctx.args);