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