Skip to content

yagop/node-telegram-bot-api

Repository files navigation

✨ A Modern Telegram Bot API Library ✨

Bot API npm package

https://telegram.me/node_telegram_bot_api https://t.me/+_IC8j_b1wSFlZTVk https://telegram.me/Yago_Perez

v2 is a from-scratch redesign, no v1 compatibility. Coming from v1? See the v1 -> v2 migration guide in the changelog.

📦 Install

npm install node-telegram-bot-api@next

**Runs on Bun, modern Node.js, Deno, Cloudflare Workers and Vercel Functions λ

🚀 Usage

import { Bot, InlineKeyboardBuilder } from "node-telegram-bot-api";
import { run } from "node-telegram-bot-api/node"; // managed runner: wires Ctrl-C to bot.stop()

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

// commands, regex and update types are all middleware - registration order wins
bot.command("start", (ctx) => ctx.reply("Hi! Send me anything."));
bot.hears(/echo (.+)/, (ctx) => ctx.reply(ctx.match![1]!));

bot.on("message", (ctx) =>
  ctx.reply("Pick one:", {
    reply_markup: new InlineKeyboardBuilder()
      .text("👍", "up")
      .text("👎", "down")
      .build(),
  }),
);

// 🔘 a tapped inline button comes back as a callback_query
bot.on("callback_query", async (ctx) => {
  await ctx.answerCallbackQuery({ text: `You tapped ${ctx.callbackQuery!.data}` });
});

await run(bot); // core-only alternative that runs anywhere: await bot.startPolling()

📡 Calling the API directly

Api mirrors the wire API 1:1 - one method per Bot API method, each taking a single params object.

import { Api } from "node-telegram-bot-api";

const api = new Api(process.env.BOT_TOKEN!);
const me = await api.getMe();
await api.sendMessage({ chat_id: 12345, text: "hello" });
// the same client is also on bot.api and ctx.api

🧩 Middleware

koa-style middleware around every update; on/command/hears are filters in the same chain. Wrap downstream work with await next().

// ⏱️ time every update - and catch anything thrown downstream
bot.use(async (ctx, next) => {
  const start = Date.now();
  try {
    await next();
  } finally {
    console.log(`update took ${Date.now() - start}ms`);
  }
});

// 🧯 last-resort error handler
bot.catch((err, ctx) => console.error("handler failed", err));

⌨️ Keyboards & formatting

Structured fields are plain typed objects - pass a literal or use a fluent builder; the pipeline serializes either.

import { Bot, InlineKeyboardBuilder, ReplyKeyboardBuilder, EntityBuilder } from "node-telegram-bot-api";

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

// 🎛️ inline keyboard as reply_markup
await bot.api.sendMessage({
  chat_id,
  text: "Choose:",
  reply_markup: new InlineKeyboardBuilder()
    .text("A", "a")
    .url("Docs", "https://core.telegram.org/bots/api")
    .row()
    .text("B", "b")
    .build(),
});

// ⌨️ reply keyboard as reply_markup
await bot.api.sendMessage({
  chat_id,
  text: "Yes or no?",
  reply_markup: new ReplyKeyboardBuilder()
    .text("Yes")
    .text("No")
    .build({ resize_keyboard: true }),
});

// ✍️ rich text - EntityBuilder computes UTF-16 offsets for you
const { text, entities } = new EntityBuilder()
  .plain("Hello ")
  .bold("world")
  .link("docs", "https://github.com/yagop/node-telegram-bot-api")
  .build();
await bot.api.sendMessage({ chat_id, text, entities });

// any structured field is just a plain object - no wrapper needed
await bot.api.sendMessage({ chat_id, text: "hi", link_preview_options: { is_disabled: true } });

📤 Uploads

A bare string is always a file_id or URL. Wrap raw bytes to upload them. Pass a filename with the right extension - the core does no content sniffing, so the name is what Telegram sees (fromPath uses the basename).

