feat: Инициализация базовой структуры проекта Telegram-бота на NestJS, Telegraf и Drizzle ORM.

This commit is contained in:
2026-01-18 12:28:22 +03:00
parent 69b29fa474
commit c07685f6ab
27 changed files with 7818 additions and 1 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
dist
session.json

View File

@@ -1,2 +1,77 @@
# t-bot # Telegram Bot Template
Шаблон Telegram-бота на NestJS + Telegraf + Drizzle ORM + PostgreSQL.
## Стек
- **NestJS** — фреймворк
- **Telegraf** — Telegram Bot API
- **Drizzle ORM** — работа с БД
- **PostgreSQL** — база данных
- **Zod** — валидация
## Быстрый старт
### 1. Установка зависимостей
```bash
pnpm install
```
### 2. Настройка окружения
Создай `.env` файл:
```env
ENV=development
PORT=3000
DATABASE_URL=postgresql://admin:admin@localhost:5432/tg-bot
DB_PORT=5432
DB_USERNAME=admin
DB_PASSWORD=admin
DB_NAME=tg-bot
TELEGRAM_TOKEN=your_bot_token_here
```
### 3. Запуск PostgreSQL
```bash
docker-compose up -d
```
### 4. Миграция БД
```bash
pnpm db:push
```
### 5. Запуск бота
```bash
pnpm start:dev
```
## Структура проекта
```
src/
├── app/
│ ├── bot/ # Telegram бот
│ └── user/ # Модуль пользователей
├── db/ # База данных (Drizzle)
├── middleware/ # Middleware
├── app.module.ts # Главный модуль
├── env.validator.ts # Валидация ENV
└── main.ts # Точка входа
```
## Команды
| Команда | Описание |
|---------|----------|
| `pnpm start:dev` | Запуск в dev режиме |
| `pnpm build` | Сборка проекта |
| `pnpm db:push` | Применить схему к БД |
| `pnpm db:generate` | Сгенерировать миграцию |

37
biome.json Normal file
View File

@@ -0,0 +1,37 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": []
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 140
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"useImportType": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single"
},
"parser": {
"unsafeParameterDecoratorsEnabled": true
}
}
}

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
version: "3.9"
services:
postgres:
container_name: postgres
image: postgres:13.3
environment:
POSTGRES_DB: "tg-bot"
POSTGRES_USER: "admin"
POSTGRES_PASSWORD: "admin"
volumes:
- .:/docker-entrypoint-initdb.d
ports:
- "5432:5432"
redis:
image: redis
container_name: redis
restart: always
environment:
- REDIS_PASSWORD=test
ports:
- "6379:6379"

15
drizzle.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import { defineConfig } from 'drizzle-kit';
import { env } from 'node:process';
export default defineConfig({
schema: './src/db/schema.ts', // Файл схемы
out: './migration', // Каталог для миграций
dialect: 'postgresql',
dbCredentials: { url: env.DATABASE_URL as string },
migrations: {
table: 'migrations',
schema: 'public',
},
});

8
nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

78
package.json Normal file
View File

