plugin authoring

build plugins as typed composer or bot extensions. a plugin receives the current chain, mutates or returns it, and tells typescript what context it requires and what context it adds.

the contract

Plugin<In, Out> is exported by @yaebal/core. In is the context your plugin needs before it can be installed. Out is what your plugin adds for downstream handlers.

contract.ts
import type { Composer, Context } from "@yaebal/core";

type Plugin<In extends Context = Context, Out extends object = Record<never, never>> =
  <C extends In>(composer: Composer<C>) => Composer<C & Out>;

// installed with:
bot.install(plugin);
typemeaning
Inrequired context. use this for explicit plugin dependencies.
Outfields added to ctx after installation.
Composer<C>the chain being extended. Bot also works because Bot extends Composer.

static helpers with decorate

use decorate() for constants, long-lived services, and pure helper objects. it has no per-update middleware cost; the value is assigned when the chain runs.

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

export interface Clock {
  now(): Date;
}

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

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

per-update state with derive

use derive() when the value depends on the current update or needs async work. the returned object is assigned to ctx before downstream handlers run.

current-user.ts
import type { Context, Plugin } from "@yaebal/core";

export interface User {
  id: number;
  name: string;
}

export interface CurrentUserOptions {
  loadUser(id: number): Promise<User | null>;
}

export function currentUser(options: CurrentUserOptions): Plugin<Context, { user: User | null }> {
  return (composer) =>
    composer.derive(async (ctx) => ({
      user: ctx.from ? await options.loadUser(ctx.from.id) : null,
    }));
}

bot.install(currentUser({ loadUser })).on("message:text", (ctx) => {
  console.log(ctx.user?.name ?? "guest");
});

dependencies are types

do not rely on hidden install order. if a plugin needs another plugin's context field, put that field in In. installing it too early becomes a compile-time error.

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

interface SessionData {
  userId?: number;
}

interface User {
  id: number;
  name: string;
}

type NeedsSession = Context & { session: SessionData };

export function sessionUser(loadUser: (id: number) => Promise<User>): Plugin<NeedsSession, { user: User | null }> {
  return (composer) =>
    composer.derive(async (ctx) => ({
      user: ctx.session.userId ? await loadUser(ctx.session.userId) : null,
    }));
}

new Bot(token).install(sessionUser(loadUser));
// TypeScript error: ctx.session is not available yet.

new Bot(token)
  .install(session<SessionData>({ initial: () => ({}) }))
  .install(sessionUser(loadUser));
// OK: the session plugin ran first, so the context type satisfies NeedsSession.

middleware and handlers

plugins can also register raw middleware, commands, filters, and handlers. if the plugin does not add fields to ctx, leave Out as the default.

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

export function audit(log: (line: string) => void): Plugin<Context> {
  return (composer) =>
    composer.use(async (ctx, next) => {
      const started = Date.now();

      try {
        await next();
      } finally {
        log(ctx.updateType + " " + (Date.now() - started) + "ms");
      }
    });
}

export function ping(): Plugin<Context> {
  return (composer) =>
    composer.command("ping", (ctx) => ctx.reply("pong"));
}

bot plugins and lifecycle

use BotPlugin when a plugin needs bot-only features such as bot.api, onStart(), or onStop(). the shape mirrors Plugin, but the function receives a Bot instead of a plain Composer.

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

interface TelemetryControls {
  telemetry: { log(line: string): void };
}

export function telemetry(log: (line: string) => void): BotPlugin<Context, TelemetryControls> {
  return (bot) =>
    bot
      .onStart((info) => log("started @" + info.username))
      .onStop(() => log("stopped"))
      .decorate({ telemetry: { log } });
}

bot.install(telemetry(console.log));

direct api hooks

request hooks still live on Api. expose a direct helper when users may want to wire a standalone API instance without a bot.

api-hook.ts
import type { Api } from "@yaebal/core";

export function traceApi(api: Api, log: (line: string) => void): void {
  api.before((method) => {
    log("telegram request: " + method);
    return undefined;
  });
}

publishing shape

third-party plugin packages should be ESM, export types, and use @yaebal/core as a peer dependency so the app and plugin share the same public types.

package.json
{
  "name": "@acme/yaebal-clock",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./lib/index.d.ts",
      "import": "./lib/index.js"
    }
  },
  "peerDependencies": {
    "@yaebal/core": ">=0.0.3"
  },
  "devDependencies": {
    "@yaebal/core": ">=0.0.3",
    "typescript": "latest"
  }
}

checklist

rulewhy
export an options interfacekeeps plugin configuration discoverable and stable.
export the added context interfacelets advanced users name or compose the resulting context.
prefer decorate for static valuesavoids unnecessary per-update work.
prefer derive for request-scoped valueskeeps async and update-dependent state honest.
encode dependencies in Inturns wrong install order into a TypeScript error.
use BotPlugin for bot lifecycle or API hookskeeps bot-only extensions installable through .install().
avoid any in public typespreserves YAEBAL's type-flow invariant.