import { Bot, InputFile, MediaGroupBuilder } from "node-telegram-bot-api";
import { fromPath } from "node-telegram-bot-api/node";

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

// upload from disk (Node only)
await bot.api.sendPhoto({ chat_id, photo: await fromPath("./cat.jpg") });
// upload raw bytes (web-standard, runs anywhere)
await bot.api.sendDocument({ chat_id, document: new InputFile(bytes, { filename: "report.pdf" }) });

// a raw InputFile nested in a structure is auto-hoisted to an attach:// part
await bot.api.sendMediaGroup({
  chat_id,
  media: [
    { type: "photo", media: new InputFile(bytesA, { filename: "a.jpg" }), caption: "A" },
    { type: "photo", media: "https://telegram.org/example/photo.jpg" },
  ],
});

// MediaGroupBuilder: optional sugar for the same array
await bot.api.sendMediaGroup({
  chat_id,
  media: new MediaGroupBuilder()
    .photo({ media: new InputFile(bytesA, { filename: "a.jpg" }), caption: "A" })
    .photo({ media: "https://telegram.org/example/photo.jpg" })
    .build(),
});

Builders cover the other attach:// methods; each .build() returns the plain shape.

import {
  Bot,
  InputFile,
  StickerSetBuilder,
  StaticProfilePhotoBuilder,
  PhotoStoryBuilder,
} from "node-telegram-bot-api";

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

// collect a sticker set
await bot.api.createNewStickerSet({
  user_id,
  name,
  title,
  stickers: new StickerSetBuilder()
    .add({ sticker: new InputFile(pngBytes, { filename: "sticker.png" }), format: "static", emoji_list: ["🙂"] })
    .build(),
});

// a single sticker is a plain InputSticker - no builder needed
await bot.api.addStickerToSet({
  user_id,
  name,
  sticker: { sticker: new InputFile(pngBytes, { filename: "sticker.png" }), format: "static", emoji_list: ["🙂"] },
});

// profile photo: Static / AnimatedProfilePhotoBuilder
await bot.api.setMyProfilePhoto({
  photo: new StaticProfilePhotoBuilder({ photo: new InputFile(pngBytes, { filename: "avatar.png" }) }).build(),
});

// story: Photo / VideoStoryBuilder
await bot.api.postStory({
  business_connection_id,
  active_period,
  content: new PhotoStoryBuilder({ photo: new InputFile(pngBytes, { filename: "story.png" }) }).build(),
});

🪝 Webhooks

The web-standard callback is a pure (Request) => Promise<Response> - one function for every serverless runtime.

Cloudflare Workers / Bun.serve / Deno Deploy / Vercel Edge:

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

const bot = new Bot(TOKEN);
bot.on("message", (ctx) => ctx.reply("hi from the edge"));

export default {
  fetch: webhookCallback(bot, { secretToken: SECRET }),
};

By default the callback awaits your handler before 200. For slow handlers, opt into early-ACK:

export default {
  // ✅ return 200 immediately, then finish the handler in the background
  // waitUntil keeps the platform alive until it settles (fastAck: true = fire-and-forget)
  fetch: (req: Request, _env: unknown, ctx: { waitUntil(promise: Promise<unknown>): void }) =>
    webhookCallback(bot, { secretToken: SECRET, waitUntil: (p) => ctx.waitUntil(p) })(req),
};

Next.js App Router (app/api/bot/route.ts):

import { Bot, nextAppWebhook } from "node-telegram-bot-api";
const bot = new Bot(process.env.BOT_TOKEN!);
export const POST = nextAppWebhook(bot, { secretToken: process.env.SECRET });

Express (mount on an app you already have):

import express from "express";
import { Bot, registerExpressWebhook } from "node-telegram-bot-api";

const app = express();
const bot = new Bot(TOKEN);
registerExpressWebhook(bot, app, { path: "/telegram", secretToken: SECRET });
app.listen(3000);

Self-hosted Node server (node-telegram-bot-api/node):

import { Bot } from "node-telegram-bot-api";
import { createWebhookServer, startWebhook } from "node-telegram-bot-api/node";