@@ -0,0 +1,78 @@
{
"name": "tg-bot-template",
"version": "1.0.0",
"description": "Шаблон Telegram-бота на NestJS + Telegraf + Drizzle ORM",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@nestjs/common": "^11.1.0",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.0",
"@nestjs/platform-express": "^11.1.0",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.43.1",
"nestjs-telegraf": "^2.9.1",
"nestjs-zod": "^4.3.1",
"pg": "^8.16.0",
"radash": "^12.1.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sql-highlight": "^6.1.0",
"telegraf": "^4.16.3",
"telegraf-session-local": "^2.1.1",
"typegram": "^5.2.0",
"zod": "^3.24.4"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@nestjs/cli": "^11.0.7",
"@nestjs/schematics": "^11.0.5",
"@nestjs/testing": "^11.1.0",
"@types/express": "^5.0.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.15.17",
"@types/pg": "^8.15.2",
"drizzle-kit": "^0.31.1",
"jest": "^29.7.0",
"source-map-support": "^0.5.21",
"ts-jest": "^29.3.2",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

7065
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

19
src/app.module.ts Normal file
View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { validateSchema, Env, envSchema } from './env.validator';
import { BotModule } from './app/bot/bot.module';
import { UserModule } from './app/user/user.module';
import { DBModule } from './db/db.module';
@Module({
imports: [
ConfigModule.forRoot({
validate: (config) => validateSchema<Env>(envSchema, config),
isGlobal: true,
}),
BotModule,
DBModule,
UserModule,
],
})
export class AppModule { }

21
src/app/bot/bot.module.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { BotService } from './bot.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TelegrafModule } from 'nestjs-telegraf';
import { Env } from '../../env.validator';
import { UserModule } from '../user/user.module';
@Module({
imports: [
TelegrafModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService<Env, true>) => ({
token: configService.get('TELEGRAM_TOKEN'),
}),
inject: [ConfigService],
}),
UserModule,
],
providers: [BotService],
})
export class BotModule { }

View File

@@ -0,0 +1,40 @@
import { Update, Ctx, Start, InjectBot } from 'nestjs-telegraf';
import { Telegraf } from 'telegraf';
import { MyContext } from './helpers/bot-types';
import { UserService } from '../user/user.service';
import LocalSession from 'telegraf-session-local';
import { Logger } from '@nestjs/common';
import { LoggerMiddleware } from '../../middleware/logger.middleware';
@Update()
export class BotService {
private readonly logger = new Logger(BotService.name);
constructor(
@InjectBot() private bot: Telegraf<MyContext>,
readonly userService: UserService,
) {
this.bot.telegram.setMyCommands([{ command: '/start', description: 'Запуск бота' }]);
this.bot.use(this.attachUserMiddleware());
const loggerMiddleware = new LoggerMiddleware();
this.bot.use(loggerMiddleware.middleware());
this.bot.use(new LocalSession({ database: 'session.json' }).middleware());
}
private attachUserMiddleware() {
return async (ctx: MyContext, next: () => Promise<void>) => {
if (ctx.from) {
const user = await this.userService.getUserByTelegramUser(ctx.from);
ctx.user = user;
}
await next();
};
}
@Start()
async onStart(@Ctx() ctx: MyContext): Promise<void> {
const name = ctx.user?.firstName || 'пользователь';
await ctx.reply(`👋 Привет, ${name}! Бот работает.`);
}
}

View File

@@ -0,0 +1,11 @@
import { InlineKeyboardMarkup, ReplyKeyboardMarkup } from 'typegram';
/**
* Базовый абстрактный класс для всех клавиатур
*/
export abstract class BaseKeyboard {
/**
* Возвращает клавиатуру для отображения
*/
abstract getKeyboard(): ReplyKeyboardMarkup | InlineKeyboardMarkup;
}

View File

@@ -0,0 +1,5 @@
export class BotError extends Error {
constructor(message: string, public type: 'System' | 'User') {
super(message);
}
}

View File

@@ -0,0 +1,10 @@
import { Context, Scenes } from 'telegraf';
import { User } from '../../user/user';
interface MySceneSession extends Scenes.SceneSessionData { }
interface MySession extends Scenes.SceneSession<MySceneSession> { }
export interface MyContext extends Context {
user: User;
session: MySession;
}

View File

