Skip to content

2.0.0

Latest

Choose a tag to compare

@yagop yagop released this 16 Aug 20:23
· 1 commit to master since this release
a66075c

Bot API 10.2 (July 14, 2026)

Rich Messages

  • Added InputRichMessageMedia, InputMediaVoiceNote, InputRichBlock,
    InputRichBlockListItem, and the 21 input rich-block variants.
  • Added media and blocks to InputRichMessage.

Ephemeral Messages

  • Added editEphemeralMessageText, editEphemeralMessageMedia,
    editEphemeralMessageCaption, editEphemeralMessageReplyMarkup, and
    deleteEphemeralMessage.
  • Added ephemeral targeting parameters to the 13 supported send methods and
    ephemeral-message fields to BotCommand, Message, and ReplyParameters.

Communities

  • Added Community, CommunityChatAdded, CommunityChatRemoved, and their
    related Message and ChatFullInfo fields.

General

  • Added BotSubscriptionUpdated and the subscription update type.

v2 is a from-scratch redesign with no backward compatibility with the v1
TelegramBot surface. There is no shim - the table below is the migration path.
The core is runtime-agnostic (Node 18+, Bun, Deno, Cloudflare Workers, Vercel/Deno
Edge); the client is a single generated Api class; dispatch is koa-style
middleware over a per-update Context.

Migrating from v1

Before (v1) After (v2)
const TelegramBot = require('node-telegram-bot-api') import { Bot } from 'node-telegram-bot-api' (or const { Bot } = require(...) - both work)
new TelegramBot(token, { polling: true }) const bot = new Bot(token); bot.startPolling()
new TelegramBot(token) (for raw API calls) const bot = new Bot(token); await bot.api.getMe()
request.fetchOptions.dispatcher / proxy options inject a custom Undici fetch: new Bot(token, { fetch: (url, init) => fetch(url, { ...init, dispatcher }) })
bot.on('message', msg => ...) bot.on('message', ctx => ...) - a router over Context, not an EventEmitter
bot.onText(/\/echo (.+)/, (msg, m) => ...) bot.hears(/\/echo (.+)/, ctx => { ctx.match[1] })
bot.onReplyToMessage(chatId, msgId, ...) middleware reading ctx.message.reply_to_message
bot.sendMessage(chatId, text, opts) bot.api.sendMessage({ chat_id, text, ...opts }) or, in a handler, ctx.reply(text, opts)
bot.sendMessage(id, t, { reply_markup: { inline_keyboard: [...] } }) ctx.reply(t, { reply_markup: new InlineKeyboardBuilder().text('A','a').build() })
{ reply_markup: JSON.stringify(markup) } (manual) a plain object { inline_keyboard: [...] } or a builder .build() - the field is a plain typed object; the pipeline serializes it
bot.sendPhoto(id, '/path/to/p.jpg') bot.api.sendPhoto({ chat_id, photo: await fromPath('/path/to/p.jpg') }) (from 'node-telegram-bot-api/node')
bot.sendPhoto(id, fs.createReadStream(...)) bot.api.sendPhoto({ chat_id, photo: new InputFile(bytes) })
bare string = path or file_id (via options.filepath) a bare string is always a file_id/URL; bytes go through new InputFile()/fromPath()
bot.sendMediaGroup(id, [{ type:'photo', media: stream }]) bot.api.sendMediaGroup({ chat_id, media: [{ type:'photo', media: new InputFile(bytes) }] }) (or the MediaGroupBuilder)
webhook via new TelegramBot(token, { webHook: { port } }) createWebhookServer(bot, { path }) (/node) or webhookCallback(bot) on any runtime
bot.setWebHook(url) bot.api.setWebhook({ url })
bot.startPolling() / bot.stopPolling() / bot.isPolling() bot.startPolling() / bot.stop() / bot.isRunning(), or longPoll(bot.api, opts, signal) directly
error.code === 'ETELEGRAM', message substring matching catch (e) { if (e instanceof TelegramApiError && e.errorCode === 429) e.retryAfter }
EFATAL split into NetworkError (EFETCH) and TimeoutError (ETIMEOUT)
update.message always Message | undefined Update is a discriminated union - if ('message' in update) update.message narrows
bot.getMe(...) etc. (positional + options) every method takes a single params object: bot.api.getMe(), bot.api.getChat({ chat_id })
CommonJS, Node-only web-standard core (Node 18+, Bun, Deno, Workers, edge); published dual ESM+CJS, so import or require both work

Longer examples

Before (v1) After (v2)

Polling handlers

const TelegramBot = require("node-telegram-bot-api");

const bot = new TelegramBot(process.env.BOT_TOKEN, { polling: true });

bot.onText(/\/start/, (msg) => {
  bot.sendMessage(msg.chat.id, "Hi");
});

bot.onText(/\/echo (.+)/, (msg, match) => {
  bot.sendMessage(msg.chat.id, match[1]);
});

bot.on("callback_query", (query) => {
  bot.answerCallbackQuery(query.id, { text: "ok" });
});

Polling handlers

import { Bot } from "node-telegram-bot-api";
import { run } from "node-telegram-bot-api/node";

const bot = new Bot(process.env.BOT_TOKEN!);

bot.command("start", (ctx) => {
  return ctx.reply("Hi");
});

bot.hears(/\/echo (.+)/, (ctx) => {
  return ctx.reply(ctx.match![1]!);
});

bot.on("callback_query", (ctx) => {
  return ctx.answerCallbackQuery({ text: "ok" });
});

