npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@abdulgalimov/telegram

v0.0.12

Published

TypeScript framework for building Telegram bots

Readme

@abdulgalimov/telegram

TypeScript framework for building Telegram bots.

Node.js TypeScript License: MIT

Features

  • Type-safe action tree routing — hierarchical action tree with full TypeScript inference
  • Compact payload encoding — schema-based callback_data encoding with automatic DB fallback for payloads >64 chars
  • Multi-step forms — state machine with KV storage, auto-cleanup of messages, cancel button
  • Inline keyboard builders — confirm menus, pagination, radio/checkbox switch buttons
  • AsyncLocalStorage context isolation — each request runs in its own isolated context
  • Locale / i18n support — key=value locale files with TextBuilder for HTML messages
  • Long polling / Webhook — built-in long polling or webhook integration via Express
  • Middleware pipeline — user creation, action resolution, form handling
  • Rate limiting — built-in queue with global, per-chat, and group rate limit control, automatic 429 retry
  • Zero runtime dependencies

Installation

npm install @abdulgalimov/telegram

Peer Dependencies

npm install @grammyjs/types

Quick Start

1. Create locale fileslocales/en.txt, locales/ru.txt:

# locales/en.txt
welcome=Hello, ${name}!
hide-button=Hide
cancel-button=Cancel
back-button=Back
refresh-button=Refresh

2. Create configtelegram.config.js in project root:

export default {
  localesDir: 'locales',
  defaultLocale: 'en',
  outputDir: 'locales/generated',
};

3. Generate locale types:

npx telegram-prepare

This creates locales/generated/locale-types.ts with a fully typed LocaleKeysType.

4. Define action tree and types:

import { readFileSync } from 'node:fs';
import {
  Telegram,
  redirectAction,
  payloadSchema,
  type InitType,
  type TgUser,
} from '@abdulgalimov/telegram';
import type { LocaleKeysType } from './locales/generated/locale-types';

const actionsTree = {
  core: {
    none: {},
    hide: {},
    command: { '@payloads': payloadSchema.object({ command: payloadSchema.string(), value: payloadSchema.string().optional() }) },
    text: {},
    inline: { '@payloads': payloadSchema.object({ query: payloadSchema.string() }), select: { '@payloads': payloadSchema.object({ query: payloadSchema.string() }) } },
    viaBot: {},
    keyboard: { button: { '@payloads': payloadSchema.object({ label: payloadSchema.string().optional() }) } },
  },
  // Custom actions
  menu: {},
  settings: {
    '@payloads': payloadSchema.object({ page: payloadSchema.number().optional() }),
  },
};

type MyUser = TgUser & { name: string };
type MyTree = typeof actionsTree;
type MyInit = InitType & { user: MyUser; locale: LocaleKeysType; tree: MyTree };

// 5. Create Telegram instance
const tg = new Telegram<MyInit>({
  config: {
    apiUrl: 'https://api.telegram.org',
    token: process.env.BOT_TOKEN!,
    debug: {
      payloadDecoderLevel: 'error',
      telegramCallServiceLevel: 'error',
      telegramUpdateLevel: 'error',
    },
  },
  actionsTree,
  loggerFactory: myLoggerFactory,
});

// 6. Create services (connect store, kv, locale)
tg.create({
  store: myStore,
  kv: myKvStore,
  locale: {
    defaultLocale: 'en',
    locales: [
      { code: 'en', text: readFileSync('locales/en.txt', 'utf-8') },
      { code: 'ru', text: readFileSync('locales/ru.txt', 'utf-8') },
    ],
  },
});

// 7. Register handlers
tg.handlers.action(tg.actions.tree.core.command, async (ctx) => {
  const { payload } = ctx;
  const { command } = payload; // automatically typed
  switch (command) {
    case '/start':
      await tg.request.reply({ text: `Welcome, ${ctx.user.name}!`, parse_mode: 'HTML' });
      break;
  }
});

tg.handlers.action(tg.actions.tree.menu, async (ctx) => {
  await tg.request.reply({ text: 'Main menu' });
});

tg.handlers.action(tg.actions.tree.settings, async (ctx) => {
  const { page } = ctx.payload; // automatically typed
  await tg.request.reply({ text: `Settings page ${page || 1}` });
});

// 8. Initialize
await tg.init();

// 9. Start long polling
tg.update.startLongpoll();

Or use a webhook instead of long polling:

