From 65310e8481edd8350815dff35e639f831f412607 Mon Sep 17 00:00:00 2001 From: Siebe Baree Date: Sat, 22 Jun 2024 18:45:36 +0200 Subject: [PATCH 1/4] Added v4.1 changes --- apps/api/src/router/index.ts | 2 + apps/api/src/router/member.ts | 100 ++ apps/bot/package.json | 2 +- apps/bot/src/bot.ts | 68 +- .../commands/business/business/employee.ts | 2 +- .../src/commands/business/business/index.ts | 2 +- .../src/commands/business/business/info.ts | 11 +- .../src/commands/business/business/invest.ts | 12 +- .../bot/src/commands/business/business/pay.ts | 5 +- .../commands/business/factory/_listItems.ts | 4 +- .../src/commands/business/factory/index.ts | 65 +- apps/bot/src/commands/business/farm.ts | 12 +- .../bot/src/commands/business/market/index.ts | 4 +- apps/bot/src/commands/business/market/sell.ts | 2 +- apps/bot/src/commands/business/tree.ts | 4 +- apps/bot/src/commands/games/slots.ts | 182 +++ apps/bot/src/commands/general/balance.ts | 119 +- apps/bot/src/commands/general/lucky-wheel.ts | 4 +- apps/bot/src/commands/general/pay.ts | 2 +- apps/bot/src/commands/general/profile.ts | 5 + apps/bot/src/commands/general/trade/create.ts | 80 ++ apps/bot/src/commands/general/trade/index.ts | 70 ++ apps/bot/src/commands/general/trade/list.ts | 189 ++++ apps/bot/src/commands/general/trade/view.ts | 1005 +++++++++++++++++ apps/bot/src/events/interactionCreate.ts | 2 +- apps/bot/src/lib/cooldown.ts | 27 +- apps/bot/src/lib/shop.ts | 35 + apps/bot/src/models/botStats.ts | 24 +- apps/bot/src/models/trade.ts | 49 + apps/bot/src/utils/index.ts | 23 + apps/crons/src/crons/index.ts | 19 - apps/crons/src/data/bot-listings.json | 76 -- apps/crons/src/lib/bot-listings.ts | 112 -- apps/crons/src/lib/types.ts | 11 - apps/crons/src/models/BotStats.ts | 23 - apps/web/.env.example | 3 + apps/web/package.json | 38 +- apps/web/prisma/schema.prisma | 76 +- apps/web/src/app/(main)/commands/page.tsx | 2 +- .../investments/investments-section.tsx | 419 ++++++- apps/web/src/app/(main)/investments/page.tsx | 27 +- apps/web/src/app/(main)/items/page.tsx | 2 +- apps/web/src/app/(main)/page.tsx | 16 +- apps/web/src/app/(main)/status/page.tsx | 67 ++ apps/web/src/app/api/investment/route.ts | 149 +++ apps/web/src/app/layout.tsx | 6 +- apps/web/src/app/sitemap.ts | 1 + apps/web/src/components/nav/footer.tsx | 1 + apps/web/src/components/statistics.tsx | 32 +- apps/web/src/components/ui/dialog.tsx | 97 ++ apps/web/src/components/ui/input.tsx | 22 + apps/web/src/components/ui/sonner.tsx | 28 + apps/web/src/env.js | 4 + apps/web/src/lib/data/changelog.json | 21 +- apps/web/src/lib/data/products.json | 8 +- bun.lockb | Bin 300248 -> 323448 bytes compose.yaml | 10 - 57 files changed, 2906 insertions(+), 475 deletions(-) create mode 100644 apps/api/src/router/member.ts create mode 100644 apps/bot/src/commands/games/slots.ts create mode 100644 apps/bot/src/commands/general/trade/create.ts create mode 100644 apps/bot/src/commands/general/trade/index.ts create mode 100644 apps/bot/src/commands/general/trade/list.ts create mode 100644 apps/bot/src/commands/general/trade/view.ts create mode 100644 apps/bot/src/models/trade.ts delete mode 100644 apps/crons/src/data/bot-listings.json delete mode 100644 apps/crons/src/lib/bot-listings.ts delete mode 100644 apps/crons/src/lib/types.ts delete mode 100644 apps/crons/src/models/BotStats.ts create mode 100644 apps/web/src/app/(main)/status/page.tsx create mode 100644 apps/web/src/app/api/investment/route.ts create mode 100644 apps/web/src/components/ui/dialog.tsx create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/components/ui/sonner.tsx diff --git a/apps/api/src/router/index.ts b/apps/api/src/router/index.ts index e6d2be31..7f33ec57 100644 --- a/apps/api/src/router/index.ts +++ b/apps/api/src/router/index.ts @@ -1,8 +1,10 @@ import { Hono } from 'hono'; import vote from './vote'; +import member from './member'; const app = new Hono(); app.route('/vote', vote); +app.route('/member', member); export default app; diff --git a/apps/api/src/router/member.ts b/apps/api/src/router/member.ts new file mode 100644 index 00000000..d87f896c --- /dev/null +++ b/apps/api/src/router/member.ts @@ -0,0 +1,100 @@ +import { Hono } from 'hono'; +import Member from '../schemas/member'; + +const app = new Hono(); + +type InvestmentBody = { + type: 'BUY' | 'SELL'; + userId: string; + ticker: string; + amount: number; + price: number; +}; + +app.put('/investment', async (c) => { + const authKey = c.req.header('Authorization'); + if (authKey !== process.env.TOKEN!) { + return c.json({ error: 'Invalid auth key' }, 401); + } + + const body: InvestmentBody = await c.req.json(); + if (!['BUY', 'SELL'].includes(body.type)) { + return c.json({ error: 'Invalid type' }, 400); + } + + const member = await Member.findOne({ id: body.userId }); + if (!member) { + return c.json({ error: 'Member not found' }, 404); + } + + if (member.premium < 2) { + return c.json( + { + error: 'You need Coinz Pro to use the web interface. Please upgrade your account.', + }, + 403, + ); + } + + const ownedInvestment = member.investments.find((i) => i.ticker === body.ticker); + if (body.type === 'BUY') { + console.log(`${body.userId} bought ${body.amount} of ${body.ticker} for ${body.price}`); + if (ownedInvestment) { + await Member.updateOne( + { id: member.id, 'investments.ticker': body.ticker }, + { + $set: { + 'investments.$.amount': `${Number.parseFloat(ownedInvestment.amount) + body.amount}`, + }, + $inc: { + 'investments.$.buyPrice': Math.round(body.price), + }, + }, + ); + } else { + await Member.updateOne( + { id: member.id }, + { + $push: { + investments: { + ticker: body.ticker, + amount: body.amount, + buyPrice: body.price, + }, + }, + }, + ); + } + } else { + if (!ownedInvestment) { + return c.json({ error: 'Investment not found' }, 404); + } + + console.log(`${body.userId} sold ${body.amount} of ${body.ticker} for ${body.price}`); + const ownedAmount = Number.parseFloat(ownedInvestment.amount); + if (Number.parseFloat(ownedInvestment.amount) <= body.amount || ownedAmount - body.amount < 0.00001) { + await Member.updateOne( + { id: member.id }, + { + $pull: { investments: { ticker: ownedInvestment.ticker } }, + }, + ); + } else { + await Member.updateOne( + { id: member.id, 'investments.ticker': body.ticker }, + { + $set: { + 'investments.$.amount': `${ownedAmount - body.amount}`, + }, + $inc: { + 'investments.$.buyPrice': -Math.floor((body.amount / ownedAmount) * ownedInvestment.buyPrice), + }, + }, + ); + } + } + + return c.json({ success: true }, 200); +}); + +export default app; diff --git a/apps/bot/package.json b/apps/bot/package.json index 647849a1..96b445f8 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -39,11 +39,11 @@ ], "prettier": "@repo/config/prettier/base.js", "dependencies": { + "@upstash/redis": "^1.30.0", "amqplib": "^0.10.4", "bufferutil": "^4.0.8", "discord-hybrid-sharding": "^2.1.9", "discord.js": "^14.14.1", - "ioredis": "^5.3.2", "moment": "^2.30.1", "mongoose": "^8.3.1", "obscenity": "^0.2.1", diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts index 75640063..7e36be37 100644 --- a/apps/bot/src/bot.ts +++ b/apps/bot/src/bot.ts @@ -5,7 +5,6 @@ import { ActivityType, GatewayIntentBits, Partials } from 'discord.js'; import { connect } from 'mongoose'; import Bot from './domain/Bot'; import BotStats from './models/botStats'; -import Investment from './models/investment'; import logger from './utils/logger'; (async () => { @@ -34,32 +33,55 @@ import logger from './utils/logger'; } bot.once('ready', async () => { - if (bot.cluster.id === bot.cluster.info.CLUSTER_COUNT - 1) { - const updateStatsInDb = async () => { - const guilds = [...bot.guilds.cache.values()]; - let users = 0; - for (const guild of guilds) { - users += guild.memberCount; - } + const updateStatsInDb = async () => { + const guilds = [...bot.guilds.cache.values()]; + let users = 0; + for (const guild of guilds) { + users += guild.memberCount; + } - const shards = bot.cluster.info.TOTAL_SHARDS; - const investmentsCount = await Investment.countDocuments(); - const botStat = new BotStats({ - guilds: guilds.length, - users: users, - shards: shards, - commands: bot.commands.size, - investments: investmentsCount, - updatedAt: new Date(), + const botStat = await BotStats.findOne({ updatedAt: { $gte: new Date().setHours(0, 0, 0, 0) } }); + if (botStat) { + const clusterIndex = botStat.clusters.findIndex((cluster) => cluster.id === bot.cluster.id); + if (clusterIndex === -1) { + botStat.clusters.push({ + id: bot.cluster.id, + guilds: guilds.length, + users: users, + totalShards: Number.parseInt(process.env.SHARDS_PER_CLUSTER!, 10), + }); + await botStat.save(); + } else { + await BotStats.updateOne( + { _id: botStat._id, 'clusters.id': bot.cluster.id }, + { + $set: { + 'clusters.$.guilds': guilds.length, + 'clusters.$.users': users, + 'clusters.$.totalShards': Number.parseInt(process.env.SHARDS_PER_CLUSTER!, 10), + }, + }, + ); + } + } else { + const newBotStat = new BotStats({ + clusters: [ + { + id: bot.cluster.id, + guilds: guilds.length, + users: users, + totalShards: Number.parseInt(process.env.SHARDS_PER_CLUSTER!, 10), + }, + ], }); - await botStat.save(); - }; + await newBotStat.save(); + } + }; - // Update stats in the database every 2 hours - setInterval(updateStatsInDb, 1000 * 60 * 120); + // Update stats in the database every day + setInterval(updateStatsInDb, 1000 * 60 * 60 * 24); - await updateStatsInDb(); - } + await updateStatsInDb(); }); await bot.login(process.env.DISCORD_TOKEN!); diff --git a/apps/bot/src/commands/business/business/employee.ts b/apps/bot/src/commands/business/business/employee.ts index c1bdebde..d486f636 100644 --- a/apps/bot/src/commands/business/business/employee.ts +++ b/apps/bot/src/commands/business/business/employee.ts @@ -46,7 +46,7 @@ export default async function employee( } async function hire(client: Bot, interaction: ChatInputCommandInteraction, member: IMember, data: BusinessData) { - const MAX_EMPLOYEES = member.premium >= 2 ? 6 : 5; + const MAX_EMPLOYEES = member.premium >= 2 ? 6 : 3; if (data.employee.position < Positions.Manager) { await interaction.reply({ content: 'You need to be a manager or higher to hire employees.', diff --git a/apps/bot/src/commands/business/business/index.ts b/apps/bot/src/commands/business/business/index.ts index 3bf1e5e1..d9985a6a 100644 --- a/apps/bot/src/commands/business/business/index.ts +++ b/apps/bot/src/commands/business/business/index.ts @@ -183,7 +183,7 @@ export default { case 'supply': await supply(client, interaction, data); break; - case 'pay': + case 'payout': await pay(client, interaction, data); break; case 'invest': diff --git a/apps/bot/src/commands/business/business/info.ts b/apps/bot/src/commands/business/business/info.ts index 05a76df4..5b7bb943 100644 --- a/apps/bot/src/commands/business/business/info.ts +++ b/apps/bot/src/commands/business/business/info.ts @@ -17,7 +17,7 @@ import { Positions, type BusinessData } from '../../../lib/types'; import type { IBusiness } from '../../../models/business'; import Business from '../../../models/business'; import type { IMember } from '../../../models/member'; -import { generateRandomString } from '../../../utils'; +import { generateRandomString, getLevel } from '../../../utils'; import { removeMoney } from '../../../utils/money'; enum Category { @@ -322,6 +322,15 @@ export default async function info( business = null; } else if (i.customId === 'business_create' && business === null) { + if (getLevel(member.experience) < 15) { + await i.deferUpdate(); + await interaction.reply({ + content: 'You need to be level 15 to create a business.', + ephemeral: true, + }); + return; + } + const createNameInput = new TextInputBuilder() .setCustomId('business-create-name') .setLabel('Business Name') diff --git a/apps/bot/src/commands/business/business/invest.ts b/apps/bot/src/commands/business/business/invest.ts index 664803f6..b18d3032 100644 --- a/apps/bot/src/commands/business/business/invest.ts +++ b/apps/bot/src/commands/business/business/invest.ts @@ -29,7 +29,15 @@ export default async function invest( const amountStr = interaction.options.getString('amount', true); const amount = parseStrToNum(amountStr); - const maxAmount = member.premium === 2 ? 10000 : 2000; + const maxAmount = member.premium === 2 ? 15000 : 1500; + + if (Number.isNaN(amount)) { + await interaction.reply({ + content: 'Please provide a valid number.', + ephemeral: true, + }); + return; + } if (amount < 100) { await interaction.reply({ @@ -45,7 +53,7 @@ export default async function invest( return; } else if (amount > maxAmount) { await interaction.reply({ - content: `You cannot invest more than :coin: ${maxAmount}.${member.premium < 2 ? `(Upgrade to [premium](<${client.config.website}/premium>) to increase the limit to :coin: 10,000)` : ''}`, + content: `You cannot invest more than :coin: ${maxAmount}.${member.premium < 2 ? ` Upgrade to [premium](<${client.config.website}/premium>) to increase the limit to :coin: 15,000.` : ''}`, ephemeral: true, }); return; diff --git a/apps/bot/src/commands/business/business/pay.ts b/apps/bot/src/commands/business/business/pay.ts index 207c42e9..2b8f5231 100644 --- a/apps/bot/src/commands/business/business/pay.ts +++ b/apps/bot/src/commands/business/business/pay.ts @@ -63,10 +63,7 @@ export default async function pay(client: Bot, interaction: ChatInputCommandInte ]); await client.cooldown.setCooldown(interaction.user.id, COMMAND_NAME, COOLDOWN_TIME); - await interaction.reply({ - embeds: [embed], - ephemeral: true, - }); + await interaction.reply({ embeds: [embed] }); await Business.updateOne({ name: data.business.name }, { $inc: { balance: -totalAmount } }); for (const employee of data.business.employees) { diff --git a/apps/bot/src/commands/business/factory/_listItems.ts b/apps/bot/src/commands/business/factory/_listItems.ts index bd64a14e..cecc2503 100644 --- a/apps/bot/src/commands/business/factory/_listItems.ts +++ b/apps/bot/src/commands/business/factory/_listItems.ts @@ -25,9 +25,9 @@ function createEmbed(client: Bot, items: FactoryItem[], data: BusinessData, page } description.push( - `${client.business.getItemString(item)}${stock ? `(Stock: ${stock.amount})` : ''}\n` + + `${client.business.getItemString(item)}${stock ? ` (Stock: ${stock.amount})` : ''}\n` + (item.producable - ? `> Produces ${item.amount} <:${item.itemId}:${item.emoteId}> every ${msToTime(item.duration * 1000)}\n> __Requirements:__ ${requirementsList.length > 0 ? requirementsList.join(', ') : 'No Requirements'}` + ? `> Normal Sell Price: :coin: ${item.price}\n> Produces ${item.amount} <:${item.itemId}:${item.emoteId}> every ${msToTime(item.duration * 1000)}\n> Requirements: ${requirementsList.length > 0 ? requirementsList.join(', ') : 'No Requirements'}` : `> Buy Price: :coin: ${item.price}`), ); } diff --git a/apps/bot/src/commands/business/factory/index.ts b/apps/bot/src/commands/business/factory/index.ts index c03bbd1a..7ce9f47b 100644 --- a/apps/bot/src/commands/business/factory/index.ts +++ b/apps/bot/src/commands/business/factory/index.ts @@ -3,10 +3,9 @@ import type { ColorResolvable, ChatInputCommandInteraction } from 'discord.js'; import type Bot from '../../../domain/Bot'; import type { Command } from '../../../domain/Command'; import { Positions, type BusinessData, type InventoryItem } from '../../../lib/types'; -// import Business, { type IFactory } from '../../../models/business'; -import Business from '../../../models/business'; +import Business, { type IFactory } from '../../../models/business'; import { filter, getBusiness } from '../../../utils'; -// import levelUp from './_levelUp'; +import levelUp from './_levelUp'; import listItems from './_listItems'; import startProduction from './_startProduction'; @@ -205,22 +204,22 @@ async function pressButton_CollectProducts(client: Bot, data: BusinessData): Pro } } -// async function pressButton_BuyFactory(data: BusinessData): Promise { -// const factoryId = data.business.factories.length; -// const cost = getFactoryCost(factoryId + 1); -// if (data.business.balance < cost) return false; - -// const factory: IFactory = { -// factoryId, -// level: 0, -// production: '', -// status: 'standby', -// produceOn: 0, -// }; - -// await Business.updateOne({ name: data.business.name }, { $push: { factories: factory }, $inc: { balance: -cost } }); -// return true; -// } +async function pressButton_BuyFactory(data: BusinessData): Promise { + const factoryId = data.business.factories.length; + const cost = getFactoryCost(factoryId + 1); + if (data.business.balance < cost) return false; + + const factory: IFactory = { + factoryId, + level: 0, + production: '', + status: 'standby', + produceOn: 0, + }; + + await Business.updateOne({ name: data.business.name }, { $push: { factories: factory }, $inc: { balance: -cost } }); + return true; +} export default { data: { @@ -271,20 +270,20 @@ export default { await pressButton_CollectProducts(client, data); await interaction.followUp({ content: 'You have collected all products.', ephemeral: true }); break; - // case 'factory_buy_factory': - // if (await pressButton_BuyFactory(data)) { - // await interaction.followUp({ content: 'You have bought a new factory.', ephemeral: true }); - // } else { - // await interaction.followUp({ - // content: 'You do not have enough balance to buy a new factory.', - // ephemeral: true, - // }); - // } - - // break; - // case 'factory_level_up': - // hasReplied = await levelUp(interaction, i, data); - // break; + case 'factory_buy_factory': + if (await pressButton_BuyFactory(data)) { + await interaction.followUp({ content: 'You have bought a new factory.', ephemeral: true }); + } else { + await interaction.followUp({ + content: 'You do not have enough balance to buy a new factory.', + ephemeral: true, + }); + } + + break; + case 'factory_level_up': + hasReplied = await levelUp(interaction, i, data); + break; case 'factory_start_production': hasReplied = await startProduction(client, interaction, i, data); break; diff --git a/apps/bot/src/commands/business/farm.ts b/apps/bot/src/commands/business/farm.ts index f6fb3585..4f8eeb44 100644 --- a/apps/bot/src/commands/business/farm.ts +++ b/apps/bot/src/commands/business/farm.ts @@ -536,6 +536,7 @@ async function getPlant(client: Bot, interaction: ChatInputCommandInteraction, m return; } + let commandSuccess = false; const modal = new ModalBuilder() .setTitle('Plant crops on your plots') .setCustomId(`farm_plant-${interaction.user.id}`) @@ -639,6 +640,7 @@ async function getPlant(client: Bot, interaction: ChatInputCommandInteraction, m } ${plots.join(', ')}.`, ephemeral: true, }); + commandSuccess = true; await client.items.removeItem(crop.itemId, member, plots.length); const harvestOn = Math.floor(Date.now() / 1_000) + (crop.duration ?? 21_600); @@ -656,10 +658,12 @@ async function getPlant(client: Bot, interaction: ChatInputCommandInteraction, m { upsert: true }, ); } catch (error) { - await modalInteraction.reply({ - content: (error as Error).message, - ephemeral: true, - }); + if (!commandSuccess) { + await modalInteraction.reply({ + content: (error as Error).message, + ephemeral: true, + }); + } } } catch (error) { if ((error as Error).name.includes('InteractionCollectorError')) { diff --git a/apps/bot/src/commands/business/market/index.ts b/apps/bot/src/commands/business/market/index.ts index c7776a35..90d3a7b8 100644 --- a/apps/bot/src/commands/business/market/index.ts +++ b/apps/bot/src/commands/business/market/index.ts @@ -50,13 +50,13 @@ export default { }, { name: 'amount', - type: ApplicationCommandOptionType.Number, + type: ApplicationCommandOptionType.Integer, description: 'The amount of the item you want to sell.', required: true, }, { name: 'price', - type: ApplicationCommandOptionType.Number, + type: ApplicationCommandOptionType.Integer, description: 'The price per item you want to sell.', required: false, }, diff --git a/apps/bot/src/commands/business/market/sell.ts b/apps/bot/src/commands/business/market/sell.ts index 50e62136..f47e7357 100644 --- a/apps/bot/src/commands/business/market/sell.ts +++ b/apps/bot/src/commands/business/market/sell.ts @@ -15,7 +15,7 @@ export default async function sell(client: Bot, interaction: ChatInputCommandInt } const itemId = interaction.options.getString('item', true); - const amount = interaction.options.getNumber('amount', true); + const amount = interaction.options.getInteger('amount', true); const item = client.business.getById(itemId) ?? client.business.getByName(itemId); if (!item) { diff --git a/apps/bot/src/commands/business/tree.ts b/apps/bot/src/commands/business/tree.ts index 2d16e35c..7e0cab2e 100644 --- a/apps/bot/src/commands/business/tree.ts +++ b/apps/bot/src/commands/business/tree.ts @@ -6,7 +6,7 @@ import { getMember, getUserStats } from '../../lib/database'; import type { IMember } from '../../models/member'; import Member from '../../models/member'; import UserStats from '../../models/userStats'; -import { feetToMeters, getRandomNumber } from '../../utils'; +import { feetToMeters, filter, getRandomNumber } from '../../utils'; type Event = { name: string; @@ -265,7 +265,7 @@ export default { } const collector = message.createMessageComponentCollector({ - filter: (i) => i.user.id === interaction.user.id, + filter: async (i) => filter(interaction, i), max: 4, time: 60_000, componentType: ComponentType.Button, diff --git a/apps/bot/src/commands/games/slots.ts b/apps/bot/src/commands/games/slots.ts new file mode 100644 index 00000000..ed9e6dc7 --- /dev/null +++ b/apps/bot/src/commands/games/slots.ts @@ -0,0 +1,182 @@ +import type { ColorResolvable } from 'discord.js'; +import { EmbedBuilder, ApplicationCommandOptionType } from 'discord.js'; +import type Bot from '../../domain/Bot'; +import type { Command } from '../../domain/Command'; +import UserStats from '../../models/userStats'; +import { getBet, getRandomNumber, wait } from '../../utils'; +import { addExperience, addMoney } from '../../utils/money'; + +type GameData = { + bet: number; + board: string; + profit: number; +}; + +const BOARD_SIZE = 3; +const SPINNING_SYMBOL = ''; + +const PAYOUTS = { + '💯': { + fullRow: 5, + halfRow: 1.5, + }, + '💰': { + fullRow: 4, + halfRow: 1.2, + }, + '💵': { + fullRow: 3, + halfRow: 1, + }, + '💎': { + fullRow: 2, + halfRow: 0.8, + }, + '🥇': { + fullRow: 1.5, + halfRow: 0.5, + }, +}; +const SYMBOLS = Object.keys(PAYOUTS); + +function countSymbols(board: string): Record { + const counts: Record = {}; + for (const symbol of board) { + if (symbol === ' ' || symbol === '⬅️') continue; + counts[symbol] = (counts[symbol] || 0) + 1; + } + + return counts; +} + +function mostOccurringSymbol(board: string): { symbol: keyof typeof PAYOUTS; count: number } { + const counts = countSymbols(board); + let maxCount = 0; + let mostOccurringSymbol = ''; + for (const symbol in counts) { + if (counts[symbol]! > maxCount) { + maxCount = counts[symbol]!; + mostOccurringSymbol = symbol; + } + } + + return { symbol: mostOccurringSymbol as keyof typeof PAYOUTS, count: maxCount }; +} + +function generateBoard(): string { + return Array.from({ length: BOARD_SIZE }, () => SYMBOLS[getRandomNumber(0, SYMBOLS.length - 1)]).join(' '); +} + +function getEmbed(client: Bot, gameData: GameData, isSpinning: boolean): EmbedBuilder { + const board = isSpinning + ? [ + SPINNING_SYMBOL.repeat(BOARD_SIZE), + `${SPINNING_SYMBOL.repeat(BOARD_SIZE)} ⬅️`, + SPINNING_SYMBOL.repeat(BOARD_SIZE), + ] + : [generateBoard(), `${gameData.board} ⬅️`, generateBoard()]; + + return new EmbedBuilder() + .setTitle('Slot Machine') + .setColor(client.config.embed.color as ColorResolvable) + .setDescription('Spinning the slots...') + .addFields([ + { + name: 'Board', + value: `${board.join('\n')}`, + inline: false, + }, + { + name: 'Profit', + value: `:coin: ${Math.round(gameData.profit - gameData.bet)}`, + inline: false, + }, + { + name: 'Payouts', + value: Object.entries(PAYOUTS) + .map( + ([symbol, { fullRow, halfRow }]) => + `${symbol.repeat(3)}: ${fullRow}x | ${symbol.repeat(2)}: ${halfRow}x`, + ) + .join('\n'), + inline: false, + }, + ]); +} + +export default { + data: { + name: 'slots', + description: 'Play the slot machine and win big!', + category: 'games', + options: [ + { + name: 'bet', + type: ApplicationCommandOptionType.String, + description: 'The bet you want to place.', + required: true, + min_length: 2, + max_length: 6, + }, + ], + cooldown: 600, + extraFields: [ + { + name: 'Bet Formatting', + value: 'You can use formatting to make it easier to use big numbers.\n\n__For Example:__\n~~1000~~ **1K**\n~~1300~~ **1.3K**\nUse `all` or `max` to use all the money you have or the maximum amount you can bet.', + inline: false, + }, + ], + usage: [''], + premium: 1, + }, + async execute(client, interaction, member) { + const { bet, error } = await getBet(client, interaction, member); + if (error) { + await interaction.reply({ content: error, ephemeral: true }); + return; + } + + const gameData: GameData = { + bet, + board: generateBoard(), + profit: 0, + }; + + await interaction.reply({ embeds: [getEmbed(client, gameData, true)] }); + await wait(3000); + + const winData = mostOccurringSymbol(gameData.board); + if (winData.count >= 2) { + const payout = winData.count === 3 ? PAYOUTS[winData.symbol].fullRow : PAYOUTS[winData.symbol].halfRow; + gameData.profit = Math.round(bet * payout); + + await addMoney(member.id, gameData.profit); + await addExperience(member); + + await UserStats.updateOne( + { id: member.id }, + { + $inc: { + 'games.won': 1, + 'games.moneyEarned': gameData.profit, + }, + }, + { upsert: true }, + ); + } else { + await UserStats.updateOne( + { id: member.id }, + { + $inc: { + 'games.lost': 1, + 'games.moneyLost': bet, + }, + }, + { upsert: true }, + ); + } + + await interaction.editReply({ embeds: [getEmbed(client, gameData, false)] }); + }, +} satisfies Command; diff --git a/apps/bot/src/commands/general/balance.ts b/apps/bot/src/commands/general/balance.ts index 182f5266..c22585e4 100644 --- a/apps/bot/src/commands/general/balance.ts +++ b/apps/bot/src/commands/general/balance.ts @@ -1,7 +1,55 @@ -import type { ColorResolvable } from 'discord.js'; -import { ApplicationCommandOptionType, EmbedBuilder } from 'discord.js'; +import { + ApplicationCommandOptionType, + ButtonStyle, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + type ColorResolvable, + type User, + ComponentType, +} from 'discord.js'; +import type Bot from '../../domain/Bot'; import type { Command } from '../../domain/Command'; import { getMember } from '../../lib/database'; +import type { IMember } from '../../models/member'; +import Member from '../../models/member'; +import { filter } from '../../utils'; + +function calculateBankLimitPrice(premium: number, bankLimit: number) { + return Math.ceil(bankLimit * (premium > 0 ? 0.35 : 0.7)); +} + +function getEmbed(client: Bot, user: User, member: IMember, price: number, enoughMoney: boolean): EmbedBuilder { + let desc = ''; + if (price !== -1) { + desc = + (enoughMoney + ? `:white_check_mark: **You can increase your bank limit to \`${member.bankLimit * 2}\` for :coin: ${price}**.` + : `:x: **You need :coin: ${price > member.wallet ? price - member.wallet : 1} to increase your bank limit to \`${member.bankLimit * 2}\`.**`) + + '\n\n'; + } + + return new EmbedBuilder() + .setAuthor({ name: `${user.username}'s Balance`, iconURL: user.avatarURL() ?? undefined }) + .setColor(client.config.embed.color as ColorResolvable) + .setDescription( + desc + + `:dollar: **Wallet:** :coin: ${member.wallet}\n` + + `:bank: **Bank:** :coin: ${member.bank} / ${member.bankLimit || 7_500}\n` + + `:moneybag: **Net Worth:** :coin: ${member.wallet + member.bank}`, + ); +} + +function getButton(enoughMoney: boolean, disabled = false): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('balance_addLimit') + .setLabel('Increase Bank Limit') + .setEmoji('🪙') + .setStyle(enoughMoney ? ButtonStyle.Success : ButtonStyle.Danger) + .setDisabled(!enoughMoney || disabled), + ); +} export default { data: { @@ -25,16 +73,61 @@ export default { return; } - const memberData = interaction.user.id === user.id ? member : await getMember(user.id); - const embed = new EmbedBuilder() - .setAuthor({ name: `${user.username}'s Balance`, iconURL: user.avatarURL() ?? undefined }) - .setColor(client.config.embed.color as ColorResolvable) - .setDescription( - `:dollar: **Wallet:** :coin: ${memberData.wallet}\n` + - `:bank: **Bank:** :coin: ${memberData.bank} / ${memberData.bankLimit || 7_500}\n` + - `:moneybag: **Net Worth:** :coin: ${memberData.wallet + memberData.bank}`, - ); - - await interaction.reply({ embeds: [embed] }); + let memberData = interaction.user.id === user.id ? member : await getMember(user.id); + let price = calculateBankLimitPrice(memberData.premium, memberData.bankLimit); + let enoughMoney = memberData.wallet >= price; + let interactionFinished = false; + + const message = await interaction.reply({ + embeds: [getEmbed(client, interaction.user, memberData, price, enoughMoney)], + components: [getButton(enoughMoney)], + fetchReply: true, + }); + + const collector = message.createMessageComponentCollector({ + filter: async (i) => filter(interaction, i), + max: 10, + idle: 45_000, + componentType: ComponentType.Button, + }); + + collector.on('collect', async (i) => { + if (i.customId === 'balance_addLimit' && !interactionFinished) { + memberData = await getMember(interaction.user.id); + price = calculateBankLimitPrice(memberData.premium, memberData.bankLimit); + enoughMoney = memberData.wallet >= price; + + const oldBankLimit = memberData.bankLimit; + memberData.wallet -= price; + memberData.bankLimit *= 2; + + if (!enoughMoney) { + await i.reply({ + content: "You don't have enough money to increase your bank limit.", + ephemeral: true, + }); + return; + } + + await Member.updateOne( + { id: interaction.user.id }, + { $inc: { wallet: -price, bankLimit: oldBankLimit } }, + ); + + price = calculateBankLimitPrice(memberData.premium, memberData.bankLimit); + enoughMoney = memberData.wallet >= price; + + if (!enoughMoney) interactionFinished = true; + await i.update({ + embeds: [getEmbed(client, user, memberData, price, enoughMoney)], + components: [getButton(enoughMoney)], + }); + } + }); + + collector.on('end', async () => { + interactionFinished = true; + await interaction.editReply({ components: [getButton(enoughMoney, true)] }); + }); }, } satisfies Command; diff --git a/apps/bot/src/commands/general/lucky-wheel.ts b/apps/bot/src/commands/general/lucky-wheel.ts index 67e42cf6..0953c345 100644 --- a/apps/bot/src/commands/general/lucky-wheel.ts +++ b/apps/bot/src/commands/general/lucky-wheel.ts @@ -50,7 +50,9 @@ async function getInfo(client: Bot, interaction: ChatInputCommandInteraction, me ); for (const [i, element] of lootTable.entries()) { - embed.addFields([{ name: `Possible Rewards (${i + 1})`, value: element, inline: true }]); + embed.addFields([ + { name: `Possible Rewards (${i + 1})`, value: element ?? 'No other rewards than money...', inline: true }, + ]); } await interaction.reply({ embeds: [embed] }); diff --git a/apps/bot/src/commands/general/pay.ts b/apps/bot/src/commands/general/pay.ts index fed195e1..ab9cb919 100644 --- a/apps/bot/src/commands/general/pay.ts +++ b/apps/bot/src/commands/general/pay.ts @@ -8,7 +8,7 @@ import { addMoney, removeMoney } from '../../utils/money'; const LOWER_LIMIT = 30; const START_LIMIT = 1000; -const MAX_LIMIT = 15_000; +const MAX_LIMIT = 10_000; const PREMIUM_LIMIT = 30_000; function getLimit(level: number, premium: number): number { diff --git a/apps/bot/src/commands/general/profile.ts b/apps/bot/src/commands/general/profile.ts index 0e0634c5..37ec3eaf 100644 --- a/apps/bot/src/commands/general/profile.ts +++ b/apps/bot/src/commands/general/profile.ts @@ -94,6 +94,11 @@ export default { }, async execute(client, interaction, member) { const user = interaction.options.getUser('user') ?? interaction.user; + if (user.bot) { + await interaction.editReply({ content: "You can't get the profile of a bot." }); + return; + } + const memberData = interaction.user.id === user.id ? member : await getMember(user.id); const inventory = getInventoryValue(client, memberData.inventory); diff --git a/apps/bot/src/commands/general/trade/create.ts b/apps/bot/src/commands/general/trade/create.ts new file mode 100644 index 00000000..d955718c --- /dev/null +++ b/apps/bot/src/commands/general/trade/create.ts @@ -0,0 +1,80 @@ +import type { ChatInputCommandInteraction } from 'discord.js'; +import type Bot from '../../../domain/Bot'; +import { getMember } from '../../../lib/database'; +import type { IMember } from '../../../models/member'; +import Trade, { TradeStatus } from '../../../models/trade'; +import { generateRandomString, getLevel, msToTime } from '../../../utils'; + +const COOLDOWN_KEY = 'trade.create'; + +export default async function create(client: Bot, interaction: ChatInputCommandInteraction, member: IMember) { + await interaction.deferReply({ ephemeral: true }); + + const cooldown = await client.cooldown.getCooldown(interaction.user.id, COOLDOWN_KEY); + if (cooldown) { + await interaction.editReply({ + content: `Please wait ${msToTime( + Math.abs(Number.parseInt(cooldown, 10) - Math.floor(Date.now() / 1_000)) * 1_000, + )} before using this command again.`, + }); + return; + } + + if (member.premium < 1) { + await interaction.editReply({ + content: `Creating trades is only for Coinz Plus or Pro subscribers. To gain access to this command, consider [**upgrading**](<${client.config.website}/premium>).`, + }); + return; + } else if (getLevel(member.experience) < 10) { + await interaction.editReply({ content: 'You need to be level 10 to use this command.' }); + return; + } + + const user = interaction.options.getUser('user', true); + if (user.bot) { + await interaction.editReply({ content: "You can't get the balance of a bot." }); + return; + } + + const toMember = await getMember(user.id); + if (!toMember) { + await interaction.editReply({ content: `${user.username} has not used Coinz before.` }); + return; + } + + const totalTrades = await Trade.countDocuments({ + $and: [ + { $or: [{ userId: member.id }, { toUserId: member.id }] }, + { status: { $in: [TradeStatus.PENDING, TradeStatus.WAITING_FOR_CONFIRMATION] } }, + { expiresAt: { $gt: new Date() } }, + ], + }); + + if (totalTrades >= 3) { + await interaction.editReply({ + content: `You or ${user.username} have too many active trades. Check your active trades using \`/trade list\` and close any trades using \`/trade view \` and then pressing the close button.`, + }); + return; + } + + await client.cooldown.setCooldown(interaction.user.id, COOLDOWN_KEY, 60 * 60 * 12); // 12 hours + + let tradeId = ''; + do { + tradeId = generateRandomString(8); + } while (await Trade.exists({ tradeId })); + + await interaction.editReply({ + content: `Trade created with ${user.username}. Add items to the trade using \`/trade view trade-id:${tradeId}\`.`, + }); + + const trade = new Trade({ + tradeId: tradeId, + userId: member.id, + toUserId: user.id, + items: [], + status: TradeStatus.PENDING, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 3), // 3 days + }); + await trade.save(); +} diff --git a/apps/bot/src/commands/general/trade/index.ts b/apps/bot/src/commands/general/trade/index.ts new file mode 100644 index 00000000..88f41c31 --- /dev/null +++ b/apps/bot/src/commands/general/trade/index.ts @@ -0,0 +1,70 @@ +import { + // ActionRowBuilder, + ApplicationCommandOptionType, + // EmbedBuilder, + // ModalBuilder, + // TextInputBuilder, + // TextInputStyle, +} from 'discord.js'; +import type { Command } from '../../../domain/Command'; +import create from './create'; +import list from './list'; +import view from './view'; + +export default { + data: { + name: 'trade', + description: 'Trade items with another user.', + category: 'general', + options: [ + { + name: 'view', + type: ApplicationCommandOptionType.Subcommand, + description: 'Update an active trade or get information about a trade.', + options: [ + { + name: 'trade-id', + type: ApplicationCommandOptionType.String, + description: 'The ID of the trade you want to view.', + required: true, + }, + ], + }, + { + name: 'list', + type: ApplicationCommandOptionType.Subcommand, + description: 'List all your trades and trades with you.', + options: [], + }, + { + name: 'create', + type: ApplicationCommandOptionType.Subcommand, + description: 'Create a new trade with another user.', + options: [ + { + name: 'user', + type: ApplicationCommandOptionType.User, + description: 'The user you want to trade with.', + required: true, + }, + ], + }, + ], + usage: ['view ', 'list', 'create '], + }, + async execute(client, interaction, member) { + switch (interaction.options.getSubcommand()) { + case 'view': + await view(client, interaction, member); + break; + case 'list': + await list(client, interaction); + break; + case 'create': + await create(client, interaction, member); + break; + default: + await interaction.reply({ content: client.config.invalidCommand, ephemeral: true }); + } + }, +} satisfies Command; diff --git a/apps/bot/src/commands/general/trade/list.ts b/apps/bot/src/commands/general/trade/list.ts new file mode 100644 index 00000000..db5322bf --- /dev/null +++ b/apps/bot/src/commands/general/trade/list.ts @@ -0,0 +1,189 @@ +import { + type ChatInputCommandInteraction, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + EmbedBuilder, + ComponentType, + type ColorResolvable, +} from 'discord.js'; +import type Bot from '../../../domain/Bot'; +import TradeModel, { TradeStatus, type Trade } from '../../../models/trade'; +import { filter, getUser } from '../../../utils'; +import { calculatePageNumber, getPageButtons } from '../../../utils/embed'; + +const ItemsPerPage = 5; + +type Filter = { + id: string; + name: string; + description: string; + getFilter(userId: string): object; +}; + +const filters: Filter[] = [ + { + id: 'ALL', + name: 'All', + description: 'Get all trades that you are involved in', + getFilter: (userId) => ({ + $or: [{ userId: userId }, { toUserId: userId }], + }), + }, + { + id: 'YOUR_TRADES', + name: 'Your Trades', + description: 'Get trades that you have created', + getFilter: (userId) => ({ + userId: userId, + }), + }, + { + id: 'TRADES_WITH_YOU', + name: 'Trades with you', + description: 'Get trades that are created by others', + getFilter: (userId) => ({ + toUserId: userId, + }), + }, + { + id: 'ACTIVE_TRADES', + name: 'Active Trades', + description: 'Get trades that are still active', + getFilter: () => ({ + $and: [ + { status: { $in: [TradeStatus.PENDING, TradeStatus.WAITING_FOR_CONFIRMATION] } }, + { expiresAt: { $gt: new Date() } }, + ], + }), + }, + { + id: 'CLOSED_TRADES', + name: 'Closed Trades', + description: 'Get trades that are closed', + getFilter: () => ({ + $or: [ + { status: { $nin: [TradeStatus.PENDING, TradeStatus.WAITING_FOR_CONFIRMATION] } }, + { status: TradeStatus.COMPLETED }, + ], + }), + }, +]; + +async function createEmbed( + client: Bot, + trades: Trade[], + filter: string[], + page: number, + maxPage: number, +): Promise { + const tradeText: string[] = []; + for (const trade of trades) { + const fromUser = await getUser(client, trade.userId); + const toUser = await getUser(client, trade.toUserId); + + const expireFormat = ``; + const expires = trade.expiresAt.getTime() > Date.now() ? `Expires ${expireFormat}` : `Expired ${expireFormat}`; + + tradeText.push( + `\`${trade.tradeId}\` | ${fromUser ? `**${fromUser.username}**` : `<@${trade.userId}>`} <-> ${toUser ? `**${toUser.username}**` : `<@${trade.toUserId}>`}\n` + + `> **Status:** \`${trade.status}\` | ${expires}`, + ); + } + + let noTradeText = 'No trades found.'; + if (filter.includes('ALL') && filter.length === 1) { + noTradeText = 'You have not created or been involved in any trades yet.'; + } else { + noTradeText = + 'No trades found with the selected filters. Try changing the filters. For example, you cannot have `Active Trades` and `Closed Trades` selected at the same time because a trade cannot be both active and closed.'; + } + + const selectedFilters = filters.filter((f) => filter.includes(f.id)).map((f) => `\`${f.name}\``); + return new EmbedBuilder() + .setTitle('Trading List') + .setDescription( + `:scales: **Use** \`/trade view \` **to get more info about a trade.**\n:file_folder: **Active Filters:** ${selectedFilters.join(', ')}\n\n` + + (tradeText.join('\n\n') || noTradeText), + ) + .setColor(client.config.embed.color as ColorResolvable) + .setFooter({ text: `Page ${page + 1}/${maxPage}` }); +} + +function createSelectMenu(activeFilters: string[], isDisabled = false): ActionRowBuilder { + const select = new StringSelectMenuBuilder() + .setCustomId('trade_list_select') + .setPlaceholder('Filter your trades') + .setMinValues(1) + .setMaxValues(5) + .setDisabled(isDisabled); + + for (const filter of filters) { + select.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(filter.name) + .setDescription(filter.description) + .setValue(filter.id) + .setDefault(activeFilters.includes(filter.id)), + ); + } + + return new ActionRowBuilder().addComponents(select); +} + +async function getTrades(activeFilters: string[], userId: string): Promise { + return TradeModel.find({ + $and: activeFilters.map((filter) => filters.find((f) => f.id === filter)!.getFilter(userId)), + }) + .sort({ createdAt: -1 }) + .limit(100); +} + +function getTradesForCurrentPage(allTrades: Trade[], page: number): Trade[] { + return allTrades.slice(page * ItemsPerPage, page * ItemsPerPage + ItemsPerPage); +} + +export default async function list(client: Bot, interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + + let activeFilters: string[] = [filters[0]!.id]; + let trades = await getTrades(activeFilters, interaction.user.id); + let page = 0; + let maxPage = Math.ceil(trades.length / ItemsPerPage); + let pageTrades = getTradesForCurrentPage(trades, page); + + const message = await interaction.editReply({ + embeds: [await createEmbed(client, pageTrades, activeFilters, page, maxPage)], + components: [createSelectMenu(activeFilters), ...getPageButtons(page, maxPage)], + }); + + const collector = message.createMessageComponentCollector({ + filter: async (i) => filter(interaction, i), + max: 20, + idle: 25_000, + time: 180_000, + }); + + collector.on('collect', async (i) => { + if (i.componentType === ComponentType.Button) { + page = calculatePageNumber(i.customId, page, maxPage); + } else if (i.componentType === ComponentType.StringSelect) { + activeFilters = i.values; + trades = await getTrades(activeFilters, interaction.user.id); + page = 0; + maxPage = Math.ceil(trades.length / ItemsPerPage); + pageTrades = getTradesForCurrentPage(trades, page); + } + + await i.update({ + embeds: [await createEmbed(client, pageTrades, activeFilters, page, maxPage)], + components: [createSelectMenu(activeFilters), ...getPageButtons(page, maxPage)], + }); + }); + + collector.on('end', async () => { + await interaction.editReply({ + components: [createSelectMenu(activeFilters, true), ...getPageButtons(page, maxPage, true)], + }); + }); +} diff --git a/apps/bot/src/commands/general/trade/view.ts b/apps/bot/src/commands/general/trade/view.ts new file mode 100644 index 00000000..fc44e431 --- /dev/null +++ b/apps/bot/src/commands/general/trade/view.ts @@ -0,0 +1,1005 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { + type ChatInputCommandInteraction, + EmbedBuilder, + type ColorResolvable, + ComponentType, + ButtonBuilder, + ActionRowBuilder, + ButtonStyle, + TextInputBuilder, + ModalBuilder, + type ModalSubmitInteraction, + TextInputStyle, + type ButtonInteraction, +} from 'discord.js'; +import type Bot from '../../../domain/Bot'; +import { getMember } from '../../../lib/database'; +import type { Item } from '../../../models/item'; +import type { IMember } from '../../../models/member'; +import Member from '../../../models/member'; +import TradeModel, { type TradeItem, TradeStatus, type Trade } from '../../../models/trade'; +import { getUser, updateTrade } from '../../../utils'; +import { addMoney, removeMoney } from '../../../utils/money'; + +function getItemsFromUser( + client: Bot, + trade: Trade, + userId: string, +): { + items: string; + totalWorth: number; +} { + const userItems = trade.items.filter((item) => item.userId === userId); + + let totalWorth = 0; + const items: string[] = []; + let money = 0; + for (const tradeItem of userItems) { + if (tradeItem.itemId === 'money') { + totalWorth += tradeItem.quantity; + money += tradeItem.quantity; + } else { + const item = client.items.getById(tradeItem.itemId); + if (!item) continue; + + totalWorth += (item.sellPrice ?? item.buyPrice ?? 0) * tradeItem.quantity; + items.push(client.items.getItemString(item, tradeItem.quantity)); + } + } + + let itemsStr = ''; + + if (money > 0) { + itemsStr += `**Money:** :coin: ${money}` + (items.length > 0 ? '\n' : ''); + } + + itemsStr += items.join('\n'); + + return { items: itemsStr, totalWorth }; +} + +async function createEmbed(client: Bot, trade: Trade): Promise { + const user = await getUser(client, trade.userId); + const toUser = await getUser(client, trade.toUserId); + const isExpired = trade.expiresAt.getTime() < Date.now(); + + const itemsUser = getItemsFromUser(client, trade, trade.userId); + const itemsToUser = getItemsFromUser(client, trade, trade.toUserId); + + return new EmbedBuilder() + .setTitle(`Trade #${trade.tradeId}`) + .setDescription( + `:scales: **Trade ID:** \`${trade.tradeId}\`\n` + + `:mirror_ball: **Status:** \`${trade.status}\`\n` + + `:wastebasket: **${isExpired ? 'Expired' : 'Expires'}** \n` + + `:busts_in_silhouette: **Trade between:** ${user?.username} <-> ${toUser?.username}`, + ) + .setFields([ + { + name: `${user?.username ?? `User ${trade.userId}`}`, + value: `**Total worth:** :coin: ${itemsUser.totalWorth}\n\n**Items:**\n${itemsUser.items}`, + }, + { + name: `${toUser?.username ?? `User ${trade.toUserId}`}`, + value: `**Total worth:** :coin: ${itemsToUser.totalWorth}\n\n**Items:**\n${itemsToUser.items}`, + }, + ]) + .setColor(client.config.embed.color as ColorResolvable) + .setFooter({ text: `Trade ID: ${trade.tradeId}` }) + .setTimestamp(); +} + +function createButtons(trade: Trade, isDisabled = false): ActionRowBuilder[] { + if (trade.expiresAt.getTime() > Date.now()) { + if (trade.status === TradeStatus.PENDING) { + const row1 = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('trade_view_addItem') + .setLabel('Add Item') + .setStyle(ButtonStyle.Secondary) + .setDisabled(isDisabled), + new ButtonBuilder() + .setCustomId('trade_view_addMoney') + .setLabel('Add Money') + .setStyle(ButtonStyle.Secondary) + .setDisabled(isDisabled), + new ButtonBuilder() + .setCustomId('trade_view_confirmTrade') + .setLabel('Confirm Trade') + .setStyle(ButtonStyle.Primary) + .setDisabled(isDisabled), + new ButtonBuilder() + .setCustomId('trade_view_deleteTrade') + .setLabel('Delete Trade') + .setStyle(ButtonStyle.Danger) + .setDisabled(isDisabled), + ); + + const row2 = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('trade_view_addItemToUser') + .setLabel('Add Item (Other user)') + .setStyle(ButtonStyle.Secondary) + .setDisabled(isDisabled), + new ButtonBuilder() + .setCustomId('trade_view_addMoneyToUser') + .setLabel('Add Money (Other user)') + .setStyle(ButtonStyle.Secondary) + .setDisabled(isDisabled), + ); + + return [row1, row2]; + } else if (trade.status === TradeStatus.WAITING_FOR_CONFIRMATION) { + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('toUser_trade_view_accept') + .setLabel('Accept Trade') + .setStyle(ButtonStyle.Primary) + .setDisabled(isDisabled), + new ButtonBuilder() + .setCustomId('toUser_trade_view_cancel') + .setLabel('Cancel Trade') + .setStyle(ButtonStyle.Danger) + .setDisabled(isDisabled), + ); + + return [row]; + } + } + + return []; +} + +type Modal = { + id: string; + title: string; + fields: { + id: string; + label: string; + placeholder: string; + style: TextInputStyle; + required: boolean; + minLength?: number; + maxLength: number; + }[]; +}; + +function createModal( + interaction: ChatInputCommandInteraction, + modalData: Modal, +): { + modal: ModalBuilder; + filter(modalInteraction: ModalSubmitInteraction): boolean; +} { + const inputs: TextInputBuilder[] = []; + for (const field of modalData.fields) { + const input = new TextInputBuilder() + .setCustomId(field.id) + .setLabel(field.label) + .setPlaceholder(field.placeholder) + .setStyle(field.style) + .setRequired(field.required) + .setMaxLength(field.maxLength); + + if (field.minLength) input.setMinLength(field.minLength); + inputs.push(input); + } + + const modal = new ModalBuilder() + .setTitle(modalData.title) + .setCustomId(modalData.id) + .addComponents(inputs.map((input) => new ActionRowBuilder().addComponents(input))); + + const filter = (modalInteraction: ModalSubmitInteraction) => + modalInteraction.customId === modalData.id && modalInteraction.user.id === interaction.user.id; + + return { + modal, + filter, + }; +} + +async function addItem( + client: Bot, + trade: Trade, + userId: string, + itemId: string, + amount: number, +): Promise<{ + trade: Trade; + error?: string; +}> { + let item: Item | null = null; + if (itemId !== 'money') { + item = client.items.getById(itemId) ?? client.items.getByName(itemId); + if (!item) { + return { + trade, + error: 'Item ID or name not found.', + }; + } + + const member = await getMember(userId); + if (!member) { + return { + trade, + error: 'I could not find the user...', + }; + } + + const invItem = client.items.getInventoryItem(itemId, member); + if (!invItem || invItem.amount < amount) { + return { + trade, + error: `<@${userId}> doesn't have ${client.items.getItemString(item, amount)} in their inventory.`, + }; + } + } + + const userHasItem = trade.items.find((item) => item.userId === userId && item.itemId === itemId); + + // Update local trade object to do some checks + if (userHasItem) { + trade.items = trade.items.map((item) => { + if (item.userId === userId && item.itemId === itemId) { + return { + ...item, + quantity: item.quantity + amount, + }; + } + + return item; + }); + } else { + trade.items.push({ + userId, + itemId, + quantity: amount, + }); + } + + let totalWorth = 0; + for (const tradeItem of trade.items) { + if (tradeItem.quantity < 0) { + return { + trade, + error: 'You cannot add a negative amount of items.', + }; + } + + if (tradeItem.userId !== userId) continue; + if (tradeItem.itemId === 'money') { + totalWorth += tradeItem.quantity; + } else { + const item = client.items.getById(tradeItem.itemId); + if (item) { + totalWorth += (item.sellPrice ?? item.buyPrice ?? 0) * tradeItem.quantity; + } + } + } + + if (totalWorth > 100_000) { + return { + trade, + error: 'You can only trade items with a total worth of :coin: 100,000 or less.', + }; + } + + let newTrade: Trade | null = null; + if (userHasItem) { + let fetchedTrade: Trade | null = null; + if (userHasItem.quantity + amount < 1) { + fetchedTrade = await TradeModel.findOneAndUpdate( + { tradeId: trade.tradeId, status: TradeStatus.PENDING }, + { $pull: { items: { userId, itemId } } }, + { new: true }, + ); + } else { + const storedTrade = await TradeModel.findOne({ tradeId: trade.tradeId, status: TradeStatus.PENDING }); + if (!storedTrade) { + return { + trade, + error: 'Failed to add item to the trade. This trade has not been found.', + }; + } + + const itemIndex = storedTrade.items.findIndex((item) => item.userId === userId && item.itemId === itemId); + if (itemIndex === -1) { + return { + trade, + error: 'Failed to add item to the trade. This item has not been found.', + }; + } + + storedTrade.items[itemIndex]!.quantity += amount; + + await storedTrade.save(); + fetchedTrade = storedTrade; + } + + newTrade = fetchedTrade; + } else { + if (amount < 1) { + return { + trade, + error: 'You need to add at least one item.', + }; + } + + const fetchedTrade = await TradeModel.findOneAndUpdate( + { tradeId: trade.tradeId, status: TradeStatus.PENDING }, + { + $push: { + items: { + userId, + itemId, + quantity: amount, + }, + }, + }, + { new: true }, + ); + + newTrade = fetchedTrade; + } + + if (!newTrade) { + return { + trade, + error: 'Failed to add item to the trade. This trade has not been found.', + }; + } + + return { + trade: newTrade, + }; +} + +function arrayToItemMap(tradeItems: TradeItem[]): Record { + const itemMap: Record = {}; + for (const item of tradeItems) { + if (!(item.itemId in itemMap)) { + itemMap[item.itemId] = item.quantity; + } + } + + return itemMap; +} + +function getMoney(trade: Trade, userId: string): number { + return trade.items.find((item) => item.itemId === 'money' && item.userId === userId)?.quantity ?? 0; +} + +function getItemsPerUser(client: Bot, trade: Trade, userId: string): TradeItem[] { + return trade.items.filter( + (item) => + item.userId === userId && + item.itemId !== 'money' && + item.quantity > 0 && + client.items.getById(item.itemId) !== null, + ); +} + +async function giveItems(client: Bot, trade: Trade): Promise { + const memberUser = await Member.findOne({ id: trade.userId }); + if (!memberUser) return; + + const memberToUser = await Member.findOne({ id: trade.toUserId }); + if (!memberToUser) return; + + const totalMoneyUser = getMoney(trade, trade.toUserId); + if (totalMoneyUser > 0) { + await addMoney(trade.userId, totalMoneyUser); + await removeMoney(trade.toUserId, totalMoneyUser); + } + + const totalMoneyToUser = getMoney(trade, trade.userId); + if (totalMoneyToUser > 0) { + await addMoney(trade.toUserId, totalMoneyToUser); + await removeMoney(trade.userId, totalMoneyToUser); + } + + const itemsUser = getItemsPerUser(client, trade, trade.toUserId); + await client.items.addItemBulk(arrayToItemMap(itemsUser), memberUser); + + const itemsToUser = getItemsPerUser(client, trade, trade.userId); + await client.items.addItemBulk(arrayToItemMap(itemsToUser), memberToUser); + + await client.items.removeItemBulk(arrayToItemMap(itemsUser), memberToUser); + await client.items.removeItemBulk(arrayToItemMap(itemsToUser), memberUser); +} + +async function userHasAllItems(client: Bot, trade: Trade, userId: string): Promise { + const member = await getMember(userId); + if (!member) return false; + + const tradeItems = trade.items.filter((item) => item.userId === userId); + for (const tradeItem of tradeItems) { + if (tradeItem.itemId === 'money') { + if (tradeItem.quantity > member.wallet) return false; + } else { + const item = client.items.getInventoryItem(tradeItem.itemId, member); + if (!item || item.amount < tradeItem.quantity) return false; + } + } + + return true; +} + +function isValidTrade( + client: Bot, + trade: Trade, +): { + isValid: boolean; + error?: string; +} { + if (trade.expiresAt.getTime() < Date.now()) { + return { + isValid: false, + error: 'This trade has expired.', + }; + } + + if (trade.status !== TradeStatus.PENDING && trade.status !== TradeStatus.WAITING_FOR_CONFIRMATION) { + return { + isValid: false, + error: 'This trade is not open.', + }; + } + + let totalWorthUser = 0; + let totalWorthToUser = 0; + let totalItems = 0; + + for (const tradeItem of trade.items) { + if (tradeItem.quantity < 0) { + return { + isValid: false, + error: 'You cannot add a negative amount of items.', + }; + } + + if (tradeItem.itemId === 'money') { + if (tradeItem.userId === trade.userId) { + totalWorthUser += tradeItem.quantity; + } else { + totalWorthToUser += tradeItem.quantity; + } + } else { + const item = client.items.getById(tradeItem.itemId); + if (item) { + totalItems += tradeItem.quantity; + if (tradeItem.userId === trade.userId) { + totalWorthUser += (item.sellPrice ?? item.buyPrice ?? 0) * tradeItem.quantity; + } else { + totalWorthToUser += (item.sellPrice ?? item.buyPrice ?? 0) * tradeItem.quantity; + } + } + } + } + + if (totalWorthUser > 100_000 || totalWorthToUser > 100_000) { + return { + isValid: false, + error: 'You can only trade items with a total worth of :coin: 100,000 or less.', + }; + } else if (totalWorthToUser <= 0 || totalWorthUser <= 0) { + return { + isValid: false, + error: 'You need to add at least one item or money to the trade.', + }; + } else if (totalItems <= 0) { + return { + isValid: false, + error: 'You need to add at least one item to the trade. If you want to pay someone, use the `/pay` command.', + }; + } + + const maximumDifference = totalWorthUser + totalWorthToUser > 50_000 ? 8000 : 4000; + + if (Math.max(totalWorthUser, totalWorthToUser) - Math.min(totalWorthUser, totalWorthToUser) > maximumDifference) { + return { + isValid: false, + error: `The total worth of the trade is too unbalanced. The difference between the worths can be at most :coin: ${maximumDifference}.`, + }; + } + + return { + isValid: true, + }; +} + +export default async function view(client: Bot, interaction: ChatInputCommandInteraction, member: IMember) { + const tradeId = interaction.options.getString('trade-id', true); + + const fetchedTrade = await TradeModel.findOne({ tradeId }); + if (!fetchedTrade) { + await interaction.reply({ content: 'Trade not found.' }); + return; + } else if (fetchedTrade.userId !== member.id && fetchedTrade.toUserId !== member.id) { + await interaction.reply({ content: 'You are not part of this trade.' }); + return; + } + + let trade: Trade = fetchedTrade; + trade = await updateTrade(trade); + const message = await interaction.reply({ + embeds: [await createEmbed(client, trade)], + components: createButtons(trade), + fetchReply: true, + }); + + const filter = (i: ButtonInteraction) => i.user.id === trade.userId || i.user.id === trade.toUserId; + const collector = message.createMessageComponentCollector({ + filter: async (i) => filter(i), + max: 20, + idle: 25_000, + time: 180_000, + componentType: ComponentType.Button, + }); + + collector.on('collect', async (i) => { + if (i.customId.startsWith('toUser_trade_view_') && i.user.id !== trade.toUserId) { + await i.deferUpdate(); + await i.followUp({ content: 'The other user needs to accept this trade.', ephemeral: true }); + return; + } else if (!i.customId.startsWith('toUser_trade_view_') && i.user.id !== trade.userId) { + await i.deferUpdate(); + await i.followUp({ content: 'Only the trade creator can use this feature.', ephemeral: true }); + return; + } + + if (i.customId === 'trade_view_addItem') { + if (trade.status !== TradeStatus.PENDING) { + await i.deferUpdate(); + await i.followUp({ content: 'This trade is not pending.', ephemeral: true }); + return; + } + + const modalData: Modal = { + id: `trade_view_addItem-${interaction.user.id}`, + title: 'Add Item', + fields: [ + { + id: 'item-id', + label: 'Item ID or Name', + placeholder: 'Enter the item ID or name', + style: TextInputStyle.Short, + required: true, + maxLength: 50, + }, + { + id: 'amount', + label: 'Amount (Use negative amount to remove items)', + placeholder: 'Enter the amount (default: 1)', + style: TextInputStyle.Short, + required: false, + maxLength: 6, + }, + ], + }; + + const { modal, filter } = createModal(interaction, modalData); + await i.showModal(modal); + + try { + const modalInteraction = await i.awaitModalSubmit({ filter, time: 60_000 }); + + const itemId = modalInteraction.fields.getTextInputValue('item-id'); + const amount = modalInteraction.fields.getTextInputValue('amount') ?? '1'; + let amountInt; + + try { + amountInt = amount.length === 0 ? 1 : Number.parseInt(amount, 10); + if (Number.isNaN(amountInt)) { + await modalInteraction.reply({ + content: 'The amount must be a number.', + ephemeral: true, + }); + return; + } + } catch { + await modalInteraction.reply({ + content: 'The amount must be a number.', + ephemeral: true, + }); + return; + } + + try { + const { trade: addItemTrade, error } = await addItem( + client, + trade, + trade.userId, + itemId, + amountInt, + ); + + if (error) { + await modalInteraction.reply({ + content: error, + ephemeral: true, + }); + return; + } + + trade = addItemTrade; + await modalInteraction.deferUpdate(); + } catch (error) { + await modalInteraction.reply({ + content: (error as Error).message, + ephemeral: true, + }); + return; + } + } catch (error) { + if ((error as Error).name.includes('InteractionCollectorError')) return; + throw error; + } + } else if (i.customId === 'trade_view_addItemToUser') { + if (trade.status !== TradeStatus.PENDING) { + await i.deferUpdate(); + await i.followUp({ content: 'This trade is not pending.', ephemeral: true }); + return; + } + + const modalData: Modal = { + id: `trade_view_addItemToUser-${interaction.user.id}`, + title: 'Add Item (Other user)', + fields: [ + { + id: 'item-id', + label: 'Item ID or Name', + placeholder: 'Enter the item ID or name', + style: TextInputStyle.Short, + required: true, + maxLength: 50, + }, + { + id: 'amount', + label: 'Amount (Use negative amount to remove items)', + placeholder: 'Enter the amount (default: 1)', + style: TextInputStyle.Short, + required: false, + maxLength: 6, + }, + ], + }; + + const { modal, filter } = createModal(interaction, modalData); + await i.showModal(modal); + + try { + const modalInteraction = await i.awaitModalSubmit({ filter, time: 60_000 }); + + const itemId = modalInteraction.fields.getTextInputValue('item-id'); + const amount = modalInteraction.fields.getTextInputValue('amount') ?? '1'; + let amountInt; + + try { + amountInt = amount.length === 0 ? 1 : Number.parseInt(amount, 10); + if (Number.isNaN(amountInt)) { + await modalInteraction.reply({ + content: 'The amount must be a number.', + ephemeral: true, + }); + return; + } + } catch { + await modalInteraction.reply({ + content: 'The amount must be a number.', + ephemeral: true, + }); + return; + } + + try { + const { trade: addItemTrade, error } = await addItem( + client, + trade, + trade.toUserId, + itemId, + amountInt, + ); + + if (error) { + await modalInteraction.reply({ + content: error, + ephemeral: true, + }); + return; + } + + trade = addItemTrade; + await modalInteraction.deferUpdate(); + } catch (error) { + await modalInteraction.reply({ + content: (error as Error).message, + ephemeral: true, + }); + return; + } + } catch (error) { + if ((error as Error).name.includes('InteractionCollectorError')) return; + throw error; + } + } else if (i.customId === 'trade_view_addMoney') { + if (trade.status !== TradeStatus.PENDING) { + await i.deferUpdate(); + await i.followUp({ content: 'This trade is not pending.', ephemeral: true }); + return; + } + + const modalData: Modal = { + id: `trade_view_addMoney-${interaction.user.id}`, + title: 'Add Money', + fields: [ + { + id: 'amount', + label: 'Amount (Use negative amount to remove money)', + placeholder: 'Enter the amount of money', + style: TextInputStyle.Short, + required: true, + maxLength: 6, + }, + ], + }; + + const { modal, filter } = createModal(interaction, modalData); + await i.showModal(modal); + + try { + const modalInteraction = await i.awaitModalSubmit({ filter, time: 60_000 }); + const amount = modalInteraction.fields.getTextInputValue('amount'); + + try { + if (Number.isNaN(Number.parseInt(amount, 10))) { + await modalInteraction.reply({ + content: 'The amount must be a number.', + ephemeral: true, + }); + return; + } + } catch { + await modalInteraction.reply({ + content: 'The amount must be a number.', + ephemeral: true, + }); + return; + } + + try { + const { trade: addItemTrade, error } = await addItem( + client, + trade, + trade.userId, + 'money', + Number.parseInt(amount, 10), + ); + + if (error) { + await modalInteraction.reply({ + content: error, + ephemeral: true, + }); + return; + } + + trade = addItemTrade; + await modalInteraction.deferUpdate(); + } catch (error) { + await modalInteraction.reply({ + content: (error as Error).message, + ephemeral: true, + }); + return; + } + } catch (error) { + if ((error as Error).name.includes('InteractionCollectorError')) return; + throw error; + } + } else if (i.customId === 'trade_view_addMoneyToUser') { + if (trade.status !== TradeStatus.PENDING) { + await i.deferUpdate(); + await i.followUp({ content: 'This trade is not pending.', ephemeral: true }); + return; + } + + const modalData: Modal = { + id: `trade_view_addMoneyToUser-${interaction.user.id}`, + title: 'Add Money (Other user)', + fields: [ + { + id: 'amount', + label: 'Amount (Use negative amount to remove money)', + placeholder: 'Enter the amount of money', + style: TextInputStyle.Short, + required: true, + maxLength: 6, + }, + ], + }; + + const { modal, filter } = createModal(interaction, modalData); + await i.showModal(modal); + + try { + const modalInteraction = await i.awaitModalSubmit({ filter, time: 60_000 }); + const amount = modalInteraction.fields.getTextInputValue('amount'); + + try { + if (Number.isNaN(Number.parseInt(amount, 10))) { + await modalInteraction.reply({ + content: 'The amount must be a number.', + ephemeral: true, + }); + return; + } + } catch { + await modalInteraction.reply({ + content: 'The amount must be a number.', + ephemeral: true, + }); + return; + } + + try { + const { trade: addItemTrade, error } = await addItem( + client, + trade, + trade.toUserId, + 'money', + Number.parseInt(amount, 10), + ); + + if (error) { + await modalInteraction.reply({ + content: error, + ephemeral: true, + }); + return; + } + + trade = addItemTrade; + await modalInteraction.deferUpdate(); + } catch (error) { + await modalInteraction.reply({ + content: (error as Error).message, + ephemeral: true, + }); + return; + } + } catch (error) { + if ((error as Error).name.includes('InteractionCollectorError')) return; + throw error; + } + } else if (i.customId === 'trade_view_confirmTrade') { + await i.deferUpdate(); + trade = await updateTrade(trade); + if (trade.status !== TradeStatus.PENDING) { + await i.followUp({ content: 'This trade is not pending.', ephemeral: true }); + return; + } + + const { isValid, error } = isValidTrade(client, trade); + if (!isValid) { + await i.followUp({ content: error, ephemeral: true }); + return; + } + + const newTrade = await TradeModel.findOneAndUpdate( + { tradeId: trade.tradeId, status: TradeStatus.PENDING }, + { $set: { status: TradeStatus.WAITING_FOR_CONFIRMATION } }, + { new: true }, + ); + + if (!newTrade) { + await i.followUp({ content: 'Failed to confirm the trade.', ephemeral: true }); + return; + } + + trade = newTrade; + await i.followUp({ + content: 'Trade confirmed. The other user has to accept the trade now.', + }); + } else if (i.customId === 'trade_view_deleteTrade') { + await i.deferUpdate(); + if (trade.status !== TradeStatus.PENDING && trade.status !== TradeStatus.WAITING_FOR_CONFIRMATION) { + await i.followUp({ content: 'This trade is not open.', ephemeral: true }); + return; + } + + collector.stop(); + await TradeModel.deleteOne({ tradeId: trade.tradeId }); + await interaction.editReply({ + content: 'The trade has been deleted.', + components: [], + }); + return; + } else if (i.customId === 'toUser_trade_view_accept') { + await i.deferUpdate(); + if (trade.status !== TradeStatus.WAITING_FOR_CONFIRMATION) { + await i.followUp({ content: 'This trade is not waiting for confirmation.', ephemeral: true }); + return; + } + + const { isValid, error } = isValidTrade(client, trade); + if (!isValid) { + await i.followUp({ content: error, ephemeral: true }); + return; + } + + const tradeUserHasItems = await userHasAllItems(client, trade, trade.userId); + if (!tradeUserHasItems) { + await i.followUp({ + content: `<@${trade.toUserId}> doesn't have the required items to trade in their inventory.`, + ephemeral: true, + }); + return; + } + + const tradeToUserHasItems = await userHasAllItems(client, trade, trade.toUserId); + if (!tradeToUserHasItems) { + await i.followUp({ + content: "You don't have the required items to trade in your inventory.", + ephemeral: true, + }); + return; + } + + collector.stop(); + const newTrade = await TradeModel.findOneAndUpdate( + { tradeId: trade.tradeId, status: TradeStatus.WAITING_FOR_CONFIRMATION }, + { $set: { status: TradeStatus.COMPLETED } }, + { new: true }, + ); + + if (!newTrade) { + await i.followUp({ content: 'Something went wrong with accepting this trade.', ephemeral: true }); + return; + } + + trade = newTrade; + await i.followUp({ + content: 'This trade has been accepted. The items have been traded.', + }); + + await giveItems(client, trade); + } else if (i.customId === 'toUser_trade_view_cancel') { + await i.deferUpdate(); + if (trade.status !== TradeStatus.WAITING_FOR_CONFIRMATION) { + await i.followUp({ content: 'This trade is not waiting for confirmation.', ephemeral: true }); + return; + } + + collector.stop(); + const newTrade = await TradeModel.findOneAndUpdate( + { tradeId: trade.tradeId, status: TradeStatus.WAITING_FOR_CONFIRMATION }, + { $set: { status: TradeStatus.DENIED } }, + { new: true }, + ); + + if (!newTrade) { + await i.followUp({ content: 'Something went wrong with canceling this trade.', ephemeral: true }); + return; + } + + trade = newTrade; + await i.followUp({ + content: 'This trade has been canceled.', + }); + } + + await interaction.editReply({ + embeds: [await createEmbed(client, trade)], + components: createButtons(trade), + }); + }); + + collector.on('end', async () => { + await interaction.editReply({ + components: createButtons(trade, true), + }); + }); +} diff --git a/apps/bot/src/events/interactionCreate.ts b/apps/bot/src/events/interactionCreate.ts index 5e58ee3f..8a27ad23 100644 --- a/apps/bot/src/events/interactionCreate.ts +++ b/apps/bot/src/events/interactionCreate.ts @@ -51,7 +51,7 @@ export default { } } - if ((command.data.premium ?? 0) > member.premium) { + if (process.env.NODE_ENV === 'production' && (command.data.premium ?? 0) > member.premium) { if (command.data.premium === 2) { await interaction.reply({ content: `This command is only for Coinz Pro subscribers. To gain access to this command, consider [**upgrading**](<${client.config.website}/premium>).`, diff --git a/apps/bot/src/lib/cooldown.ts b/apps/bot/src/lib/cooldown.ts index 0abc0d78..eb234e8c 100644 --- a/apps/bot/src/lib/cooldown.ts +++ b/apps/bot/src/lib/cooldown.ts @@ -1,36 +1,27 @@ import process from 'node:process'; -import Redis from 'ioredis'; +import { Redis } from '@upstash/redis'; export default class Cooldown { - public readonly redis: Redis | undefined; + public readonly redis: Redis; public constructor() { - if (process.env.NODE_ENV === 'production') { - this.redis = new Redis({ - port: Number(process.env.REDIS_PORT), - host: process.env.REDIS_HOST, - maxRetriesPerRequest: 3, - }); - } + this.redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, + }); } public async setCooldown(userId: string, commandName: string, cooldown: number): Promise { - if (!this.redis) return; - await this.redis.set( - `cooldown:${userId}:${commandName}`, - Math.floor(Date.now() / 1_000) + cooldown, - 'EX', - cooldown, - ); + await this.redis.set(`cooldown:${userId}:${commandName}`, Math.floor(Date.now() / 1_000) + cooldown, { + ex: cooldown, + }); } public async getCooldown(userId: string, commandName: string): Promise { - if (!this.redis) return null; return this.redis.get(`cooldown:${userId}:${commandName}`); } public async deleteCooldown(userId: string, commandName: string): Promise { - if (!this.redis) return; await this.redis.del(`cooldown:${userId}:${commandName}`); } } diff --git a/apps/bot/src/lib/shop.ts b/apps/bot/src/lib/shop.ts index d2255570..05362d1d 100644 --- a/apps/bot/src/lib/shop.ts +++ b/apps/bot/src/lib/shop.ts @@ -103,6 +103,41 @@ export default class Shop { return true; } + public async removeItemBulk(items: { [key: string]: number }, member: IMember): Promise { + const bulkOps = []; + + for (const [id, amount] of Object.entries(items)) { + if (!this.hasInInventory(id, member)) return false; + + const item = this.getInventoryItem(id, member); + if (item === undefined) return false; + + if (item.amount <= amount) { + // Remove item from inventory + bulkOps.push({ + updateOne: { + filter: { id: member.id, 'inventory.itemId': id }, + update: { $pull: { inventory: { itemId: id } } }, + }, + }); + } else { + // Decrement amount for existing items + bulkOps.push({ + updateOne: { + filter: { id: member.id, 'inventory.itemId': id }, + update: { $inc: { 'inventory.$.amount': -amount } }, + }, + }); + } + } + + if (bulkOps.length > 0) { + await Member.bulkWrite(bulkOps); + } + + return true; + } + public async checkForDuplicates(member: IMember): Promise { const inventory = member.inventory; diff --git a/apps/bot/src/models/botStats.ts b/apps/bot/src/models/botStats.ts index c727bf61..22d8ad20 100644 --- a/apps/bot/src/models/botStats.ts +++ b/apps/bot/src/models/botStats.ts @@ -1,21 +1,27 @@ import { Schema, model } from 'mongoose'; -export type IBotStats = { +type ICluster = { + id: number; guilds: number; users: number; - shards: number; - commands: number; - investments: number; + totalShards: number; +}; + +export type IBotStats = { + clusters: ICluster[]; updatedAt: Date; + createdAt: Date; }; +const ClusterSchema = new Schema({ + id: { type: Number, required: true }, + guilds: { type: Number, required: true }, + users: { type: Number, required: true }, +}); + export const botStatsSchema = new Schema( { - guilds: { type: Number, required: true }, - users: { type: Number, required: true }, - shards: { type: Number, required: true }, - commands: { type: Number, required: true }, - investments: { type: Number, required: true }, + clusters: [{ type: ClusterSchema }], }, { timestamps: true }, ); diff --git a/apps/bot/src/models/trade.ts b/apps/bot/src/models/trade.ts new file mode 100644 index 00000000..41f913a7 --- /dev/null +++ b/apps/bot/src/models/trade.ts @@ -0,0 +1,49 @@ +import { Schema, model } from 'mongoose'; + +export enum TradeStatus { + COMPLETED = 'COMPLETED', + DENIED = 'DENIED', + EXPIRED = 'EXPIRED', + PENDING = 'PENDING', + WAITING_FOR_CONFIRMATION = 'WAITING_FOR_CONFIRMATION', +} + +export type TradeItem = { + userId: string; + itemId: string; + quantity: number; +}; + +export type Trade = { + tradeId: string; + userId: string; + toUserId: string; + items: TradeItem[]; + status: TradeStatus; + expiresAt: Date; + createdAt: Date; + updatedAt: Date; +}; + +const tradeItemSchema = new Schema({ + userId: { type: String, required: true }, + itemId: { type: String, required: true }, + quantity: { type: Number, required: true }, +}); + +export const tradeSchema = new Schema( + { + tradeId: { type: String, required: true, unique: true, index: true }, + userId: { type: String, required: true, index: true }, + toUserId: { type: String, required: true, index: true }, + items: { type: [tradeItemSchema], default: [] }, + status: { type: String, default: TradeStatus.PENDING, enum: Object.values(TradeStatus) }, + expiresAt: { type: Date, required: true }, + }, + { + collection: 'Trade', + timestamps: true, + }, +); + +export default model('Trade', tradeSchema); diff --git a/apps/bot/src/utils/index.ts b/apps/bot/src/utils/index.ts index df441b14..9a22f3f0 100644 --- a/apps/bot/src/utils/index.ts +++ b/apps/bot/src/utils/index.ts @@ -6,6 +6,7 @@ import type { MentionableSelectMenuInteraction, RoleSelectMenuInteraction, StringSelectMenuInteraction, + User, UserSelectMenuInteraction, } from 'discord.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; @@ -14,6 +15,7 @@ import type Bot from '../domain/Bot'; import type { Loot } from '../lib/types'; import Business from '../models/business'; import type { IMember } from '../models/member'; +import TradeModel, { TradeStatus, type Trade } from '../models/trade'; import { removeBetMoney } from './money'; const countries = new Map(Object.entries(countriesData)); @@ -273,3 +275,24 @@ export async function getBusiness(memberId: string) { export function generateRandomString(length: number = 6): string { return Array.from({ length }, () => Math.random().toString(36)[2]).join(''); } + +export async function getUser(client: Bot, userId: string): Promise { + try { + return client.users.cache.get(userId) || (await client.users.fetch(userId)); + } catch { + return null; + } +} + +export async function updateTrade(trade: Trade): Promise { + if ( + (trade.status === TradeStatus.PENDING || trade.status === TradeStatus.WAITING_FOR_CONFIRMATION) && + trade.expiresAt.getTime() < Date.now() + ) { + const newTrade = await TradeModel.findOneAndUpdate({ tradeId: trade.tradeId }, { status: TradeStatus.EXPIRED }); + if (!newTrade) return trade; + return newTrade; + } + + return trade; +} diff --git a/apps/crons/src/crons/index.ts b/apps/crons/src/crons/index.ts index d62d064a..d743133e 100644 --- a/apps/crons/src/crons/index.ts +++ b/apps/crons/src/crons/index.ts @@ -1,13 +1,9 @@ import process from 'node:process'; import axios from 'axios'; import { schedule } from 'node-cron'; -import botListings from '../data/bot-listings.json'; import investments from '../data/investments.json'; -import ApiController from '../lib/bot-listings'; import crypto from '../lib/crypto'; import { calculatePercentageChange, getChunks, getExpireTime, isMarketOpen } from '../lib/stocks'; -import type { BotListing } from '../lib/types'; -import BotStats from '../models/BotStats'; import Investment from '../models/Investment'; // Run every 10 minutes from Monday to Friday @@ -105,18 +101,3 @@ schedule( timezone: 'America/New_York', }, ); - -// Run every 2 hours -schedule('0 */2 * * *', async () => { - const stats = await BotStats.findOne({}, {}, { sort: { updatedAt: -1 } }); - if (stats === null) return; - - const sendApi = new ApiController(stats.guilds, stats.shards, stats.users); - - for (const botListing of botListings as unknown as BotListing[]) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - sendApi.sendApiCall(botListing).then((response) => { - if (!response) console.log(`API call for ${botListing.name} failed`); - }); - } -}); diff --git a/apps/crons/src/data/bot-listings.json b/apps/crons/src/data/bot-listings.json deleted file mode 100644 index 27cfdbaf..00000000 --- a/apps/crons/src/data/bot-listings.json +++ /dev/null @@ -1,76 +0,0 @@ -[ - { - "name": "TOP_GG", - "api": "https://top.gg/api/bots/$ID/stats", - "body": { - "server_count": "$SERVER_COUNT", - "shard_count": "$SHARD_COUNT" - } - }, - { - "name": "BOTLIST_ME", - "api": "https://api.botlist.me/api/v1/bots/$ID/stats", - "body": { - "server_count": "$SERVER_COUNT", - "shard_count": "$SHARD_COUNT" - } - }, - { - "name": "DISCORD_BOT_LIST", - "api": "https://discordbotlist.com/api/v1/bots/$ID/stats", - "body": { - "guilds": "$SERVER_COUNT", - "users": "$MEMBER_COUNT" - } - }, - { - "name": "DISCORD_BOTS_GG", - "api": "https://discord.bots.gg/api/v1/bots/$ID/stats", - "body": { - "guildCount": "$SERVER_COUNT", - "shardCount": "$SHARD_COUNT" - } - }, - { - "name": "DISCORD_EXTREME_LIST", - "api": "https://api.discordextremelist.xyz/v2/bot/$ID/stats", - "body": { - "guildCount": "$SERVER_COUNT", - "shardCount": "$SHARD_COUNT" - } - }, - { - "name": "DISCORDLIST", - "api": "https://api.discordlist.gg/v0/bots/$ID/stats", - "body": { - "server_count": "$SERVER_COUNT" - }, - "headers": { - "Authorization": "Bearer $TOKEN" - } - }, - { - "name": "INFINITY_BOTS", - "api": "https://spider.infinitybots.gg/bots/stats", - "body": { - "servers": "$SERVER_COUNT", - "shards": "$SHARD_COUNT", - "users": "$MEMBER_COUNT" - } - }, - { - "name": "DISFORGE", - "api": "https://disforge.com/api/botstats/$ID", - "body": { - "servers": "$SERVER_COUNT" - } - }, - { - "name": "VOIDBOTS", - "api": "https://api.voidbots.net/bot/stats/$ID", - "body": { - "server_count": "$SERVER_COUNT", - "shard_count": "$SHARD_COUNT" - } - } -] diff --git a/apps/crons/src/lib/bot-listings.ts b/apps/crons/src/lib/bot-listings.ts deleted file mode 100644 index c8e6e061..00000000 --- a/apps/crons/src/lib/bot-listings.ts +++ /dev/null @@ -1,112 +0,0 @@ -import process from 'node:process'; -import type { BotListing } from './types'; - -const tokens = { - TOP_GG_TOKEN: process.env.TOP_GG_TOKEN!, - DISCORD_BOT_LIST_TOKEN: process.env.DISCORD_BOT_LIST_TOKEN!, - BOTLIST_ME_TOKEN: process.env.BOTLIST_ME_TOKEN!, - DISCORD_BOTS_GG_TOKEN: process.env.DISCORD_BOTS_GG_TOKEN!, - DISCORD_EXTREME_LIST_TOKEN: process.env.DISCORD_EXTREME_LIST_TOKEN!, - DISCORDLIST_TOKEN: process.env.DISCORDLIST_TOKEN!, - INFINITY_BOTS_TOKEN: process.env.INFINITY_BOTS_TOKEN!, - DISFORGE_TOKEN: process.env.DISFORGE_TOKEN!, - VOIDBOTS_TOKEN: process.env.VOIDBOTS_TOKEN!, -}; - -export default class ApiController { - private readonly variables: Map = new Map(); - - public constructor(serverCount: number, shardCount: number, memberCount: number) { - this.variables.set('$SERVER_COUNT', serverCount); - this.variables.set('$SHARD_COUNT', shardCount); - this.variables.set('$MEMBER_COUNT', memberCount); - } - - public async sendApiCall(listing: BotListing): Promise { - try { - if (listing.api.length <= 0) return false; - const API_URL = listing.api.replace('$ID', process.env.BOT_ID!); - - if (!listing.body) listing.body = {}; - if (!listing.headers) { - listing.headers = { - Authorization: '$TOKEN', - }; - } - - const headers = this.convertToHeaders(this.getFields(listing.name, listing.headers)); - const body = this.getFields(listing.name, listing.body); - - const response = await fetch(API_URL, { - method: listing.method || 'POST', - headers: headers, - body: JSON.stringify(body), - }); - - if (response.status !== 200 && response.status !== 204) { - try { - console.log('ERROR:', listing.name, response.status, await response.json()); - } catch { - console.log(`ERROR: ${listing.name} ${response.status} BODY FAILED...`); - } - } - - return response.status === 200 || response.status === 204; - } catch (error) { - console.log(error); - return false; - } - } - - private getFields(name: string, fields: { [key: string]: number | string }): { [key: string]: number | string } { - const keys = Object.keys(fields); - for (const key of keys) { - const value = fields[key]; - if (value === undefined) continue; - - if (value.toString().includes('$TOKEN')) { - fields[key] = value.toString().replace('$TOKEN', this.getToken(name)); - } else { - fields[key] = this.replaceVariable(value.toString()); - } - } - - return fields; - } - - private convertToHeaders(headers: { [key: string]: number | string }): Headers { - const newHeaders = new Headers(); - for (const [key, value] of Object.entries(headers)) { - newHeaders.set(key, value.toString()); - } - - newHeaders.set('Content-Type', 'application/json'); - return newHeaders; - } - - private replaceVariable(variable: string): number | string { - let newVariable = variable; - for (const [key, value] of this.variables.entries()) { - newVariable = variable.replace(key, value.toString()); - if (variable !== newVariable) { - try { - return Number.parseInt(newVariable, 10); - } catch { - return newVariable; - } - } - } - - return newVariable; - } - - private getToken(name: string): string { - const tokenKeys = Object.keys(tokens); - const validOptions = tokenKeys.filter((tk) => tk === `${name}_TOKEN`); - if (validOptions.length <= 0) { - return ''; - } - - return (tokens as { [key: string]: string })[validOptions[0]!]!; - } -} diff --git a/apps/crons/src/lib/types.ts b/apps/crons/src/lib/types.ts deleted file mode 100644 index cc7a001b..00000000 --- a/apps/crons/src/lib/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type BotListing = { - name: string; - api: string; - method?: string; - body?: { - [key: string]: string; - }; - headers?: { - [key: string]: string; - }; -}; diff --git a/apps/crons/src/models/BotStats.ts b/apps/crons/src/models/BotStats.ts deleted file mode 100644 index c727bf61..00000000 --- a/apps/crons/src/models/BotStats.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Schema, model } from 'mongoose'; - -export type IBotStats = { - guilds: number; - users: number; - shards: number; - commands: number; - investments: number; - updatedAt: Date; -}; - -export const botStatsSchema = new Schema( - { - guilds: { type: Number, required: true }, - users: { type: Number, required: true }, - shards: { type: Number, required: true }, - commands: { type: Number, required: true }, - investments: { type: Number, required: true }, - }, - { timestamps: true }, -); - -export default model('BotStats', botStatsSchema, 'BotStats'); diff --git a/apps/web/.env.example b/apps/web/.env.example index 4d8c3704..894fbd6d 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -11,3 +11,6 @@ WEBHOOK_URL="" NODE_ENV="" NEXT_PUBLIC_BASE_URL="" + +API_TOKEN="" +API_URL="" \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 08d3382e..5be5b11f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,49 +14,51 @@ "db:studio": "prisma studio" }, "dependencies": { - "@auth/prisma-adapter": "^1.6.0", + "@auth/prisma-adapter": "^2.0.0", "@hookform/resolvers": "^3.3.4", "@lemonsqueezy/lemonsqueezy.js": "^2.2.0", - "@prisma/client": "^5.12.1", + "@prisma/client": "^5.13.0", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", - "@t3-oss/env-nextjs": "^0.9.2", - "@tanstack/react-query": "^5.29.0", + "@t3-oss/env-nextjs": "^0.10.1", + "@tanstack/react-query": "^5.32.0", "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", + "clsx": "^2.1.1", "fuse.js": "^7.0.0", "html-react-parser": "^5.1.10", - "lucide-react": "^0.367.0", - "next": "^14.2.1", + "lucide-react": "^0.372.0", + "next": "^14.2.3", "next-auth": "^5.0.0-beta.16", "next-plausible": "^3.12.0", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-hook-form": "^7.51.2", + "next-themes": "^0.3.0", + "react": "18.3.0", + "react-dom": "18.3.0", + "react-hook-form": "^7.51.3", "react-markdown": "^9.0.1", "sharp": "^0.33.3", - "tailwind-merge": "^2.2.2", + "sonner": "^1.4.41", + "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", - "zod": "^3.22.4" + "zod": "^3.23.3" }, "devDependencies": { "@repo/config": "*", "@repo/eslint-config": "*", "@types/node": "^20.12.7", - "@types/react": "^18.2.75", - "@types/react-dom": "^18.2.24", - "@typescript-eslint/eslint-plugin": "^7.6.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.7.1", "autoprefixer": "^10.4.19", "eslint": "^8.57.0", - "eslint-config-next": "^14.1.4", + "eslint-config-next": "^14.2.3", "postcss": "^8.4.38", "prettier": "^3.2.5", - "prisma": "^5.12.1", + "prisma": "^5.13.0", "tailwindcss": "^3.4.3", - "typescript": "^5.4.4" + "typescript": "^5.4.5" }, "prettier": "@repo/config/prettier/next.js", "ct3aMetadata": { diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index edb7a0ce..56571372 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -53,13 +53,18 @@ model Investment { @@unique([ticker, type]) } -model BotStats { - id String @id @default(auto()) @map("_id") @db.ObjectId +type Cluster { + id Int guilds Int users Int - commands Int - investments Int - updatedAt DateTime @updatedAt + totalShards Int +} + +model BotStats { + id String @id @default(auto()) @map("_id") @db.ObjectId + clusters Cluster[] + updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) } model Commands { @@ -86,10 +91,65 @@ model Item { duration Int? } +type InvestmentData { + ticker String + amount String + buyPrice Int +} + model members { - oid String @id @default(auto()) @map("_id") @db.ObjectId - userId String @unique @map("id") - premium Int + oid String @id @default(auto()) @map("_id") @db.ObjectId + userId String @unique @map("id") + wallet Int @default(0) + bank Int @default(0) + investments InvestmentData[] + premium Int @default(0) +} + +type Games { + won Int @default(0) + lost Int @default(0) + tied Int @default(0) + moneySpent Int @default(0) + moneyEarned Int @default(0) + moneyLost Int @default(0) +} + +type DailyActivity { + startDay DateTime @default(now()) + totalCommands Int @default(0) +} + +type InvestmentStats { + amountOfTimesBought Int @default(0) + amountOfTimesSold Int @default(0) + totalBuyPrice Int @default(0) +} + +model userstats { + oid String @id @default(auto()) @map("_id") @db.ObjectId + userId String @unique @map("id") + totalEarned Int @default(0) + totalSpend Int @default(0) + itemsBought Int @default(0) + itemsSold Int @default(0) + games Games + treesCutDown Int @default(0) + totalTreeHeight Int @default(0) + luckyWheelSpins Int @default(0) + timesWorked Int @default(0) + fishCaught Int @default(0) + animalsKilled Int @default(0) + timesRobbed Int @default(0) + timesPlotPlanted Int @default(0) + timesPlotHarvested Int @default(0) + timesPlotWatered Int @default(0) + timesPlotFertilized Int @default(0) + moneyEarnedOnBusinesses Int @default(0) + dailyActivity DailyActivity + moneyDonated Int @default(0) + moneyReceived Int @default(0) + investments InvestmentStats } model WebhookEvents { diff --git a/apps/web/src/app/(main)/commands/page.tsx b/apps/web/src/app/(main)/commands/page.tsx index 080b079f..aed858f2 100644 --- a/apps/web/src/app/(main)/commands/page.tsx +++ b/apps/web/src/app/(main)/commands/page.tsx @@ -3,7 +3,7 @@ import PageTitle from '@/components/page-title'; import SectionWrapper from '@/components/section-wrapper'; import { db } from '@/server/db'; -export const revalidate = 3600; +export const revalidate = 86400; export default async function CommandsPage() { const commands = await db.commands.findMany({}); diff --git a/apps/web/src/app/(main)/investments/investments-section.tsx b/apps/web/src/app/(main)/investments/investments-section.tsx index 3a52e953..d555eb8e 100644 --- a/apps/web/src/app/(main)/investments/investments-section.tsx +++ b/apps/web/src/app/(main)/investments/investments-section.tsx @@ -1,13 +1,20 @@ 'use client'; import { useState } from 'react'; -import { Investment } from '@prisma/client'; +import { Investment, InvestmentData } from '@prisma/client'; import Image from 'next/image'; import { Badge } from '@/components/ui/badge'; import CategoryCard from '@/components/category-card'; import Search from '@/components/Search'; import { cn } from '@/lib/utils'; -import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react'; +import { ArrowUpDownIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import Link from 'next/link'; +import { useQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { useRouter } from 'next/navigation'; const categories: { [key: string]: string } = { all: 'All', @@ -15,7 +22,7 @@ const categories: { [key: string]: string } = { Crypto: 'Crypto', }; -function getInvestments(list: Investment[], category: string, searchTerm: string) { +function getInvestments(list: Investment[], category: string, searchTerm: string): Investment[] { if (searchTerm) { return list.filter( (i) => @@ -29,10 +36,38 @@ function getInvestments(list: Investment[], category: string, searchTerm: string } } -export default function InvestmentsSection({ data }: { data: Investment[] }) { - const [category, setCategory] = useState(Object.keys(categories)[0]); +export default function InvestmentsSection({ + investments, + data, + userId, + hasPremium, +}: { + investments: Investment[]; + data: InvestmentData[]; + userId?: string; + hasPremium: boolean; +}) { + const [category, setCategory] = useState(Object.keys(categories)[0]!); const [searchTerm, setSearchTerm] = useState(''); + const { data: investmentData } = useQuery({ + queryKey: ['investments'], + queryFn: async () => { + const res = await fetch('/api/investment'); + + if (!res.ok) { + toast.error('Failed to update investments'); + return investments as Investment[]; + } + + return res.json() as Promise; + }, + refetchInterval: 60_000, + initialData: investments, + initialDataUpdatedAt: Date.now(), + staleTime: 60_000, + }); + return (
@@ -63,63 +98,351 @@ export default function InvestmentsSection({ data }: { data: Investment[] }) { gridTemplateColumns: 'repeat(auto-fit, minmax(325px, 1fr))', }} > - {getInvestments(data, category!, searchTerm).map((dataObj: Investment) => ( - + {getInvestments(investmentData, category, searchTerm).map((dataObj: Investment) => ( + ))}
); } -function InvestmentCard({ investment }: { investment: Investment }) { +function InvestmentCard({ + investment, + data, + userId, + hasPremium, +}: { + investment: Investment; + data: InvestmentData[]; + userId?: string; + hasPremium: boolean; +}) { + const router = useRouter(); + const [tradingMode, setTradingMode] = useState<'amount' | 'price'>('amount'); + const [inputValue, setInputValue] = useState(0); + const [amount, setAmount] = useState(0); + const [price, setPrice] = useState(0); + const [personalInvestment] = useState(data.find((i) => i.ticker === investment.ticker)); + const [personalChanged] = useState( + personalInvestment && + calculateProfitOrLoss(personalInvestment.amount, personalInvestment.buyPrice, investment.price), + ); + + function roundNumber(num: number, dec: number): number { + const factor = 10 ** dec; + const roundedNumber = Math.round((num + Number.EPSILON) * factor) / factor; + return parseFloat(roundedNumber.toFixed(dec)); + } + + function calculateProfitOrLoss(amount: string, buyPrice: number, currentPrice: string): string { + const totalCurrentValue = parseFloat(amount) * parseFloat(currentPrice); + const profitOrLoss = totalCurrentValue - buyPrice; + const profitOrLossPercentage = (profitOrLoss / buyPrice) * 100; + return roundNumber(profitOrLossPercentage, 2) + '%'; + } + + async function buyOrSellInvestment(type: 'BUY' | 'SELL') { + if (!userId) { + toast.error(`You need to be logged in to ${type.toLowerCase()} investments.`); + return; + } + + const res = await fetch('/api/investment', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId: userId, + type, + ticker: investment.ticker, + amount, + }), + }); + + const data = await res.json(); + if (!res.ok) { + toast.error(data.error); + } else { + toast.success(`${data.message} Please refresh the page to see the changes.`); + router.refresh(); + } + } + return ( -
-
- Investment company logo + + +
+ Investment company logo -
-

{investment.fullName}

-
-

{investment.ticker}

- - {investment.type} - +
+

{investment.fullName}

+
+

{investment.ticker}

+ + {investment.type} + +
-
-
-
- Coinz Currency -

{investment.price}

+
+
+ Coinz Currency +

{investment.price}

+
+
+ {parseFloat(investment.changed) >= 0 ? ( + + ) : ( + + )} +

= 0 ? 'text-green-600' : 'text-red-600', + 'font-medium text-lg', + )} + > + {investment.changed}% +

+
-
- {parseFloat(investment.changed) >= 0 ? ( - + + + + + Investment company logo + +
+

{investment.fullName}

+
+

{investment.ticker}

+ + {investment.type} + +
+
+
+
+
+
+
+ + Current Price + +
+ Coinz Currency +

{investment.price}

+
+
+ {parseFloat(investment.changed) >= 0 ? ( + + ) : ( + + )} +

= 0 ? 'text-green-600' : 'text-red-600', + 'font-medium text-lg', + )} + > + {investment.changed}% +

+
+
+ +
+ + Your Holdings + +

+ {personalInvestment ? roundNumber(parseFloat(personalInvestment.amount), 3) : '0'}x{' '} + {investment.type === 'Stock' ? 'Shares' : 'Coins'} +

+
+ Coinz Currency +

+ {personalInvestment + ? roundNumber( + parseFloat(personalInvestment.amount) * parseFloat(investment.price), + 3, + ) + : '0'} +

+
+ {personalChanged && ( +
+ {parseFloat(personalChanged) >= 0 ? ( + + ) : ( + + )} +

= 0 ? 'text-green-600' : 'text-red-600', + 'font-medium text-lg', + )} + > + {personalChanged} +

+
+ )} +
+
+ + {hasPremium ? ( +
+
+ + Buy or sell {amount} {investment.ticker} for + + Coinz Currency + {price} +
+ +
+ { + let value = parseInt(e.target.value); + if (Number.isNaN(value) || value < 0) { + value = 0; + } + + const investmentPrice = parseFloat(investment.price); + if (tradingMode === 'amount' && value * investmentPrice > 1_000_000) { + value = Math.floor(1_000_000 / investmentPrice); + } else if (tradingMode === 'price' && value > 1_000_000) { + value = 1_000_000; + } + + setInputValue(value); + if (tradingMode === 'amount') { + setAmount(value); + setPrice(Math.round(value * investmentPrice)); + } else { + setPrice(value); + setAmount(roundNumber(value / investmentPrice, 3)); + } + }} + /> + +
+ + {tradingMode === 'amount' ? ( +
+ Coinz Currency +

{price}

+
+ ) : ( +
+

{amount}

+

{investment.type === 'Stock' ? 'Shares' : 'Coins'}

+
+ )} +
+
+ +
+ + +
+ {investment.type === 'Stock' && ( + + + + )} +
) : ( - +
+

+ You need Coinz Pro to buy or sell investments. +

+ + + +
)} -

= 0 ? 'text-green-600' : 'text-red-600', - 'font-medium text-lg', - )} - > - {investment.changed}% -

