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", "@nestjs/typeorm": "^10.0.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"handlebars": "^4.7.8",
"nestjs-telegraf": "^2.8.1", "nestjs-telegraf": "^2.8.1",
"node-html-to-image": "^5.0.0",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",

View File

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

View File

@ -15,7 +15,12 @@ export class PurchaseService {
} }
async getUserPurchases(userId: number): Promise<Purchase[]> { async getUserPurchases(userId: number): Promise<Purchase[]> {
return this.purchaseRepository.find({ where: { userId } }); return this.purchaseRepository.find({
where: { userId },
order: {
date: 'DESC',
},
});
} }
async getStats(userId: number) { async getStats(userId: number) {
@ -25,14 +30,48 @@ export class PurchaseService {
} }
const totalAmount = userPurchases.reduce((sum, p) => sum + p.amount, 0); const totalAmount = userPurchases.reduce((sum, p) => sum + p.amount, 0);
const totalSpent = userPurchases.reduce((sum, p) => sum + (p.amount * p.price), 0); const totalSpent = userPurchases.reduce(
(sum, p) => sum + p.amount * p.price,
0,
);
const averagePrice = totalSpent / totalAmount; 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;
const averageCost = totalAmount * lastPurchasePrice;
const totalProfit = averageCost - totalSpent;
return { return {
totalAmount, totalAmount: totalAmount.toFixed(0),
totalSpent, totalSpent: totalSpent.toFixed(2),
averagePrice, averagePrice: averagePrice.toFixed(2),
purchaseCount: userPurchases.length 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,24 +1,33 @@
import { Command, Ctx, Update } from 'nestjs-telegraf'; import { Command, On, Update } from 'nestjs-telegraf';
import { Context, NarrowedContext } from 'telegraf'; import { Context, Markup, NarrowedContext } from 'telegraf';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { PurchaseService } from './purchase.service'; import { PurchaseService } from './purchase.service';
import { validate } from 'class-validator'; 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 { class PurchaseDto {
userId: number; userId: number;
amount: number; amount: number;
price: number; price: number;
date: Date; date: Date;
lastPurchasePrice: number;
lastPurchaseDate: Date;
lastPurchaseChange: number;
lastPurchaseChangePercent: number;
} }
@Update() @Update()
export class BotUpdate { export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name); private readonly logger = new Logger(BotUpdate.name);
constructor ( constructor(private readonly purchaseService: PurchaseService) {
private readonly purchaseService: PurchaseService
) {
this.logger.log('BotUpdate initialized'); this.logger.log('BotUpdate initialized');
} }
@ -28,17 +37,27 @@ export class BotUpdate {
'Привет! Я помогу отслеживать ваши покупки TON.\n' + 'Привет! Я помогу отслеживать ваши покупки TON.\n' +
'Используйте команду /buy <количество> <цена> для записи покупки\n' + 'Используйте команду /buy <количество> <цена> для записи покупки\n' +
'Например: /buy 100 2.5\n' + 'Например: /buy 100 2.5\n' +
'Используйте /stats для просмотра статистики' 'Используйте /stats для просмотра статистики',
Markup.keyboard([['💰 Купить', '📊 Статистика']])
.resize()
.oneTime(false),
); );
} }
@Command('buy') @Command('buy')
async onBuy(ctx: NarrowedContext<Context, UpdateType.MessageUpdate<Message.TextMessage>>) { async onBuy(
ctx: NarrowedContext<
Context,
UpdateType.MessageUpdate<Message.TextMessage>
>,
) {
try { try {
const message = ctx.message.text; const message = ctx.message.text;
const [_, amount, price] = message.split(' '); const [_, amount, price] = message.split(' ');
if (!amount || !price) { if (!amount || !price) {
await ctx.reply('Пожалуйста, укажите количество и цену.\nПример: /buy 100 2.5'); await ctx.reply(
'Пожалуйста, укажите количество и цену.\nПример: /buy 100 2.5',
);
return; return;
} }
@ -59,7 +78,7 @@ export class BotUpdate {
`✅ Покупка записана:\n` + `✅ Покупка записана:\n` +
`Количество: ${purchaseDto.amount.toFixed(2)} TON\n` + `Количество: ${purchaseDto.amount.toFixed(2)} TON\n` +
`Цена: $${purchaseDto.price.toFixed(2)}\n` + `Цена: $${purchaseDto.price.toFixed(2)}\n` +
`Сумма: $${(purchaseDto.amount * purchaseDto.price).toFixed(2)}` `Сумма: $${(purchaseDto.amount * purchaseDto.price).toFixed(2)}`,
); );
} catch (error) { } catch (error) {
this.logger.error('Error processing buy command:', error); this.logger.error('Error processing buy command:', error);
@ -76,17 +95,44 @@ export class BotUpdate {
await ctx.reply('У вас пока нет записанных покупок'); await ctx.reply('У вас пока нет записанных покупок');
return; return;
} }
const templatePath = path.join(__dirname, '../../templates/stats.hbs');
const templateSource = fs.readFileSync(templatePath, 'utf8');
await ctx.reply( const template = handlebars.compile(templateSource);
`📊 Ваша статистика:\n\n` + handlebars.registerHelper('changeClass', function (value) {
`Всего куплено: ${stats.totalAmount.toFixed(2)} TON\n` + return parseFloat(value) < 0
`Потрачено: $${stats.totalSpent.toFixed(2)}\n` + ? 'stock-change-negative'
`Средняя цена покупки: $${stats.averagePrice.toFixed(2)}\n` + : 'stock-change-positive';
`Количество покупок: ${stats.purchaseCount}` });
);
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) { } catch (error) {
this.logger.error('Error processing stats command:', error); this.logger.error('Error processing stats command:', error);
await ctx.reply('Произошла ошибка при получении статистики'); await ctx.reply('Произошла ошибка при получении статистики');
} }
} }
@On('text')
async onText(ctx: Context) {
const message = ctx.message as Message.TextMessage;
if (message.text === '💰 Купить') {
await ctx.reply(
'Введите команду /buy <количество> <цена> для записи покупки.',
);
} else if (message.text === '📊 Статистика') {
await this.onStats(ctx);
}
}
} }

View File

@ -1,6 +1,7 @@
import { NestFactory } from "@nestjs/core"; import { NestFactory } from '@nestjs/core';
import { AppModule } from "./app.module"; import { AppModule } from './app.module';
import { getBotToken } from "nestjs-telegraf"; import { getBotToken } from 'nestjs-telegraf';
import * as express from 'express';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
@ -9,6 +10,8 @@ async function bootstrap() {
console.log('Setting up webhook callback...'); console.log('Setting up webhook callback...');
app.use('/webhook', bot.webhookCallback()); app.use('/webhook', bot.webhookCallback());
app.use('/public', express.static('public'));
await app.listen(2000); await app.listen(2000);
console.log('Application is running on port 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