// 8. Initialize
await tg.init();

// 9. Start webhook via Express
import express from 'express';

const app = express();
app.use(express.json());

app.post('/webhook', (req, res) => {
  tg.update.execute(req.body);
  res.sendStatus(200);
});

app.listen(3000);

Core Concepts

Action Tree

The action tree is a hierarchical structure that defines all routes in your bot. Every tree must include core actions:

const actionsTree = {
  core: {
    none: {},       // No-op / default action
    hide: {},       // Hide message
    command: {},    // Slash commands (/start, /help)
    text: {},       // Free text input
    inline: {       // Inline queries
      select: {},   // Inline result selection
    },
    viaBot: {},     // Messages sent via bot
    keyboard: {     // Reply keyboard
      button: {},   // Reply keyboard button press
    },
  },
  // Your custom actions:
  wallet: {
    send: {
      '@payloads': payloadSchema.object({
        tokenAddress: payloadSchema.string(),
        amount: payloadSchema.number().optional(),
      }),
      confirm: {},
      reject: {},
    },
  },
};

Actions are resolved automatically from updates: commands map to core.command, callback queries are decoded by payload, text messages go to core.text (or active form), and inline queries go to core.inline.

Handlers

Register handlers for specific actions using tg.handlers.action(). The callback receives a fully typed context — ctx.payload is automatically inferred from the action's @payloads schema:

tg.handlers.action(tg.actions.tree.core.command, async (ctx) => {
  const { command } = ctx.payload; // typed as string
  if (command === '/start') {
    await tg.request.reply({ text: 'Welcome!' });
  }
});

Use tg.handlers.middleware() to register middleware that runs before all child action handlers:

// Runs before wallet.send, wallet.send.confirm, etc.
tg.handlers.middleware(tg.actions.tree.wallet, async (ctx) => {
  // common logic for all wallet actions
});

tg.handlers.action(tg.actions.tree.wallet.send, async (ctx) => {
  const { tokenAddress, amount } = ctx.payload;
  // ...
});

Middleware — Real-World Example

Middleware is useful for loading shared data. For example, all wallet actions (wallet.editName, wallet.delete) need the wallet object — you can load it once in a middleware on wallet and pass it via ctx.extra:

// Action tree
const actionsTree = {
  core: { /* ... */ },
  wallet: {
    '@payloads': payloadSchema.object({
      walletId: payloadSchema.string(),
    }),
    editName: {},
    delete: {},
  },
};

// Extra type for wallet actions
type WalletExtra = {
  wallet: { id: string; name: string; balance: number };
};

// Middleware: loads wallet for all wallet.* actions
tg.handlers.middleware<{
    action: typeof tg.actions.tree.wallet;
    extra: WalletExtra;
}>(tg.actions.tree.wallet, async (ctx) => {
  const { walletId } = ctx.payload;
  const wallet = await db.wallets.findById(walletId);
  if (!wallet) {
    await tg.request.showAlert('Wallet not found');
    return {
      stop: true
    }
  }
  ctx.extra = { wallet };
});

// Handler: uses the loaded wallet
tg.handlers.action<{
    action: typeof tg.actions.tree.wallet.delete;
    extra: WalletExtra;
}>(tg.actions.tree.wallet.delete, async (ctx) => {
  const { wallet } = ctx.extra;
  await db.wallets.delete(wallet.id);
  await tg.request.reply({ text: `Wallet "${wallet.name}" deleted` });
});

// Handler: rename also uses wallet from middleware
tg.handlers.action<{
    action: typeof tg.actions.tree.wallet.editName;
    extra: WalletExtra;
}>(tg.actions.tree.wallet.editName, async (ctx) => {
  const { wallet } = ctx.extra;
  await tg.request.reply({ text: `Current name: ${wallet.name}\nEnter new name:` });
});

Handlers can return void (request completes) or a redirect to chain actions:

tg.handlers.action(tg.actions.tree.core.command, async (ctx) => {
  if (ctx.payload.command === '/start') {
    return { redirect: redirectAction({ action: tg.actions.tree.menu }) };
  }
});

Payload System

Payloads encode typed parameters into Telegram's callback_data (max 64 bytes). Available schema types:

import { payloadSchema } from '@abdulgalimov/telegram';

