first commit
This commit is contained in:
commit
93d13b613d
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
README.md
|
||||||
9
.env.example
Normal file
9
.env.example
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
TELEGRAM_BOT_TOKEN=bot_token
|
||||||
|
DATABASE_HOST=postgres
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USER=postgres
|
||||||
|
DATABASE_PASSWORD=postgres
|
||||||
|
DATABASE_NAME=karta_soup
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/main"]
|
||||||
135
README.md
Normal file
135
README.md
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
# Карта Суп Telegram Bot
|
||||||
|
|
||||||
|
Telegram бот для проверки баланса и истории транзакций карты Карта Суп.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- 💳 Сохранение кода карты для каждого пользователя
|
||||||
|
- ✍️ Ввод кода карты (13 цифр, начинается с 2001)
|
||||||
|
- 💰 Проверка баланса карты
|
||||||
|
- 📊 Просмотр последних транзакций
|
||||||
|
- 🔄 Изменение кода карты
|
||||||
|
- 📱 Удобное меню с кнопками
|
||||||
|
- 🛡️ Защита от спама (rate limiting)
|
||||||
|
- ✅ Валидация кода карты
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
- NestJS
|
||||||
|
- TypeScript
|
||||||
|
- Telegraf (Telegram Bot API)
|
||||||
|
- TypeORM
|
||||||
|
- PostgreSQL
|
||||||
|
- Docker
|
||||||
|
|
||||||
|
## Установка и запуск
|
||||||
|
|
||||||
|
### Локальная разработка
|
||||||
|
|
||||||
|
1. Установите зависимости:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Создайте файл `.env` на основе `.env.example`:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Заполните переменные окружения в `.env`:
|
||||||
|
```env
|
||||||
|
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||||
|
DATABASE_HOST=localhost
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
DATABASE_USER=postgres
|
||||||
|
DATABASE_PASSWORD=postgres
|
||||||
|
DATABASE_NAME=karta_soup
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Запустите PostgreSQL (или используйте Docker):
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name postgres \
|
||||||
|
-e POSTGRES_PASSWORD=postgres \
|
||||||
|
-e POSTGRES_DB=karta_soup \
|
||||||
|
-p 5432:5432 \
|
||||||
|
postgres:15-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Запустите приложение:
|
||||||
|
```bash
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск с Docker
|
||||||
|
|
||||||
|
1. Создайте файл `.env`:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Укажите токен бота в `.env`:
|
||||||
|
```env
|
||||||
|
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Запустите контейнеры:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Проверьте логи:
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Деплой на сервер
|
||||||
|
|
||||||
|
1. Скопируйте проект на сервер:
|
||||||
|
```bash
|
||||||
|
scp -r . user@your-server:/path/to/app
|
||||||
|
```
|
||||||
|
|
||||||
|
2. На сервере создайте `.env` файл с вашими настройками
|
||||||
|
|
||||||
|
3. Запустите:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Команды бота
|
||||||
|
|
||||||
|
- `/start` - Начать работу с ботом
|
||||||
|
- `/balance` - Проверить баланс
|
||||||
|
- `/changecode` - Изменить код карты
|
||||||
|
- `/help` - Показать справку
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── bot/
|
||||||
|
│ ├── bot.module.ts # Модуль бота
|
||||||
|
│ └── bot.update.ts # Обработчики команд и сообщений
|
||||||
|
├── entities/
|
||||||
|
│ └── user.entity.ts # Сущность пользователя
|
||||||
|
├── interfaces/
|
||||||
|
│ └── balance.interface.ts # Интерфейсы для API
|
||||||
|
├── services/
|
||||||
|
│ ├── karta-soup.service.ts # Сервис для работы с API Карта Суп
|
||||||
|
│ └── user.service.ts # Сервис для работы с пользователями
|
||||||
|
├── app.module.ts # Главный модуль приложения
|
||||||
|
└── main.ts # Точка входа
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Карта Суп
|
||||||
|
|
||||||
|
Бот использует публичный API для получения информации о балансе:
|
||||||
|
```
|
||||||
|
GET https://meal.gift-cards.ru/api/1/cards/{code}?limit=100
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: karta-soup-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: karta_soup
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- karta-soup-network
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: karta-soup-bot
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||||
|
DATABASE_HOST: postgres
|
||||||
|
DATABASE_PORT: 5432
|
||||||
|
DATABASE_USER: postgres
|
||||||
|
DATABASE_PASSWORD: postgres
|
||||||
|
DATABASE_NAME: karta_soup
|
||||||
|
NODE_ENV: production
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
networks:
|
||||||
|
- karta-soup-network
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
karta-soup-network:
|
||||||
|
driver: bridge
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
6970
package-lock.json
generated
Normal file
6970
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
package.json
Normal file
48
package.json
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "karta-soup-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Telegram bot for Карта Суп balance checking",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.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",
|
||||||
|
"broadcast": "ts-node src/scripts/broadcast.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.3.0",
|
||||||
|
"@nestjs/config": "^3.1.1",
|
||||||
|
"@nestjs/core": "^10.3.0",
|
||||||
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
|
"@nestjs/typeorm": "^10.0.1",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"nestjs-telegraf": "^2.7.0",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"telegraf": "^4.15.0",
|
||||||
|
"typeorm": "^0.3.19"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.3.0",
|
||||||
|
"@nestjs/schematics": "^10.1.0",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||||
|
"@typescript-eslint/parser": "^6.19.0",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"prettier": "^3.2.4",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app.module.ts
Normal file
29
src/app.module.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BotModule } from './bot/bot.module';
|
||||||
|
import { User } from './entities/user.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
type: 'postgres',
|
||||||
|
host: configService.get('DATABASE_HOST'),
|
||||||
|
port: configService.get('DATABASE_PORT'),
|
||||||
|
username: configService.get('DATABASE_USER'),
|
||||||
|
password: configService.get('DATABASE_PASSWORD'),
|
||||||
|
database: configService.get('DATABASE_NAME'),
|
||||||
|
entities: [User],
|
||||||
|
synchronize: true,
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
BotModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
23
src/bot/bot.module.ts
Normal file
23
src/bot/bot.module.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TelegrafModule } from "nestjs-telegraf";
|
||||||
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
|
import { BotUpdate } from "./bot.update";
|
||||||
|
import { UserService } from "../services/user.service";
|
||||||
|
import { KartaSoupService } from "../services/karta-soup.service";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { User } from "../entities/user.entity";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TelegrafModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
token: configService.get<string>("TELEGRAM_BOT_TOKEN"),
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forFeature([User]),
|
||||||
|
],
|
||||||
|
providers: [BotUpdate, UserService, KartaSoupService],
|
||||||
|
})
|
||||||
|
export class BotModule {}
|
||||||
262
src/bot/bot.update.ts
Normal file
262
src/bot/bot.update.ts
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
import { Update, Ctx, Start, Help, Command, On, Action } from "nestjs-telegraf";
|
||||||
|
import { Context, Markup } from "telegraf";
|
||||||
|
import { UserService } from "../services/user.service";
|
||||||
|
import { KartaSoupService } from "../services/karta-soup.service";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
interface SessionContext extends Context {
|
||||||
|
session?: {
|
||||||
|
awaitingCode?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Update()
|
||||||
|
export class BotUpdate {
|
||||||
|
private readonly logger = new Logger(BotUpdate.name);
|
||||||
|
private userSessions: Map<string, { awaitingCode: boolean }> = new Map();
|
||||||
|
private lastBalanceCheck: Map<
|
||||||
|
string,
|
||||||
|
{ timestamp: number; success: boolean }
|
||||||
|
> = new Map();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly userService: UserService,
|
||||||
|
private readonly kartaSoupService: KartaSoupService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Start()
|
||||||
|
async start(@Ctx() ctx: SessionContext) {
|
||||||
|
const telegramId = ctx.from.id.toString();
|
||||||
|
|
||||||
|
let user = await this.userService.findByTelegramId(telegramId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = await this.userService.createUser(
|
||||||
|
telegramId,
|
||||||
|
ctx.from.username,
|
||||||
|
ctx.from.first_name,
|
||||||
|
ctx.from.last_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const welcomeMessage = `Добро пожаловать в бот Карта Суп! 🍲
|
||||||
|
|
||||||
|
Я помогу вам проверить баланс и историю транзакций вашей карты.
|
||||||
|
|
||||||
|
Используйте кнопки ниже для управления:`;
|
||||||
|
|
||||||
|
if (user.kartaSoupCode) {
|
||||||
|
await ctx.reply(welcomeMessage, this.getMainMenu());
|
||||||
|
} else {
|
||||||
|
await ctx.reply(welcomeMessage);
|
||||||
|
await ctx.reply(
|
||||||
|
"Пожалуйста, отправьте код вашей карты Карта Суп (13 цифр, начинается с 2001):"
|
||||||
|
);
|
||||||
|
this.userSessions.set(telegramId, { awaitingCode: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Help()
|
||||||
|
async help(@Ctx() ctx: Context) {
|
||||||
|
await ctx.reply(
|
||||||
|
`Доступные команды:
|
||||||
|
|
||||||
|
/start - Начать работу с ботом
|
||||||
|
/balance - Проверить баланс
|
||||||
|
/changecode - Изменить код карты
|
||||||
|
/help - Показать эту справку`,
|
||||||
|
this.getMainMenu()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command("balance")
|
||||||
|
async checkBalance(@Ctx() ctx: Context) {
|
||||||
|
const telegramId = ctx.from.id.toString();
|
||||||
|
|
||||||
|
const rateLimitMessage = this.checkRateLimit(telegramId);
|
||||||
|
if (rateLimitMessage) {
|
||||||
|
await ctx.reply(rateLimitMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await this.userService.getKartaSoupCode(telegramId);
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
await ctx.reply(
|
||||||
|
"У вас не сохранен код карты. Пожалуйста, отправьте код вашей карты:"
|
||||||
|
);
|
||||||
|
this.userSessions.set(telegramId, { awaitingCode: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.fetchAndDisplayBalance(ctx, code, telegramId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command("changecode")
|
||||||
|
async changeCode(@Ctx() ctx: Context) {
|
||||||
|
const telegramId = ctx.from.id.toString();
|
||||||
|
await ctx.reply(
|
||||||
|
"Отправьте новый код вашей карты Карта Суп (13 цифр, начинается с 2001):"
|
||||||
|
);
|
||||||
|
this.userSessions.set(telegramId, { awaitingCode: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Action("check_balance")
|
||||||
|
async onCheckBalance(@Ctx() ctx: any) {
|
||||||
|
await ctx.answerCbQuery();
|
||||||
|
const telegramId = ctx.from.id.toString();
|
||||||
|
|
||||||
|
const rateLimitMessage = this.checkRateLimit(telegramId);
|
||||||
|
if (rateLimitMessage) {
|
||||||
|
await ctx.reply(rateLimitMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await this.userService.getKartaSoupCode(telegramId);
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
await ctx.reply(
|
||||||
|
"У вас не сохранен код карты. Пожалуйста, отправьте код вашей карты:"
|
||||||
|
);
|
||||||
|
this.userSessions.set(telegramId, { awaitingCode: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.fetchAndDisplayBalance(ctx, code, telegramId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Action("change_code")
|
||||||
|
async onChangeCode(@Ctx() ctx: any) {
|
||||||
|
await ctx.answerCbQuery();
|
||||||
|
const telegramId = ctx.from.id.toString();
|
||||||
|
await ctx.reply(
|
||||||
|
"Отправьте новый код вашей карты Карта Суп (13 цифр, начинается с 2001):"
|
||||||
|
);
|
||||||
|
this.userSessions.set(telegramId, { awaitingCode: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
@On("text")
|
||||||
|
async onText(@Ctx() ctx: Context & { message: { text: string } }) {
|
||||||
|
const telegramId = ctx.from.id.toString();
|
||||||
|
const session = this.userSessions.get(telegramId);
|
||||||
|
|
||||||
|
if (session?.awaitingCode) {
|
||||||
|
const code = ctx.message.text.replace(/\s/g, "");
|
||||||
|
|
||||||
|
const validationError = this.validateCardCode(code);
|
||||||
|
if (validationError) {
|
||||||
|
await ctx.reply(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ctx.reply("Проверяю код... ⏳");
|
||||||
|
|
||||||
|
const balanceData = await this.kartaSoupService.getBalance(code);
|
||||||
|
|
||||||
|
await this.userService.updateKartaSoupCode(telegramId, code);
|
||||||
|
|
||||||
|
this.userSessions.delete(telegramId);
|
||||||
|
|
||||||
|
await ctx.reply(`✅ Код успешно сохранен!`);
|
||||||
|
await this.displayBalance(ctx, balanceData);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error saving code for user ${telegramId}:`, error);
|
||||||
|
await ctx.reply(
|
||||||
|
"❌ Не удалось проверить код. Убедитесь, что код введен правильно и попробуйте снова."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateCardCode(code: string): string | null {
|
||||||
|
if (!/^\d{13}$/.test(code)) {
|
||||||
|
return "❌ Код карты должен содержать ровно 13 цифр.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code.startsWith("2001")) {
|
||||||
|
return "❌ Код карты должен начинаться с 2001.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkRateLimit(telegramId: string): string | null {
|
||||||
|
const lastCheck = this.lastBalanceCheck.get(telegramId);
|
||||||
|
|
||||||
|
if (!lastCheck) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const timePassed = now - lastCheck.timestamp;
|
||||||
|
|
||||||
|
const requiredDelay = lastCheck.success ? 60000 : 10000;
|
||||||
|
const remainingTime = requiredDelay - timePassed;
|
||||||
|
|
||||||
|
if (remainingTime > 0) {
|
||||||
|
const seconds = Math.ceil(remainingTime / 1000);
|
||||||
|
return `⏳ Пожалуйста, подождите ${seconds} секунд перед следующей проверкой баланса.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchAndDisplayBalance(
|
||||||
|
ctx: Context,
|
||||||
|
code: string,
|
||||||
|
telegramId: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await ctx.reply("Получаю данные... ⏳");
|
||||||
|
const balanceData = await this.kartaSoupService.getBalance(code);
|
||||||
|
await this.displayBalance(ctx, balanceData);
|
||||||
|
|
||||||
|
this.lastBalanceCheck.set(telegramId, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Error fetching balance:", error);
|
||||||
|
await ctx.reply("❌ Не удалось получить баланс. Попробуйте позже.");
|
||||||
|
|
||||||
|
this.lastBalanceCheck.set(telegramId, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async displayBalance(ctx: Context, balanceData: any) {
|
||||||
|
const balance = balanceData.data.balance.availableAmount;
|
||||||
|
const phone = balanceData.data.phone;
|
||||||
|
const history = balanceData.data.history;
|
||||||
|
|
||||||
|
let message = `💳 Баланс карты\n\n`;
|
||||||
|
message += `📱 Телефон: ${phone}\n\n`;
|
||||||
|
message += `📊 Последние транзакции:\n`;
|
||||||
|
message += `━━━━━━━━━━━━━━━━━━━━\n\n`;
|
||||||
|
|
||||||
|
const recentTransactions = history.slice(0, 10).reverse();
|
||||||
|
|
||||||
|
for (const transaction of recentTransactions) {
|
||||||
|
message += this.kartaSoupService.formatTransaction(transaction);
|
||||||
|
message += `\n━━━━━━━━━━━━━━━━━━━━\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (history.length > 10) {
|
||||||
|
message += `... и еще ${history.length - 10} транзакций\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
message += `💰 Доступно: ${this.kartaSoupService.formatBalance(balance)}`;
|
||||||
|
|
||||||
|
await ctx.reply(message, this.getMainMenu());
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMainMenu() {
|
||||||
|
return Markup.inlineKeyboard([
|
||||||
|
[Markup.button.callback("💰 Проверить баланс", "check_balance")],
|
||||||
|
[Markup.button.callback("🔄 Изменить код", "change_code")],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/entities/user.entity.ts
Normal file
28
src/entities/user.entity.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('users')
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
telegramId: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
kartaSoupCode: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
29
src/interfaces/balance.interface.ts
Normal file
29
src/interfaces/balance.interface.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export interface Transaction {
|
||||||
|
time: string;
|
||||||
|
amount: number;
|
||||||
|
locationName: string[];
|
||||||
|
trnType: number;
|
||||||
|
mcc: string;
|
||||||
|
currency: string;
|
||||||
|
merchantId: string;
|
||||||
|
reversal: boolean;
|
||||||
|
posRechargeReceipt: any;
|
||||||
|
credit: boolean;
|
||||||
|
locationCity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BalanceData {
|
||||||
|
phone: string;
|
||||||
|
balance: {
|
||||||
|
availableAmount: number;
|
||||||
|
};
|
||||||
|
history: Transaction[];
|
||||||
|
smsInfoStatus: string;
|
||||||
|
smsNotificationAvailable: boolean;
|
||||||
|
cardType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BalanceResponse {
|
||||||
|
status: string;
|
||||||
|
data: BalanceData;
|
||||||
|
}
|
||||||
13
src/main.ts
Normal file
13
src/main.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const logger = new Logger('Bootstrap');
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
await app.listen(3000);
|
||||||
|
logger.log('🚀 Telegram bot is running...');
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
138
src/scripts/broadcast.ts
Normal file
138
src/scripts/broadcast.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { Telegraf } from "telegraf";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
import { User } from "../entities/user.entity";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
import * as readline from "readline";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const dataSource = new DataSource({
|
||||||
|
type: "postgres",
|
||||||
|
host: process.env.DATABASE_HOST || "localhost",
|
||||||
|
port: parseInt(process.env.DATABASE_PORT || "5432"),
|
||||||
|
username: process.env.DATABASE_USER || "postgres",
|
||||||
|
password: process.env.DATABASE_PASSWORD || "postgres",
|
||||||
|
database: process.env.DATABASE_NAME || "karta_soup",
|
||||||
|
entities: [User],
|
||||||
|
synchronize: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function askQuestion(question: string): Promise<string> {
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(question, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(answer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function broadcast() {
|
||||||
|
const token = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.error("❌ TELEGRAM_BOT_TOKEN не найден в .env файле");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = new Telegraf(token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dataSource.initialize();
|
||||||
|
console.log("✅ Подключение к базе данных установлено");
|
||||||
|
|
||||||
|
const userRepository = dataSource.getRepository(User);
|
||||||
|
const users = await userRepository.find();
|
||||||
|
|
||||||
|
console.log(`\n📊 Найдено пользователей: ${users.length}\n`);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log("❌ Нет зарегистрированных пользователей");
|
||||||
|
await dataSource.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Введите сообщение для рассылки (для многострочного сообщения используйте \\n):"
|
||||||
|
);
|
||||||
|
const message = await askQuestion("> ");
|
||||||
|
|
||||||
|
if (!message.trim()) {
|
||||||
|
console.log("❌ Сообщение не может быть пустым");
|
||||||
|
await dataSource.destroy();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedMessage = message.replace(/\\n/g, "\n");
|
||||||
|
|
||||||
|
console.log("\n📝 Предпросмотр сообщения:");
|
||||||
|
console.log("─".repeat(40));
|
||||||
|
console.log(formattedMessage);
|
||||||
|
console.log("─".repeat(40));
|
||||||
|
|
||||||
|
const confirm = await askQuestion(
|
||||||
|
`\nОтправить это сообщение ${users.length} пользователям? (да/нет): `
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
confirm.toLowerCase() !== "да" &&
|
||||||
|
confirm.toLowerCase() !== "yes" &&
|
||||||
|
confirm.toLowerCase() !== "y"
|
||||||
|
) {
|
||||||
|
console.log("❌ Рассылка отменена");
|
||||||
|
await dataSource.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n🚀 Начинаю рассылку...\n");
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
const failedUsers: string[] = [];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
await bot.telegram.sendMessage(user.telegramId, formattedMessage);
|
||||||
|
successCount++;
|
||||||
|
console.log(
|
||||||
|
`✅ Отправлено: ${user.username || user.firstName || user.telegramId}`
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
} catch (error: any) {
|
||||||
|
failCount++;
|
||||||
|
const errorMessage =
|
||||||
|
error.description || error.message || "Unknown error";
|
||||||
|
failedUsers.push(
|
||||||
|
`${user.telegramId} (${user.username || "no username"}): ${errorMessage}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`❌ Ошибка: ${user.username || user.firstName || user.telegramId} - ${errorMessage}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n" + "═".repeat(40));
|
||||||
|
console.log("📊 Результаты рассылки:");
|
||||||
|
console.log(`✅ Успешно: ${successCount}`);
|
||||||
|
console.log(`❌ Ошибок: ${failCount}`);
|
||||||
|
console.log("═".repeat(40));
|
||||||
|
|
||||||
|
if (failedUsers.length > 0) {
|
||||||
|
console.log("\n❌ Не удалось отправить:");
|
||||||
|
failedUsers.forEach((f) => console.log(` - ${f}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
await dataSource.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Ошибка:", error);
|
||||||
|
await dataSource.destroy();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast();
|
||||||
58
src/services/karta-soup.service.ts
Normal file
58
src/services/karta-soup.service.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { BalanceResponse } from '../interfaces/balance.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KartaSoupService {
|
||||||
|
private readonly logger = new Logger(KartaSoupService.name);
|
||||||
|
private readonly baseUrl = 'https://meal.gift-cards.ru/api/1/cards';
|
||||||
|
|
||||||
|
async getBalance(code: string): Promise<BalanceResponse> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.baseUrl}/${code}?limit=100`, {
|
||||||
|
headers: {
|
||||||
|
'accept': 'application/json;text/html;*/*',
|
||||||
|
'accept-language': 'en-US,en;q=0.9,ru;q=0.8,ar;q=0.7',
|
||||||
|
'cache-control': 'no-cache',
|
||||||
|
'pragma': 'no-cache',
|
||||||
|
'priority': 'u=1, i',
|
||||||
|
'referer': 'https://meal.gift-cards.ru/balance',
|
||||||
|
'sec-ch-ua': '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"',
|
||||||
|
'sec-ch-ua-mobile': '?0',
|
||||||
|
'sec-ch-ua-platform': '"macOS"',
|
||||||
|
'sec-fetch-dest': 'empty',
|
||||||
|
'sec-fetch-mode': 'cors',
|
||||||
|
'sec-fetch-site': 'same-origin',
|
||||||
|
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36',
|
||||||
|
'x-requested-with': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to fetch balance for code ${code}:`, error.message);
|
||||||
|
throw new Error('Не удалось получить баланс. Проверьте правильность кода.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBalance(balance: number): string {
|
||||||
|
return `${balance.toFixed(2)} ₽`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTransaction(transaction: any): string {
|
||||||
|
const date = new Date(transaction.time);
|
||||||
|
const formattedDate = date.toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
const amount = transaction.amount > 0 ? `+${transaction.amount}` : transaction.amount;
|
||||||
|
const location = transaction.locationName.join(', ');
|
||||||
|
const city = transaction.locationCity;
|
||||||
|
|
||||||
|
return `${formattedDate}\n${amount} ₽ | ${location}\n${city}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/services/user.service.ts
Normal file
42
src/services/user.service.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { User } from '../entities/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private userRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findByTelegramId(telegramId: string): Promise<User> {
|
||||||
|
return this.userRepository.findOne({ where: { telegramId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(telegramId: string, username?: string, firstName?: string, lastName?: string): Promise<User> {
|
||||||
|
const user = this.userRepository.create({
|
||||||
|
telegramId,
|
||||||
|
username,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
});
|
||||||
|
return this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateKartaSoupCode(telegramId: string, code: string): Promise<User> {
|
||||||
|
let user = await this.findByTelegramId(telegramId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = await this.createUser(telegramId);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.kartaSoupCode = code;
|
||||||
|
return this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getKartaSoupCode(telegramId: string): Promise<string | null> {
|
||||||
|
const user = await this.findByTelegramId(telegramId);
|
||||||
|
return user?.kartaSoupCode || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"forceConsistentCasingInFileNames": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user