@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

terminal
pnpm add @yaebal/typing

usage

install typing() once. it adds ctx.typing via derive — no other plugin dependency required.

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

bot.ts
// 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.

bot.ts
// 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.ts
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.

bot.ts
// 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

exportsignaturedescription
typing(defaults?: TypingOptions) => Plugin<Context, TypingControl>installable plugin — adds ctx.typing

TypingControl

membersignaturedescription
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

fieldtypedefaultdescription
actionChatAction"typing"chat action to display while fn runs
intervalMsnumber4000re-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:

typing.test.ts
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);
doesn't replace ctx.typing(action) on message contexts. the built-in one-off sugar (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.