@@ -0,0 +1,48 @@
import { Markup } from 'telegraf';
import { cluster } from 'radash';
import { ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton } from 'typegram';
const SPECIAL_CHARS = ['\\', '_', '*', '[', ']', '(', ')', '~', '`', '>', '<', '&', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
export const escapeMarkdown = (text: string) => {
let result = text;
for (const char of SPECIAL_CHARS) {
result = result.replaceAll(char, `\\${char}`);
}
return result;
};
type ButtonLayoutOptions<T> = {
maxPerRow?: number;
extraButtons?: T[][];
};
export const createKeyboard = (buttons: string[], options: ButtonLayoutOptions<string>): Markup.Markup<ReplyKeyboardMarkup> => {
const { maxPerRow = 4, extraButtons = [] } = options;
if (!buttons || (buttons.length === 0 && extraButtons.length === 0)) {
return Markup.keyboard([]);
}
const mainButtonRows: string[][] = cluster(buttons, maxPerRow);
const keyboard: string[][] = [...mainButtonRows, ...extraButtons];
return Markup.keyboard(keyboard);
};
export const createInlineKeyboard = (
buttons: InlineKeyboardButton[],
options: ButtonLayoutOptions<InlineKeyboardButton>,
): Markup.Markup<InlineKeyboardMarkup> => {
const { maxPerRow = 3, extraButtons = [] } = options;
if (!buttons || (buttons.length === 0 && extraButtons.length === 0)) {
return Markup.inlineKeyboard([]);
}
const mainButtonRows: InlineKeyboardButton[][] = cluster(buttons, maxPerRow);
const keyboard: InlineKeyboardButton[][] = [...mainButtonRows, ...extraButtons];
return Markup.inlineKeyboard(keyboard);
};

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserRepository } from './user.repository';
@Module({
providers: [UserService, UserRepository],
exports: [UserService, UserRepository],
})
export class UserModule {}

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { User } from './user';
import { DBService } from '../../db/db.service';
import { userSchema } from '../../db/schema';
import { eq, inArray } from 'drizzle-orm';
@Injectable()
export class UserRepository {
constructor(private dbService: DBService) { }
public async create(userCreateData: { telegramId: number; firstName: string; nickname?: string; secondName?: string }): Promise<User> {
const model = await this.dbService.db
.insert(userSchema)
.values({
firstName: userCreateData.firstName,
telegramId: userCreateData.telegramId,
nickname: userCreateData.nickname,
secondName: userCreateData.secondName,
})
.onConflictDoUpdate({
set: {
firstName: userCreateData.firstName,
nickname: userCreateData.nickname,
secondName: userCreateData.secondName,
},
target: userSchema.telegramId,
})
.returning();
return new User(model[0]);
}
public async findByTelegramId(telegramId: number): Promise<User | null> {
const model = await this.dbService.db.select().from(userSchema).where(eq(userSchema.telegramId, telegramId)).limit(1);
return model.length ? new User(model[0]) : null;
}
public async findById(id: number): Promise<User | null> {
const model = await this.dbService.db.select().from(userSchema).where(eq(userSchema.id, id)).limit(1);
return model.length ? new User(model[0]) : null;
}
public async findByIds(ids: number[]): Promise<User[]> {
const model = await this.dbService.db.select().from(userSchema).where(inArray(userSchema.id, ids));
return model.length ? model.map((item) => new User(item)) : [];
}
}

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { UserRepository } from './user.repository';
import { User as TelegramUser } from 'typegram';
import { User } from './user';
import { objectify } from 'radash';
export enum ResultType {
Dictionary = 'dictionary',
Array = 'array',
}
type ResultFormatOptions = {
resultType: ResultType;
};
@Injectable()
export class UserService {
constructor(readonly repository: UserRepository) { }
public async getUserByTelegramUser({ id, first_name, last_name, username }: TelegramUser): Promise<User> {
const user = await this.repository.findByTelegramId(id);
return user ?? this.repository.create({ telegramId: id, firstName: first_name, secondName: last_name, nickname: username });
}
public async getUserById(id: number): Promise<User | null> {
return this.repository.findById(id);
}
public async getUserByIds(ids: number[], options?: { resultType: ResultType.Array }): Promise<User[]>;
public async getUserByIds(ids: number[], options?: { resultType: ResultType.Dictionary }): Promise<Record<number, User>>;
public async getUserByIds(
ids: number[],
options: ResultFormatOptions = { resultType: ResultType.Array },
): Promise<User[] | Record<number, User>> {
const users = await this.repository.findByIds(ids);
switch (options.resultType) {
case ResultType.Dictionary:
return objectify<User, number>(users, (user) => user.id);
case 'array':
return users;
default:
throw new Error(`Unknown resultType: ${options.resultType}`);
}
}
}

