@yaebal/auto-answer

clears the client's loading spinner on every callback_query — no await ctx.answerCallbackQuery() in every button handler, and no forgotten one leaving a user staring at a spinner until telegram gives up on it.

install

terminal
pnpm add @yaebal/auto-answer

usage

install autoAnswer() once. it attaches a callback_query middleware and adds ctx.skipAutoAnswer() — no other plugin dependency required, and it composes with anything.

bot.ts
import { Bot } from "@yaebal/core";
import { autoAnswer } from "@yaebal/auto-answer";

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

bot.callbackQuery("ping", (ctx) => ctx.reply("pong"));

await bot.start();
bot.ts
import { InlineKeyboard, createBot } from "yaebal";
import { autoAnswer } from "@yaebal/auto-answer";

const bot = createBot(process.env.BOT_TOKEN!)
  .install(autoAnswer());

bot.command("start", (ctx) =>
  ctx.reply("deploy to production?", {
    reply_markup: new InlineKeyboard().text("confirm", "confirm:deploy").build(),
  }),
);

bot.callbackQuery(/^confirm:/, async (ctx) => {
  // no ctx.answerCallbackQuery() here — the plugin already clears the spinner,
  // and would still answer even if this handler took its time or threw.
  await ctx.reply("confirmed");
});

bot.start();

modes

pick "deadline" (the default) unless you have a specific reason not to — it's the only mode where both "the spinner always clears promptly" and "a handler's own alert still works" hold at once.

bot.ts
// "deadline" (default) — races the handler chain against `timeout` (default 1500ms). a
// handler that answers first — with its own text/alert — always wins; if nothing answers
// before the timer fires, the plugin fills the gap with an empty ack.
bot.install(autoAnswer({ timeout: 2000 }));
bot.ts
// "deferred" — waits for the whole handler chain to finish, however long that takes, and
// only answers if nothing already did. no timer, so no risk of racing a still-running
// handler — but a hung or truly slow handler leaves the spinner spinning for as long as it
// takes.
bot.install(autoAnswer({ mode: "deferred" }));
bot.ts
// "immediate" — answers the instant the update arrives, before any handler runs. zero
// added latency, but a handler's own answerCallbackQuery(...) can no longer win — it's
// silently turned into a no-op instead of a second call that would fail against telegram.
// only reach for this if no handler downstream ever answers its own callback queries.
bot.install(autoAnswer({ mode: "immediate" }));

never double-answers, never throws

whichever call reaches ctx.answerCallbackQuery first — this plugin's own fallback, or a handler's manual call — wins; every later one (even in the same synchronous tick, e.g. an "immediate" fire racing a handler that answers right away) becomes a safe no-op instead of a second network call racing the first to telegram's 400: query is too old.

