From 5689dbb90ef40dc833c97e72c33930385d2a8889 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Wed, 14 Aug 2024 23:15:16 +0300 Subject: [PATCH] Record balance changes --- .eslintrc.json | 2 +- src/backend/analytics.ts | 12 +- src/backend/auth-handler.ts | 6 +- src/backend/captcha.ts | 9 +- src/backend/nft-handler.ts | 11 +- src/bot/features/admin/parameters.ts | 15 +- src/bot/features/admin/queue.ts | 45 ++---- src/bot/features/admin/topup.ts | 21 +-- src/bot/features/admin/transaction.ts | 7 +- src/bot/features/balance.ts | 34 +++++ src/bot/features/dice.ts | 177 +++++++++++------------ src/bot/features/index.ts | 1 + src/bot/features/language.ts | 4 +- src/bot/features/line.ts | 7 +- src/bot/features/mint.ts | 79 ++++------ src/bot/features/reset.ts | 21 +-- src/bot/features/unhandled.ts | 10 +- src/bot/handlers/commands/setcommands.ts | 14 +- src/bot/helpers/account-subscription.ts | 21 +-- src/bot/helpers/enum.ts | 7 + src/bot/helpers/files.ts | 20 +-- src/bot/helpers/generation.ts | 19 +-- src/bot/helpers/ipfs.ts | 8 +- src/bot/helpers/nft-collection.ts | 29 +--- src/bot/helpers/nft-item.ts | 6 +- src/bot/helpers/numbers.ts | 5 + src/bot/helpers/photo.ts | 10 +- src/bot/helpers/telegram.ts | 32 ++-- src/bot/helpers/time.ts | 14 +- src/bot/helpers/ton.ts | 20 +-- src/bot/index.ts | 8 +- src/bot/keyboards/queue-menu.ts | 23 +-- src/bot/middlewares/attach-user.ts | 4 +- src/bot/middlewares/check-not-minted.ts | 13 +- src/bot/models/balance.ts | 81 +++++++++++ src/bot/models/cnft.ts | 7 +- src/bot/models/transaction.ts | 7 +- src/bot/models/user.ts | 60 +++++--- src/config.ts | 4 +- src/main.ts | 6 +- src/subscription.ts | 21 +-- 41 files changed, 398 insertions(+), 502 deletions(-) create mode 100644 src/bot/features/balance.ts create mode 100644 src/bot/helpers/enum.ts create mode 100644 src/bot/models/balance.ts diff --git a/.eslintrc.json b/.eslintrc.json index 7e2c711..1aaab7e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -52,7 +52,7 @@ "trailingComma": "all", "semi": false, "singleQuote": false, - "printWidth": 80, + "printWidth": 100, "tabWidth": 2, "useTabs": false, "bracketSpacing": true, diff --git a/src/backend/analytics.ts b/src/backend/analytics.ts index 9c774be..b9890a0 100644 --- a/src/backend/analytics.ts +++ b/src/backend/analytics.ts @@ -76,12 +76,7 @@ function aesEncrypt( } // Main function to process and send an event -async function processEvent( - event: object, - publicKey: string, - apiGateway: string, - apiKey: string, -) { +async function processEvent(event: object, publicKey: string, apiGateway: string, apiKey: string) { try { // Generate AES key and IV const { key, iv } = generateAESKeyIV() @@ -116,10 +111,7 @@ async function processEvent( const analyticsHandler = (fastify: any, _options: any, done: () => void) => { fastify.get("/config", async (_request: any, _reply: any) => { - return getEncryptionKeys( - config.TELEMETREE_PROJECT_ID, - config.TELEMETREE_API_KEY, - ) + return getEncryptionKeys(config.TELEMETREE_PROJECT_ID, config.TELEMETREE_API_KEY) }) fastify.post("/send", async (_request: any, _reply: any) => { diff --git a/src/backend/auth-handler.ts b/src/backend/auth-handler.ts index 55cfd12..6749ec3 100644 --- a/src/backend/auth-handler.ts +++ b/src/backend/auth-handler.ts @@ -5,11 +5,7 @@ import { logger } from "#root/logger" import { validate } from "@tma.js/init-data-node" import { FastifyInstance } from "fastify" -export const authHandler = ( - fastify: FastifyInstance, - _options: unknown, - done: () => void, -) => { +export const authHandler = (fastify: FastifyInstance, _options: unknown, done: () => void) => { // eslint-disable-next-line no-unused-vars fastify.post("/:userId", async (request, _reply) => { const { userId } = request.params as any diff --git a/src/backend/captcha.ts b/src/backend/captcha.ts index 60968ae..277e4c4 100644 --- a/src/backend/captcha.ts +++ b/src/backend/captcha.ts @@ -28,15 +28,10 @@ const checkCaptcha = (fastify: any, _options: any, done: () => void) => { const user = await findUserById(userId) if (!user || !user.suspicionDices) return { result: false } if (user.suspicionDices === Number(data) + 1 + 100) { - logger.info( - `Solved captcha from ${user.id} with ${user.suspicionDices} suspicion dices`, - ) + logger.info(`Solved captcha from ${user.id} with ${user.suspicionDices} suspicion dices`) user.suspicionDices = 0 await user.save() - await _options.bot.api.sendMessage( - userId, - i18n.t(user.language, "dice.captcha_solved"), - ) + await _options.bot.api.sendMessage(userId, i18n.t(user.language, "dice.captcha_solved")) return { result: true } } } diff --git a/src/backend/nft-handler.ts b/src/backend/nft-handler.ts index 902203a..0c6bde7 100644 --- a/src/backend/nft-handler.ts +++ b/src/backend/nft-handler.ts @@ -51,11 +51,7 @@ function toJSON(nft: DocumentType) { } } -const nftHandler = ( - fastify: FastifyInstance, - _options: any, - done: () => void, -) => { +const nftHandler = (fastify: FastifyInstance, _options: any, done: () => void) => { fastify.get("/collection.json", (_request: any, _reply: any) => { return { name: "Cube Worlds Citizens", @@ -63,10 +59,7 @@ const nftHandler = ( image: "https://cubeworlds.club/avatar.png", // external_url: null, // external_link: null, - social_links: [ - "https://t.me/cube_worlds_bot", - "https://twitter.com/cube_worlds", - ], + social_links: ["https://t.me/cube_worlds_bot", "https://twitter.com/cube_worlds"], marketplace: "getgems.io", cover_image: "https://cubeworlds.club/background.png", } diff --git a/src/bot/features/admin/parameters.ts b/src/bot/features/admin/parameters.ts index a228fdd..83f08d3 100644 --- a/src/bot/features/admin/parameters.ts +++ b/src/bot/features/admin/parameters.ts @@ -17,9 +17,7 @@ feature.command("description", logHandle("command-description"), async ctx => { await ctx.reply(`/description ${ctx.dbuser.customDescription}`) return } - await ctx.reply( - `/description ${oldCustomDescription ?? "about selected person"}`, - ) + await ctx.reply(`/description ${oldCustomDescription ?? "about selected person"}`) }) feature.command("positive", logHandle("command-positive"), async ctx => { @@ -73,9 +71,7 @@ feature.command("scale", logHandle("command-scale"), async ctx => { await ctx.dbuser.save() return ctx.reply(`New scale: /scale ${newScale}`) } - return ctx.reply( - `Current scale: /scale ${oldScale}. Can be in range [0 .. 35]`, - ) + return ctx.reply(`Current scale: /scale ${oldScale}. Can be in range [0 .. 35]`) }) feature.command("steps", logHandle("command-steps"), async ctx => { @@ -89,9 +85,7 @@ feature.command("steps", logHandle("command-steps"), async ctx => { await ctx.dbuser.save() return ctx.reply(`New steps: /steps ${newSteps}`) } - return ctx.reply( - `Current steps: /steps ${oldSteps}. Can be in range [ 10 .. 50 ]`, - ) + return ctx.reply(`Current steps: /steps ${oldSteps}. Can be in range [ 10 .. 50 ]`) }) feature.command("preset", logHandle("command-preset"), async ctx => { @@ -115,8 +109,7 @@ Can be: ${presets.join(", ")}`, feature.command("sampler", logHandle("command-sampler"), async ctx => { const oldSampler = ctx.dbuser.sampler ?? SDSampler.K_DPMPP_2S_ANCESTRAL - const newSampler: SDSampler = - SDSampler[ctx.match.trim() as keyof typeof SDSampler] + const newSampler: SDSampler = SDSampler[ctx.match.trim() as keyof typeof SDSampler] if (newSampler) { ctx.dbuser.sampler = newSampler await ctx.dbuser.save() diff --git a/src/bot/features/admin/queue.ts b/src/bot/features/admin/queue.ts index 5d3df71..6dc8663 100644 --- a/src/bot/features/admin/queue.ts +++ b/src/bot/features/admin/queue.ts @@ -2,11 +2,7 @@ import { Composer, InputFile } from "grammy" import type { Context } from "#root/bot/context.js" import { logHandle } from "#root/bot/helpers/logging.js" -import { - photoCaption, - queueMenu, - sendUserMetadata, -} from "#root/bot/keyboards/queue-menu.js" +import { photoCaption, queueMenu, sendUserMetadata } from "#root/bot/keyboards/queue-menu.js" import { isAdmin } from "#root/bot/filters/is-admin.js" import { config } from "#root/config.js" import { Address, toNano } from "@ton/core" @@ -15,17 +11,8 @@ import { changeImageData } from "#root/bot/callback-data/image-selection.js" import { SelectImageButton, photoKeyboard } from "#root/bot/keyboards/photo.js" import { NftCollection } from "#root/bot/helpers/nft-collection.js" import { NFTMintParameters, NftItem } from "#root/bot/helpers/nft-item.js" -import { - pinImageURLToIPFS, - pinJSONToIPFS, - unpin, - warmIPFSHash, -} from "#root/bot/helpers/ipfs.js" -import { - ClipGuidancePreset, - SDSampler, - generate, -} from "#root/bot/helpers/generation.js" +import { pinImageURLToIPFS, pinJSONToIPFS, unpin, warmIPFSHash } from "#root/bot/helpers/ipfs.js" +import { ClipGuidancePreset, SDSampler, generate } from "#root/bot/helpers/generation.js" import { randomAttributes } from "#root/bot/helpers/attributes.js" import { countUsers, findUserById } from "#root/bot/models/user.js" import { ChatGPTAPI } from "chatgpt" @@ -37,7 +24,7 @@ import { sendPreviewNFT, sendToGroupsNewNFT, // sendNewPlaces, -} from "../../helpers/telegram" +} from "#root/bot/helpers/telegram.js" const composer = new Composer() @@ -78,8 +65,7 @@ feature.callbackQuery( }, }) const name = selectedUser.name ?? "" - const info = - ctx.dbuser.customDescription ?? selectedUser.description ?? "" + const info = ctx.dbuser.customDescription ?? selectedUser.description ?? "" const result = await api.sendMessage( `Write an inspiring text about a person named "${name}" who has decided to start a journey. You could also use additional information: "${info}", if it feels appropriate, and translate into English if not. @@ -139,14 +125,10 @@ feature.callbackQuery( case SelectImageButton.Avatar: { const nextAvatarNumber = - ctx.dbuser.selectedUser === selectedUser.id - ? (ctx.dbuser.avatarNumber ?? -1) + 1 - : 0 + ctx.dbuser.selectedUser === selectedUser.id ? (ctx.dbuser.avatarNumber ?? -1) + 1 : 0 ctx.dbuser.avatarNumber = nextAvatarNumber await ctx.dbuser.save() - sendUserMetadata(ctx, selectedUser).catch(error => - ctx.reply((error as Error).message), - ) + sendUserMetadata(ctx, selectedUser).catch(error => ctx.reply((error as Error).message)) break } @@ -185,11 +167,7 @@ feature.callbackQuery( attributes: randomAttributes(), } ctx.logger.info(json) - const ipfsJSONHash = await pinJSONToIPFS( - adminIndex(ctx.dbuser.id), - username, - json, - ) + const ipfsJSONHash = await pinJSONToIPFS(adminIndex(ctx.dbuser.id), username, json) selectedUser.nftImage = ipfsImageHash selectedUser.nftJson = ipfsJSONHash await selectedUser.save() @@ -222,8 +200,7 @@ feature.callbackQuery( selectedUser.mintedAt = new Date() await selectedUser.save() - const nextItemIndex = - await NftCollection.fetchNextItemIndexWithRetry() + const nextItemIndex = await NftCollection.fetchNextItemIndexWithRetry() const userAddress = Address.parse(selectedUser.wallet ?? "") const parameters: NFTMintParameters = { queryId: 0, @@ -297,9 +274,7 @@ feature.callbackQuery( } catch (error) { ctx.logger.error(error) const { message } = error as Error - await (message - ? ctx.reply(`Error: ${message}`) - : ctx.reply(ctx.t("wrong"))) + await (message ? ctx.reply(`Error: ${message}`) : ctx.reply(ctx.t("wrong"))) } ctx.chatAction = null }, diff --git a/src/bot/features/admin/topup.ts b/src/bot/features/admin/topup.ts index daab64b..146e6dd 100644 --- a/src/bot/features/admin/topup.ts +++ b/src/bot/features/admin/topup.ts @@ -11,18 +11,13 @@ const composer = new Composer() const feature = composer.chatType("private").filter(isAdmin) -feature.command( - "topup", - logHandle("command-topup"), - chatAction("upload_document"), - async ctx => { - const wallet = await openWallet(config.MNEMONICS!.split(" ")) - const collectionAddress = Address.parse(config.COLLECTION_ADDRESS) - const seqno = await topUpBalance(wallet, collectionAddress, 10) - ctx.logger.info(`Collection ${collectionAddress} will be topUpped`) - await waitSeqno(seqno, wallet) - await ctx.reply(`Collection ${collectionAddress} topUpped!`) - }, -) +feature.command("topup", logHandle("command-topup"), chatAction("upload_document"), async ctx => { + const wallet = await openWallet(config.MNEMONICS!.split(" ")) + const collectionAddress = Address.parse(config.COLLECTION_ADDRESS) + const seqno = await topUpBalance(wallet, collectionAddress, 10) + ctx.logger.info(`Collection ${collectionAddress} will be topUpped`) + await waitSeqno(seqno, wallet) + await ctx.reply(`Collection ${collectionAddress} topUpped!`) +}) export { composer as topupFeature } diff --git a/src/bot/features/admin/transaction.ts b/src/bot/features/admin/transaction.ts index c62c1c3..600f218 100644 --- a/src/bot/features/admin/transaction.ts +++ b/src/bot/features/admin/transaction.ts @@ -3,6 +3,7 @@ import type { Context } from "#root/bot/context.js" import { logHandle } from "#root/bot/helpers/logging.js" import { isAdmin } from "#root/bot/filters/is-admin.js" import { fromNano } from "@ton/core" +import { BalanceChangeType } from "#root/bot/models/balance" import { addPoints, findUserByName } from "../../models/user" import { findTransaction } from "../../models/transaction" import { tonToPoints } from "../../helpers/ton" @@ -20,9 +21,7 @@ feature.command("transaction", logHandle("command-transaction"), async ctx => { const trx = await findTransaction(numberLt, hash) if (!trx) { - return ctx.reply( - `Transaction with hash \`${hash}\` and lt \`${numberLt}\` not found`, - ) + return ctx.reply(`Transaction with hash \`${hash}\` and lt \`${numberLt}\` not found`) } if (trx.accepted) { @@ -45,7 +44,7 @@ feature.command("transaction", logHandle("command-transaction"), async ctx => { // add points to user balance const ton = fromNano(trx.coins) const points = tonToPoints(Number(ton)) - await addPoints(user.id, points) + await addPoints(user.id, points, BalanceChangeType.Donation) // send messages await sendPlaceInLine(ctx.api, user.id, true) diff --git a/src/bot/features/balance.ts b/src/bot/features/balance.ts new file mode 100644 index 0000000..0d55b15 --- /dev/null +++ b/src/bot/features/balance.ts @@ -0,0 +1,34 @@ +import { Composer } from "grammy" +import { getMarkdownTable, Row } from "markdown-table-ts" +import type { Context } from "#root/bot/context.js" +import { logHandle } from "#root/bot/helpers/logging.js" +import { kFormatter } from "#root/bot/helpers/numbers.js" +import { + countAllBalanceRecords, + getBalanceChangeTypeName, + getUserBalanceRecords, +} from "#root/bot/models/balance.js" +import { formatDateTimeCompact } from "#root/bot/helpers/time.js" + +const composer = new Composer() +const feature = composer.chatType("private") + +feature.command("balance", logHandle("command-balance"), async ctx => { + const count = await countAllBalanceRecords() + const records = await getUserBalanceRecords(ctx.dbuser.id, count) + const body: Row[] = records.map(v => [ + kFormatter(v.amount), + getBalanceChangeTypeName(v.type), + v.createdAt ? formatDateTimeCompact(v.createdAt) : "", + ]) + const md = `${ctx.t("line.count", { count })} +\`\`\`\n${getMarkdownTable({ + table: { + head: ["$CUBE", "Type", "Date"], + body, + }, + })}\n\`\`\`` + await ctx.replyWithMarkdown(md) +}) + +export { composer as balanceFeature } diff --git a/src/bot/features/dice.ts b/src/bot/features/dice.ts index fea2954..31da045 100644 --- a/src/bot/features/dice.ts +++ b/src/bot/features/dice.ts @@ -3,11 +3,12 @@ import { Composer, InlineKeyboard } from "grammy" import type { Context } from "#root/bot/context.js" import { logHandle } from "#root/bot/helpers/logging.js" import { logger } from "#root/logger" -import { sleep } from "../helpers/ton" -import { timeUnitsBetween } from "../helpers/time" -import { sendMessageToAdmins, sendPlaceInLine } from "../helpers/telegram" -import { UserState, addPoints } from "../models/user" -import { generateRandomString } from "../helpers/text" +import { sleep } from "#root/bot/helpers/ton.js" +import { timeUnitsBetween } from "#root/bot/helpers/time.js" +import { sendMessageToAdmins, sendPlaceInLine } from "#root/bot/helpers/telegram.js" +import { UserState, addPoints } from "#root/bot/models/user.js" +import { generateRandomString } from "#root/bot/helpers/text.js" +import { BalanceChangeType } from "#root/bot/models/balance.js" const composer = new Composer() @@ -19,9 +20,7 @@ feature.command("dice", logHandle("command-dice"), async ctx => { return } const waitMinutes = config.isProd ? 5 : 1 - const waitDate = new Date( - ctx.dbuser.dicedAt.getTime() + waitMinutes * 60 * 1000, - ) + const waitDate = new Date(ctx.dbuser.dicedAt.getTime() + waitMinutes * 60 * 1000) const now = new Date() const waitDateToCompare = new Date(waitDate.getTime() - 3000) if (waitDateToCompare > now) { @@ -40,14 +39,10 @@ feature.command("dice", logHandle("command-dice"), async ctx => { } const lastDicedTime = ctx.dbuser.dicedAt.getTime() const suspicionTime = waitMinutes * 3 - const compareDateForCaptcha = new Date( - lastDicedTime + suspicionTime * 60 * 1000, - ) + const compareDateForCaptcha = new Date(lastDicedTime + suspicionTime * 60 * 1000) if (compareDateForCaptcha > now) { ctx.dbuser.suspicionDices += 1 - logger.info( - `${ctx.dbuser.id} has ${ctx.dbuser.suspicionDices} suspicion dices`, - ) + logger.info(`${ctx.dbuser.id} has ${ctx.dbuser.suspicionDices} suspicion dices`) } // else { // ctx.dbuser.suspicionDices = 0; @@ -72,91 +67,87 @@ feature.command("dice", logHandle("command-dice"), async ctx => { }) }) -feature.callbackQuery( - /^dice_/, - logHandle("dice-callback-query"), - async (ctx: Context) => { - const userDiceData = `dice_${ctx.dbuser.diceKey}` - const data = ctx.callbackQuery?.data ?? "" - try { - await ctx.deleteMessage() - } catch { - // do nothing - } - if (!(userDiceData === data)) { - return +feature.callbackQuery(/^dice_/, logHandle("dice-callback-query"), async (ctx: Context) => { + const userDiceData = `dice_${ctx.dbuser.diceKey}` + const data = ctx.callbackQuery?.data ?? "" + try { + await ctx.deleteMessage() + } catch { + // do nothing + } + if (!(userDiceData === data)) { + return + } + ctx.dbuser.diceKey = generateRandomString(10) + await ctx.dbuser.save() + const dice1 = ctx.replyWithDice("🎲") + const dice2 = ctx.replyWithDice("🎲") + const result = await Promise.all([dice1, dice2]) + const value1 = result[0].dice.value + const value2 = result[1].dice.value + const isRecurred = value1 === value2 + if (isRecurred) { + if (!ctx.dbuser.diceSeries) { + ctx.dbuser.diceSeries = 1 } - ctx.dbuser.diceKey = generateRandomString(10) - await ctx.dbuser.save() - const dice1 = ctx.replyWithDice("🎲") - const dice2 = ctx.replyWithDice("🎲") - const result = await Promise.all([dice1, dice2]) - const value1 = result[0].dice.value - const value2 = result[1].dice.value - const isRecurred = value1 === value2 - if (isRecurred) { - if (!ctx.dbuser.diceSeries) { - ctx.dbuser.diceSeries = 1 - } - if (ctx.dbuser.diceSeriesNumber === value1) { - ctx.dbuser.diceSeries = (ctx.dbuser.diceSeries ?? 0) + 1 - } else { - ctx.dbuser.diceSeries = 1 - ctx.dbuser.diceSeriesNumber = value1 - } + if (ctx.dbuser.diceSeriesNumber === value1) { + ctx.dbuser.diceSeries = (ctx.dbuser.diceSeries ?? 0) + 1 } else { - ctx.dbuser.diceSeries = undefined - ctx.dbuser.diceSeriesNumber = undefined + ctx.dbuser.diceSeries = 1 + ctx.dbuser.diceSeriesNumber = value1 } + } else { + ctx.dbuser.diceSeries = undefined + ctx.dbuser.diceSeriesNumber = undefined + } - const diceSeries = ctx.dbuser.diceSeries ?? 0 - const diceSeriesNumber = ctx.dbuser.diceSeriesNumber ?? 0 - const username = ctx.dbuser.name ?? "undefined" + const diceSeries = ctx.dbuser.diceSeries ?? 0 + const diceSeriesNumber = ctx.dbuser.diceSeriesNumber ?? 0 + const username = ctx.dbuser.name ?? "undefined" - let score = value1 + value2 - if (diceSeries > 1) { - score *= diceSeries - } + let score = value1 + value2 + if (diceSeries > 1) { + score *= diceSeries + } - ctx.dbuser.dicedAt = new Date() - await ctx.dbuser.save() - await addPoints(ctx.dbuser.id, BigInt(score)) + ctx.dbuser.dicedAt = new Date() + await ctx.dbuser.save() + await addPoints(ctx.dbuser.id, BigInt(score), BalanceChangeType.Dice) - sleep(3000) - .then(async () => { - if (!ctx.dbuser.minted && diceSeries === 3) { - ctx.dbuser.diceWinner = true - await ctx.dbuser.save() - await ctx.reply( - ctx.t("dice.mint_winner", { - username, - diceSeriesNumber, - diceSeries, - }), - ) - await sendMessageToAdmins( - ctx.api, - `🎲 Pair of ${diceSeriesNumber} dices ${diceSeries} times in a row by @${username}!`, - ) - await ctx.replyWithSticker( - "CAACAgIAAxkBAAEq6zpmIPgeW-peX09nTeFVvHXneFJZaQACQxoAAtzjkEhebdhBXbkEnzQE", - ) - } else { - await (diceSeries > 1 - ? ctx.reply( - ctx.t("dice.success_series", { - score, - diceSeries, - diceSeriesNumber, - }), - ) - : ctx.reply(ctx.t("dice.success", { score }))) - await sleep(1000) - await sendPlaceInLine(ctx.api, ctx.dbuser.id, true) - } - }) - .catch(error => logger.error(error)) - }, -) + sleep(3000) + .then(async () => { + if (!ctx.dbuser.minted && diceSeries === 3) { + ctx.dbuser.diceWinner = true + await ctx.dbuser.save() + await ctx.reply( + ctx.t("dice.mint_winner", { + username, + diceSeriesNumber, + diceSeries, + }), + ) + await sendMessageToAdmins( + ctx.api, + `🎲 Pair of ${diceSeriesNumber} dices ${diceSeries} times in a row by @${username}!`, + ) + await ctx.replyWithSticker( + "CAACAgIAAxkBAAEq6zpmIPgeW-peX09nTeFVvHXneFJZaQACQxoAAtzjkEhebdhBXbkEnzQE", + ) + } else { + await (diceSeries > 1 + ? ctx.reply( + ctx.t("dice.success_series", { + score, + diceSeries, + diceSeriesNumber, + }), + ) + : ctx.reply(ctx.t("dice.success", { score }))) + await sleep(1000) + await sendPlaceInLine(ctx.api, ctx.dbuser.id, true) + } + }) + .catch(error => logger.error(error)) +}) export { composer as diceFeature } diff --git a/src/bot/features/index.ts b/src/bot/features/index.ts index 5d47f34..ec131fc 100644 --- a/src/bot/features/index.ts +++ b/src/bot/features/index.ts @@ -10,6 +10,7 @@ export * from "./whales.js" export * from "./line.js" export * from "./webapp.js" export * from "./play.js" +export * from "./balance.js" export * from "./admin/queue.js" export * from "./admin/collection.js" export * from "./admin/topup.js" diff --git a/src/bot/features/language.ts b/src/bot/features/language.ts index 8daa0cc..1149d9e 100644 --- a/src/bot/features/language.ts +++ b/src/bot/features/language.ts @@ -19,9 +19,7 @@ feature.callbackQuery( changeLanguageData.filter(), logHandle("keyboard-language-select"), async (ctx: Context) => { - const { code: languageCode } = changeLanguageData.unpack( - ctx.callbackQuery?.data ?? "", - ) + const { code: languageCode } = changeLanguageData.unpack(ctx.callbackQuery?.data ?? "") if (i18n.locales.includes(languageCode)) { await ctx.i18n.setLocale(languageCode) diff --git a/src/bot/features/line.ts b/src/bot/features/line.ts index cff3a46..ab0b2e2 100644 --- a/src/bot/features/line.ts +++ b/src/bot/features/line.ts @@ -1,12 +1,7 @@ import { Composer } from "grammy" import type { Context } from "#root/bot/context.js" import { logHandle } from "#root/bot/helpers/logging.js" -import { - UserState, - countAllLine, - findLine, - placeInLine, -} from "#root/bot/models/user.js" +import { UserState, countAllLine, findLine, placeInLine } from "#root/bot/models/user.js" import { getMarkdownTable } from "markdown-table-ts" import { bigIntWithCustomSeparator } from "../helpers/numbers" import { removeMiddle } from "../helpers/text" diff --git a/src/bot/features/mint.ts b/src/bot/features/mint.ts index 90b5824..aa744af 100644 --- a/src/bot/features/mint.ts +++ b/src/bot/features/mint.ts @@ -2,23 +2,16 @@ import { VoteModel, isUserAlreadyVoted } from "#root/bot/models/vote.js" import { Composer } from "grammy" import type { Context } from "#root/bot/context.js" import { logHandle } from "#root/bot/helpers/logging.js" -import { - UserState, - addPoints, - findUserByAddress, -} from "#root/bot/models/user.js" +import { UserState, addPoints, findUserByAddress } from "#root/bot/models/user.js" import { getUserProfilePhoto } from "#root/bot/helpers/photo.js" import { Address } from "@ton/core" import { voteScore } from "#root/bot/helpers/votes.js" import { ChatFullInfo } from "grammy/types" import { logger } from "#root/logger" -import { - getCubeChannel, - getCubeChat, - sendPlaceInLine, -} from "../helpers/telegram" +import { getCubeChannel, getCubeChat, sendPlaceInLine } from "../helpers/telegram" import { sendMintedMessage } from "../middlewares/check-not-minted" import { isUserAddressValid } from "../helpers/ton" +import { BalanceChangeType } from "../models/balance" const composer = new Composer() @@ -49,10 +42,7 @@ async function sendWaitDescription(ctx: Context) { } } -async function isUserSubscribed( - ctx: Context, - channel: string, -): Promise { +async function isUserSubscribed(ctx: Context, channel: string): Promise { try { const subscriber = await ctx.api.getChatMember(channel, ctx.dbuser.id) const validStatuses = ["creator", "administrator", "member"] @@ -75,22 +65,13 @@ async function sendReferralBonus(ctx: Context) { voteModel.receiver = receiverId voteModel.quantity = add await voteModel.save() - - await addPoints(receiverId, BigInt(add)) + await addPoints(receiverId, BigInt(add), BalanceChangeType.Referral) await sendPlaceInLine(ctx.api, receiverId, true) } -async function mintAction( - ctx: Context, - removeSubscriptionCheckMessage: boolean = false, -) { +async function mintAction(ctx: Context, removeSubscriptionCheckMessage: boolean = false) { if (ctx.dbuser.minted) { - return sendMintedMessage( - ctx.api, - ctx.dbuser.id, - ctx.dbuser.language, - ctx.dbuser.nftUrl ?? "", - ) + return sendMintedMessage(ctx.api, ctx.dbuser.id, ctx.dbuser.language, ctx.dbuser.nftUrl ?? "") } const chat = getCubeChat(ctx.dbuser.language) @@ -183,29 +164,25 @@ feature.on("message:text", logHandle("message-handler")).filter( }, ) -feature - .on("callback_query", logHandle("check-subscription-callback-query")) - .filter( - (ctx: Context) => ctx.hasCallbackQuery("check_subscription"), - async ctx => { - await mintAction(ctx, true) - }, - ) - -feature - .on("callback_query", logHandle("correct_description-callback-query")) - .filter( - (ctx: Context) => ctx.hasCallbackQuery("correct_description"), - async (ctx: Context) => { - const bio = await getBio(ctx) - if (bio) { - await saveDescription(ctx, bio) - await ctx.deleteMessage() - } else { - return ctx.reply("wrong") - } - }, - ) +feature.on("callback_query", logHandle("check-subscription-callback-query")).filter( + (ctx: Context) => ctx.hasCallbackQuery("check_subscription"), + async ctx => { + await mintAction(ctx, true) + }, +) + +feature.on("callback_query", logHandle("correct_description-callback-query")).filter( + (ctx: Context) => ctx.hasCallbackQuery("correct_description"), + async (ctx: Context) => { + const bio = await getBio(ctx) + if (bio) { + await saveDescription(ctx, bio) + await ctx.deleteMessage() + } else { + return ctx.reply("wrong") + } + }, +) feature.on("message:text", logHandle("message-handler")).filter( ctx => ctx.dbuser.state === UserState.WaitWallet, @@ -220,9 +197,7 @@ feature.on("message:text", logHandle("message-handler")).filter( } const userWithWallet = await findUserByAddress(address) if (userWithWallet && ctx.dbuser.id !== userWithWallet.id) { - return ctx.reply( - ctx.t("wallet.already_exists", { wallet: address.toString() }), - ) + return ctx.reply(ctx.t("wallet.already_exists", { wallet: address.toString() })) } ctx.dbuser.wallet = address.toString() ctx.dbuser.state = UserState.Submited diff --git a/src/bot/features/reset.ts b/src/bot/features/reset.ts index a98e746..75fc840 100644 --- a/src/bot/features/reset.ts +++ b/src/bot/features/reset.ts @@ -9,18 +9,13 @@ const composer = new Composer() const feature = composer.chatType("private") -feature.command( - "reset", - checkNotMinted(), - logHandle("command-reset"), - async ctx => { - ctx.dbuser.state = UserState.WaitNothing - if (!ctx.dbuser.votes) { - ctx.dbuser.votes = BigInt(await voteScore(ctx)) - } - await ctx.dbuser.save() - await ctx.reply(ctx.t("reset")) - }, -) +feature.command("reset", checkNotMinted(), logHandle("command-reset"), async ctx => { + ctx.dbuser.state = UserState.WaitNothing + if (!ctx.dbuser.votes) { + ctx.dbuser.votes = BigInt(await voteScore(ctx)) + } + await ctx.dbuser.save() + await ctx.reply(ctx.t("reset")) +}) export { composer as resetFeature } diff --git a/src/bot/features/unhandled.ts b/src/bot/features/unhandled.ts index f6f4858..e4ca3c9 100644 --- a/src/bot/features/unhandled.ts +++ b/src/bot/features/unhandled.ts @@ -10,12 +10,8 @@ feature.on("message", logHandle("unhandled-message"), (ctx: Context) => { return ctx.reply(ctx.t("unhandled")) }) -feature.on( - "callback_query", - logHandle("unhandled-callback-query"), - (ctx: Context) => { - return ctx.answerCallbackQuery() - }, -) +feature.on("callback_query", logHandle("unhandled-callback-query"), (ctx: Context) => { + return ctx.answerCallbackQuery() +}) export { composer as unhandledFeature } diff --git a/src/bot/handlers/commands/setcommands.ts b/src/bot/handlers/commands/setcommands.ts index 6f7fc4f..d0a7593 100644 --- a/src/bot/handlers/commands/setcommands.ts +++ b/src/bot/handlers/commands/setcommands.ts @@ -115,9 +115,7 @@ export async function setCommandsHandler(ctx: CommandContext) { ctx.api.setMyCommands( [ ...getPrivateChatCommands(code), - ...(isMultipleLocales - ? [getLanguageCommand(DEFAULT_LANGUAGE_CODE)] - : []), + ...(isMultipleLocales ? [getLanguageCommand(DEFAULT_LANGUAGE_CODE)] : []), ], { language_code: code as LanguageCode, @@ -164,11 +162,7 @@ export async function setCommandsHandler(ctx: CommandContext) { language_code: code as LanguageCode, }), ) - await Promise.all([ - ...requests, - ...descriptionRequests, - ...shortDescriptionRequests, - ]) + await Promise.all([...requests, ...descriptionRequests, ...shortDescriptionRequests]) } // set private chat commands for owner @@ -179,9 +173,7 @@ export async function setCommandsHandler(ctx: CommandContext) { [ ...getPrivateChatCommands(DEFAULT_LANGUAGE_CODE), ...getPrivateChatAdminCommands(DEFAULT_LANGUAGE_CODE), - ...(isMultipleLocales - ? [getLanguageCommand(DEFAULT_LANGUAGE_CODE)] - : []), + ...(isMultipleLocales ? [getLanguageCommand(DEFAULT_LANGUAGE_CODE)] : []), ], { scope: { diff --git a/src/bot/helpers/account-subscription.ts b/src/bot/helpers/account-subscription.ts index 136ed6f..11ec3c5 100644 --- a/src/bot/helpers/account-subscription.ts +++ b/src/bot/helpers/account-subscription.ts @@ -15,12 +15,7 @@ export class AccountSubscription { onTransaction: any - constructor( - tonweb: any, - accountAddress: any, - startTime: any, - onTransaction: any, - ) { + constructor(tonweb: any, accountAddress: any, startTime: any, onTransaction: any) { this.tonweb = tonweb this.accountAddress = accountAddress this.startTime = startTime // start unixtime (stored in your database), transactions made earlier will be discarded. @@ -65,12 +60,7 @@ export class AccountSubscription { retryCount += 1 if (retryCount < 10) { await sleep(retryCount * 1000) - return getTransactions( - time, - offsetTransactionLT, - offsetTransactionHash, - retryCount, - ) + return getTransactions(time, offsetTransactionLT, offsetTransactionHash, retryCount) } return 0 } @@ -98,12 +88,7 @@ export class AccountSubscription { } const lastTx = transactions.at(-1) - return getTransactions( - time, - lastTx.transaction_id.lt, - lastTx.transaction_id.hash, - 0, - ) + return getTransactions(time, lastTx.transaction_id.lt, lastTx.transaction_id.hash, 0) } let isProcessing = false diff --git a/src/bot/helpers/enum.ts b/src/bot/helpers/enum.ts new file mode 100644 index 0000000..64e6e77 --- /dev/null +++ b/src/bot/helpers/enum.ts @@ -0,0 +1,7 @@ +export function getEnumKeyByValue( + enumObject: T, + value: string | number, +): keyof T | undefined { + const keys = Object.keys(enumObject) as Array + return keys.find(key => enumObject[key] === value) +} diff --git a/src/bot/helpers/files.ts b/src/bot/helpers/files.ts index a452e03..0cd10e7 100644 --- a/src/bot/helpers/files.ts +++ b/src/bot/helpers/files.ts @@ -9,11 +9,7 @@ export function folderPath(username: string): string { return fp } -export function saveImage( - username: string, - fileName: string, - content: Buffer, -): string { +export function saveImage(username: string, fileName: string, content: Buffer): string { const fp = folderPath(username) const filePath = path.join(fp, fileName) fs.writeFileSync(filePath, content) @@ -29,22 +25,14 @@ export async function saveImageFromUrl( const image = await fetch(imageURL) const arrayBuffer = await image.arrayBuffer() const buffer = Buffer.from(arrayBuffer) - const imageFileName = - imageURL.slice((imageURL.lastIndexOf("/") ?? 0) + 1) ?? "" + const imageFileName = imageURL.slice((imageURL.lastIndexOf("/") ?? 0) + 1) ?? "" const fileExtension = imageFileName.split(".").pop() const newFileName = `${original ? "ava_" : ""}${username}_${adminIndex}.${fileExtension}` return saveImage(username, newFileName, buffer) } -export function saveJSON( - adminIndex: number, - username: string, - json: object, -): string { - const jsonPath = path.join( - folderPath(username), - `${username}_${adminIndex}.json`, - ) +export function saveJSON(adminIndex: number, username: string, json: object): string { + const jsonPath = path.join(folderPath(username), `${username}_${adminIndex}.json`) fs.writeFileSync(jsonPath, JSON.stringify(json)) return jsonPath } diff --git a/src/bot/helpers/generation.ts b/src/bot/helpers/generation.ts index 50ef887..739fee0 100644 --- a/src/bot/helpers/generation.ts +++ b/src/bot/helpers/generation.ts @@ -85,18 +85,15 @@ export async function generate( // DO NOT CHANGE formData.append("samples", 1) - const response = await fetch( - `${apiHost}/v1/generation/${engineId}/image-to-image`, - { - method: "POST", - headers: { - ...formData.getHeaders(), - Accept: "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: formData, + const response = await fetch(`${apiHost}/v1/generation/${engineId}/image-to-image`, { + method: "POST", + headers: { + ...formData.getHeaders(), + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, }, - ) + body: formData, + }) if (!response.ok) { throw new Error(`Non-200 response: ${await response.text()}`) diff --git a/src/bot/helpers/ipfs.ts b/src/bot/helpers/ipfs.ts index f2458b7..8d96e4a 100644 --- a/src/bot/helpers/ipfs.ts +++ b/src/bot/helpers/ipfs.ts @@ -19,8 +19,7 @@ export async function pinImageURLToIPFS( const image = await fetch(imageURL) const arrayBuffer = await image.arrayBuffer() const buffer = Buffer.from(arrayBuffer) - const imageFileName = - imageURL.slice((imageURL.lastIndexOf("/") ?? 0) + 1) ?? "" + const imageFileName = imageURL.slice((imageURL.lastIndexOf("/") ?? 0) + 1) ?? "" const fileExtension = imageFileName.split(".").pop() const newFileName = `${username}_${adminIndex}.${fileExtension}` saveImage(username, newFileName, buffer) @@ -52,10 +51,7 @@ export function linkToIPFSGateway(hash: string) { return `${config.PINATA_GATEWAY}/ipfs/${hash}?pinataGatewayToken=${config.PINATA_GATEWAY_KEY}` } -async function fetchFileFromIPFS( - cid: string, - gateway: string, -): Promise { +async function fetchFileFromIPFS(cid: string, gateway: string): Promise { const response = await fetch(`${gateway}${cid}`, { signal: AbortSignal.timeout(120_000), }) diff --git a/src/bot/helpers/nft-collection.ts b/src/bot/helpers/nft-collection.ts index 5a20ce8..a29017b 100644 --- a/src/bot/helpers/nft-collection.ts +++ b/src/bot/helpers/nft-collection.ts @@ -1,17 +1,5 @@ -import { - Address, - Cell, - internal, - beginCell, - contractAddress, - StateInit, - SendMode, -} from "@ton/core" -import { - encodeOffChainContent, - sleep, - tonClient, -} from "#root/bot/helpers/ton.js" +import { Address, Cell, internal, beginCell, contractAddress, StateInit, SendMode } from "@ton/core" +import { encodeOffChainContent, sleep, tonClient } from "#root/bot/helpers/ton.js" import { config } from "#root/config.js" import { logger } from "#root/logger" import { OpenedWallet } from "#root/bot/helpers/wallet.js" @@ -55,21 +43,16 @@ export class NftCollection { private static async fetchNextItemIndex(): Promise { const nftCollectionAddress = Address.parse(config.COLLECTION_ADDRESS) - const { stack } = await tonClient.runMethod( - nftCollectionAddress, - "get_collection_data", - ) + const { stack } = await tonClient.runMethod(nftCollectionAddress, "get_collection_data") const nextItemIndex = stack.readBigNumber() return Number(nextItemIndex) } static async getNftAddressByIndex(itemIndex: number): Promise
{ const collectionAddress = Address.parse(config.COLLECTION_ADDRESS) - const response = await tonClient.runMethod( - collectionAddress, - "get_nft_address_by_index", - [{ type: "int", value: BigInt(itemIndex) }], - ) + const response = await tonClient.runMethod(collectionAddress, "get_nft_address_by_index", [ + { type: "int", value: BigInt(itemIndex) }, + ]) return response.stack.readAddress() } diff --git a/src/bot/helpers/nft-item.ts b/src/bot/helpers/nft-item.ts index cb3d12f..19fe069 100644 --- a/src/bot/helpers/nft-item.ts +++ b/src/bot/helpers/nft-item.ts @@ -40,11 +40,7 @@ export class NftItem { return nftUrl } - private async deploy( - wallet: OpenedWallet, - seqno: number, - parameters: NFTMintParameters, - ) { + private async deploy(wallet: OpenedWallet, seqno: number, parameters: NFTMintParameters) { logger.info(`Deploy NFT with seqno ${seqno} was started.`) const collectionAddress = Address.parse(config.COLLECTION_ADDRESS) await wallet.contract.sendTransfer({ diff --git a/src/bot/helpers/numbers.ts b/src/bot/helpers/numbers.ts index 6fb5a7b..7604fab 100644 --- a/src/bot/helpers/numbers.ts +++ b/src/bot/helpers/numbers.ts @@ -1,3 +1,8 @@ export function bigIntWithCustomSeparator(x: bigint, separator = ","): string { return x.toString().replaceAll(/\B(?=(\d{3})+(?!\d))/g, separator) } + +export function kFormatter(input: number | bigint): string { + const formatter = Intl.NumberFormat("en", { notation: "compact" }) + return formatter.format(input) +} diff --git a/src/bot/helpers/photo.ts b/src/bot/helpers/photo.ts index 933b45b..2d246df 100644 --- a/src/bot/helpers/photo.ts +++ b/src/bot/helpers/photo.ts @@ -11,9 +11,7 @@ export async function getUserProfilePhoto( ctx.logger.debug(photos) if (photos.total_count > 0) { const lastPhotoArray = photos.photos[avatarNumber % photos.photos.length] - const squarePhotos = lastPhotoArray?.filter( - (p: PhotoSize) => p.width === p.height, - ) + const squarePhotos = lastPhotoArray?.filter((p: PhotoSize) => p.width === p.height) if (squarePhotos.length > 0) { const photo = squarePhotos.sort( (a, b) => (b.file_size ?? b.width) - (a.file_size ?? a.width), @@ -26,11 +24,7 @@ export async function getUserProfilePhoto( throw new Error("No profile avatars") } -export async function getUserProfileFile( - ctx: Context, - userId: number, - avatarNumber: number, -) { +export async function getUserProfileFile(ctx: Context, userId: number, avatarNumber: number) { const photo = await getUserProfilePhoto(ctx, userId, avatarNumber) if (photo) { return ctx.api.getFile(photo.file_id) diff --git a/src/bot/helpers/telegram.ts b/src/bot/helpers/telegram.ts index 90efe8d..08c6ecb 100644 --- a/src/bot/helpers/telegram.ts +++ b/src/bot/helpers/telegram.ts @@ -2,12 +2,7 @@ import { config } from "#root/config" import { Api, InputMediaBuilder, RawApi } from "grammy" import { TranslationVariables } from "@grammyjs/i18n" import { logger } from "#root/logger" -import { - findMintedWithDate, - findQueue, - findUserById, - placeInLine, -} from "../models/user" +import { findMintedWithDate, findQueue, findUserById, placeInLine } from "../models/user" import { getRandomCoolEmoji } from "./emoji" import { bigIntWithCustomSeparator } from "./numbers" import { i18n } from "../i18n" @@ -84,10 +79,7 @@ export async function sendPlaceInLine( const placeDecreased = place < lastSendedPlace if (sendAnyway || placeDecreased) { const inviteLink = inviteTelegramUrl(user.id) - const shareLink = shareTelegramLink( - user.id, - i18n.t(user.language, "mint.share"), - ) + const shareLink = shareTelegramLink(user.id, i18n.t(user.language, "mint.share")) const titleKey = `speedup.${user.minted ? "title_minted" : "title_not_minted"}` const titleVariables: TranslationVariables = { points: bigIntWithCustomSeparator(user.votes), @@ -134,16 +126,12 @@ export async function sendPreviewNFT( const collectionLink = `Cube Worlds` const emoji1 = diceWinner ? "🎲" : getRandomCoolEmoji().emoji const emoji2 = diceWinner ? "🎲" : getRandomCoolEmoji().emoji - const caption = i18n.t( - lang, - `queue.${diceWinner ? `new_nft_dice` : `new_nft`}`, - { - emoji1, - emoji2, - number: nftNumber, - collectionLink, - }, - ) + const caption = i18n.t(lang, `queue.${diceWinner ? `new_nft_dice` : `new_nft`}`, { + emoji1, + emoji2, + number: nftNumber, + collectionLink, + }) const linkTitle = i18n.t(lang, "queue.new_nft_button") // eslint-disable-next-line no-await-in-loop return api.sendPhoto(chat, linkToIPFSGateway(ipfsImageHash), { @@ -184,9 +172,7 @@ export async function sendToGroupsNewNFT( diceWinner, ) // eslint-disable-next-line no-await-in-loop - await api.setMessageReaction(result.chat.id, result.message_id, [ - getRandomCoolEmoji(), - ]) + await api.setMessageReaction(result.chat.id, result.message_id, [getRandomCoolEmoji()]) } } catch (error) { logger.error(error) diff --git a/src/bot/helpers/time.ts b/src/bot/helpers/time.ts index 9427aad..8263f9b 100644 --- a/src/bot/helpers/time.ts +++ b/src/bot/helpers/time.ts @@ -1,12 +1,7 @@ export function timeUnitsBetween(startDate: Date, endDate: Date) { let delta = Math.abs(endDate.getTime() - startDate.getTime()) / 1000 const isNegative = startDate > endDate ? -1 : 1 - const units: [ - [string, number], - [string, number], - [string, number], - [string, number], - ] = [ + const units: [[string, number], [string, number], [string, number], [string, number]] = [ ["days", 24 * 60 * 60], ["hours", 60 * 60], ["minutes", 60], @@ -25,3 +20,10 @@ export function timeUnitsBetween(startDate: Date, endDate: Date) { {}, ) } + +export function formatDateTimeCompact(input: Date): string { + const d = input.toISOString().split("T") + const date = d[0].slice(2) // .split("-").join("/") + const time = d[1].split(".")[0] + return `${date} ${time}` +} diff --git a/src/bot/helpers/ton.ts b/src/bot/helpers/ton.ts index 74b4fd4..f3b36e5 100644 --- a/src/bot/helpers/ton.ts +++ b/src/bot/helpers/ton.ts @@ -2,15 +2,7 @@ /* eslint-disable no-use-before-define */ /* eslint-disable no-await-in-loop */ import { mnemonicToPrivateKey } from "@ton/crypto" -import { - Address, - beginCell, - Cell, - internal, - SendMode, - TonClient, - WalletContractV4, -} from "@ton/ton" +import { Address, beginCell, Cell, internal, SendMode, TonClient, WalletContractV4 } from "@ton/ton" import { config } from "#root/config.js" import { OpenedWallet } from "./wallet" @@ -105,11 +97,7 @@ export function encodeOffChainContent(content: string) { return makeSnakeCell(data) } -export async function waitSeqno( - seqno: number, - wallet: OpenedWallet, - maxAttempts = 30, -) { +export async function waitSeqno(seqno: number, wallet: OpenedWallet, maxAttempts = 30) { for (let attempt = 0; attempt < maxAttempts; attempt++) { await sleep(2000) const seqnoAfter = await wallet.contract.getSeqno() @@ -117,9 +105,7 @@ export async function waitSeqno( return } } - throw new Error( - `Seqno wait failed. Check https://tonviewer.com/${config.COLLECTION_OWNER}`, - ) + throw new Error(`Seqno wait failed. Check https://tonviewer.com/${config.COLLECTION_OWNER}`) } export function sleep(ms: number): Promise { diff --git a/src/bot/index.ts b/src/bot/index.ts index a753b77..ff1d6bf 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -4,11 +4,7 @@ import { hydrate } from "@grammyjs/hydrate" import { hydrateReply, parseMode } from "@grammyjs/parse-mode" import { BotConfig, StorageAdapter, Bot as TelegramBot, session } from "grammy" import { autoRetry } from "@grammyjs/auto-retry" -import { - Context, - SessionData, - createContextConstructor, -} from "#root/bot/context.js" +import { Context, SessionData, createContextConstructor } from "#root/bot/context.js" import { adminFeature, languageFeature, @@ -29,6 +25,7 @@ import { statsFeature, playFeature, balancesFeature, + balanceFeature, } from "#root/bot/features/index.js" import { errorHandler } from "#root/bot/handlers/index.js" import { i18n, isMultipleLocales } from "#root/bot/i18n.js" @@ -91,6 +88,7 @@ export function createBot(token: string, options: Options) { protectedBot.use(transactionFeature) protectedBot.use(addressesFeature) protectedBot.use(playFeature) + protectedBot.use(balanceFeature) protectedBot.use(userFeature) protectedBot.use(balancesFeature) diff --git a/src/bot/keyboards/queue-menu.ts b/src/bot/keyboards/queue-menu.ts index 148908c..66a5b12 100644 --- a/src/bot/keyboards/queue-menu.ts +++ b/src/bot/keyboards/queue-menu.ts @@ -1,12 +1,7 @@ /* eslint-disable no-restricted-syntax */ import { Menu } from "@grammyjs/menu" import { Context } from "#root/bot/context.js" -import { - User, - UserState, - findQueue, - findUserById, -} from "#root/bot/models/user.js" +import { User, UserState, findQueue, findUserById } from "#root/bot/models/user.js" import { config } from "#root/config.js" import { getUserProfileFile } from "#root/bot/helpers/photo.js" import { saveImageFromUrl } from "#root/bot/helpers/files.js" @@ -29,10 +24,7 @@ Minted: ${user.minted ? "✅" : "❌"} ${user.nftUrl ? `[NFT](${user.nftUrl})` : ` } -export async function sendUserMetadata( - context: Context, - selectedUser: DocumentType, -) { +export async function sendUserMetadata(context: Context, selectedUser: DocumentType) { context.chatAction = "upload_document" const adminUser = context.dbuser @@ -55,12 +47,7 @@ export async function sendUserMetadata( const username = selectedUser.name if (!username) return context.reply("Empty username") // eslint-disable-next-line no-param-reassign - selectedUser.avatar = await saveImageFromUrl( - photoUrl, - admIndex, - username, - true, - ) + selectedUser.avatar = await saveImageFromUrl(photoUrl, admIndex, username, true) await selectedUser.save() await context.replyWithPhoto(nextAvatar.file_id, { @@ -120,9 +107,7 @@ export const queueMenu = new Menu("queue").dynamic(async (cntxt, range) => { const oldUsername = user.name user.name = author.user.username await user.save() - return ctx.reply( - `Username changed from @${oldUsername} to @${user.name}`, - ) + return ctx.reply(`Username changed from @${oldUsername} to @${user.name}`) } await sendUserMetadata(context, user) diff --git a/src/bot/middlewares/attach-user.ts b/src/bot/middlewares/attach-user.ts index 278e0c3..98bbed7 100644 --- a/src/bot/middlewares/attach-user.ts +++ b/src/bot/middlewares/attach-user.ts @@ -1,8 +1,8 @@ import { NextFunction } from "grammy" import { findOrCreateUser } from "#root/bot/models/user.js" import { Context } from "#root/bot/context.js" -import { i18n } from "../i18n" -import { createChangeLanguageKeyboard } from "../keyboards/change-language" +import { i18n } from "#root/bot/i18n.js" +import { createChangeLanguageKeyboard } from "#root/bot/keyboards/change-language.js" export default async function attachUser(ctx: Context, next: NextFunction) { if (!ctx.from) { diff --git a/src/bot/middlewares/check-not-minted.ts b/src/bot/middlewares/check-not-minted.ts index 0e742b2..536173b 100644 --- a/src/bot/middlewares/check-not-minted.ts +++ b/src/bot/middlewares/check-not-minted.ts @@ -1,8 +1,8 @@ import { Api, Middleware, RawApi } from "grammy" import { config } from "#root/config" -import { Context } from "../context" -import { i18n } from "../i18n" -import { inviteTelegramUrl, shareTelegramLink } from "../helpers/telegram" +import { Context } from "#root/bot/context.js" +import { i18n } from "#root/bot/i18n.js" +import { inviteTelegramUrl, shareTelegramLink } from "#root/bot/helpers/telegram.js" export async function sendMintedMessage( api: Api, @@ -32,12 +32,7 @@ ${i18n.t(userLocale, "speedup.variants", { export function checkNotMinted(): Middleware { return (ctx, next) => { if (ctx.dbuser.minted) { - return sendMintedMessage( - ctx.api, - ctx.dbuser.id, - ctx.dbuser.language, - ctx.dbuser.nftUrl ?? "", - ) + return sendMintedMessage(ctx.api, ctx.dbuser.id, ctx.dbuser.language, ctx.dbuser.nftUrl ?? "") } return next() } diff --git a/src/bot/models/balance.ts b/src/bot/models/balance.ts new file mode 100644 index 0000000..e36de79 --- /dev/null +++ b/src/bot/models/balance.ts @@ -0,0 +1,81 @@ +/* eslint-disable no-restricted-syntax */ +import { modelOptions, prop, getModelForClass } from "@typegoose/typegoose" +import { getEnumKeyByValue } from "../helpers/enum" + +export enum BalanceChangeType { + Initial = 0, + Deposit = 1, // $CUBE jettons + Withdraw = 2, + Dice = 3, + Referral = 4, + Donation = 5, // TON +} + +export function getBalanceChangeTypeName(type: BalanceChangeType): string { + return getEnumKeyByValue(BalanceChangeType, type) ?? "Unknown" +} + +@modelOptions({ + schemaOptions: { timestamps: { createdAt: true, updatedAt: false } }, + options: {}, +}) +class Balance { + @prop({ type: Number, required: true, index: true }) + userId!: number + + @prop({ type: BigInt, required: true }) + amount!: bigint + + @prop({ type: Number, required: true, index: true }) + type!: BalanceChangeType + + @prop({ type: Date, required: false }) + createdAt?: Date +} + +const BalanceModel = getModelForClass(Balance) + +export async function countAllBalanceRecords(): Promise { + return BalanceModel.countDocuments() +} + +export async function countUserBalanceRecords(userId: number): Promise { + return BalanceModel.countDocuments({ userId }) +} + +export async function addChangeBalanceRecord( + userId: number, + amount: bigint, + type: BalanceChangeType, +) { + const balance = new BalanceModel({ + userId, + amount, + type, + }) + return balance.save() +} + +export async function getAggregatedBalance(userId: number): Promise { + const balance = await BalanceModel.aggregate([ + { + $match: { + userId, + }, + }, + { + $group: { + _id: undefined, + amount: { $sum: "$amount" }, + }, + }, + ]) + return balance[0].amount +} + +export async function getUserBalanceRecords( + userId: number, + count: number = 20, +): Promise { + return BalanceModel.find({ userId }).sort({ createdAt: -1 }).limit(count) +} diff --git a/src/bot/models/cnft.ts b/src/bot/models/cnft.ts index 7d47e11..1f9b07c 100644 --- a/src/bot/models/cnft.ts +++ b/src/bot/models/cnft.ts @@ -1,10 +1,5 @@ import { Address } from "@ton/core" -import { - DocumentType, - getModelForClass, - modelOptions, - prop, -} from "@typegoose/typegoose" +import { DocumentType, getModelForClass, modelOptions, prop } from "@typegoose/typegoose" export enum CNFTImageType { Dice = "Dice", diff --git a/src/bot/models/transaction.ts b/src/bot/models/transaction.ts index cdfbaed..e5a0872 100644 --- a/src/bot/models/transaction.ts +++ b/src/bot/models/transaction.ts @@ -1,9 +1,4 @@ -import { - modelOptions, - prop, - getModelForClass, - DocumentType, -} from "@typegoose/typegoose" +import { modelOptions, prop, getModelForClass, DocumentType } from "@typegoose/typegoose" @modelOptions({ schemaOptions: { timestamps: false }, diff --git a/src/bot/models/user.ts b/src/bot/models/user.ts index 3b16235..c48b8dd 100644 --- a/src/bot/models/user.ts +++ b/src/bot/models/user.ts @@ -1,13 +1,15 @@ -import { - getModelForClass, - modelOptions, - prop, - DocumentType, -} from "@typegoose/typegoose" +import { getModelForClass, modelOptions, prop, DocumentType } from "@typegoose/typegoose" import { TimeStamps } from "@typegoose/typegoose/lib/defaultClasses.js" import { Address } from "@ton/core" -import { logger } from "#root/logger" -import { ClipGuidancePreset, SDSampler } from "../helpers/generation" +import { logger } from "#root/logger.js" +import { ClipGuidancePreset, SDSampler } from "#root/bot/helpers/generation.js" +import { + addChangeBalanceRecord, + BalanceChangeType, + countAllBalanceRecords, + countUserBalanceRecords, + getAggregatedBalance, +} from "#root/bot/models/balance" export enum UserState { WaitNothing = "WaitNothing", @@ -131,7 +133,11 @@ export class User extends TimeStamps { const UserModel = getModelForClass(User) -export function findOrCreateUser(id: number) { +export async function findOrCreateUser(id: number) { + const isEmptyRecords = (await countUserBalanceRecords(id)) === 0 + if (isEmptyRecords) { + await addChangeBalanceRecord(id, BigInt(0), BalanceChangeType.Initial) + } return UserModel.findOneAndUpdate( { id }, {}, @@ -142,9 +148,7 @@ export function findOrCreateUser(id: number) { ) } -export async function findUserByAddress( - address: Address, -): Promise | null> { +export async function findUserByAddress(address: Address): Promise | null> { // EQ address const bounceableAddress = address.toString({ bounceable: true }) // UQ address @@ -158,15 +162,11 @@ export async function findUserByAddress( return UserModel.findOne({ wallet: nonBounceableAddress }) } -export async function findUserById( - id: number, -): Promise | null> { +export async function findUserById(id: number): Promise | null> { return UserModel.findOne({ id }) } -export async function findUserByName( - name: string, -): Promise | null> { +export async function findUserByName(name: string): Promise | null> { return UserModel.findOne({ name }) } @@ -240,9 +240,7 @@ export async function placeInLine(votes: bigint): Promise { return count } -export async function placeInWhales( - votes: bigint, -): Promise { +export async function placeInWhales(votes: bigint): Promise { const count = await UserModel.countDocuments({ votes: { $gte: votes }, }) @@ -252,7 +250,7 @@ export async function placeInWhales( return count } -export async function addPoints(userId: number, add: bigint) { +export async function addPoints(userId: number, add: bigint, reason: BalanceChangeType) { try { const updatedUser = await UserModel.findOneAndUpdate( { id: userId }, @@ -262,6 +260,14 @@ export async function addPoints(userId: number, add: bigint) { if (!updatedUser) { throw new Error("User for addPoints not found") } + + const newRecord = await addChangeBalanceRecord(userId, add, reason) + if (logger.level === "debug") { + logger.debug( + `Add ${newRecord.amount} points to ${userId}. Now ${await getAggregatedBalance(userId)}`, + ) + } + logger.info(`Add ${add} points to ${userId}. Now ${updatedUser.votes}`) // TODO: save log in db return updatedUser.votes @@ -291,3 +297,13 @@ export async function userStats() { }) return { all, minted, notMinted, month, week, day } } + +export async function createInitialBalancesIfNotExists() { + if ((await countAllBalanceRecords()) > 0) { + return + } + const users = await UserModel.find() + await Promise.all( + users.map(user => addChangeBalanceRecord(user.id, user.votes, BalanceChangeType.Initial)), + ) +} diff --git a/src/config.ts b/src/config.ts index 4ffd835..f0ad327 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,9 +21,7 @@ const createConfigFromEnvironment = (environment: NodeJS.ProcessEnv) => { BOT_WEBHOOK: z.string().default(""), BOT_SERVER_HOST: z.string().default("0.0.0.0"), BOT_SERVER_PORT: port().default(80), - BOT_ALLOWED_UPDATES: z - .array(z.enum(API_CONSTANTS.ALL_UPDATE_TYPES)) - .default([]), + BOT_ALLOWED_UPDATES: z.array(z.enum(API_CONSTANTS.ALL_UPDATE_TYPES)).default([]), MONGO: z.string(), BOT_ADMINS: z.array(z.number()).default([]), WEB_APP_URL: z.string().url(), diff --git a/src/main.ts b/src/main.ts index 4f3d479..acf2445 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,15 +1,17 @@ #!/usr/bin/env tsx import { onShutdown } from "node-graceful-shutdown" +import mongoose from "mongoose" import { createBot } from "#root/bot/index.js" import { config } from "#root/config.js" import { logger } from "#root/logger.js" import { createServer } from "#root/server.js" -import mongoose from "mongoose" -import { Subscription } from "#root/subscription" +import { Subscription } from "#root/subscription.js" +import { createInitialBalancesIfNotExists } from "#root/bot/models/user.js" try { await mongoose.connect(config.MONGO) const bot = createBot(config.BOT_TOKEN, {}) + await createInitialBalancesIfNotExists() const server = await createServer(bot) // Graceful shutdown diff --git a/src/subscription.ts b/src/subscription.ts index 5113505..d676b07 100644 --- a/src/subscription.ts +++ b/src/subscription.ts @@ -13,10 +13,8 @@ import { } from "#root/bot/models/transaction" import { AccountSubscription } from "#root/bot/helpers/account-subscription" import { tonToPoints } from "#root/bot/helpers/ton" -import { - sendMessageToAdmins, - sendPlaceInLine, -} from "#root/bot/helpers/telegram" +import { sendMessageToAdmins, sendPlaceInLine } from "#root/bot/helpers/telegram" +import { BalanceChangeType } from "./bot/models/balance" export class Subscription { bot: Bot> @@ -51,9 +49,7 @@ export class Subscription { // save to the database that this payment has been processed. const trx = await findTransaction(lt, hash) if (trx) { - return logger.debug( - `Exists ${TonWeb.utils.fromNano(value)} TON from ${senderAddress}`, - ) + return logger.debug(`Exists ${TonWeb.utils.fromNano(value)} TON from ${senderAddress}`) } const ton = Number(fromNano(value)) @@ -67,9 +63,7 @@ export class Subscription { trxModel.hash = hash trxModel.accepted = true await trxModel.save() - logger.info( - `Receive ${TonWeb.utils.fromNano(value)} TON from ${senderAddress.toString()}"`, - ) + logger.info(`Receive ${TonWeb.utils.fromNano(value)} TON from ${senderAddress.toString()}"`) const points = tonToPoints(ton) logger.info(`Received ${ton} TON => ${points} points`) @@ -86,12 +80,9 @@ export class Subscription { return } - await addPoints(user.id, points) + await addPoints(user.id, points, BalanceChangeType.Donation) - await this.bot.api.sendMessage( - user.id, - i18n.t(user.language, "donation", { ton }), - ) + await this.bot.api.sendMessage(user.id, i18n.t(user.language, "donation", { ton })) await sendPlaceInLine(this.bot.api, user.id, true) await sendMessageToAdmins(