payloadSchema.string()    // string values
payloadSchema.number()    // numeric values
payloadSchema.bigint()    // bigint values
payloadSchema.boolean()   // boolean values
payloadSchema.enum(['a', 'b', 'c'])  // enum (string or number)
payloadSchema.object({ key: payloadSchema.string() })  // composite object

// Any schema supports .optional()
payloadSchema.number().optional()

Attach a payload schema to an action using the @payloads key:

const action = {
  '@payloads': payloadSchema.object({
    page: payloadSchema.number(),
    filter: payloadSchema.string().optional(),
  }),
};

Encoding format: v1_salt_actionId_key1_value1_key2_value2. If the encoded payload exceeds 64 characters, it is automatically stored in the database and replaced with db_[UUID].

Use tg.payload.configureShorts() to define abbreviations for frequently used payload values.

Context

Each request runs inside an AsyncLocalStorage context. Access it via:

const ctx = tg.context.get();

ctx.action;   // Current resolved action
ctx.payload;  // Decoded payload (typed by action's @payloads schema)
ctx.user;     // Current user (your TgUser extension)
ctx.update;   // Raw Telegram Update object
ctx.form;     // Active form (if any)
ctx.flags;    // Request flags (callbackAnswered, messageDeleted, etc.)
ctx.from;     // Telegram User object from the update
ctx.inline;   // Inline query data

Typed payload access: When using tg.handlers.action(), the context is automatically typed based on the action's @payloads schema — no manual casting needed:

tg.handlers.action(tg.actions.tree.settings, async (ctx) => {
  const { page } = ctx.payload; // page is typed as number | undefined
});

Request Service

Send messages and interact with the Telegram API:

// Reply (edits callback message or sends new)
await tg.request.reply({
  text: 'Hello!',
  parse_mode: 'HTML',
  reply_markup: { inline_keyboard: [[{ text: 'Click', callback_data: '...' }]] },
});

// Reply with options
await tg.request.reply(
  { text: 'New message' },
  { sendMode: true, tryReplyMessage: true },
);

// Send a photo
await tg.request.sendPhoto({ photo: 'https://example.com/image.jpg', caption: 'Photo' });

// Delete messages
await tg.request.delete();             // delete current message
await tg.request.delete(messageId);    // delete specific message
await tg.request.delete([id1, id2]);   // delete multiple messages

// Answer callback query
await tg.request.answerCallbackQuery({ text: 'Done!' });

// Show alert popup
await tg.request.showAlert('Are you sure?');

// Redirect callback to URL
await tg.request.redirectCallback('https://example.com');

// Answer inline query
await tg.request.answerInlineQuery({ results: [...] });

Keyboards

Inline Keyboards

// Confirm menu (Yes/No)
await tg.inlineKeyboard.confirmMenu({
  action: tree.wallet.send,  // must have .confirm and .reject
  text: 'Send 1 ETH?',
  yesLabel: 'Yes',
  noLabel: 'No',
});

// Pagination buttons
const buttons = tg.inlineKeyboard.pagingButtons({
  action: tree.settings,  // must have { page?: number } payload
  currentPage: 3,
  totalPages: 10,
});

// Switch (radio) buttons
const rows = tg.inlineKeyboard.switchButtons({
  action: tree.settings,
  mode: SwitchButtonMode.Radio,
  maxOnLine: 3,
  callbackField: 'filter',
  currentValue: 'all',
  buttons: [
    { label: 'All', payload: { filter: 'all' } },
    { label: 'Active', payload: { filter: 'active' } },
  ],
});

// Back button
const back = tg.inlineKeyboard.backButton({ actionItem: tree.menu });

// Refresh button (re-encodes current action)
const refresh = tg.inlineKeyboard.refreshButton();

Forms

Multi-step forms with state stored in KV:

// Define a form action in the tree
const actionsTree = {
  // ...
  createWallet: {
    progress: {},   // Required: handles each form step
    cancel: {},     // Optional: cancel button action
  },
};

// Start a form
tg.handlers.action(tg.actions.tree.createWallet, async (ctx) => {
  await tg.form.create({
    action: tg.actions.tree.createWallet,
    defaultData: { step: 1, name: '' },
  });
  await tg.form.reply({ text: 'Enter wallet name:' });
});

// Process form input
tg.handlers.action(tg.actions.tree.createWallet.progress, async (ctx) => {
  const { form } = ctx;
  form.data.name = ctx.update.message?.text;
  form.data.step = 2;
  await tg.form.save(form);
});

