hi
- Июн
- 261
- 205
Редактор
Надоел скучный код ботов, где всё в одном файле? Хотите писать масштабируемых ботов с правильной архитектурой? Добро пожаловать!
О чём статья
В этой статье я поделюсь современной архитектурой Telegram-бота на TypeScript с использованием PostgreSQL и MikroORM. Мы разберём модульную структуру, которая позволит легко масштабировать проект любой сложности.- Чистая архитектура - каждый компонент на своём месте
- Модульность - легко добавлять новые команды и обработчики
- TypeScript - типизация спасает от глупых ошибок
- MikroORM - удобная работа с PostgreSQL
- Легкое тестирование - благодаря разделению логики
Структура проекта
Код:
src/
├── commands/ # Управление командами бота
│ ├── abstract/ # Абстрактный класс для команд
│ └── *.command.ts # Конкретные команды
├── configuration/ # Конфигурация MikroORM
├── database/
│ ├── entities/ # Сущности БД (модели)
│ └── storages/ # Storages для работы с БД
├── handlers/
│ ├── inline/ # Обработчики inline-кнопок
│ │ └── abstract/ # Абстрактный класс для inline
│ └── reply/ # Обработчики reply-кнопок
│ └── abstract/ # Абстрактный класс для reply
├── keyboard/
│ ├── inline/ # Inline-клавиатуры
│ └── reply/ # Reply-клавиатуры
├── lib/ # Сервисы и утилиты
└── main.ts # Точка входа
Команды (Commands)
Абстрактный класс команды:
JavaScript:
import {CommandContext, Context} from 'grammy';
export abstract class ACommand {
readonly command: string;
readonly rules: string[];
constructor(command: string, rules: string[] = []) {
this.command = command;
this.rules = rules;
}
abstract run(ctx: CommandContext<Context>): Promise<void>;
}
Пример команды:
JavaScript:
import {ACommand} from "@/commands/abstract";
import {CommandContext, Context} from 'grammy';
export class StartCommand extends ACommand {
constructor() {
super('start', ['admin', 'user']);
}
async run(ctx: CommandContext<Context>): Promise<void> {
await ctx.reply(`Привет! Я бот`);
}
}
Работа с базой данных (PostgreSQL + MikroORM)
Конфигурация MikroORM:
JavaScript:
import 'dotenv/config';
import {defineConfig, PostgreSqlDriver} from '@mikro-orm/postgresql';
export default defineConfig({
debug: true,
allowGlobalContext: true,
dbName: process.env.DB_NAME,
user: process.env.DB_USER,
host: process.env.DB_HOST,
password: process.env.DB_PASS,
driver: PostgreSqlDriver,
entities: ['./dist/src/database/entities/*.js'],
entitiesTs: ['./src/database/entities/*.ts'],
});
Сущность (Entity):
Users.entity.ts:
import {Entity, OneToMany, ManyToMany, PrimaryKey, Property, Enum} from '@mikro-orm/decorators/legacy';
import {Collection} from "@mikro-orm/core";
export enum UserRole {
USER = 'user',
ADMIN = 'admin'
}
@Entity({ tableName: 'users' })
export class User {
@PrimaryKey({ type: 'int' })
id!: number;
@Property({ type: 'bigint', unique: true })
telegram_id!: string;
@Property({ type: 'text' })
first_name: string = '';
@Property({ type: 'text' })
last_name: string = '';
@Property({ type: 'string' })
@Enum(() => UserRole)
role: UserRole = UserRole.USER;
}
Storage для работы с БД:
Users.storage.ts:
import { User } from '@/database/entities';
export class UsersStorage {
private get em() {
return (global as any).orm.em.fork();
}
async update(id: number, info: Omit<Partial<User>, 'id'>): Promise<User> {
const user = await this.em.findOne(User, { id });
if(!user) return;
Object.assign(user, info);
await this.em.persist(user).flush();
return user;
}
async findById(id: number): Promise<User> {
return await this.em.findOne(User, { id });
}
async create(user: Partial<User>): Promise<User> {
if(!user.telegram_id) {
throw new Error('Не указан TelegramId');
}
const created = this.em.create(User, {
...user
});
await this.em.persist(created).flush();
return created;
}
async findAll() {
return await this.em.find(User, {}, {
orderBy: { id: 'asc' },
});
}
async findByTelegramId(telegramId: number): Promise<User | null> {
return await this.em.findOne(User, { telegram_id: telegramId });
}
}
Клавиатуры (Keyboards)
Абстрактный класс для клавиатур:
JavaScript:
import { InlineKeyboard, Keyboard } from 'grammy';
export abstract class AKeyboard {
protected replyKeyboard: Keyboard | null = null;
protected inlineKeyboard: InlineKeyboard | null = null;
constructor() {
this.build();
}
abstract build(): void;
getReply(): Keyboard | null {
return this.replyKeyboard;
}
getInline(): InlineKeyboard | null {
return this.inlineKeyboard;
}
clear(): this {
this.replyKeyboard = null;
this.inlineKeyboard = null;
return this;
}
}
Пример Reply-клавиатуры:
JavaScript:
import {AKeyboard} from "@/keyboard/abstract";
import {Keyboard} from "grammy";
export class MainMenuKeyboard extends AKeyboard {
private isAdmin: boolean;
constructor(isAdmin: boolean = false) {
super();
this.isAdmin = isAdmin;
this.build();
}
build(): void {
this.replyKeyboard = new Keyboard()
.text('📱 Профиль').style('primary')
if(this.isAdmin) {
this.replyKeyboard.row();
this.replyKeyboard.text('🛡️ Админ-панель').style('danger');
}
this.replyKeyboard.resized();
}
}
Обработчики кнопок (Handlers)
Абстрактный класс для Inline-обработчиков:
JavaScript:
import {CallbackQueryContext, Context} from 'grammy';
export abstract class AInlineHandler {
readonly pattern: string | RegExp | (string | RegExp)[];
constructor(pattern: string | RegExp | (string | RegExp)[]) {
this.pattern = pattern;
}
abstract handle(ctx: CallbackQueryContext<Context>): Promise<void>;
}
Менеджер Inline-обработчиков:
JavaScript:
import { Bot } from 'grammy';
import {AInlineHandler} from "@/handlers/inline/abstract";
export class InlineHandlerManager {
static register(bot: Bot, handlers: AInlineHandler[]): void {
handlers.forEach(handler => {
bot.callbackQuery(handler.pattern, async (ctx) => {
try {
await handler.handle(ctx);
} catch (error) {
console.error(`Ошибка в обработчике ${handler.pattern}:`, error);
await ctx.answerCallbackQuery('Произошла ошибка');
}
});
});
console.log(`✅ Зарегистрировано inline-обработчиков: ${handlers.length}`);
}
}
Аналогично для Reply-обработчиков:
JavaScript:
import {Bot, Context, HearsContext} from 'grammy';
import {AReplyHandler} from "@/handlers/reply/abstract";
export class ReplyHandlerManager {
static register(bot: Bot, handlers: AReplyHandler[]): void {
handlers.forEach(handler => {
bot.hears(handler.trigger, async (ctx: HearsContext<Context>) => {
await handler.handle(ctx);
});
});
console.log(`✅ Зарегистрировано reply-обработчиков: ${handlers.length}`);
}
}
Точка входа (main.ts)
JavaScript:
import "dotenv/config";
import {Bot, session} from "grammy";
import { CommandManager } from "@/commands";
import {ReplyHandlerManager} from "@/handlers/reply";
import {ProfileReplyHandler} from "@/handlers/reply/profile";
import { StartCommand } from "@/commands/start";
import { MikroORM } from "@mikro-orm/postgresql";
import config from "@/configuration/mikro-orm.config.ts";
import {InlineHandlerManager} from "@/handlers/inline";
import {EventSignupStage} from "@/lib/state-manager/stages/event-signup";
async function main() {
if (!process.env.BOT_TOKEN) {
console.error("❌ Не задан BOT_TOKEN в .env");
process.exit(1);
}
const bot = new Bot(process.env.BOT_TOKEN);
bot.use(session({
initial: () => ({})
}));
try { await bot.api.deleteWebhook({ drop_pending_updates: true }); } catch (error) { }
try {
// @ts-ignore
global.orm = await MikroORM.init(config);
} catch (error) {
console.error("❌ Ошибка подключения к БД:", error);
process.exit(1);
}
const em = global.orm.em;
const commands = [new StartCommand()];
const replyHandlers = [new ProfileReplyHandler()];
const inlineHandlers = [];
const stages = [
EventSignupStage.getInstance(),
]
for(const stage of stages) {
bot.use(stage.middleware());
}
CommandManager.register(bot, commands);
ReplyHandlerManager.register(bot, replyHandlers);
InlineHandlerManager.register(bot, inlineHandlers);
bot.start({
onStart: (botInfo) => {
console.log(`🤖 Бот запущен: @${botInfo.username}`);
},
});
}
main().catch((err) => {
console.error("❌ Ошибка запуска бота:", err);
});
Установка зависимостей
Код:
npm install grammy @mikro-orm/core @mikro-orm/postgresql @mikro-orm/reflection pg dotenv
npm install -D typescript @types/node @types/pg
Настройка package.json
JavaScript:
{
"name": "grammy-tg-bot",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "nodemon --exec tsx src/main.ts",
"build": "tsc",
"start:prod": "node dist/index.js"
}
}
Настройка tsconfig.json
JavaScript:
{
"compilerOptions": {
"lib": [
"es5",
"es6",
"dom",
"esnext"
],
"module": "CommonJS",
"moduleResolution": "Node",
"allowImportingTsExtensions": true,
"target": "es6",
"outDir": "./dist",
"baseUrl": "./",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"esModuleInterop": true,
"paths": {
"@/*": [
"src/*"
]
}
}
}
- https://grammy.dev/ — документация grammY
- https://mikro-orm.io/ — документация MikroORM
Ниже прикреплена готовая первоначальная коробка для вашего бота