// Low-level: you own the server and the port.
const server = createWebhookServer(new Bot(TOKEN), { path: "/telegram", secretToken: SECRET });
server.listen(8080);

// Or the managed one-liner (listen + graceful shutdown, the webhook peer of run()):
await startWebhook(new Bot(TOKEN), { port: 8080, path: "/telegram", secretToken: SECRET });

Register the URL once: api.setWebhook({ url, secret_token }). The secret_token is the only thing authenticating callers (payloads aren't signed) - treat it as required in production, and terminate TLS at your proxy.

⚠️ Errors

Errors expose structured fields, so you branch on values, not message text.

import { TelegramApiError, NetworkError, TimeoutError } from "node-telegram-bot-api";

try {
  await api.sendMessage({ chat_id, text });
} catch (err) {
  // 🔁 429s are auto-retried (honoring retry_after) by default - this is the manual form
  if (err instanceof TelegramApiError && err.errorCode === 429) {
    await sleep((err.retryAfter ?? 1) * 1000);
  } else if (err instanceof NetworkError || err instanceof TimeoutError) {
    // transient transport failure
  }
}

🧯 Handler errors: the boundary contract

An error thrown by a handler never stops the bot. It is routed to the error boundary, which by default logs it via console.error and consumes the update - polling keeps pumping, and a webhook delivery is ACKed (Telegram does not redeliver it). Install your own boundary with bot.catch():

bot.catch((err, ctx) => {
  console.error("update", ctx.update.update_id, "failed:", err);
});

Throwing from the boundary opts back into fail-loud: startPolling() rejects (the update was never confirmed, so Telegram redelivers it on restart) and webhookCallback responds 500 (Telegram redelivers). bot.catch((err) => { throw err; }) is the explicit fail-loud opt-in.

🛡️ Resilience & rate limiting

Safe defaults out of the box - set only what you want to change.

import { Api } from "node-telegram-bot-api";

const api = new Api(TOKEN, {
  // 🔁 retries 429 (retry_after first), network/timeout/5xx with jittered backoff
  maxRetries: 2,        // default 2
  retryBackoffMs: 300,  // default 300

  // 🚦 opt-in throttle (requests/sec); omit for zero overhead
  rateLimit: { global: 30, perChat: 1 },
});

Long polling resumes through transient errors instead of dying on the first blip:

import { longPoll } from "node-telegram-bot-api";

for await (const update of longPoll(api, {
  timeout: 30,
  retry: true,          // default true - resume on transient errors, keep the offset
  maxBackoffMs: 60_000, // default 60s - cap between failed polls
  onError: (err) => console.warn("poll failed, backing off", err),
}, signal)) {
  // ... fatal 4xx still stops the loop; an aborted signal returns cleanly
}

🌊 Low-level update stream

longPoll is a plain async generator - for await, take(n), filter, batch or fan out as you like.

import { Api, longPoll } from "node-telegram-bot-api";

const api = new Api(TOKEN);
const ac = new AbortController();
for await (const update of longPoll(api, { timeout: 30 }, ac.signal)) {
  console.log(update.update_id);
}

🐛 Debugging

Set DEBUG (the debug convention) to trace request lifecycle, polling and webhooks to stderr:

DEBUG="node-telegram-bot-api:*" node app.js
# node-telegram-bot-api:transport -> sendMessage
# node-telegram-bot-api:transport <- sendMessage ok +142ms

Namespaces: :transport, :polling, :webhook (filter, or exclude one with a leading -). Tracing is Node-only - wired up by importing node-telegram-bot-api/node; on edge runtimes it's an inert no-op.

🛠️ Development

bun run generate:types   # regenerate types + client from the live Bot API docs
bun run check            # tsc (strict) + core-isolation lint + edge bundle + unit tests
bun run build            # emit dist/

👥 Contributors

📄 License

MIT

About

Telegram Bot API for NodeJS

Topics

Resources

License

Code of conduct

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors