@abdulgalimov/telegram
v0.0.12
Published
TypeScript framework for building Telegram bots
Maintainers
Readme
@abdulgalimov/telegram
TypeScript framework for building Telegram bots.
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/telegramPeer Dependencies
npm install @grammyjs/typesQuick Start
1. Create locale files — locales/en.txt, locales/ru.txt:
# locales/en.txt
welcome=Hello, ${name}!
hide-button=Hide
cancel-button=Cancel
back-button=Back
refresh-button=Refresh2. Create config — telegram.config.js in project root:
export default {
localesDir: 'locales',
defaultLocale: 'en',
outputDir: 'locales/generated',
};3. Generate locale types:
npx telegram-prepareThis 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 dataTyped 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 promptform.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-prepareThis 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-prepareto 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 afterretry_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- Long Polling / Webhook — receives updates via long polling (
tg.update.startLongpoll()) or webhook (tg.update.execute(update)) - AsyncContext — creates isolated AsyncLocalStorage context
- UserMw — creates or updates user in the database
- ActionsMw — resolves the action:
- Active form →
form.progress - Text → command (
/cmd) | text | viaBot - Callback query → decode payload → action
- Inline query → inline action
- Active form →
- Handler — your business logic
- Redirect — chains up to 5 redirects between actions
- Auto-answer — automatically answers unanswered callback queries
- 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 entitiesTextBuilder
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:
License
MIT