39
src/app/user/user.ts Normal file
View File

@@ -0,0 +1,39 @@
import { InferSelectModel } from 'drizzle-orm';
import { userSchema } from '../../db/schema';
export type UserSchemaType = InferSelectModel<typeof userSchema>;
export class User {
readonly id: number;
readonly telegramId: number;
readonly firstName: string;
readonly secondName?: string;
readonly nickname?: string;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly inWhitelist: boolean;
constructor(dbModel: UserSchemaType) {
this.id = dbModel.id;
this.telegramId = dbModel.telegramId;
this.firstName = dbModel.firstName;
this.secondName = dbModel.secondName ?? undefined;
this.nickname = dbModel.nickname ?? undefined;
this.updatedAt = new Date(dbModel.updatedAt);
this.createdAt = new Date(dbModel.createdAt);
this.inWhitelist = dbModel.inWhitelist;
}
get name(): string {
if (this.nickname) {
return this.nickname;
}
return this.secondName ? `${this.firstName} ${this.secondName}` : this.firstName;
}
}

8
src/db/db.module.ts Normal file
View File

@@ -0,0 +1,8 @@
import { Global, Module } from '@nestjs/common';
import { DBService } from './db.service.js';
@Global()
@Module({
providers: [DBService],
exports: [DBService],
})
export class DBModule {}

37
src/db/db.service.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import 'dotenv/config';
import { drizzle, NodePgClient, NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Env } from '../env.validator';
import { Logger as DBLogger } from 'drizzle-orm/logger';
import { highlight } from 'sql-highlight';
class MyLogger implements DBLogger {
constructor(private logger: Logger) {}
logQuery(query: string, params: unknown[]): void {
if (params && params.length === 0) {
this.logger.debug(`\nQuery: ${highlight(query)}\n`);
return;
}
let inlineQuery = query;
params.forEach((param, index) => {
const placeholder = `\\$${index + 1}\\b`;
const regex = new RegExp(placeholder, 'g');
const displayValue = typeof param === 'string' ? `'${param}'` : param === null ? 'NULL' : String(param);
inlineQuery = inlineQuery.replace(regex, displayValue);
});
this.logger.debug(`\nQuery: ${highlight(inlineQuery)}`);
}
}
@Injectable()
export class DBService implements OnModuleInit {
private readonly logger = new Logger(DBService.name);
public db: NodePgDatabase<Record<string, never>> & {
$client: NodePgClient;
};
constructor(private readonly config: ConfigService<Env, true>) {}
async onModuleInit() {
this.db = drizzle({ connection: this.config.get<string>('DATABASE_URL'), logger: new MyLogger(this.logger) });
}
}

14
src/db/schema.ts Normal file
View File

@@ -0,0 +1,14 @@
import { boolean, integer, pgTable, serial, text, date } from 'drizzle-orm/pg-core';
export const userSchema = pgTable('users', {
id: serial('id').primaryKey(),
telegramId: integer('telegram_id').unique().notNull(),
firstName: text('first_name').notNull(),
secondName: text('second_name'),
nickname: text('nickname'),
createdAt: date('created_at').defaultNow().notNull(),
updatedAt: date('updated_at').defaultNow().notNull(),
inWhitelist: boolean('in_whitelist').default(false).notNull(),
});

38
src/env.validator.ts Normal file
View File

@@ -0,0 +1,38 @@
import { createZodDto } from 'nestjs-zod';
import { ZodError, z } from 'zod';
export function validateSchema<T>(schema: z.ZodType, config: Record<string, unknown>): T {
try {
return schema.parse(config);
} catch (error) {
if (error instanceof ZodError) {
throw new Error(createErrorMessage(error));
}
throw error;
}
}
function createErrorMessage(error: z.ZodError<unknown>): string {
let message = 'Validation errors! \n';
for (const issue of error.issues) {
message += `[${issue.code}] ${issue.path.join('.')} ${issue.message}\n`;
}
return message;
}
export const envSchema = z.object({
ENV: z.string().min(1),
PORT: z.string().min(1).transform(Number).default('3000'),
DB_PORT: z.string().min(1).transform(Number),
DB_USERNAME: z.string().min(1),
DB_PASSWORD: z.string().min(1),
DB_NAME: z.string().min(1),
DATABASE_URL: z.string().min(1),
TELEGRAM_TOKEN: z.string().min(1),
});
export class Env extends createZodDto(envSchema) {}

