tgforge
v0.1.1
Published
Zero-dependency TypeScript client for 100% of the official Telegram Bot API.
Maintainers
Readme
tgforge
A zero-runtime-dependency TypeScript client for the official Telegram Bot API — currently Bot API 10.1.
- 100% API coverage — all 180 methods and 359 types are generated
from the official Bot API specification. Track a new Telegram release by
re-running codegen (
npm run gen:fetch && npm run gen). - No dependencies — built entirely on Node's native
fetch,FormData,Blob,ReadableStream,node:http, andnode:crypto. - Dual ESM + CJS output with full type declarations.
- Long polling, a typed event API (
on/once/off/onText), and webhooks. - A composable middleware router (
use/command/hears/callbackQuery/on), a per-updateContextwith reply helpers, and pluggable sessions. - End-to-end Mini Apps: a typed browser SDK (
/miniapp) forwindow.Telegram.WebAppplus server-sideinitDatavalidation. - Automatic rate-limit (429
retry_after) and transient-error handling. - Fluent keyboard builders, safe HTML/MarkdownV2 formatting helpers, and a mock transport for offline unit tests.
- A generic
callMethodescape hatch so new methods are callable even before the next codegen run.
Requires Node.js 20+ (developed and tested on Node 26).
Install
npm install tgforgeQuick start
import { TelegramBot, InputFile } from "tgforge";
const bot = new TelegramBot(process.env.BOT_TOKEN!, { polling: true });
bot.on("message", (msg) => {
bot.sendMessage({ chat_id: msg.chat.id, text: `You said: ${msg.text}` });
});
bot.onText(/^\/start$/, (msg) => {
bot.sendMessage({ chat_id: msg.chat.id, text: "Welcome!" });
});
// Upload a local file (multipart is handled automatically):
bot.sendPhoto({
chat_id: 123,
photo: InputFile.fromPath("./cat.jpg"),
caption: "🐱",
});Middleware & routing
Beyond the raw event API, the bot has a composable middleware layer
(grammY/Telegraf style): register use / command / hears /
callbackQuery / on / filter handlers, each receiving a Context with
chat-bound reply helpers. Handlers run in order; call next() to continue.
import { TelegramBot, InlineKeyboard } from "tgforge";
const bot = new TelegramBot(process.env.BOT_TOKEN!, { polling: true });
// Logger middleware (runs for every update).
bot.use(async (ctx, next) => {
console.log("update", ctx.update.update_id);
await next();
});
bot.command("start", (ctx) => ctx.reply(`Hi ${ctx.from?.first_name}!`));
// `ctx.match` holds the text after the command.
bot.command("echo", (ctx) => ctx.reply(ctx.match || "(nothing to echo)"));
// Regex trigger; `ctx.match` is the RegExpMatchArray.
bot.hears(/^add (\d+)$/, (ctx) => ctx.reply(`Adding ${ctx.match![1]}`));
// Inline-button callbacks (alias: bot.action).
bot.command("menu", (ctx) =>
ctx.reply("Choose:", { reply_markup: new InlineKeyboard().text("Ping", "ping") }),
);
bot.callbackQuery("ping", async (ctx) => {
await ctx.answerCallbackQuery({ text: "Pong!" });
});
// Update-type / content filters.
bot.on("message:photo", (ctx) => ctx.reply("Nice photo!"));
// Error boundary for middleware.
bot.catch((err, ctx) => console.error("handler failed", err));Context exposes update, message, callbackQuery, chat, from, text,
callbackData, match, session, plus helpers reply, replyWithPhoto,
replyWithChatAction, answerCallbackQuery, editMessageText and
deleteMessage — all with the current chat/ids filled in.
Sessions
session() loads per-conversation state before your handlers and persists it
after. State is keyed by chat id and held in memory by default; pass a custom
SessionStorage to back it with SQLite, Redis, etc. Parameterise the bot with
your session type for a typed ctx.session:
import { TelegramBot, session } from "tgforge";
interface Session { count: number }
const bot = new TelegramBot<Session>(process.env.BOT_TOKEN!, { polling: true });
bot.use(session<Session>({ initial: () => ({ count: 0 }) }));
bot.on("message", (ctx) => {
ctx.session.count++;
return ctx.reply(`I've seen ${ctx.session.count} of your messages`);
});A Composer can be built independently and mounted with bot.use(composer),
so routers can live in separate modules. Both the polling loop and webhook
receiver feed updates through this chain (and the legacy on/onText events).
Calling methods
Every Bot API method is a typed method taking a single params object whose shape matches the official documentation, and returning the documented result:
const me = await bot.getMe(); // Promise<User>
await bot.deleteMessage({ chat_id, message_id }); // Promise<boolean>
// Anything not yet generated (or that you'd rather call dynamically):
await bot.callMethod("someBrandNewMethod", { foo: "bar" });Files
const url = await bot.getFileLink(fileId); // direct download URL
const stream = await bot.getFileStream(fileId); // Node Readable streamInputFile accepts uploads from a path, buffer, stream, or Blob; pass a plain
string to send an existing file_id or a public URL.
Webhooks
const server = bot.createWebhookServer({ path: "/hook", secretToken: SECRET });
await server.listen(); // standalone server
// ...or mount server.handler in an existing node:http / framework server.
await bot.setWebhook({
url: "https://example.com/hook",
secret_token: SECRET,
});By default the server replies 200 immediately and then runs the middleware
chain, which keeps latency low but means Telegram will not retry an update
if your handler throws — the update is already acknowledged. Pass
awaitProcessing: true to createWebhookServer to instead reply 200 only
after processing succeeds (and 500 on failure), trading latency for
at-least-once delivery. Oversized bodies (maxBodyBytes, default 1 MiB) and
stalled uploads (requestTimeoutMs, default 30 s) are rejected promptly and the
connection is closed.
Errors
Failed API calls throw TelegramError (with code, description, and
retryAfter); transport failures throw TelegramNetworkError. 429s and 5xx are
retried automatically per the retry options.
Mini Apps (Web Apps)
End-to-end typed Mini Apps from one package — a browser SDK and the matching server-side validator.
In the browser (import from the /miniapp subpath; load Telegram's
telegram-web-app.js first):
import { getWebApp } from "tgforge/miniapp";
const tg = getWebApp(); // typed window.Telegram.WebApp (throws if not in Telegram)
tg.ready();
tg.MainButton.setText("Submit").show().onClick(() => {
fetch("/api/submit", { headers: { "X-Init-Data": tg.initData } });
});On your backend, never trust initData until you validate it:
import { validateWebAppInitData } from "tgforge";
const result = validateWebAppInitData(initDataFromClient, process.env.BOT_TOKEN!);
if (result.ok) {
const user = result.data?.user; // now trustworthy
}The client SDK is browser-only (no Node builtins) and ships on a separate export, so server code never pulls it in.
Keyboards
InlineKeyboard and Keyboard are chainable builders. Each instance is a valid
markup object, so pass it straight to reply_markup — no .build():
import { InlineKeyboard, Keyboard } from "tgforge";
await bot.sendMessage({
chat_id,
text: "Pick one",
reply_markup: new InlineKeyboard()
.text("Yes", "yes").text("No", "no") // two buttons, one row
.row()
.url("Docs", "https://core.telegram.org/bots/api"),
});
// Reply keyboards, and removing them:
const kb = new Keyboard().requestContact("Share number").resized().oneTime();
await bot.sendMessage({ chat_id, text: "Tap below", reply_markup: kb });
await bot.sendMessage({ chat_id, text: "Done", reply_markup: Keyboard.remove() });Formatting
escapeHtml / escapeMarkdownV2 and the html / md tagged templates escape
interpolated values while trusting the literal parts — so user input can never
break parse_mode (a classic MarkdownV2 footgun):
import { md, html } from "tgforge";
await bot.sendMessage({
chat_id,
text: md`*Order ${orderId}* shipped to ${address}`, // values escaped
parse_mode: "MarkdownV2",
});
await bot.sendMessage({
chat_id,
text: html`Hello <b>${userName}</b>!`,
parse_mode: "HTML",
});Testing
createMockTransport returns a fetch-compatible mock so you can unit-test
handlers offline — it records every call and returns the results you script:
import { TelegramBot, createMockTransport } from "tgforge";
const mock = createMockTransport();
mock.on("getMe", { id: 1, is_bot: true, first_name: "Test" });
const bot = new TelegramBot("TEST_TOKEN", { fetch: mock.fetch });
await bot.sendMessage({ chat_id: 1, text: "hi" });
mock.lastCall("sendMessage")?.params; // { chat_id: 1, text: "hi" }
// Simulate an API error (surfaces as TelegramError, no real network):
mock.on("sendMessage", { error_code: 403, description: "Forbidden" });Migrating from node-telegram-bot-api
Method names and event names match, so the shape is familiar — but this library takes a single params object instead of positional arguments, and methods live directly on the bot.
| node-telegram-bot-api | tgforge |
| ----------------------------------------------- | ----------------------------------------------------------- |
| new TelegramBot(token, { polling: true }) | new TelegramBot(token, { polling: true }) |
| bot.sendMessage(chatId, text, opts) | bot.sendMessage({ chat_id, text, ...opts }) |
| bot.sendPhoto(chatId, fileOrStream, opts) | bot.sendPhoto({ chat_id, photo: InputFile.fromPath(p) }) |
| bot.editMessageText(text, opts) | bot.editMessageText({ text, chat_id, message_id, ... }) |
| bot.deleteMessage(chatId, messageId) | bot.deleteMessage({ chat_id, message_id }) |
| bot.answerCallbackQuery(id, opts) | bot.answerCallbackQuery({ callback_query_id: id, ...opts }) |
| bot.on("message", cb) / bot.onText(re, cb) | identical |
| bot.getFileLink(fileId) | bot.getFileLink(fileId) (identical) |
| bot.getFileStream(fileId) | bot.getFileStream(fileId) → Node Readable (identical) |
| bot.stopPolling() | bot.stopPolling() (returns a Promise) |
All option keys use Telegram's snake_case names (parse_mode,
reply_markup, disable_notification, ...), matching the official API exactly.
Development
source ~/.nvm/nvm.sh && nvm use 26
npm install
npm run gen:fetch # refresh spec/api.min.json from the official spec
npm run gen # regenerate src/generated/{types,methods}.ts
npm run build # dual ESM + CJS build into dist/
npm test # node:test (builds esm first)How coverage stays current
spec/api.min.json is vendored from the community-maintained, machine-readable
Bot API spec.
scripts/generate.ts turns it into src/generated/types.ts (every object;
abstract types become unions) and src/generated/methods.ts (a *Params
interface per method plus the typed method surface). When Telegram ships a new
version, run npm run gen:fetch && npm run gen && npm run build.
License
MIT
