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.
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);| type | meaning |
|---|---|
In | required context. use this for explicit plugin dependencies. |
Out | fields 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.
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.
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.
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.
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.
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.
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.
{
"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
| rule | why |
|---|---|
| export an options interface | keeps plugin configuration discoverable and stable. |
| export the added context interface | lets advanced users name or compose the resulting context. |
prefer decorate for static values | avoids unnecessary per-update work. |
prefer derive for request-scoped values | keeps async and update-dependent state honest. |
encode dependencies in In | turns wrong install order into a TypeScript error. |
use BotPlugin for bot lifecycle or API hooks | keeps bot-only extensions installable through .install(). |
avoid any in public types | preserves YAEBAL's type-flow invariant. |