calls that go around ctx.answerCallbackQuery entirely — a rich @yaebal/contexts contextFor("callback_query", ...).answer(), or a raw ctx.api.call("answerCallbackQuery", ...) — are still observed (so "deferred"/"deadline"'s fallback correctly backs off once the handler chain has had time to run), but not blocked outright: in "immediate" mode specifically, a bypass call issued in the very same tick as the plugin's own fire can still double-dispatch. stick to ctx.answerCallbackQuery (or ctx.skipAutoAnswer()) inside handlers this plugin watches, and this never comes up.

a failed auto-answer (an expired query, a dropped connection, a throwing filter/params) is always swallowed and handed to onError — this plugin never crashes the chain over a best-effort spinner clear, and a broken onAnswer/onError callback can't crash it either.

opting out per update

ctx.skipAutoAnswer() opts the current update out entirely — no fallback answer, regardless of mode. it's a no-op outside a callback_query update, so it's always safe to call.

bot.ts
bot.callbackQuery("archive", async (ctx) => {
  // answering later, from a queued job — tell the plugin not to fill the gap in the meantime.
  ctx.skipAutoAnswer();
  await queue.push({ type: "archive", callbackQueryId: ctx.callbackQuery.id });
});
bot.ts
import { InlineKeyboard, createBot } from "yaebal";
import { autoAnswer } from "@yaebal/auto-answer";

const bot = createBot(process.env.BOT_TOKEN!)
  .install(autoAnswer());

bot.command("start", (ctx) =>
  ctx.reply("archive this chat?", {
    reply_markup: new InlineKeyboard().text("archive", "archive:go").build(),
  }),
);

bot.callbackQuery("archive:go", async (ctx) => {
  // answering later, from a queued job — tell the plugin not to fill the gap meanwhile.
  ctx.skipAutoAnswer();
  await ctx.reply("queued for archiving");
});

bot.start();

dynamic params

params accepts text, showAlert, url and cacheTime — the same fields answerCallbackQuery takes, camelCased.

bot.ts
bot.install(
  autoAnswer({
    // static params...
    params: { text: "got it", showAlert: false },
  }),
);

bot.install(
  autoAnswer({
    // ...or computed per update (sync or async)
    params: (ctx) => ({
      text: ctx.callbackQuery.data === "danger" ? "careful!" : undefined,
    }),
  }),
);

filter

skip specific updates entirely — useful when another plugin (e.g. pagination) already answers a subset of callback queries its own way.

bot.ts
// skip updates another plugin already answers its own way
bot.install(
  autoAnswer({
    filter: (ctx) => !ctx.callbackQuery.data?.startsWith("page:"),
  }),
);

observability

onAnswer observes every answer the plugin actually sent. onError observes a failed one instead of throwing.

bot.ts
bot.install(
  autoAnswer({
    onAnswer: (ctx) => metrics.increment("callback_answered"),
    onError: (error, ctx) => logger.warn("auto-answer failed", error, ctx.callbackQuery.id),
  }),
);

api

exportsignaturedescription
autoAnswer(options?: AutoAnswerOptions) => Plugin<Context, AutoAnswerContext>installable plugin — adds ctx.skipAutoAnswer()

AutoAnswerOptions

fieldtypedefaultdescription
mode"deadline" | "deferred" | "immediate""deadline"race a timer against the chain, wait for the whole chain, or fire before it runs
timeoutnumber1500how long "deadline" waits for the chain before answering on its own (ms)
paramsAutoAnswerParams | (ctx) => AutoAnswerParams | undefined-static params, or computed per update (sync or async)
filter(ctx) => boolean | Promise<boolean>-skip auto-answering this update
onAnswer(ctx) => unknown-observe every answer the plugin actually sent
onError(error, ctx) => unknown-observe a failed auto-answer instead of throwing

AutoAnswerContext

membersignaturedescription
skipAutoAnswer() => voidopt the current update out of auto-answering; a no-op outside a callback_query

AutoAnswerParams

fieldtypemaps to
textstringtext
showAlertbooleanshow_alert
urlstringurl
cacheTimenumbercache_time

testing

@yaebal/test stubs answerCallbackQuery to true by default, so assert on the recorded call:

auto-answer.test.ts
const env = createTestEnv(bot);
await env.createUser().click("ping");

assert.equal(env.callsTo("answerCallbackQuery").length, 1);

testing "deadline"'s fallback timer needs the virtual clock to fast-forward without a real wait — restore it afterward, or every later test in the same file silently inherits the fake setTimeout:

auto-answer.test.ts
const env = createTestEnv(bot);
env.useFakeTimers(); // arm before the handler chain schedules anything

const clicked = env.createUser().click("ping");
await env.advanceTime(1500); // the default timeout
await clicked;

assert.equal(env.callsTo("answerCallbackQuery").length, 1);
env.shutdown(); // restore real timers for the rest of the file
pairs with callback-data. autoAnswer() reads only ctx.callbackQuery, so it composes ahead of or behind callback-data routing and pagination without any ordering requirement.