hooks & errors
three extension points on the Api — before, after, onError — wrap every request, the error hook drives the retry loop, and every failure surfaces as one of two typed error classes.
the request lifecycle
every call goes through before hooks, the request itself, then after hooks. if the request throws, onError hooks decide whether to retry. plugins hang off
these same three points.
import { createApi } from "@yaebal/core";
const api = createApi(process.env.BOT_TOKEN!)
.before((m, p) => p)
.after((m, r) => r)
.onError((m, e) => undefined);
// each registrar returns the Api, so registration chainsbefore
a before hook receives the method name and params and may return replacement params.
returning undefined leaves them as-is. hooks run in registration order, each seeing
the previous one's output. they run for every actual request attempt, including retries requested
by onError hooks.
import { Bot } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
// before — inspect or rewrite params; return new params to replace them
bot.api.before((method, params) => {
if (method === "sendMessage") {
return { parse_mode: "HTML", ...params };
}
// return undefined → params unchanged
});after
an after hook receives the method name and the successful result, and may return a
replacement value. returning undefined leaves the result unchanged.
import { Bot } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
// after — inspect or rewrite the result; return a value to replace it
bot.api.after((method, params, result) => {
console.log(method, "ok");
// return undefined → result unchanged
});onError and the retry loop
when a request throws, every registered onError hook runs — each
sees the method, the error, the 1-based attempt that just failed, and the request
params after before hooks. it isn't a short-circuit: even after one hook asks for a
retry, the rest still run (so a logger/metrics hook never misses a failure). the first hook that returned { retry: true } wins and triggers a
re-run; an optional delayMs waits before retrying. if no hook requests a retry, the
error is rethrown.
import { Bot, TelegramError } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
// onError — runs when a request throws; ask for a retry by returning an action
bot.api.onError((method, error, attempt, params) => {
if (error instanceof TelegramError && error.code === 429 && attempt < 5) {
const retryAfterMs = (error.parameters?.retry_after ?? 1) * 1000;
return { retry: true, delayMs: retryAfterMs };
}
// return undefined (or { retry: false }) → the error is rethrown
});attempt, or install @yaebal/again which does exactly that with backoff and a cap.errors
a failed call throws one of two typed classes, depending on where it failed:
| class | when | carries |
|---|---|---|
TelegramError | Telegram answered with ok: false | method, code, description, parameters |
HttpError | the HTTP layer failed before a Bot API answer existed — a proxy or self-hosted server replied with something that isn't the JSON envelope (e.g. an HTML 502) | method, status, statusText |
| a plain rejection (not either class) | the network call itself failed — DNS, connection reset, a deliberate AbortSignal | whatever fetch threw |
import { Bot, HttpError, TelegramError } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
bot.command("risky", async (ctx) => {
try {
await bot.api.sendMessage({ chat_id: ctx.chat!.id, text: "" });
} catch (error) {
if (error instanceof TelegramError) {
// telegram answered ok: false — a real api-level rejection
console.error(`[telegram] ${error.method} -> ${error.code}: ${error.description}`);
} else if (error instanceof HttpError) {
// the server (or a proxy in front of it) replied with something that
// isn't the json envelope — e.g. an html 502 from a local bot api server
console.error(`[transport] ${error.method} -> http ${error.status} ${error.statusText}`);
} else {
// a network failure (DNS, connection reset, abort, …) — a plain fetch rejection
throw error;
}
}
});import { TelegramError } from "@yaebal/core";
declare function riskyCall(): Promise<unknown>;
try {
await riskyCall();
} catch (e) {
if (e instanceof TelegramError) {
e.method; // "sendMessage"
e.code; // error_code from Telegram
e.description; // raw Telegram description
e.parameters; // response_parameters, e.g. { retry_after: 7 }
e.message; // "[sendMessage] 400: message text is empty"
}
}bot.onError() instead; see core concepts.the typed proxy, and the escape hatch
bot.api.<method>(params) is fully typed — every Bot API method, generated from
the schema, with params and return type both checked. bot.api.call(method, params) is
the untyped passthrough underneath it (the puregram idea): reach for it for a brand-new method the
generated types don't cover yet, or when params are built dynamically as a plain record.
import { Bot } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
// every Bot API method is typed — params and return type both checked
await bot.api.sendMessage({ chat_id: 1, text: "hi" });
// call() is the untyped escape hatch: a brand-new method the generated types
// don't know about yet, or params built dynamically as a plain record
await bot.api.call("sendMessage", { chat_id: 1, text: "hi" });cancellation
every call takes an optional { signal } — Bot.stop() uses this to
cancel an in-flight long poll. a deliberate abort is a cancellation, not a failure: it's never
offered to onError hooks for a retry.
import { Bot } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
const controller = new AbortController();
const pending = bot.api.call("sendMessage", { chat_id: 1, text: "hi" }, { signal: controller.signal });
controller.abort(); // a deliberate cancellation — never offered to onError hooks for a retryapiRoot
point the client at a different origin — most commonly a local bot api server. pass the bare origin, no
trailing /bot; the client appends /bot<token>/<method> itself.
import { Bot } from "@yaebal/core";
// bare origin, no trailing "/bot" — the client appends /bot<token>/<method> itself
new Bot(process.env.BOT_TOKEN!, { apiRoot: "http://localhost:8081" }); // local bot api server
// a copy-pasted GramIO-style apiRoot ending in "/bot" is detected and the
// suffix stripped (with a console.warn) instead of silently 401ing on
// /bot/bot<token>/<method>
new Bot(process.env.BOT_TOKEN!, { apiRoot: "https://api.telegram.org/bot" });formatted values work on every method
splitting a fmt/md/html result into text/entities (or caption/caption_entities) isn't special-cased to ctx.send/reply/sendPhoto — it happens for any method
called through bot.api, including raw call(...).
import { Bot } from "@yaebal/core";
import { html } from "@yaebal/fmt";
const bot = new Bot(process.env.BOT_TOKEN!);
// fmt/md/html results are split into text+entities (or caption+caption_entities)
// for ANY method that takes them — not just the ctx.send/reply/sendPhoto shortcuts
await bot.api.call("sendMessage", { chat_id: 1, text: html`<b>bold</b>` });
await bot.api.call("editMessageCaption", {
chat_id: 1,
message_id: 1,
caption: html`<i>edited</i>`,
});recipe: timing every call
a before/after pair is enough to measure request latency:
import { Bot } from "@yaebal/core";
const bot = new Bot(process.env.BOT_TOKEN!);
const startedAt = new Map<string, number>();
bot.api
.before((method, params) => {
startedAt.set(method, Date.now());
return params;
})
.after((method, _params, result) => {
const started = startedAt.get(method);
if (started !== undefined) console.log(`${method} took ${Date.now() - started}ms`);
return result;
});encodeRequest: JSON vs multipart
encodeRequest is exported for adapters and tests. the body encoding is chosen per
request. plain params (and url/fileId media) serialize to JSON; the
moment a path/buffer upload is present the request becomes multipart with attach:// references. see media for the full encoding
rules, and for downloadFile/fileUrl.
import { encodeRequest, media } from "@yaebal/core";
const req = await encodeRequest({ photo: media.buffer(new Uint8Array([1, 2, 3])), caption: "hi" });
req.body; // FormData
req.contentType; // undefined for multipart — the runtime sets it (with its boundary) itself