@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
pnpm add @yaebal/auto-answerusage
install autoAnswer() once. it attaches a callback_query middleware and
adds ctx.skipAutoAnswer() — no other plugin dependency required, and it composes
with anything.
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();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.
// "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 }));// "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" }));// "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.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 });
});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.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.
// 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.install(
autoAnswer({
onAnswer: (ctx) => metrics.increment("callback_answered"),
onError: (error, ctx) => logger.warn("auto-answer failed", error, ctx.callbackQuery.id),
}),
);api
| export | signature | description |
|---|---|---|
autoAnswer | (options?: AutoAnswerOptions) => Plugin<Context, AutoAnswerContext> | installable plugin — adds ctx.skipAutoAnswer() |
AutoAnswerOptions
| field | type | default | description |
|---|---|---|---|
mode | "deadline" | "deferred" | "immediate" | "deadline" | race a timer against the chain, wait for the whole chain, or fire before it runs |
timeout | number | 1500 | how long "deadline" waits for the chain before answering on its own (ms) |
params | AutoAnswerParams | (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
| member | signature | description |
|---|---|---|
skipAutoAnswer | () => void | opt the current update out of auto-answering; a no-op outside a callback_query |
AutoAnswerParams
| field | type | maps to |
|---|---|---|
text | string | text |
showAlert | boolean | show_alert |
url | string | url |
cacheTime | number | cache_time |
testing
@yaebal/test stubs answerCallbackQuery to true by default, so assert on the recorded call:
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:
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 fileautoAnswer() reads only ctx.callbackQuery, so it composes ahead of or behind callback-data routing and pagination without any ordering requirement.