Welcome!

By registering with us, you'll be able to discuss, share and private message with other members of our community.

SignUp Now!

StandartBot - пиши Telegram-ботов по-новому!

Июн
261
205
Редактор
🚀 Пишем Telegram-бота на TypeScript: Современная архитектура с PostgreSQL и MikroORM



Надоел скучный код ботов, где всё в одном файле? Хотите писать масштабируемых ботов с правильной архитектурой? Добро пожаловать!

📌 О чём статья​

В этой статье я поделюсь современной архитектурой 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/*"
      ]
    }
  }
}



📚 Полезные ссылки:

Ниже прикреплена готовая первоначальная коробка для вашего бота​
 

Вложения

  • standart-bot.zip
    12.8 MB · Просмотры: 0
Июн
261
205
Редактор
Для форматирования статьи(добавление эмодзи и ББ кодов) - использовалась нейросеть.

Все содержание темы, а также код и идея архитектуры - сделано собственноручно мною.
 
Сверху