12
src/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
logger: ['error', 'fatal', 'log', 'warn','debug'],
});
await app.listen(3000);
}
bootstrap();

View File

@@ -0,0 +1,83 @@
import { Logger } from '@nestjs/common';
import { MyContext } from '../app/bot/helpers/bot-types';
import { Message } from 'typegram';
export class LoggerMiddleware {
private logger = new Logger('MessageLogger');
middleware() {
return async (ctx: MyContext, next: () => Promise<void>) => {
const { name, id } = ctx.user;
const userInfo = `👤 ${name} (${id})`;
if (ctx.message) {
const messageInfo = this.getMessageInfo(ctx.message);
this.logger.debug(`Received ${messageInfo.type} from ${userInfo}: ${messageInfo.content}`);
} else if (ctx.updateType === 'callback_query' && ctx.callbackQuery) {
const data = 'data' in ctx.callbackQuery ? ctx.callbackQuery.data : 'empty';
this.logger.debug(`Received callback query from ${userInfo}: ${data}`);
} else if (ctx.updateType === 'inline_query') {
const query = ctx.inlineQuery?.query || 'empty';
this.logger.debug(`Received inline query from ${userInfo}: ${query}`);
}
await next();
};
}
private getMessageInfo(message: Message): { type: string; content: string } {
if ('text' in message && message.text) {
return { type: 'text message', content: message.text };
}
if ('photo' in message && message.photo) {
const caption = message.caption || '';
return { type: 'photo', content: caption ? `with caption: ${caption}` : '[NO CAPTION]' };
}
if ('video' in message && message.video) {
const caption = message.caption || '';
return { type: 'video', content: caption ? `with caption: ${caption}` : '[NO CAPTION]' };
}
if ('voice' in message && message.voice) {
return { type: 'voice message', content: `duration: ${message.voice.duration}s` };
}
if ('audio' in message && message.audio) {
const title = message.audio.title || '[NO TITLE]';
return { type: 'audio', content: `title: ${title}, duration: ${message.audio.duration}s` };
}
if ('document' in message && message.document) {
const fileName = message.document.file_name || '[UNNAMED]';
return { type: 'document', content: `filename: ${fileName}` };
}
if ('sticker' in message && message.sticker) {
const emoji = message.sticker.emoji || '';
return { type: 'sticker', content: emoji ? `emoji: ${emoji}` : '[NO EMOJI]' };
}
if ('location' in message && message.location) {
const { latitude, longitude } = message.location;
return { type: 'location', content: `lat: ${latitude}, long: ${longitude}` };
}
if ('contact' in message && message.contact) {
const { first_name, last_name, phone_number } = message.contact;
const contactName = [first_name, last_name].filter(Boolean).join(' ');
return { type: 'contact', content: `${contactName}: ${phone_number}` };
}
if ('poll' in message && message.poll) {
return { type: 'poll', content: `question: ${message.poll.question}` };
}
if ('animation' in message && message.animation) {
return { type: 'animation', content: `duration: ${message.animation.duration}s` };
}
return { type: 'unknown message type', content: '[CONTENT UNAVAILABLE]' };
}
}

4
tsconfig.build.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "ES2021",
"strictPropertyInitialization": false,
"strict": true,
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./src",
"skipLibCheck": true,
"incremental": true,
"resolveJsonModule": true,
"paths": {
"@contracts/*": ["contracts/*"]
},
"esModuleInterop": true
},
"exclude": ["node_modules", "dist"]
}