// Cancel form
tg.handlers.action(tg.actions.tree.createWallet.cancel, async (ctx) => {
  await tg.form.delete();
  return { redirect: redirectAction({ action: tg.actions.tree.menu }) };
});

Form features:

  • State persisted in KV as user_form_{userId}
  • Auto-deletes user messages during form flow
  • Automatically adds a cancel button (using locale key cancel-button)
  • form.reply() — sends/edits form prompt
  • form.send() — sends additional message tracked in form history

Locale / i18n

Locale types are auto-generated from .txt files. You never define them manually.

1. Create locale files in key=value format with ${arg} interpolation:

# locales/en.txt
welcome=Hello, ${name}!
balance=Balance: ${amount}
hide-button=Hide
cancel-button=Cancel
back-button=Back
refresh-button=Refresh
# locales/ru.txt
welcome=Привет, ${name}!
balance=Баланс: ${amount}
hide-button=Скрыть
cancel-button=Отмена
back-button=Назад
refresh-button=Обновить

2. Create telegram.config.js in project root:

export default {
  localesDir: 'locales',
  defaultLocale: 'en',
  outputDir: 'locales/generated',
};

3. Run code generation before building your app:

npx telegram-prepare

This reads the default locale file, parses all keys and ${arg} placeholders, and generates locales/generated/locale-types.ts:

// This file is auto-generated by tg-framework. Do not edit manually.

type ArgValue = string | number;

export type LocaleKeysType = {
  "welcome": { text: "Hello, ${name}!"; args: { name: ArgValue } };
  "balance": { text: "Balance: ${amount}"; args: { amount: ArgValue } };
  "hide-button": { text: "Hide"; args: undefined };
  "cancel-button": { text: "Cancel"; args: undefined };
  "back-button": { text: "Back"; args: undefined };
  "refresh-button": { text: "Refresh"; args: undefined };
};

4. Import and use — the generated type plugs into InitType:

import type { LocaleKeysType } from './locales/generated/locale-types';

type MyInit = InitType & { user: MyUser; locale: LocaleKeysType; tree: MyTree };

5. Pass locale data at runtime — read .txt files and pass their content:

import { readFileSync } from 'node:fs';

tg.create({
  // ...
  locale: {
    defaultLocale: 'en',
    locales: [
      { code: 'en', text: readFileSync('locales/en.txt', 'utf-8') },
      { code: 'ru', text: readFileSync('locales/ru.txt', 'utf-8') },
    ],
  },
});

6. Use in handler — fully type-safe:

// Get localized text (key and args are type-checked)
const text = tg.locale.text('welcome', { args: { name: 'Alice' } });

// TextBuilder — fluent API for HTML messages
const message = tg.locale.build()
  .addLocale('welcome', { args: { name: 'Alice' } })
  .break()
  .addText('Your balance:', { bold: true })
  .appendText(' 100 USDT', { code: true })
  .addSection()
  .addText('Choose an option:')
  .toString();

Required locale keys: hide-button, cancel-button, back-button, refresh-button.

Tip: Add npx telegram-prepare to your build script (e.g. "prebuild": "telegram-prepare") so types stay in sync.

Redirect

A handler can return a redirect to chain actions (max 5 redirects):

import { redirectAction } from '@abdulgalimov/telegram';

tg.handlers.action(tg.actions.tree.core.command, async (ctx) => {
  const { command } = ctx.payload;
  if (command === '/start') {
    // Redirect /start to menu
    return { redirect: redirectAction({ action: tg.actions.tree.menu }) };
  }
});

tg.handlers.action(tg.actions.tree.menu, async (ctx) => {
  await tg.request.reply({ text: 'Menu' });
});

Redirect with payload:

return {
  redirect: redirectAction({
    action: tg.actions.tree.settings,
    payload: { page: 1 },
  }),
};

Interfaces to Implement

| Interface | Purpose | |---|---| | TelegramStore | Database adapter: actions, users, inlineKeyboards, replyKeyboards | | ActionsStore | createAll(paths) — persist action paths and return IDs | | UsersStore<TUser> | createOrUpdate(data) — upsert user by Telegram ID | | InlineKeyboardsStore | Store/retrieve long payloads, track message contexts | | ReplyKeyboardsStore | Store/retrieve reply keyboard state per chat | | KvStore | Key-value store: getValue, setValue, removeValue, expire | | TgLoggerFactory | create(name) — returns TgLogger instance | | TgLogger | error, warn, info, debug, setLogLevel |