await run(bot);

Uploads

const TelegramBot = require("node-telegram-bot-api");
const fs = require("node:fs");

const bot = new TelegramBot(token);

await bot.sendPhoto(chatId, "./cat.jpg");
await bot.sendDocument(chatId, fs.createReadStream("./report.pdf"));
await bot.sendMediaGroup(chatId, [
  { type: "photo", media: fs.createReadStream("./a.jpg") },
  { type: "photo", media: "https://example.com/b.jpg" },
]);

Uploads

import { Bot, InputFile } from "node-telegram-bot-api";
import { fromPath } from "node-telegram-bot-api/node";
import { readFile } from "node:fs/promises";

const bot = new Bot(token);

await bot.api.sendPhoto({ chat_id: chatId, photo: await fromPath("./cat.jpg") });

const report = await readFile("./report.pdf");
await bot.api.sendDocument({
  chat_id: chatId,
  document: new InputFile(report, { filename: "report.pdf" }),
});

await bot.api.sendMediaGroup({
  chat_id: chatId,
  media: [
    { type: "photo", media: await fromPath("./a.jpg") },
    { type: "photo", media: "https://example.com/b.jpg" },
  ],
});

Proxy request options

const TelegramBot = require("node-telegram-bot-api");
const { ProxyAgent } = require("undici");

const dispatcher = new ProxyAgent("http://127.0.0.1:8080");

const bot = new TelegramBot(token, {
  request: {
    fetchOptions: { dispatcher },
  },
});

Proxy request options

import { fetch as undiciFetch, ProxyAgent, type Dispatcher } from "undici";
import { Bot } from "node-telegram-bot-api";

const dispatcher = new ProxyAgent("http://127.0.0.1:8080");

const bot = new Bot(token, {
  fetch: (url, init) =>
    undiciFetch(url, {
      ...init,
      dispatcher,
    } as RequestInit & { dispatcher: Dispatcher }),
});

await bot.api.getMe();

Runtime & module format

v2's source and runtime-agnostic core are ESM / web-standard, but the published package is dual-module: zshy emits both an ESM build (*.js / *.d.ts) and a CommonJS build (*.cjs / *.d.cts), and the package.json exports map exposes both import and require conditions. So unlike v1, the module system is not a migration blocker - a CommonJS project can keep calling require():

// ESM
import { Bot, Api } from "node-telegram-bot-api";

// CommonJS
const { Bot, Api } = require("node-telegram-bot-api");

The real break from v1 is the API surface (no TelegramBot class, single-argument methods, middleware instead of events - see the table above), not the way you load the module. The runtime-agnostic core still imports only web-standard APIs, so it runs unchanged on Node 18+, Bun, Deno, and edge runtimes; the CommonJS build is purely a convenience for Node consumers and pulls no Node dependency into the core.

The package name is intentionally retained (node-telegram-bot-api) even though v2 shares no API surface with v1. This is a deliberate semver-major: the name carries the install base and the docs/SEO, and v2 owns the lineage. The cost is that npm install node-telegram-bot-api on an old tutorial now lands you on a completely different API - the version (^2) is the only signal, so pin it.

Mental-model shifts

  • One client, single-argument methods. Api mirrors the wire API: one method per Bot API method, each taking one params object. Positional ergonomics (ctx.reply(text)) live on Context.
  • Structured fields are plain typed objects. reply_markup, entities, reply_parameters, media, ... take a plain object/array (or a fluent builder, which returns the same plain shape); the pipeline serializes them once. No json() wrapper, no branded strings. A nested file is just an InputFile dropped into the file field - the pipeline hoists it to an attach:// part.
  • Composition over events. bot.use(mw) and the filter helpers (on/command/hears) are koa-style middleware over a per-update Context, so sessions/auth/rate-limiting/error-boundaries wrap one another via await next(). A handler error never stops the bot: it is routed to the error boundary (default: log via console.error and continue); install your own with bot.catch(), and rethrow from it to opt back into fail-loud.
  • Two entry points, one dispatch path. bot.startPolling(source) pumps an async generator for long-running processes; bot.handleUpdate(update) handles a single update and is what the edge/webhook callback calls.
  • Node helpers are opt-in. import ... from 'node-telegram-bot-api' is the runtime-agnostic core; import ... from 'node-telegram-bot-api/node' adds fromPath, createWebhookServer, and run.
  • Uploads stream. Multipart bodies are hand-rolled as a web ReadableStream and handed straight to fetch (duplex: "half"), so file bytes flow from their source without ever being buffered - upload memory stays flat regardless of file size. fromPath() wraps the file as a stream factory that re-opens a disk read stream per attempt (fs.openAsBlob was rejected: Deno's node-compat implementation buffers the whole file eagerly). Blob/Uint8Array uploads re-stream on retry; a one-shot ReadableStream InputFile is sent exactly once and a failure surfaces immediately instead of retrying; a stream factory (InputFileStreamFactory, () => ReadableStream) opens a fresh stream per attempt and stays retryable. A runtime whose fetch cannot stream a request body (Bun < 1.4.0 with an HTTP(S) proxy configured - oven-sh/bun#33918, fixed upstream by oven-sh/bun#32635) transparently sends the same bytes as one buffered Blob. inputFileToBlob is gone - nothing converts to FormData anymore. The default per-request timeoutMs is now 300000 (5 min, was 30s) so a large upload on a slow link is not cut off mid-stream.

Full compare: v2.0.0-alpha.3...v2.0.0