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.

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

methodwhencostuse for
deriveasync, per requestruns every updatedb lookups, computed state
decoratestatic, oncezero per-requestconstants, helpers, services
derive.ts
bot.derive(async (ctx) => {
  const user = await db.users.find(ctx.from!.id);
  return { user };
});
// every downstream handler now sees ctx.user: User

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

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

plugin.ts
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 downstream
invariant: any composer method that enriches the context must return an augmented type, never widen to any. 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.

scoped-derive.ts
// 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.

filter.ts
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);