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)(aliasedsession) attaches a persistent, keyed bag, which you read withctx.getSession<T>(). The returned middleware also has.get(ctx)/.find(ctx), typed once when you create it.storeis required, so you always choose the durability explicitly. Optional settings:getSessionKey(per chat by default),initial,codec,ttlSeconds.SessionStoreis a string key-value contract:read,write(key, value, { ttlSeconds }),delete, plus optionaltouch,initandclose. 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,SqlSessionStorageandRedisSessionStorage, available through the newnode-telegram-bot-api/bunsubpath. The SQL stores also keepcreated_at/updated_atcolumns for maintenance queries.
- The stored value is a versioned envelope,
{ v, data, ext }. Layers built on sessions each get their own namespace inext. 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)andforgetReply(ctx, messageId)match onreply_to_message.message_id.expectCallback,matchCallback<M>(ctx, { once? })andforgetCallbackmatch oncallback_query.message.message_id. These are for markers thatcallback_datacan'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)providesexpect/match/forget/expectPress/matchPress/forgetPressfor 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 throughttlSeconds. - 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()andbot.close()methods. Any middleware withinit/closeis registered automatically byuse()and byon/command/hears. Setup runs once at startup (startPolling()andhandleUpdate()wait for it), and teardown runs in reverse order.run()callsclose()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 anAggregateErrorif there were several).
Long polling and transport
- A
409 Conflictno longer stops the polling loop for good (#1350). It now retries everyconflictRetryDelayMs(default 5000) and throws aftermaxConflictRetriesconsecutive conflicts (default 10). The existingretryoption controls this as well. New exports:isPollConflict()andHTTP_STATUS_CONFLICT. - An empty or whitespace-only
apiRootnow falls back to the default API root (#1354).
Node helpers
run()has a new opt-inexitOnErroroption. 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 newshutdownTimeoutMs(default 10000). Signal handlers are installed only once the server is listening, and a repeated signal is ignored (#1350).createWebhookServer()now answers500when the handler throws, instead of leaving an unhandled rejection.RedisSessionStoragecloses the client it creates from aurl(#1351).
Types and fixes
reply_markupnow 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 in12-conversation.ts(#1369, #1368). - CI now enforces Biome formatting and a cognitive-complexity limit, and
src/corewas 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