-
-
+ +
); } diff --git a/apps/web/src/app/(main)/investments/page.tsx b/apps/web/src/app/(main)/investments/page.tsx index 08ed6dcf..f5f7491f 100644 --- a/apps/web/src/app/(main)/investments/page.tsx +++ b/apps/web/src/app/(main)/investments/page.tsx @@ -1,11 +1,27 @@ import InvestmentsSection from './investments-section'; import { db } from '@/server/db'; import PageTitle from '@/components/page-title'; - -export const revalidate = 60; +import { auth } from '@/server/auth'; +import { InvestmentData } from '@prisma/client'; export default async function InvestmentsPage() { const investments = await db.investment.findMany({}); + const session = await auth(); + + let data: InvestmentData[] = []; + let hasPremium = false; + if (session) { + const member = await db.members.findFirst({ + where: { + userId: session.user.discordId, + }, + }); + + if (member) { + data = member.investments; + hasPremium = member.premium >= 2; + } + } return (
@@ -13,7 +29,12 @@ export default async function InvestmentsPage() { title="Investments" description="Get an overview of all investments in Coinz and their current value. Investments track real-time prices, this makes investing in Coinz a nice demo for real-life investing." /> - +
); } diff --git a/apps/web/src/app/(main)/items/page.tsx b/apps/web/src/app/(main)/items/page.tsx index d8d693a6..03ffacdf 100644 --- a/apps/web/src/app/(main)/items/page.tsx +++ b/apps/web/src/app/(main)/items/page.tsx @@ -2,7 +2,7 @@ import ItemsSection from './items-section'; import PageTitle from '@/components/page-title'; import { db } from '@/server/db'; -export const revalidate = 3600; +export const revalidate = 86400; export default async function ItemsPage() { const items = await db.item.findMany({}); diff --git a/apps/web/src/app/(main)/page.tsx b/apps/web/src/app/(main)/page.tsx index 806104fc..6881df6d 100644 --- a/apps/web/src/app/(main)/page.tsx +++ b/apps/web/src/app/(main)/page.tsx @@ -11,7 +11,7 @@ import GamesIcon from '@/components/icons/games'; import FarmingIcon from '@/components/icons/farming'; import { db } from '@/server/db'; -export const revalidate = 3600; +export const revalidate = 14400; export default async function Home() { const botStats = await db.botStats.findFirst({ @@ -20,7 +20,12 @@ export default async function Home() { }, }); - const users = formatNumber(botStats?.users || 0); + const totalGuilds = botStats?.clusters.reduce((acc, cluster) => acc + cluster.guilds, 0) || 0; + const totalUsers = botStats?.clusters.reduce((acc, cluster) => acc + cluster.users, 0) || 0; + const totalCommands = await db.commands.count(); + const totalInvestments = await db.investment.count(); + + const users = formatNumber(totalUsers); return (
- +
diff --git a/apps/web/src/app/(main)/status/page.tsx b/apps/web/src/app/(main)/status/page.tsx new file mode 100644 index 00000000..3cd18feb --- /dev/null +++ b/apps/web/src/app/(main)/status/page.tsx @@ -0,0 +1,67 @@ +import PageTitle from '@/components/page-title'; +import { formatNumber } from '@/lib/utils'; +import { db } from '@/server/db'; + +export const revalidate = 14400; + +function getFormattedNumber(number: number) { + const formatted = formatNumber(number); + return `${formatted.value}${formatted.suffix}`; +} + +export default async function StatusPage() { + const botStats = await db.botStats.findFirst({ + orderBy: { + updatedAt: 'desc', + }, + }); + + return ( +
+ + + {botStats ? ( +
+ {botStats.clusters.map((cluster, index) => ( +
+

+ #{cluster.id + 1} +

+ +
+
+

Guilds

+

{cluster.guilds}

+
+
+

Users

+

{getFormattedNumber(cluster.users)}

+
+
+ +
+ {Array.from({ length: cluster.totalShards }).map((_, index) => ( +
+

#{(cluster.id + 1) * (index + 1)}

+
+ ))} +
+
+ ))} +
+ ) : ( +
+

+ Could not get the status of Coinz, please try again. +

+
+ )} +
+ ); +} diff --git a/apps/web/src/app/api/investment/route.ts b/apps/web/src/app/api/investment/route.ts new file mode 100644 index 00000000..abca0c76 --- /dev/null +++ b/apps/web/src/app/api/investment/route.ts @@ -0,0 +1,149 @@ +import { env } from '@/env'; +import { auth } from '@/server/auth'; +import { db } from '@/server/db'; +import { type NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +export async function GET() { + const investments = await db.investment.findMany({}); + return NextResponse.json(investments, { status: 200 }); +} + +const investmentBodySchema = z.object({ + userId: z.string(), + type: z.enum(['BUY', 'SELL']), + ticker: z.string(), + amount: z.number().min(0).max(20_000_000), +}); + +export async function PUT(request: NextRequest) { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: 'You need to be logged in to use this feature.' }, { status: 401 }); + } + + const body = await request.json(); + const parsedBody = investmentBodySchema.safeParse(body); + if (!parsedBody.success) { + return NextResponse.json( + { error: 'Something went wrong... Please refresh the page and try again.' }, + { status: 400 }, + ); + } + + if (parsedBody.data.userId !== session.user.discordId) { + return NextResponse.json({ error: 'You need to be logged in to use this feature.' }, { status: 401 }); + } + + const member = await db.members.findUnique({ + where: { userId: session.user.discordId }, + }); + + const userStats = await db.userstats.findUnique({ + where: { userId: session.user.discordId }, + }); + if (!member || !userStats) { + return NextResponse.json( + { error: 'You need to have used Coinz before buying your first investment.' }, + { status: 404 }, + ); + } + + const investment = await db.investment.findFirst({ + where: { + ticker: parsedBody.data.ticker, + }, + }); + if (!investment) { + return NextResponse.json( + { error: `Investment with ticker "${parsedBody.data.ticker}" is not found.` }, + { status: 404 }, + ); + } + + const totalPrice = parseFloat(investment.price) * parsedBody.data.amount; + if (parsedBody.data.type === 'BUY') { + if (totalPrice < 50) { + return NextResponse.json({ error: 'Minimum investment is 50.' }, { status: 400 }); + } else if (totalPrice > member.wallet) { + return NextResponse.json( + { error: 'You do not have enough money to buy this investment.' }, + { status: 400 }, + ); + } + } + + const ownedInvestment = member.investments.find((i) => i.ticker === parsedBody.data.ticker); + if (parsedBody.data.type === 'SELL' && !ownedInvestment) { + return NextResponse.json({ error: 'You do not own this investment.' }, { status: 400 }); + } + + const ownInvestmentAmount = parseFloat(ownedInvestment?.amount ?? '0'); + if (parsedBody.data.type === 'BUY') { + if (ownInvestmentAmount + parsedBody.data.amount > 20_000_000) { + return NextResponse.json( + { error: 'You cannot own more than 20,000,000 shares of the same investment.' }, + { status: 400 }, + ); + } + } else { + if (ownInvestmentAmount < parsedBody.data.amount) { + return NextResponse.json({ error: 'You do not have enough shares to sell.' }, { status: 400 }); + } + } + + try { + const response = await fetch(`${env.API_URL}/member/investment`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `${env.API_TOKEN}`, + }, + body: JSON.stringify({ + userId: session.user.discordId, + type: parsedBody.data.type, + ticker: parsedBody.data.ticker, + amount: parsedBody.data.amount, + price: totalPrice, + }), + }); + + console.log(response.status, response.statusText, await response.json()); + + if (response.status !== 200) { + return NextResponse.json({ error: 'Could not fetch data from the server.' }, { status: 500 }); + } + } catch (error) { + return NextResponse.json({ error: 'Could not fetch data from the server.' }, { status: 500 }); + } + + await db.members.update({ + where: { userId: session.user.discordId }, + data: { + wallet: { + increment: parsedBody.data.type === 'BUY' ? -totalPrice : totalPrice, + }, + }, + }); + + const amountOfTimesBought = userStats.investments.amountOfTimesBought ?? 0; + const amountOfTimesSold = userStats.investments.amountOfTimesSold ?? 0; + const totalBuyPrice = userStats.investments.totalBuyPrice ?? 0; + await db.userstats.update({ + where: { userId: session.user.discordId }, + data: { + investments: { + amountOfTimesBought: parsedBody.data.type === 'BUY' ? amountOfTimesBought + 1 : amountOfTimesBought, + amountOfTimesSold: parsedBody.data.type === 'SELL' ? amountOfTimesSold + 1 : amountOfTimesSold, + totalBuyPrice: parsedBody.data.type === 'BUY' ? Math.floor(totalBuyPrice + totalPrice) : totalBuyPrice, + }, + }, + }); + + return NextResponse.json( + { + message: `You successfully ${parsedBody.data.type === 'BUY' ? 'bought' : 'sold'} ${parsedBody.data.amount}x ${investment.fullName} (${investment.ticker}) ${investment.type === 'Stock' ? 'shares' : 'coins'}.`, + }, + { status: 200 }, + ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 955acc70..0102e1a2 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -6,6 +6,7 @@ import type { Metadata, Viewport } from 'next'; import { env } from '@/env'; import Providers from '@/components/providers'; import PlausibleProvider from 'next-plausible'; +import { Toaster } from '@/components/ui/sonner'; const inter = Inter({ subsets: ['latin'], @@ -107,7 +108,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {children} + + {children} + + ); diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index adc606ca..4c216f4d 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -14,6 +14,7 @@ export default function sitemap(): MetadataRoute.Sitemap { 'guide', 'roadmap', 'vote', + 'status', ]; const routes: MetadataRoute.Sitemap = []; diff --git a/apps/web/src/components/nav/footer.tsx b/apps/web/src/components/nav/footer.tsx index 2f1dde44..efe93636 100644 --- a/apps/web/src/components/nav/footer.tsx +++ b/apps/web/src/components/nav/footer.tsx @@ -14,6 +14,7 @@ export default function Footer() {
Invite Coinz Support Server + Status Roadmap
diff --git a/apps/web/src/components/statistics.tsx b/apps/web/src/components/statistics.tsx index 3aac48e5..df4b26ec 100644 --- a/apps/web/src/components/statistics.tsx +++ b/apps/web/src/components/statistics.tsx @@ -2,30 +2,28 @@ import { CandlestickChartIcon, LucideIcon, ServerIcon, TerminalSquareIcon, Users2Icon } from 'lucide-react'; import React, { useEffect, useState } from 'react'; -import { BotStats } from '@prisma/client'; import { formatNumber } from '@/lib/utils'; -export default function Statistics({ botStats }: { botStats: BotStats | null }) { - if (!botStats) { - return ( - <> - - - - - - ); - } - - const serverCount = formatNumber(botStats.guilds); - const userCount = formatNumber(botStats.users); +export default function Statistics({ + guilds, + users, + commands, + investments, +}: { + guilds: number; + users: number; + commands: number; + investments: number; +}) { + const serverCount = formatNumber(guilds); + const userCount = formatNumber(users); return ( <> - - + + ); } diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 00000000..63505569 --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,97 @@ +'use client'; + +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 00000000..aa453020 --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = React.forwardRef(({ className, type, ...props }, ref) => { + return ( + + ); +}); +Input.displayName = 'Input'; + +export { Input }; diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx new file mode 100644 index 00000000..df5fcc5e --- /dev/null +++ b/apps/web/src/components/ui/sonner.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useTheme } from 'next-themes'; +import { Toaster as Sonner } from 'sonner'; + +type ToasterProps = React.ComponentProps; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = 'system' } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/apps/web/src/env.js b/apps/web/src/env.js index 60d75b6c..254a4867 100644 --- a/apps/web/src/env.js +++ b/apps/web/src/env.js @@ -15,6 +15,8 @@ export const env = createEnv({ LEMONSQUEEZY_STORE_ID: z.string(), LEMONSQUEEZY_WEBHOOK_SECRET: z.string(), WEBHOOK_URL: z.string().url(), + API_TOKEN: z.string(), + API_URL: z.string().url(), }, client: { NEXT_PUBLIC_BASE_URL: z.string().url(), @@ -30,6 +32,8 @@ export const env = createEnv({ LEMONSQUEEZY_WEBHOOK_SECRET: process.env.LEMONSQUEEZY_WEBHOOK_SECRET, WEBHOOK_URL: process.env.WEBHOOK_URL, NODE_ENV: process.env.NODE_ENV, + API_TOKEN: process.env.API_TOKEN, + API_URL: process.env.API_URL, }, skipValidation: !!process.env.SKIP_ENV_VALIDATION, emptyStringAsUndefined: true, diff --git a/apps/web/src/lib/data/changelog.json b/apps/web/src/lib/data/changelog.json index 699b34d5..a69d8b5c 100644 --- a/apps/web/src/lib/data/changelog.json +++ b/apps/web/src/lib/data/changelog.json @@ -1,4 +1,23 @@ [ + { + "version": "4.1", + "name": "Trade & Premium Update", + "timestamp": 1713772800, + "content": [ + "#### Added", + "- Added the ability to trade items with other users. Use `/trade` to start a trade.", + "- Added upgradable bank limits back in Coinz. Use `/balance` to upgrade your bank limit.", + "- Added the [**status page**](https://coinzbot.xyz/status) to the website.", + "- Added the ability to buy and sell investments on the [**investments page**](https://coinzbot.xyz/investments).", + "- Added `/slots` command to play a slot machine.", + "#### Changed", + "- Let Coinz Pro users invest up to 🪙 15.000 in their business instead of the previous 🪙 10.000.", + "- Coinz Premium users will now receive a 50% discount on upgrading their bank limit.", + "- Fixed a lot of small bugs in Coinz.", + "- Lowered the maximum amount of money you can pay to other users from 🪙 15.000 to 🪙 10.000 for Free users.", + "- Fixed the factory buy and upgrade buttons." + ] + }, { "version": "4.0.1", "name": "Stability Bug Fixes", @@ -7,7 +26,7 @@ "#### Changed", "- Let Coinz Pro users invest up to 🪙 10.000 in their business.", "- Coinz Pro users can now pay up to 🪙 30.000 to other users instead of the previous 🪙 25.000.", - "- Fix an issue where all CEO's are removed from their businesses.", + "- Fixed an issue where all CEO's are removed from their businesses.", "- Updated small typo's in messages.", "- Added the ability for business employee's to leave the business.", "#### Known Bugs", diff --git a/apps/web/src/lib/data/products.json b/apps/web/src/lib/data/products.json index 320f8e18..d0e4cd84 100644 --- a/apps/web/src/lib/data/products.json +++ b/apps/web/src/lib/data/products.json @@ -14,7 +14,9 @@ "Gamble limit increased to 🪙 10.000", "Earn up to 🪙 320/day with /daily (Free tier: 🪙 160/day)", "Change your profile color", - "Get faster ticket support" + "Get faster ticket support", + "Ability to trade items up to 🪙 100.000 per trade", + "Get 50% off bank limit upgrades" ], "planId": 247785, "variantId": 342284, @@ -37,9 +39,9 @@ "Get a lucky wheel spin with /daily", "Earn double XP", "Gamble cooldowns reduced from 10 to 4 minutes", - "Hire a 6th employee for your business", + "Hire 6 employees for your business", "Donate someone up to 🪙 30.000 using /pay", - "Invest up to 🪙 10.000 in your business" + "Invest up to 🪙 15.000 in your business" ], "planId": 247787, "variantId": 342288, diff --git a/bun.lockb b/bun.lockb index 2778a1b3f96aecbac5743cf5f6eac3ff184c4b4e..485fbfb8d59df513c8444f517c99667063064cda 100755 GIT binary patch delta 78871 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 delta 64965 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 diff --git a/compose.yaml b/compose.yaml index d9d86185..a1cf99e0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,13 +1,6 @@ version: '3.8' services: - cooldowns_db: - image: redis:alpine - restart: always - networks: - - redis-network - ports: - - '6379:6379' rabbitmq: image: rabbitmq:alpine restart: always @@ -26,7 +19,6 @@ services: environment: REDIS_URI: redis://cooldowns_db:6379 networks: - - redis-network - queue-network support: build: @@ -50,7 +42,5 @@ services: depends_on: - rabbitmq networks: - redis-network: - driver: bridge queue-network: driver: bridge From d8a5eb41c8da8eced2e70fc07067d57d92f51e2e Mon Sep 17 00:00:00 2001 From: Siebe Baree Date: Sat, 22 Jun 2024 19:04:57 +0200 Subject: [PATCH 2/4] Removed cooldowns db service --- compose.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/compose.yaml b/compose.yaml index a1cf99e0..632ff8a6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,10 +14,7 @@ services: dockerfile: ./docker/Bot.Dockerfile restart: always depends_on: - - cooldowns_db - rabbitmq - environment: - REDIS_URI: redis://cooldowns_db:6379 networks: - queue-network support: From f3d475048376bc20f7ee5985833438608703b6fa Mon Sep 17 00:00:00 2001 From: Siebe Baree Date: Sat, 22 Jun 2024 19:09:39 +0200 Subject: [PATCH 3/4] Fixed turbo config --- turbo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index fba613ba..b7ec4c8b 100644 --- a/turbo.json +++ b/turbo.json @@ -1,7 +1,7 @@ { "$schema": "https://turbo.build/schema.json", "globalDependencies": ["**/.env.*local"], - "pipeline": { + "tasks": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**"] From 77de54da6448a047b14a2712b66682ad9c8f2bd8 Mon Sep 17 00:00:00 2001 From: Siebe Baree Date: Sun, 23 Jun 2024 18:33:45 +0200 Subject: [PATCH 4/4] fix: added missing env values to gh action --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 699cabef..ae7d66b0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,6 +25,8 @@ jobs: WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} NODE_ENV: ${{ secrets.NODE_ENV }} NEXT_PUBLIC_BASE_URL: ${{ secrets.NEXT_PUBLIC_BASE_URL }} + API_TOKEN: ${{ secrets.API_TOKEN }} + API_URL: ${{ secrets.API_URL }} steps: - uses: actions/checkout@v4