npm node-telegram-bot-api 2.2.0

3 hours ago

2.2.0 adds opt-in sessions with persistent reply and callback tracking, a bot lifecycle for middleware that owns resources, and a set of robustness fixes for long polling and the Node runners. Everything new is opt-in: a 2.1.0 bot runs unchanged. This release rolls up 2.2.0-rc0 to 2.2.0-rc2 (#1349, #1352); the notes below describe the final 2.2.0 API.

Sessions

Session middleware that behaves the same under long polling and one-invocation-per-update serverless (Lambda, Workers).

import { Bot, createSession, MemorySessionStorage } from "node-telegram-bot-api";

type Session = { count: number };
bot.use(createSession<Session>({ store: new MemorySessionStorage(), initial: () => ({ count: 0 }) }));

bot.command("count", async (ctx) => {
  const s = ctx.getSession<Session>();
  s.data.count++;
  await ctx.reply(`Seen ${s.data.count} times`);
});
  • createSession<T>(options) (aliased session) attaches a persistent, keyed bag, which you read with ctx.getSession<T>(). The returned middleware also has .get(ctx) / .find(ctx), typed once when you create it. store is required, so you always choose the durability explicitly. Optional settings: getSessionKey (per chat by default), initial, codec, ttlSeconds.
  • SessionStore is a string key-value contract: read, write(key, value, { ttlSeconds }), delete, plus optional touch, init and close. Values are encoded once, by the middleware's codec. Stores never parse values, so the in-memory store round-trips data exactly like a durable one.
  • Stores:
    • MemorySessionStorage (core, edge-safe, optional TTL)
    • FileSessionStorage (/node; one file per key, written atomically)
    • SqliteSessionStorage, SqlSessionStorage and RedisSessionStorage, available through the new node-telegram-bot-api/bun subpath. The SQL stores also keep created_at / updated_at columns for maintenance queries.
  • The stored value is a versioned envelope, { v, data, ext }. Layers built on sessions each get their own namespace in ext. If nothing changed, the session isn't written back, so an untouched chat costs one read and no writes; touch() refreshes the TTL instead. ctx.getSession().delete() evicts the key.
  • Updates for the same key are processed one at a time within a process, so concurrent webhook invocations can't interleave their read-modify-write. Across processes, the last write wins.

Reply and callback tracking

"Waiting for a reply to this message" is saved as data, not kept as a live continuation, so it survives restarts and works on serverless.

Wait for a reply with expectReply:

import { expectReply, matchReply } from "node-telegram-bot-api";

bot.command("rename", async (ctx) => {
  const sent = await ctx.reply("New name for the project?", { reply_markup: { force_reply: true } });
  expectReply(ctx, sent.message_id, { field: "name" }, { ttlSeconds: 600 });
});

bot.on("message", async (ctx, next) => {
  const hit = matchReply<{ field: string }>(ctx); // consumed on match
  if (!hit) return next();
  await ctx.reply(`Saved ${hit.field}: ${ctx.message?.text}`);
});

A one-shot button with expectCallback:

import { expectCallback, matchCallback } from "node-telegram-bot-api";

bot.command("delete", async (ctx) => {
  const sent = await ctx.reply("Delete everything?", {
    reply_markup: { inline_keyboard: [[{ text: "Yes, delete", callback_data: "confirm" }]] },
  });
  expectCallback(ctx, sent.message_id, { by: ctx.from?.id ?? 0 }, { ttlSeconds: 3600 });
});

bot.on("callback_query", async (ctx, next) => {
  const hit = matchCallback<{ by: number }>(ctx); // peek: does not consume
  if (!hit) return next();
  if (ctx.from?.id !== hit.by) {
    await ctx.answerCallbackQuery({ text: "Not your button." });
    return;
  }
  matchCallback(ctx, { once: true }); // consume: a second tap finds nothing
  await ctx.answerCallbackQuery({ text: "Deleting..." });
});

Checking the user before consuming means a bystander's tap in a group can't use up the button.

  • expectReply(ctx, messageId, marker?, { ttlSeconds? }), matchReply<M>(ctx) and forgetReply(ctx, messageId) match on reply_to_message.message_id.
  • expectCallback, matchCallback<M>(ctx, { once? }) and forgetCallback match on callback_query.message.message_id. These are for markers that callback_data can't carry: one over 64 bytes, one the client must not see, or a button that should work only once ({ once: true }). Replies and presses are stored in separate tables.
  • taggedReplies<Tag>(ctx) provides expect / match / forget / expectPress / matchPress / forgetPress for string tags, with one union type checking both ends.
  • Optional per-chat LRU limit: createSession({ store, replyTracking: { maxEntriesPerChat, maxBytesPerChat, defaultTtlSeconds, slidingTtl } }). Once a limit is exceeded, the least-recently-used markers are evicted. Without it, the tables are unbounded and expire only through ttlSeconds.
  • The default session key is per chat, so in a group any member's reply or button press matches. When that matters, store the asker's id in the marker and check it.

Bot lifecycle

  • New bot.init() and bot.close() methods. Any middleware with init / close is registered automatically by use() and by on / command / hears. Setup runs once at startup (startPolling() and handleUpdate() wait for it), and teardown runs in reverse order. run() calls close() on shutdown.
  • init() tracks each plugin separately, so a retry resumes from the plugin that failed. close() attempts every teardown even if one throws, then reports the failures (as an AggregateError if there were several).

Long polling and transport

  • A 409 Conflict no longer stops the polling loop for good (#1350). It now retries every conflictRetryDelayMs (default 5000) and throws after maxConflictRetries consecutive conflicts (default 10). The existing retry option controls this as well. New exports: isPollConflict() and HTTP_STATUS_CONFLICT.
  • An empty or whitespace-only apiRoot now falls back to the default API root (#1354).

Node helpers

  • run() has a new opt-in exitOnError option. If polling fails fatally, the process exits non-zero after teardown, so a supervisor can restart it; previously it stayed alive and silent (#1351). A fatal poll stop is now always logged to stderr.
  • startWebhook() shutdown no longer hangs. It drops idle keep-alive sockets immediately and force-closes busy connections after the new shutdownTimeoutMs (default 10000). Signal handlers are installed only once the server is listening, and a repeated signal is ignored (#1350).
  • createWebhookServer() now answers 500 when the handler throws, instead of leaving an unhandled rejection.
  • RedisSessionStorage closes the client it creates from a url (#1351).

Types and fixes

  • reply_markup now takes exactly one reply-markup object. Mixing them (e.g. inline_keyboard + keyboard) is now a type error, because Telegram uses only one per message. This is type-only; the wire format is unchanged (#1374).
  • bot.hears() now resets a sticky (/y) RegExp trigger before matching, so the trigger no longer fails on every other message (#1375).
  • The minimum Node version is now 18.2 (engines); startWebhook()'s connection cleanup needs 18.2 APIs.

Examples and maintenance

  • New examples: 16-sessions.ts, 17-callback-tracking.ts, 18-reply-tracking-lru.ts, and a typed-sessions flow in 12-conversation.ts (#1369, #1368).
  • CI now enforces Biome formatting and a cognitive-complexity limit, and src/core was refactored to meet it with no behavior change (#1364, #1365). The API reference was regenerated (#1366). The e2e suite fails fast against a local Bot API emulator (#1371).

Full changelog: v2.1.0...v2.2.0

Don't miss a new node-telegram-bot-api release

NewReleases is sending notifications on new releases.