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.

api.ts
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 chains

before

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.

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

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

onError.ts
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
});
bounded by the hooks themselves. the retry loop has no built-in cap — it loops only while a hook keeps asking for a retry. gate on 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:

classwhencarries
TelegramErrorTelegram answered with ok: falsemethod, code, description, parameters
HttpErrorthe 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 AbortSignalwhatever fetch threw
error-kinds.ts
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;
    }
  }
});
error.ts
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"
  }
}
this page covers errors thrown by an API call. an error thrown by a handler/middleware — including one rethrown from here — is caught by 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.

typed-proxy.ts
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.

signal.ts
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 retry

apiRoot

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.

api-root.ts
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(...).

format-any-method.ts
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:

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

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