@yaebal/typing
keeps the "is typing…" indicator alive for the duration of an async operation — no manual sendChatAction calls, no forgotten indicator left stuck after telegram's ~5s
expiry. built for long LLM/API calls where the reply takes a noticeable amount of time to
arrive.
install
pnpm add @yaebal/typingusage
install typing() once. it adds ctx.typing via derive — no
other plugin dependency required.
import { Bot } from "@yaebal/core";
import { typing } from "@yaebal/typing";
const bot = new Bot(process.env.BOT_TOKEN!)
.install(typing());
bot.on("message:text", async (ctx) => {
const reply = await ctx.typing(() => llm.complete(ctx.message.text));
await ctx.reply(reply);
});
await bot.start();two forms
ctx.typing(action?) sends a single chat action and resolves to the api's boolean result — the same one-off indicator ctx.typing() already
offers on message contexts, just installable on any context.
// the plain, one-off form — a single sendChatAction call
await ctx.typing(); // defaults to "typing"
await ctx.typing("upload_photo");ctx.typing(fn, options?) wraps an async operation: sends the chat action right
away, keeps it alive on an interval, and clears it the instant fn() settles.
// sent immediately, re-sent every intervalMs so it survives telegram's ~5s
// expiry, cleared the instant fn() settles — resolved or rejected
const image = await ctx.typing(() => generateImage(prompt), {
action: "upload_photo",
});options
set defaults at install time, override any of them per call — a per-call action/intervalMs/onError always wins.
bot.install(
typing({
action: "typing", // default chat action, overridable per call
intervalMs: 4000, // re-send cadence, in ms — stay under telegram's ~5s expiry
onError: (error) => logger.warn("typing keep-alive failed", error),
}),
);no chat, no indicator
updates without a chat (inline_query, …) can't show an indicator. the one-off form
rejects — there's nothing to send. the fn form just runs fn() plain,
since the operation itself still matters even without a chat to animate.
// an update with no chat (e.g. inline_query) can't show an indicator:
// the fn form just runs fn() plain — the operation itself still matters
bot.on("inline_query", async (ctx) => {
const results = await ctx.typing(() => search(ctx.inlineQuery.query));
await ctx.answerInlineQuery(results);
});api
| export | signature | description |
|---|---|---|
typing | (defaults?: TypingOptions) => Plugin<Context, TypingControl> | installable plugin — adds ctx.typing |
TypingControl
| member | signature | description |
|---|---|---|
typing | (action?: ChatAction) => Promise<boolean> | send a chat action once, defaults to "typing" |
typing | <T>(fn: () => Promise<T>, options?: TypingOptions) => Promise<T> | keep the action alive for as long as fn is pending |
TypingOptions
| field | type | default | description |
|---|---|---|---|
action | ChatAction | "typing" | chat action to display while fn runs |
intervalMs | number | 4000 | re-send cadence, in ms — must stay under telegram's ~5s expiry |
onError | (error: unknown) => unknown | - | observe a failed keep-alive ping — swallowed either way, never aborts fn |
testing
@yaebal/test stubs sendChatAction to true by default, so assert on the recorded calls.
the keep-alive interval needs the virtual clock to fast-forward without a real wait:
const env = createTestEnv(bot);
env.useFakeTimers(); // arm before the handler schedules its interval
const dispatched = env.createUser().sendMessage("hi");
await env.advanceTime(5000);
await dispatched;
assert.ok(env.callsTo("sendChatAction").length > 1);ctx.typing("upload_photo") on message-bearing contexts) still exists —
this plugin overloads the same name so both forms keep working together: call it with an
action for a single ping, or with a function to keep it alive across an async call.