feat: Инициализация базовой структуры проекта Telegram-бота на NestJS, Telegraf и Drizzle ORM.
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
dist
|
||||
session.json
|
||||
77
README.md
77
README.md
@@ -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
37
biome.json
Normal 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
21
docker-compose.yml
Normal 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
15
drizzle.config.ts
Normal 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
8
nest-cli.json
Normal 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
78
package.json
Normal 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
7065
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
src/app.module.ts
Normal file
19
src/app.module.ts
Normal 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
21
src/app/bot/bot.module.ts
Normal 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 { }
|
||||
40
src/app/bot/bot.service.ts
Normal file
40
src/app/bot/bot.service.ts
Normal 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}! Бот работает.`);
|
||||
}
|
||||
}
|
||||
11
src/app/bot/common/base.keyboard.ts
Normal file
11
src/app/bot/common/base.keyboard.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { InlineKeyboardMarkup, ReplyKeyboardMarkup } from 'typegram';
|
||||
|
||||
/**
|
||||
* Базовый абстрактный класс для всех клавиатур
|
||||
*/
|
||||
export abstract class BaseKeyboard {
|
||||
/**
|
||||
* Возвращает клавиатуру для отображения
|
||||
*/
|
||||
abstract getKeyboard(): ReplyKeyboardMarkup | InlineKeyboardMarkup;
|
||||
}
|
||||
5
src/app/bot/helpers/bot-error.ts
Normal file
5
src/app/bot/helpers/bot-error.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class BotError extends Error {
|
||||
constructor(message: string, public type: 'System' | 'User') {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
10
src/app/bot/helpers/bot-types.ts
Normal file
10
src/app/bot/helpers/bot-types.ts
Normal 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;
|
||||
}
|
||||
48
src/app/bot/helpers/utils.ts
Normal file
48
src/app/bot/helpers/utils.ts
Normal 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);
|
||||
};
|
||||
9
src/app/user/user.module.ts
Normal file
9
src/app/user/user.module.ts
Normal 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 {}
|
||||
46
src/app/user/user.repository.ts
Normal file
46
src/app/user/user.repository.ts
Normal 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)) : [];
|
||||
}
|
||||
}
|
||||
46
src/app/user/user.service.ts
Normal file
46
src/app/user/user.service.ts
Normal 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
39
src/app/user/user.ts
Normal 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
8
src/db/db.module.ts
Normal 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
37
src/db/db.service.ts
Normal 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
14
src/db/schema.ts
Normal 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
38
src/env.validator.ts
Normal 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
12
src/main.ts
Normal 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();
|
||||
83
src/middleware/logger.middleware.ts
Normal file
83
src/middleware/logger.middleware.ts
Normal 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
4
tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user