Bot API 10.2 (July 14, 2026)
Rich Messages
- Added
InputRichMessageMedia,InputMediaVoiceNote,InputRichBlock,
InputRichBlockListItem, and the 21 input rich-block variants. - Added
mediaandblockstoInputRichMessage.
Ephemeral Messages
- Added
editEphemeralMessageText,editEphemeralMessageMedia,
editEphemeralMessageCaption,editEphemeralMessageReplyMarkup, and
deleteEphemeralMessage. - Added ephemeral targeting parameters to the 13 supported send methods and
ephemeral-message fields toBotCommand,Message, andReplyParameters.
Communities
- Added
Community,CommunityChatAdded,CommunityChatRemoved, and their
relatedMessageandChatFullInfofields.
General
- Added
BotSubscriptionUpdatedand thesubscriptionupdate 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.
Apimirrors the wire API: one method per Bot API method, each taking one params object. Positional ergonomics (ctx.reply(text)) live onContext. - 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. Nojson()wrapper, no branded strings. A nested file is just anInputFiledropped into the file field - the pipeline hoists it to anattach://part. - Composition over events.
bot.use(mw)and the filter helpers (on/command/hears) are koa-style middleware over a per-updateContext, so sessions/auth/rate-limiting/error-boundaries wrap one another viaawait next(). A handler error never stops the bot: it is routed to the error boundary (default: log viaconsole.errorand continue); install your own withbot.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'addsfromPath,createWebhookServer, andrun. - Uploads stream. Multipart bodies are hand-rolled as a web
ReadableStreamand handed straight tofetch(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.openAsBlobwas rejected: Deno's node-compat implementation buffers the whole file eagerly).Blob/Uint8Arrayuploads re-stream on retry; a one-shotReadableStreamInputFileis 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 whosefetchcannot 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 bufferedBlob.inputFileToBlobis gone - nothing converts toFormDataanymore. The default per-requesttimeoutMsis 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