bot w/ cmds, for buy & stat

This commit is contained in:
Eduard Zvyagintsev 2025-01-07 20:16:09 +04:00
parent fa1451696f
commit 6a55314183
10 changed files with 1145 additions and 315 deletions

1051
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,7 +30,9 @@
"@nestjs/typeorm": "^10.0.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"handlebars": "^4.7.8",
"nestjs-telegraf": "^2.8.1",
"node-html-to-image": "^5.0.0",
"nodemon": "^3.1.9",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",

View File

@ -6,29 +6,30 @@ import { BotModule } from './bot/bot.module';
import { BotUpdate } from './bot/update';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import * as path from 'path';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '../.env',
envFilePath: './../_deploy/dev/.env',
}),
TelegrafModule.forRoot({
token: process.env.TELEGRAM_BOT_TOKEN,
launchOptions: {
webhook: {
domain: process.env.TELEGRAM_BOT_WEBHOOK_URL,
path: '/webhook',
}
}
// webhook: {
// domain: process.env.TELEGRAM_BOT_WEBHOOK_URL,
// path: '/webhook',
// }
},
}),
TypeOrmModule.forRoot({
type: 'sqlite',
database: '../db/database.sqlite',
database: path.resolve(__dirname, '../../db/database.sqlite'),
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: true,
}),
BotModule
BotModule,
],
controllers: [AppController],
providers: [AppService, BotUpdate],

View File

@ -16,4 +16,4 @@ export class Purchase {
@Column()
date: Date;
}
}

View File

@ -5,34 +5,73 @@ import { Purchase } from './entities/purchase.entity';
@Injectable()
export class PurchaseService {
constructor(
@InjectRepository(Purchase)
private purchaseRepository: Repository<Purchase>,
) { }
constructor(
@InjectRepository(Purchase)
private purchaseRepository: Repository<Purchase>,
) {}
async addPurchase(purchase: Partial<Purchase>): Promise<void> {
await this.purchaseRepository.save(purchase);
async addPurchase(purchase: Partial<Purchase>): Promise<void> {
await this.purchaseRepository.save(purchase);
}
async getUserPurchases(userId: number): Promise<Purchase[]> {
return this.purchaseRepository.find({
where: { userId },
order: {
date: 'DESC',
},
});
}
async getStats(userId: number) {
const userPurchases = await this.getUserPurchases(userId);
if (userPurchases.length === 0) {
return null;
}
async getUserPurchases(userId: number): Promise<Purchase[]> {
return this.purchaseRepository.find({ where: { userId } });
}
const totalAmount = userPurchases.reduce((sum, p) => sum + p.amount, 0);
const totalSpent = userPurchases.reduce(
(sum, p) => sum + p.amount * p.price,
0,
);
const averagePrice = totalSpent / totalAmount;
const lastPurchasePrice = userPurchases[0].price;
const lastPurchaseDate = userPurchases[0].date;
const lastPurchaseChange =
lastPurchasePrice - (userPurchases[1]?.price || 0);
const lastPurchaseChangePercent =
(lastPurchaseChange / (userPurchases[1]?.price || 1)) * 100;
async getStats(userId: number) {
const userPurchases = await this.getUserPurchases(userId);
if (userPurchases.length === 0) {
return null;
}
const averageCost = totalAmount * lastPurchasePrice;
const totalAmount = userPurchases.reduce((sum, p) => sum + p.amount, 0);
const totalSpent = userPurchases.reduce((sum, p) => sum + (p.amount * p.price), 0);
const averagePrice = totalSpent / totalAmount;
const totalProfit = averageCost - totalSpent;
return {
totalAmount,
totalSpent,
averagePrice,
purchaseCount: userPurchases.length
};
}
}
return {
totalAmount: totalAmount.toFixed(0),
totalSpent: totalSpent.toFixed(2),
averagePrice: averagePrice.toFixed(2),
averageCost: averageCost.toFixed(2),
totalProfit:
totalProfit < 0
? totalProfit.toFixed(2)
: `+ ${totalProfit.toFixed(2)}`,
purchaseCount: userPurchases.length,
lastPurchasePrice: lastPurchasePrice.toFixed(2),
lastPurchaseDate,
lastPurchaseChange:
lastPurchaseChange < 0
? lastPurchaseChange.toFixed(2)
: `+ ${lastPurchaseChange.toFixed(2)}`,
lastPurchaseChangePercent: lastPurchaseChangePercent.toFixed(2),
};
}
async saveChannelBinding(userId: number, channelId: string): Promise<void> {
// Сохраните привязку в базе данных
}
async getChannelBinding(userId: number): Promise<string | null> {
// Получите привязку из базы данных
return null; // Замените на реальную логику
}
}