TgUser base interface (extend with your own fields):

interface TgUser {
  id: bigint;
  telegramId: number;
  langCode: string | null;
}

Configuration

type TelegramConfig = {
  apiUrl: string;     // Telegram API URL (https://api.telegram.org)
  token: string;      // Bot token from @BotFather
  debug: {
    payloadDecoderLevel: string;       // Log level for payload decoding
    telegramCallServiceLevel: string;  // Log level for API calls
    telegramUpdateLevel: string;       // Log level for update processing
  };
  throttle?: ThrottleConfig;           // Rate limiting settings (optional)
};

Rate Limiting

Built-in request rate control for Telegram Bot API. All outgoing calls pass through a queue that respects Telegram's rate limits. Enabled by default with sensible values.

type ThrottleConfig = {
  globalLimit?: number;        // Max requests per second globally (default: 30)
  perChatInterval?: number;    // Min interval in ms between messages to same chat (default: 1000)
  groupPerMinuteLimit?: number; // Max messages per minute for group chats (default: 20)
  exemptMethods?: ApiMethodType[]; // Methods exempt from throttling
};

How it works:

  • Global limit — max 30 requests per second (sliding window)
  • Per-chat limit — max 1 message per second to the same chat
  • Group limit — max 20 messages per minute for groups (chat_id < 0)
  • 429 retry — on Too Many Requests, the request is automatically retried after retry_after (up to 3 attempts)

Exempt methods — bypass the queue (read-only and responses to incoming requests):

  • All answer* methods (answerCallbackQuery, answerInlineQuery, answerPreCheckoutQuery, answerShippingQuery, answerWebAppQuery)
  • All get* methods (getMe, getChat, getFile, getUpdates, etc.)

Configuration example:

const tg = new Telegram<MyInit>({
  config: {
    apiUrl: 'https://api.telegram.org',
    token: process.env.BOT_TOKEN!,
    debug: { /* ... */ },
    // Throttle settings (all fields are optional)
    throttle: {
      globalLimit: 25,
      perChatInterval: 1500,
      groupPerMinuteLimit: 15,
    },
  },
  // ...
});

Without specifying throttle, default Telegram limits apply. To provide custom exempt methods, pass the exemptMethods array (fully replaces the default list).

Update Pipeline

Update → AsyncContext → UserMw → ActionsMw → handler → redirect chain → auto-answer callback → batch delete
  1. Long Polling / Webhook — receives updates via long polling (tg.update.startLongpoll()) or webhook (tg.update.execute(update))
  2. AsyncContext — creates isolated AsyncLocalStorage context
  3. UserMw — creates or updates user in the database
  4. ActionsMw — resolves the action:
    • Active form → form.progress
    • Text → command (/cmd) | text | viaBot
    • Callback query → decode payload → action
    • Inline query → inline action
  5. Handler — your business logic
  6. Redirect — chains up to 5 redirects between actions
  7. Auto-answer — automatically answers unanswered callback queries
  8. Batch delete — deletes all messages marked for deletion during the request

Utilities

HTMLFormat

Static methods for Telegram HTML formatting:

import { HTMLFormat } from '@abdulgalimov/telegram';

HTMLFormat.bold('text')           // <b>text</b>
HTMLFormat.italic('text')         // <i>text</i>
HTMLFormat.code('text')           // <code>text</code>
HTMLFormat.pre('text')            // <pre>text</pre>
HTMLFormat.strike('text')         // <s>text</s>
HTMLFormat.spoiler('text')        // <spoiler>text</spoiler>
HTMLFormat.blockquote('text')     // <blockquote>text</blockquote>
HTMLFormat.link('text', 'url')    // <a href="url">text</a>
HTMLFormat.encode('a < b')        // escapes HTML entities

TextBuilder

Fluent API for building HTML messages (see Locale / i18n):

const text = tg.locale.build()
  .addLocale('key')                  // add localized line
  .addText('raw text')               // add raw text line
  .appendText(' suffix')             // append to last line
  .appendText('value', { bold: true })  // append with formatting
  .appendButton('Click', 'https://...')  // append link
  .addSection()                      // add separator line
  .break()                           // add empty line
  .toString();

Template

For a production-ready example, see the template bot:

telegram-bot-template

License

MIT