bot w/ cmds, for buy & stat
This commit is contained in:
parent
fa1451696f
commit
6a55314183
1051
client/package-lock.json
generated
1051
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -16,4 +16,4 @@ export class Purchase {
|
||||
|
||||
@Column()
|
||||
date: Date;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; // Замените на реальную логику
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
100
client/templates/stats.hbs
Normal 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
BIN
public/tonlogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Loading…
Reference in New Issue
Block a user