View File

@ -1,92 +1,138 @@
import { Command, Ctx, Update } from 'nestjs-telegraf';
import { Context, NarrowedContext } from 'telegraf';
import { Command, On, Update } from 'nestjs-telegraf';
import { Context, Markup, NarrowedContext } from 'telegraf';
import { Logger } from '@nestjs/common';
import { PurchaseService } from './purchase.service';
import { validate } from 'class-validator';
import { Message, Update as UpdateType } from 'telegraf/typings/core/types/typegram';
import {
Message,
Update as UpdateType,
} from 'telegraf/typings/core/types/typegram';
import nodeHtmlToImage from 'node-html-to-image';
import * as fs from 'fs';
import * as path from 'path';
import handlebars from 'handlebars';
class PurchaseDto {
userId: number;
amount: number;
price: number;
date: Date;
userId: number;
amount: number;
price: number;
date: Date;
lastPurchasePrice: number;
lastPurchaseDate: Date;
lastPurchaseChange: number;
lastPurchaseChangePercent: number;
}
@Update()
export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name);
private readonly logger = new Logger(BotUpdate.name);
constructor (
private readonly purchaseService: PurchaseService
) {
this.logger.log('BotUpdate initialized');
}
constructor(private readonly purchaseService: PurchaseService) {
this.logger.log('BotUpdate initialized');
}
@Command('start')
async onStart(ctx: Context) {
@Command('start')
async onStart(ctx: Context) {
await ctx.reply(
'Привет! Я помогу отслеживать ваши покупки TON.\n' +
'Используйте команду /buy <количество> <цена> для записи покупки\n' +
'Например: /buy 100 2.5\n' +
'Используйте /stats для просмотра статистики',
Markup.keyboard([['💰 Купить', '📊 Статистика']])
.resize()
.oneTime(false),
);
}
@Command('buy')
async onBuy(
ctx: NarrowedContext<
Context,
UpdateType.MessageUpdate<Message.TextMessage>
>,
) {
try {
const message = ctx.message.text;
const [_, amount, price] = message.split(' ');
if (!amount || !price) {
await ctx.reply(
'Привет! Я помогу отслеживать ваши покупки TON.\n' +
'Используйте команду /buy <количество> <цена> для записи покупки\n' +
'Например: /buy 100 2.5\n' +
'Используйте /stats для просмотра статистики'
'Пожалуйста, укажите количество и цену.\nПример: /buy 100 2.5',
);
return;
}
const purchaseDto = new PurchaseDto();
purchaseDto.userId = ctx.from.id;
purchaseDto.amount = parseFloat(amount);
purchaseDto.price = parseFloat(price);
purchaseDto.date = new Date();
const errors = await validate(purchaseDto);
if (errors.length > 0) {
await ctx.reply('Пожалуйста, укажите корректные данные');
return;
}
await this.purchaseService.addPurchase(purchaseDto);
await ctx.reply(
`✅ Покупка записана:\n` +
`Количество: ${purchaseDto.amount.toFixed(2)} TON\n` +
`Цена: $${purchaseDto.price.toFixed(2)}\n` +
`Сумма: $${(purchaseDto.amount * purchaseDto.price).toFixed(2)}`,
);
} catch (error) {
this.logger.error('Error processing buy command:', error);
await ctx.reply('Произошла ошибка. Попробуйте еще раз');
}
@Command('buy')
async onBuy(ctx: NarrowedContext<Context, UpdateType.MessageUpdate<Message.TextMessage>>) {
try {
const message = ctx.message.text;
const [_, amount, price] = message.split(' ');
}
if (!amount || !price) {
await ctx.reply('Пожалуйста, укажите количество и цену.\nПример: /buy 100 2.5');
return;
}
@Command('stats')
async onStats(ctx: Context) {
try {
const stats = await this.purchaseService.getStats(ctx.from.id);
const purchaseDto = new PurchaseDto();
purchaseDto.userId = ctx.from.id;
purchaseDto.amount = parseFloat(amount);
purchaseDto.price = parseFloat(price);
purchaseDto.date = new Date();
if (!stats) {
await ctx.reply('У вас пока нет записанных покупок');
return;
}
const templatePath = path.join(__dirname, '../../templates/stats.hbs');
const templateSource = fs.readFileSync(templatePath, 'utf8');
const errors = await validate(purchaseDto);
if (errors.length > 0) {
await ctx.reply('Пожалуйста, укажите корректные данные');
return;
}
const template = handlebars.compile(templateSource);
handlebars.registerHelper('changeClass', function (value) {
return parseFloat(value) < 0
? 'stock-change-negative'
: 'stock-change-positive';
});
await this.purchaseService.addPurchase(purchaseDto);
await ctx.reply(
`✅ Покупка записана:\n` +
`Количество: ${purchaseDto.amount.toFixed(2)} TON\n` +
`Цена: $${purchaseDto.price.toFixed(2)}\n` +
`Сумма: $${(purchaseDto.amount * purchaseDto.price).toFixed(2)}`
);
} catch (error) {
this.logger.error('Error processing buy command:', error);
await ctx.reply('Произошла ошибка. Попробуйте еще раз');
}
const html = template(stats);
const image = await nodeHtmlToImage({
html,
// output: './stats.png',
type: 'png',
transparent: true,
puppeteerArgs: {
args: ['--no-sandbox', '--disable-setuid-sandbox'],
},
});
// await ctx.replyWithPhoto({ source: './stats.png' });
await ctx.replyWithPhoto({ source: image as Buffer });
} catch (error) {
this.logger.error('Error processing stats command:', error);
await ctx.reply('Произошла ошибка при получении статистики');
}
}
@Command('stats')
async onStats(ctx: Context) {
try {
const stats = await this.purchaseService.getStats(ctx.from.id);
@On('text')
async onText(ctx: Context) {
const message = ctx.message as Message.TextMessage;
if (!stats) {
await ctx.reply('У вас пока нет записанных покупок');
return;
}
await ctx.reply(
`📊 Ваша статистика:\n\n` +
`Всего куплено: ${stats.totalAmount.toFixed(2)} TON\n` +
`Потрачено: $${stats.totalSpent.toFixed(2)}\n` +
`Средняя цена покупки: $${stats.averagePrice.toFixed(2)}\n` +
`Количество покупок: ${stats.purchaseCount}`
);
} catch (error) {
this.logger.error('Error processing stats command:', error);
await ctx.reply('Произошла ошибка при получении статистики');
}
if (message.text === '💰 Купить') {
await ctx.reply(
'Введите команду /buy <количество> <цена> для записи покупки.',
);
} else if (message.text === '📊 Статистика') {
await this.onStats(ctx);
}
}
}
}

View File

@ -1,14 +1,17 @@
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { getBotToken } from "nestjs-telegraf";
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { getBotToken } from 'nestjs-telegraf';
import * as express from 'express';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const bot = app.get(getBotToken());
console.log('Setting up webhook callback...');
app.use('/webhook', bot.webhookCallback());
app.use('/public', express.static('public'));
await app.listen(2000);
console.log('Application is running on port 2000');
}

