diff --git a/server/ecommerce/src/app.module.ts b/server/ecommerce/src/app.module.ts index ce05798..b18cd86 100644 --- a/server/ecommerce/src/app.module.ts +++ b/server/ecommerce/src/app.module.ts @@ -24,6 +24,7 @@ import { DiscordGuildModule } from './discord-guild/discord-guild.module'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { UserSyncModule } from './user-sync/user-sync.module'; import { ScheduleModule } from '@nestjs/schedule'; +import { BinanceModule } from './binance/binance.module'; @Module({ imports: [ @@ -49,6 +50,7 @@ import { ScheduleModule } from '@nestjs/schedule'; EventEmitterModule.forRoot(), UserSyncModule, ScheduleModule.forRoot(), + BinanceModule, ], controllers: [AppController], providers: [AppService], diff --git a/server/ecommerce/src/auth/auth.controller.ts b/server/ecommerce/src/auth/auth.controller.ts index 39b7cbe..5798eb8 100644 --- a/server/ecommerce/src/auth/auth.controller.ts +++ b/server/ecommerce/src/auth/auth.controller.ts @@ -89,7 +89,6 @@ export class AuthController { @Get('discord/redirect') async disordAuthRedirect(@Req() request: Request, @Res() response: Response) { const user = request.user as DiscordProfile; - console.log(user); const refreshToken = await this.authService.generateRefreshTokenFor( Number(user.id), ); diff --git a/server/ecommerce/src/binance/binance-client.ts b/server/ecommerce/src/binance/binance-client.ts new file mode 100644 index 0000000..d950eef --- /dev/null +++ b/server/ecommerce/src/binance/binance-client.ts @@ -0,0 +1,44 @@ +import { Logger } from '@nestjs/common'; +import Binance from 'binance-api-node'; +import 'dotenv/config'; +import { TickerPrice, TickerPriceType } from 'src/utils/dtos/binance.dto'; + +type Config = { + BINANCE_API_KEY: string; + BINANCE_SECRET_KEY: string; +}; + +export class BinanceClient { + private client: any; + private logger: Logger; + + constructor(config: Config) { + this.client = Binance({ + apiKey: config.BINANCE_API_KEY, + apiSecret: config.BINANCE_SECRET_KEY, + }); + this.logger = new Logger(BinanceClient.name); + } + + public getBinanceAccountInfo = async () => { + return await this.client.accountInfo(); + }; + + public getSymbolTickerPrice = async ( + symbol: string, + ): Promise => { + try { + const tickerResponse: TickerPriceType = await this.client.prices({ + symbol, + }); + + const ticker: TickerPrice = { + symbol: symbol, + price: tickerResponse[symbol], + }; + return ticker; + } catch (error) { + this.logger.error(`Error when getting price for symbol ${symbol}`); + } + }; +} diff --git a/server/ecommerce/src/binance/binance.controller.ts b/server/ecommerce/src/binance/binance.controller.ts new file mode 100644 index 0000000..dbfffa0 --- /dev/null +++ b/server/ecommerce/src/binance/binance.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, UsePipes } from '@nestjs/common'; +import { BinanceService } from './binance.service'; +import { BinanceAccountInfo } from 'src/utils/dtos/binance.dto'; + +@Controller('binance') +export class BinanceController { + constructor(private readonly binanceService: BinanceService) {} + + @Get('/accountInfo') + public async getBinanceAccountInfo() { + return await this.binanceService.getAccountInfo(3); + } +} diff --git a/server/ecommerce/src/binance/binance.module.ts b/server/ecommerce/src/binance/binance.module.ts new file mode 100644 index 0000000..5aaf02d --- /dev/null +++ b/server/ecommerce/src/binance/binance.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BinanceService } from './binance.service'; +import { BinanceController } from './binance.controller'; + +@Module({ + providers: [BinanceService], + exports: [BinanceService], + controllers: [BinanceController], +}) +export class BinanceModule {} diff --git a/server/ecommerce/src/binance/binance.service.ts b/server/ecommerce/src/binance/binance.service.ts new file mode 100644 index 0000000..796bc55 --- /dev/null +++ b/server/ecommerce/src/binance/binance.service.ts @@ -0,0 +1,147 @@ +import 'dotenv/config'; +import { Injectable, Logger } from '@nestjs/common'; +import { BinanceClient } from './binance-client'; +import { + BinanceAccountInfo, + BinanceAccountInfoSchema, + BinanceBalance, + BinanceBalanceWithTotalValueAndSymbol, + TickerPriceType, +} from 'src/utils/dtos/binance.dto'; + +const CURRENCY_FOR_TOKEN_PRICES = 'USDT'; + +@Injectable() +export class BinanceService { + private logger: Logger; + private binanceClient: BinanceClient; + + constructor() { + this.logger = new Logger(BinanceService.name); + this.binanceClient = new BinanceClient({ + BINANCE_API_KEY: process.env.BINANCE_API_KEY ?? '', + BINANCE_SECRET_KEY: process.env.BINANCE_SECRET_KEY ?? '', + }); + } + + public getAccountInfo = async ( + numberOfTopBalances: number, + currencyName?: string, + ): Promise => { + try { + const accountInfo = await this.binanceClient.getBinanceAccountInfo(); + const parseResult = BinanceAccountInfoSchema.safeParse(accountInfo); + if (parseResult.success) { + const topBalances = this.mapBinanceAccountInfo( + parseResult.data, + numberOfTopBalances, + currencyName, + ); + return topBalances; + } else if (parseResult.success === false) { + this.logger.error( + `Validation errors when parsing account info ${parseResult.error.errors}`, + ); + } + } catch (error) { + this.logger.error( + `Error when parsing binance account info ${error.message}`, + ); + } + }; + + private mapBinanceAccountInfo = async ( + account: BinanceAccountInfo, + numberOfTopBalances: number, + currencyName?: string, + ) => { + const accountBalancesWithMinValue = account.balances.filter( + (balance) => parseFloat(balance.free) >= 0.1, + ); + + const balancesWithTotalValueCalculated: BinanceBalanceWithTotalValueAndSymbol[] = + await this.addTotalValueAndCurrencySymbolToBalances( + accountBalancesWithMinValue, + currencyName, + ); + + const sortedBalances = this.sortBalancesByTotalValue( + balancesWithTotalValueCalculated, + ); + + const revertedBalances = this.revertBalances(sortedBalances); + + const topBalances = revertedBalances.slice(0, numberOfTopBalances); + + return topBalances; + }; + + private sortBalancesByTotalValue = ( + balances: BinanceBalanceWithTotalValueAndSymbol[], + ) => { + return balances.sort( + (a, b) => parseFloat(b.totalValue) - parseFloat(a.totalValue), + ); + }; + + private revertBalances = ( + balances: BinanceBalanceWithTotalValueAndSymbol[], + ) => { + return balances.map((balance) => ({ + asset: balance.asset, + free: balance.free, + locked: balance.locked, + symbol: balance.symbol, + totalValue: balance.totalValue, + })); + }; + + private addTotalValueAndCurrencySymbolToBalances = async ( + balances: BinanceBalance[], + currencyName?: string, + ) => { + return await Promise.all( + balances.map(async (balance) => { + const symbol = currencyName + ? `${balance.asset}${currencyName.toUpperCase()}` + : `${balance.asset}${CURRENCY_FOR_TOKEN_PRICES}`; + if (balance.asset === CURRENCY_FOR_TOKEN_PRICES) { + return { + asset: balance.asset, + free: balance.free, + locked: balance.locked, + symbol: balance.asset, + totalValue: balance.free, + }; + } + const tickerResponse = + await this.binanceClient.getSymbolTickerPrice(symbol); + + const totalValue = this.calculateTotalSymbolValue( + balance, + tickerResponse, + ); + return { + asset: balance.asset, + free: balance.free, + locked: balance.locked, + symbol: symbol, + totalValue: totalValue ?? 'Unavailable', + }; + }), + ); + }; + + private calculateTotalSymbolValue = ( + balance: BinanceBalance, + tickerResponse: TickerPriceType, + ) => { + if (!tickerResponse) { + return ''; + } + const price = tickerResponse.price; + const tokenAmount = balance.free; + + return (parseFloat(price) * parseFloat(tokenAmount)).toFixed(4); + }; +} diff --git a/server/ecommerce/src/discord-bot/discord-bot.module.ts b/server/ecommerce/src/discord-bot/discord-bot.module.ts index 7947da7..f99e238 100644 --- a/server/ecommerce/src/discord-bot/discord-bot.module.ts +++ b/server/ecommerce/src/discord-bot/discord-bot.module.ts @@ -10,17 +10,14 @@ import { Avatar } from 'src/utils/entities/avatar.entity'; import { ProductsService } from 'src/products/products.service'; import { Image } from 'src/utils/entities/image.entity'; import { Product } from 'src/utils/entities/product.entity'; -import { ProductNotificationService } from 'src/product-notification/product-notification.service'; import { ProductNotification } from 'src/utils/entities/product-notification.entity'; import { FollowersService } from 'src/followers/followers.service'; -import { DiscordNotificationsService } from 'src/discord-notifications/discord-notifications.service'; import { IProductsService } from 'src/spi/products'; -import { DiscordGuildModule } from 'src/discord-guild/discord-guild.module'; -import { DiscordGuildService } from 'src/discord-guild/discord-guild.service'; import { DiscordNotificationsModule } from 'src/discord-notifications/discord-notifications.module'; import { ProductsModule } from 'src/products/products.module'; import { ItemNotifierModule } from './src/commands/notifiers/item-notifier.module'; import { ProductNotificationModule } from 'src/product-notification/product-notification.module'; +import { BinanceModule } from 'src/binance/binance.module'; @Module({ controllers: [DiscordBotController], @@ -45,6 +42,7 @@ import { ProductNotificationModule } from 'src/product-notification/product-noti ItemNotifierModule, DiscordNotificationsModule, ProductNotificationModule, + BinanceModule, ], exports: [DiscordBotService], }) diff --git a/server/ecommerce/src/discord-bot/discord-bot.service.ts b/server/ecommerce/src/discord-bot/discord-bot.service.ts index 889b4f3..c4b82ac 100644 --- a/server/ecommerce/src/discord-bot/discord-bot.service.ts +++ b/server/ecommerce/src/discord-bot/discord-bot.service.ts @@ -19,6 +19,8 @@ import { FollowersService } from 'src/followers/followers.service'; import { StartTrackingCommand } from './src/commands/trackers/start-tracking'; import { StopTrackingCommand } from './src/commands/trackers/stop-tracking'; import { IProductsService } from 'src/spi/products'; +import { BinanceAccountTokenInfo } from './src/commands/binance-account-token-info'; +import { BinanceService } from 'src/binance/binance.service'; @Injectable() export class DiscordBotService implements OnModuleInit { @@ -31,6 +33,7 @@ export class DiscordBotService implements OnModuleInit { private userService: UsersService, @Inject(IProductsService) private productsService: IProductsService, private followersService: FollowersService, + private binanceService: BinanceService, ) { this.botToken = process.env.DISCORD_BOT_TOKEN; this.botApplicationId = process.env.DISCORD_CLIENT_ID; @@ -70,6 +73,7 @@ export class DiscordBotService implements OnModuleInit { followersService: this.followersService, usersService: this.userService, }), + new BinanceAccountTokenInfo({ binanceService: this.binanceService }), ], }); } diff --git a/server/ecommerce/src/discord-bot/src/commands/binance-account-token-info.ts b/server/ecommerce/src/discord-bot/src/commands/binance-account-token-info.ts new file mode 100644 index 0000000..0280e0b --- /dev/null +++ b/server/ecommerce/src/discord-bot/src/commands/binance-account-token-info.ts @@ -0,0 +1,121 @@ +import { + AutocompleteInteraction, + CacheType, + ChatInputCommandInteraction, + EmbedBuilder, + SlashCommandBuilder, +} from 'discord.js'; +import { SlashCommand } from './slash-command'; +import { BinanceService } from 'src/binance/binance.service'; +import { DiscordEmbedColors } from '../discord-embeds/colors'; +import { BinanceBalanceWithTotalValueAndSymbol } from 'src/utils/dtos/binance.dto'; + +// there are many symbols that cant be converted I.E COMBOBTC, USTCETH, COMBOETH, LUNCETH so only ones that can be converted will be +// displayed on discord +// might delete that feature later with choosing specific currency to convert to +// DEFAULT ONE IS USDT + +const CURRENCIES = ['BTC', 'USDT', 'ETH']; + +type Config = { + binanceService: BinanceService; +}; + +const AMOUNT_OF_TOKENS_OPTION_NAME = 'tokens'; +const CURRENCY_OPTION_NAME = 'currency'; +const DEFAULT_NUMBER_OF_TOKENS_TO_DISPLAY = 3; + +export class BinanceAccountTokenInfo implements SlashCommand { + private binanceService: BinanceService; + + readonly config: any = new SlashCommandBuilder() + .setName('binance-account') + .setDescription('Shows your most valuable tokens') + .addIntegerOption((option) => + option + .setName(AMOUNT_OF_TOKENS_OPTION_NAME) + .setDescription( + 'Displays amount of tokens that you inserted here which value is greater than 0', + ) + .setRequired(true) + .setMaxValue(10) + .setMinValue(1), + ) + .addStringOption((option) => + option + .setName(CURRENCY_OPTION_NAME) + .setDescription('Choose currency to convert to') + .setRequired(false) + .setAutocomplete(true), + ); + + constructor(config: Config) { + this.binanceService = config.binanceService; + } + + public autocomplete = async (interaction: AutocompleteInteraction) => { + const value = interaction.options.getFocused().toLowerCase(); + + const choices = CURRENCIES.filter((c) => c.includes(value)); + await interaction.respond( + choices.map((choice) => ({ + name: choice, + value: choice, + })), + ); + }; + + public execute = async (interaction: ChatInputCommandInteraction) => { + const numberOfTokens = + interaction.options.getInteger(AMOUNT_OF_TOKENS_OPTION_NAME) ?? + DEFAULT_NUMBER_OF_TOKENS_TO_DISPLAY; + + await interaction.deferReply({ ephemeral: true }); + const currencyName = + interaction.options.getString(CURRENCY_OPTION_NAME) ?? ''; + + if (currencyName && !this.isCurrencyNameValid(currencyName)) { + await interaction.reply({ + content: + 'Invalid currency to convert tokens value to. Please select a valid currency from the autocomplete options', + }); + } + + const balances = await this.binanceService.getAccountInfo( + numberOfTokens, + currencyName, + ); + + if (balances.length === 0) { + await interaction.editReply('No tokens found'); + } + + const embed = await this.createEmbed(balances, numberOfTokens); + + await interaction.editReply({ + embeds: [embed], + }); + }; + + private isCurrencyNameValid = (currencyName: string) => { + return CURRENCIES.includes(currencyName.toUpperCase()); + }; + + private createEmbed = async ( + balances: BinanceBalanceWithTotalValueAndSymbol[], + numberOfTokens: number, + ) => { + const description = balances + .map( + (balance) => + `**Token**: ${balance.asset}\n**Symbol**: ${balance.symbol}\n**Amount**: ${balance.free}\n**Total value**: ${balance.totalValue}`, + ) + .join('\n\n'); + const embed = new EmbedBuilder() + .setTitle(`Your top ${numberOfTokens} tokens`) + .setColor(DiscordEmbedColors.success) + .setDescription(description); + + return embed; + }; +} diff --git a/server/ecommerce/src/discord-bot/src/commands/notifiers/item-notifier.service.ts b/server/ecommerce/src/discord-bot/src/commands/notifiers/item-notifier.service.ts index ae475ea..2c0f9ee 100644 --- a/server/ecommerce/src/discord-bot/src/commands/notifiers/item-notifier.service.ts +++ b/server/ecommerce/src/discord-bot/src/commands/notifiers/item-notifier.service.ts @@ -1,12 +1,8 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { ItemNotifier } from './item-notifier'; -import { UsersService } from 'src/users/users.service'; -import { DiscordNotificationsService } from 'src/discord-notifications/discord-notifications.service'; import { Product } from 'src/utils/entities/product.entity'; import { OnEvent } from '@nestjs/event-emitter'; import { GeneralEvents } from 'src/events/constants/events'; -import { ProductsService } from 'src/products/products.service'; -import { IProductsService } from 'src/spi/products'; @Injectable() export class ItemNotifierService { diff --git a/server/ecommerce/src/discord-bot/src/commands/notifyUsers.ts b/server/ecommerce/src/discord-bot/src/commands/notifyUsers.ts deleted file mode 100644 index e69de29..0000000 diff --git a/server/ecommerce/src/discord-bot/src/commands/trackers/start-tracking.ts b/server/ecommerce/src/discord-bot/src/commands/trackers/start-tracking.ts index b42f259..e0e23ae 100644 --- a/server/ecommerce/src/discord-bot/src/commands/trackers/start-tracking.ts +++ b/server/ecommerce/src/discord-bot/src/commands/trackers/start-tracking.ts @@ -86,7 +86,7 @@ export class StartTrackingCommand implements SlashCommand { `You will get notified about all products listed from now on from this user.`, `Remember to enable DMs in the server privacy settings so the bot can DM you.`, ].join('\n'), - ephemeral: true + ephemeral: true, }); }; diff --git a/server/ecommerce/src/discord-guild/discord-guild.service.ts b/server/ecommerce/src/discord-guild/discord-guild.service.ts index d64c916..3cee817 100644 --- a/server/ecommerce/src/discord-guild/discord-guild.service.ts +++ b/server/ecommerce/src/discord-guild/discord-guild.service.ts @@ -92,15 +92,6 @@ export class DiscordGuildService { } }; - private checkUserRoles = async (userId: string, roleId: string) => { - const member = await this.getGuildMember(userId); - if (member.roles.cache.some((role) => role.id === roleId)) { - console.log('user has that role'); - } else { - console.log('user doesnt have that role'); - } - }; - private getUser = async (userId: number) => { const user = await this.usersService.findUserById(userId); return user; diff --git a/server/ecommerce/src/utils/dtos/binance.dto.ts b/server/ecommerce/src/utils/dtos/binance.dto.ts new file mode 100644 index 0000000..b81fcb4 --- /dev/null +++ b/server/ecommerce/src/utils/dtos/binance.dto.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +export const BinanceBalanceSchema = z.object({ + asset: z.string(), + free: z.string(), + locked: z.string(), +}); + +export const BinanceAccountInfoSchema = z.object({ + balances: z.array(BinanceBalanceSchema), +}); + +export type BinanceAccountInfo = z.infer; + +export type BinanceBalanceWithTotalValueAndSymbol = { + asset: string; + free: string; + locked: string; + symbol: string; + totalValue: string; +}; + +export type BinanceBalance = z.infer; + +export type TickerPriceType = { [symbol: string]: string }; + +export type TickerPrice = { + symbol: string; + price: string; +};