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. every context-enriching method returns an augmented type, so handlers downstream see exactly what was installed before them.

composer.ts
import { Bot } from "@yaebal/core";
import { session } from "@yaebal/session";

const loadUser = async (id: number) => ({ id, name: "somebody" });

const bot = new Bot(process.env.BOT_TOKEN!)
  .install(session({ initial: () => ({ count: 0 }) }))
  .derive(async (ctx) => ({ user: await loadUser(ctx.from!.id) }))
  .decorate({ version: "1.0.0" })
  .on("message:text", (ctx) => {
    ctx.session.count; // added by session()
    ctx.user;          // added by derive()
    ctx.version;       // added by decorate()
    ctx.text;          // narrowed by on("message: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();

execution order

an update goes through the same four stops every time, and the first three only ever run once per update, before routing decides which handler fires:

  1. context construction. contextFactory (or the default new Context(...) when none is set) builds ctx from the raw update.
  2. decorate values are assigned. every decorate() call anywhere in the chain is collected once, when the chain is first realized, and merged onto ctx before any middleware runs — so a field added by decorate() is visible even to a handler registered before the decorate() call. this is the one place registration order doesn't matter; see the note below.
  3. middleware runs in registration order. use, derive, on, command, hears, guard, filter and merged extended composers are all ordinary entries in the same list — whichever was chained first runs first. a handler only sees a derived or plugin-added field if that call happened earlier in the chain.
  4. the matched handler runs — the first routing method whose query/predicate matches.
decorate is chain-wide, derive is positional. bot.on("x", (ctx) => ctx.version) sees a later bot.decorate({ version: "1.0.0" }) just fine — decorations apply once, up front. the same handler would not see a later bot.derive(...) field, because derive is a middleware hop that has to run first. when in doubt, put both before the handlers that use them; only rely on decorate's chain-wide visibility once you understand why it works.

routing methods

Composer is a Koa-style middleware pipeline with Telegram-aware routing helpers. handlers run in registration order; call next() inside raw middleware to continue.

routing.ts
import { Bot } from "@yaebal/core";

const ADMIN_ID = 12345;
const bot = new Bot(process.env.BOT_TOKEN!);

bot.command("start", (ctx) => ctx.reply("hi"));
bot.hears(/buy (.+)/, (ctx) => ctx.reply(`match: ${ctx.match[1] ?? ""}`));
bot.callbackQuery(/^page:(\d+)$/, (ctx) => ctx.answerCallbackQuery());
bot.guard((ctx) => ctx.from?.id === ADMIN_ID).command("admin", (ctx) => ctx.reply("admin only"));

bot.on("message:text", (ctx) => ctx.text);        // ctx.text: string
bot.on("callback_query:data", (ctx) => ctx.callbackQuery.data);
methodwhat it does
use(...middleware)raw middleware. receives (ctx, next).
on(query, ...handlers)filter-query routing: "message:text", "callback_query:data", ":photo".
command(name, ...handlers)matches /name in fresh message text (edits don't re-fire), verifies @botname against the bot's username when known, adds ctx.command and ctx.args.
hears(trigger, ...handlers)matches text/caption by string or RegExp and adds ctx.match.
callbackQuery(trigger, ...handlers)matches callback_query.data and adds ctx.match.
guard(predicate)continues only when the predicate returns true. a type-guard predicate (ctx is …) narrows the context for everything after it.
filter(filter, ...handlers)runs a composable filter (sync or async, may stage typed data), e.g. from @yaebal/filters.

derive vs decorate

two ways to add to the context, kept deliberately distinct:

methodwhenruntime shapeuse for
deriveasync, per updateruns a function and assigns its resultdb lookups, computed state, request-scoped services
decoratestatic, chain-build timemerged into one decoration object and assigned at the top of the realized chainconstants, helpers, long-lived services
derive.ts
import { Bot } from "@yaebal/core";

const db = { users: { find: async (id: number) => ({ id }) } };
const bot = new Bot(process.env.BOT_TOKEN!);

// unscoped: runs for every update
bot.derive(async (ctx) => ({ requestId: crypto.randomUUID() }));

// scoped: runs only for listed update types; typed as Partial<D> downstream
bot.derive(["message", "edited_message"], async (ctx) => ({
  user: await db.users.find(ctx.from!.id),
}));

the scoped form's fields come back typed as Partial<D>, not D: a handler further downstream can see update types the scoped derive never ran for (nothing stops on("message", ...) from also matching after a derive scoped to ["edited_message"], for instance), so TypeScript can't promise the field is always there — you narrow it with a plain if, same as any other optional field.

derive-scoped.ts
import { Bot } from "@yaebal/core";

const bot = new Bot(process.env.BOT_TOKEN!)
  .derive(["message", "edited_message"], async (ctx) => ({ user: { id: 1 } }))
  .on("message", (ctx) => {
    ctx.user; // { id: number } | undefined — Partial<D>: this handler also
              // sees update types the derive never ran for, so TypeScript
              // can't promise the field is there.
    if (ctx.user) ctx.user.id; // narrowed after the check
  });

plugins

a plugin is (composer) => composer with explicit input and output context types. dependencies are type-checked: if a plugin requires ctx.session, typescript rejects installing it before the session plugin. use BotPlugin for extensions that need bot-only features such as bot.api, onStart(), or onStop().

plugin.ts
import { Bot, type BotPlugin, type Context, type Plugin } from "@yaebal/core";

type Clock = { now: () => Date };

const clock: Plugin<Context, { clock: Clock }> = (composer) =>
  composer.decorate({ clock: { now: () => new Date() } });

const lifecycle: BotPlugin = (bot) =>
  bot.onStart((info) => console.log("started @" + info.username))
     .onStop(() => console.log("stopped"));

const bot = new Bot(process.env.BOT_TOKEN!);

bot.install(clock).install(lifecycle).command("time", (ctx) => {
  return ctx.reply(String(ctx.clock.now()));
});

error handling

a throw anywhere in the chain — sync, async, or a rejected promise — is caught by handleUpdate and handed to a single error handler. it does not crash the bot and it does not affect other updates: only the update that triggered the error stops processing.

errors.ts
import { Bot, TelegramError } from "@yaebal/core";

const bot = new Bot(process.env.BOT_TOKEN!);

bot.command("risky", () => {
  throw new Error("boom"); // thrown anywhere in the chain — sync, async, or a rejected promise
});

// one handler for every uncaught error. the default just console.errors and
// moves on; replacing it does not change that isolation — a throw here still
// only aborts the update that triggered it.
bot.onError((error, ctx) => {
  if (error instanceof TelegramError) {
    // a failed Bot API call: error.method, error.code, error.description
    console.error(`[telegram] ${error.method} -> ${error.code}: ${error.description}`);
    return;
  }

  console.error(`[update ${ctx.update.update_id}]`, error);
});
surfacewhat it catches
bot.onError(handler)anything thrown by middleware/handlers for one update. default: console.error.
TelegramErrora failed Bot API call — method, code, description, optional parameters (e.g. retry_after).
bot.onPollingError(handler)a failed getUpdates long-poll call — unrelated to any single update. polling retries either way.
bot.api.onError(hook)per-call retry hook on the API client itself — can return { retry, delayMs } to retry the same call. see hooks & errors and @yaebal/again.

bot lifecycle

apidescription
new Bot(token, options?)creates an API client and a composer-backed bot.
bot.start()starts sequential long-polling. resolves when stop() is called.
bot.stop()stops the polling loop and resolves after stop handlers run.
bot.handleUpdate(update)runs one update through the frozen middleware chain.
bot.onStart(handler)runs after getMe() succeeds in start().
bot.onStop(handler)runs once when stop() is requested or polling exits.
bot.onError(handler)handles errors thrown by middleware for a specific context.
bot.onPollingError(handler)handles getUpdates failures (default: console.error); polling retries either way, backing off 3s → 30s while failures repeat and resetting on the first success. the second argument is { attempt, retryInMs, aborted }. hung connections are aborted and retried automatically — the first three such aborts in a row are silent, since that is normal recovery, not an outage.

BotOptions

optiondescription
apiRoot?: stringTelegram API root. defaults to https://api.telegram.org.
readFile?: FileReaderruntime-provided file reader for media.path(). bare core leaves it unset.
allowedUpdates?: UpdateName[]update types requested by long polling.
contextFactory?: (...) => Contextbuilds a custom context per update. the yaebal meta package uses this for rich generated contexts.

low-level exports

low-level.ts
import { compose, matchQuery, type Context, type Middleware } from "@yaebal/core";

declare const one: Middleware<Context>;
declare const two: Middleware<Context>;
declare const ctx: Context;

const stack = compose<Context>([one, two] satisfies Middleware<Context>[]);
await stack(ctx, async () => {});

if (matchQuery(ctx, "message:text")) {
  // runtime check used by Composer.on()
}
exportdescription
composeKoa-style middleware composition with double-next() protection.
matchQueryruntime evaluator used by on().
Middleware, NextFnmiddleware function types.
Plugin, BotPlugintyped composer and bot extension types.
Filter, FilterQuery, Filteredfilter and query typing primitives.
ContextOptionsconstructor options for the base Context.

formatting helpers in core

Core includes entity-based formatting helpers: format, bold, italic, underline, strikethrough, spoiler, code, pre (with a language), blockquote, expandableBlockquote, link, mention, customEmoji, dateTime, and join (keeps entities where [].join() would drop them). Helpers nest — bold(italic("x")) — and double as tagged templates: bold`…`.

A format/fmt result is accepted by every API call, not just ctx.send: the api client splits it into text + the right *_entities sibling wherever the schema allows formatted text — including nested spots like reply_parameters.quote, poll options, media groups and inline results (driven by the code-generated formatFields map in @yaebal/types). For HTML/Markdown parsing, use @yaebal/fmt. For telegram's block-tree rich message format (sendRichMessage/sendRichMessageDraft), use @yaebal/rich.

invariant: any composer method that enriches the context must return an augmented type, never widen to any. that's what keeps the chain honest.