100
client/templates/stats.hbs Normal file
View File

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background: #000;
font-family: Arial, sans-serif;
text-align: center;
margin: 0;
padding: 0;
}
.stock-card {
display: inline-block;
text-align: left;
padding: 40px;
border-radius: 20px;
background-color: #111;
min-width: 700px;
}
.stock-header {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.stock-header img {
width: 24px;
height: 24px;
margin-right: 10px;
}
.stock-price {
font-size: 96px;
margin: 10px 0;
color: #fff;
}
.stock-change-positive {
color: rgb(20, 171, 20);
font-size: 20px;
}
.stock-change-negative {
color: rgb(213, 11, 11);
font-size: 20px;
}
.flex-row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 20px;
}
</style>
</head>
<body>
<div class="stock-card">
<div style="display: flex; flex-direction: row; align-items: flex-start; justify-content: space-between;">
<div class="stock-header">
{{!-- <img src="/public/tonlogo.png" alt="Logo"> --}}
<img src="https://static.coinstats.app/coins/1685602314954.png" alt="Logo">
<div style="display: flex; flex-direction: column; align-items: flex-start; color: #939393;">
<div style="font-size: 16px; color: #fff;">Toncoin</div>
<div style="font-size: 24px;">TON • USDT</div>
</div>
</div>
{{!-- <div style="color: #939393;"> --}}
<div style="color: #fff;">
<div style="font-size: 36px;"><span style="font-size: 16px; color: #939393">+1</span>
{{totalAmount}} TON</div>
</div>
</div>
<div class="stock-price"> {{lastPurchasePrice}} <span style="font-size: 24px; ">USDT</span></div>
<div style="display: flex; flex-direction: column; align-items: flex-start; gap: 10px; font-size: 20px;">
<div class="stock-change flex-row {{changeClass lastPurchaseChange}}">
<span>{{lastPurchaseChange}}</span>
<span>{{lastPurchaseChangePercent}}%</span>
<span>сегодня</span>
</div>
<div class="stock-change {{changeClass totalProfit}} flex-row">
<span>{{averageCost}} USDT</span>
<span>{{totalProfit}}</span>
<span>за всё время</span>
</div>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

BIN
public/tonlogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB