diff --git a/.env-sample b/.env-sample index f60b1e6b..cd274919 100644 --- a/.env-sample +++ b/.env-sample @@ -82,5 +82,10 @@ RELAYS='ws://localhost:7000,ws://localhost:8000,ws://localhost:9000' # Seconds to wait to allow disputes to be started DISPUTE_START_WINDOW=600 + +# Probability of golden honey badger appearance (1 in X orders) +GOLDEN_HONEY_BADGER_PROBABILITY=100 + # Number of notification messages sent to the admin, informing them of lack of solvers before disabling the community. The admin receives `MAX_ADMIN_WARNINGS_BEFORE_DEACTIVATION - 1` notification messages. MAX_ADMIN_WARNINGS_BEFORE_DEACTIVATION=10 + diff --git a/bot/commands.ts b/bot/commands.ts index c5c2a1dc..6e998f20 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -356,11 +356,12 @@ const cancelAddInvoice = async (ctx: CommunityContext, order: IOrder | null = nu } }; -const showHoldInvoice = async (ctx: CommunityContext, bot: HasTelegram, order: IOrder | null = null) => { +const showHoldInvoice = async (ctx: CommunityContext, bot: HasTelegram, order?: IOrder | null) => { try { ctx.deleteMessage(); + if (!order) { - const orderId = (ctx.update as any).callback_query.message.text; + const orderId = (ctx.update as any).callback_query?.message?.text; if (!orderId) return; order = await Order.findOne({ _id: orderId }); if (order === null) return; @@ -369,7 +370,6 @@ const showHoldInvoice = async (ctx: CommunityContext, bot: HasTelegram, order: I const user = await User.findOne({ _id: order.seller_id }); if (!user) return; - // Sellers only can take orders with status WAITING_PAYMENT if (order.status !== 'WAITING_PAYMENT') { await messages.invalidDataMessage(ctx, bot, user); return; @@ -384,13 +384,13 @@ const showHoldInvoice = async (ctx: CommunityContext, bot: HasTelegram, order: I return; } - // We create the hold invoice and show it to the seller const description = ctx.i18n.t('hold_invoice_memo', { botName: ctx.botInfo.username, orderId: order._id, fiatCode: order.fiat_code, fiatAmount: order.fiat_amount, }); + let amount; if (order.amount === 0) { amount = await getBtcFiatPrice(order.fiat_code, order.fiat_amount); @@ -399,10 +399,17 @@ const showHoldInvoice = async (ctx: CommunityContext, bot: HasTelegram, order: I const marginPercent = order.price_margin / 100; amount = amount - amount * marginPercent; amount = Math.floor(amount); - order.fee = await getFee(amount, order.community_id); + + + order.fee = await getFee(amount, order.community_id, order.is_golden_honey_badger); order.amount = amount; } - amount = Math.floor(order.amount + order.fee); + + + amount = order.is_golden_honey_badger ? + Math.floor(order.amount) : + Math.floor(order.amount + order.fee); + const { request, hash, secret } = await createHoldInvoice({ description, amount, @@ -411,15 +418,17 @@ const showHoldInvoice = async (ctx: CommunityContext, bot: HasTelegram, order: I order.secret = secret; await order.save(); - // We monitor the invoice to know when the seller makes the payment await subscribeInvoice(bot, hash); + + await messages.showHoldInvoiceMessage( ctx, request, amount, order.fiat_code, order.fiat_amount, - order.random_image + order.random_image, + order.is_golden_honey_badger ); } catch (error) { logger.error(`Error in showHoldInvoice: ${error}`); diff --git a/bot/messages.ts b/bot/messages.ts index cf7c5c73..0df3cbe7 100644 --- a/bot/messages.ts +++ b/bot/messages.ts @@ -111,14 +111,16 @@ const pendingSellMessage = async (ctx: MainContext, user: UserDocument, order: I try { const orderExpirationWindow = Number(process.env.ORDER_PUBLISHED_EXPIRATION_WINDOW) / 60 / 60; - + + const pendingSellCaption = i18n.t('pending_sell', { + channel, + orderExpirationWindow: Math.round(orderExpirationWindow), + }); + await ctx.telegram.sendMediaGroup(user.tg_id, [{ type: 'photo', media: { source: Buffer.from(order.random_image, 'base64') }, - caption: `${i18n.t('pending_sell', { - channel, - orderExpirationWindow: Math.round(orderExpirationWindow), - })}`, + caption: pendingSellCaption, }] ); @@ -127,6 +129,13 @@ const pendingSellMessage = async (ctx: MainContext, user: UserDocument, order: I i18n.t('cancel_order_cmd', { orderId: order._id }), { parse_mode: 'MarkdownV2' } ); + + if (order.is_golden_honey_badger === true && order.type === 'sell') { + await ctx.telegram.sendMessage( + user.tg_id, + i18n.t('golden_honey_badger') + ); + } } catch (error) { logger.error(error); } @@ -326,11 +335,13 @@ const beginTakeBuyMessage = async (ctx: MainContext, bot: HasTelegram, seller: U try { const expirationTime = Number(process.env.HOLD_INVOICE_EXPIRATION_WINDOW) / 60; + + let caption = ctx.i18n.t('begin_take_buy', { expirationTime }); await bot.telegram.sendMediaGroup(seller.tg_id, [{ type: 'photo', media: { source: Buffer.from(order.random_image, 'base64') }, - caption: `${ctx.i18n.t('begin_take_buy', { expirationTime })}`, + caption: caption, }] ); @@ -355,33 +366,41 @@ const beginTakeBuyMessage = async (ctx: MainContext, bot: HasTelegram, seller: U } }; + const showHoldInvoiceMessage = async ( ctx: MainContext, request: string, amount: number, fiatCode: IOrder["fiat_code"], fiatAmount: IOrder["fiat_amount"], - randomImage: IOrder["random_image"] + randomImage: string, + isGoldenHoneyBadger: boolean = false ) => { try { - const currencyObject = getCurrency(fiatCode); - const currency = - !!currencyObject && !!currencyObject.symbol_native - ? currencyObject.symbol_native + const currency = getCurrency(fiatCode); + const currencySymbol = + (currency !== null && currency.symbol_native) + ? currency.symbol_native : fiatCode; + await ctx.reply( ctx.i18n.t('pay_invoice', { amount: numberFormat(fiatCode, amount), - fiatAmount: numberFormat(fiatCode, fiatAmount!), - currency, + fiatAmount: numberFormat(fiatCode, fiatAmount), + currency: currencySymbol, }) ); + + + if (isGoldenHoneyBadger) { + await ctx.reply(ctx.i18n.t('golden_honey_badger')); + } + - // Create QR code const qrBytes = await generateQRWithImage(request, randomImage); - // Send payment request in QR and text + await ctx.replyWithMediaGroup([ { type: 'photo', diff --git a/bot/modules/orders/takeOrder.ts b/bot/modules/orders/takeOrder.ts index 60b1fcf5..a3a7817f 100644 --- a/bot/modules/orders/takeOrder.ts +++ b/bot/modules/orders/takeOrder.ts @@ -1,7 +1,7 @@ import { Telegraf } from 'telegraf'; import { logger } from '../../../logger'; import { Order } from '../../../models'; -import { deleteOrderFromChannel } from '../../../util'; +import { deleteOrderFromChannel, generateRandomImage } from '../../../util'; import * as messages from '../../messages'; import { HasTelegram, MainContext } from '../../start'; import { validateUserWaitingOrder, isBannedFromCommunity, validateTakeSellOrder, validateSeller, validateObjectId, validateTakeBuyOrder } from '../../validations'; @@ -42,26 +42,33 @@ export const takebuy = async (ctx: MainContext, bot: HasTelegram, orderId: strin if (!(await validateObjectId(ctx, orderId))) return; const order = await Order.findOne({ _id: orderId }); if (!order) return; - // We verify if the user is not banned on this community + if (await isBannedFromCommunity(user, order.community_id)) return await messages.bannedUserErrorMessage(ctx, user); if (!(await validateTakeBuyOrder(ctx, bot, user, order))) return; - // We change the status to trigger the expiration of this order - // if the user don't do anything + + const { randomImage, isGoldenHoneyBadger } = await generateRandomImage(user._id.toString()); + order.status = 'WAITING_PAYMENT'; order.seller_id = user._id; order.taken_at = new Date(Date.now()); + + order.random_image = randomImage; + order.is_golden_honey_badger = isGoldenHoneyBadger; + await order.save(); order.status = 'in-progress'; OrderEvents.orderUpdated(order); - // We delete the messages related to that order from the channel + await deleteOrderFromChannel(order, bot.telegram); + await messages.beginTakeBuyMessage(ctx, bot, user, order); } catch (error) { logger.error(error); } }; + export const takesell = async (ctx: MainContext, bot: HasTelegram, orderId: string) => { try { const { user } = ctx; @@ -85,4 +92,4 @@ export const takesell = async (ctx: MainContext, bot: HasTelegram, orderId: stri } catch (error) { logger.error(error); } -}; +}; \ No newline at end of file diff --git a/bot/ordersActions.ts b/bot/ordersActions.ts index 9256bfb1..c15bd4de 100644 --- a/bot/ordersActions.ts +++ b/bot/ordersActions.ts @@ -32,9 +32,10 @@ interface BuildDescriptionArguments { fiatAmount: number[]; fiatCode: string; paymentMethod: string; - priceMargin: any; // ? + priceMargin: any; priceFromAPI: boolean; currency: IFiat; + isGoldenHoneyBadger?: boolean; } interface FiatAmountData { @@ -90,12 +91,30 @@ const createOrder = async ( } const fiatAmountData = getFiatAmountData(fiatAmount); + + + let randomImage = ''; + let isGoldenHoneyBadger = false; + let isGoldenHoneyBadgerOrder = false; + + if (type === 'sell') { + + const result = await generateRandomImage(user._id.toString()); + randomImage = result.randomImage; + isGoldenHoneyBadger = result.isGoldenHoneyBadger; + isGoldenHoneyBadgerOrder = isGoldenHoneyBadger; + } + + const recalculatedFee = isGoldenHoneyBadgerOrder ? + await getFee(amount, community_id, true) : + fee; const baseOrderData = { ...fiatAmountData, amount, - fee, - bot_fee: botFee, + fee: recalculatedFee, + bot_fee: isGoldenHoneyBadgerOrder ? 0 : botFee, + is_golden_honey_badger: isGoldenHoneyBadgerOrder, community_fee: communityFee, creator_id: user._id, type, @@ -116,6 +135,7 @@ const createOrder = async ( priceMargin, priceFromAPI, currency, + isGoldenHoneyBadger }), range_parent_id, community_id, @@ -123,7 +143,6 @@ const createOrder = async ( }; let order; - if (type === 'sell') { order = new Order({ seller_id: user._id, @@ -136,10 +155,12 @@ const createOrder = async ( }); } await order.save(); + - const randomImage = await generateRandomImage(order._id); - order.random_image = randomImage; - await order.save(); + if (type === 'sell' && randomImage) { + order.random_image = randomImage; + await order.save(); + } if (order.status !== 'PENDING') { OrderEvents.orderUpdated(order); @@ -175,6 +196,7 @@ const buildDescription = ( priceMargin, priceFromAPI, currency, + isGoldenHoneyBadger } : BuildDescriptionArguments ) => { try { @@ -237,6 +259,7 @@ const buildDescription = ( description += hashtag; description += tasaText; description += rateText; + return description; } catch (error) { diff --git a/images/Honeybadger.png b/images/Honeybadger.png new file mode 100644 index 00000000..30da9bc7 Binary files /dev/null and b/images/Honeybadger.png differ diff --git a/locales/de.yaml b/locales/de.yaml index 56220972..1829e061 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -613,6 +613,7 @@ bot_info: | User info: ${user_info} +golden_honey_badger: 🍯 Goldener Honigdachs! Es werden keine Gebühren erhoben! 🦡 user_info: | Volume traded: ${volume_traded} Total rating: ${total_rating} diff --git a/locales/en.yaml b/locales/en.yaml index dcf0b6e3..1dd1cb3c 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -617,6 +617,7 @@ bot_info: | User info: ${user_info} +golden_honey_badger: 🍯 Golden Honey Badger! No fee will be charged! 🦡 user_info: | Volume traded: ${volume_traded} Total rating: ${total_rating} diff --git a/locales/es.yaml b/locales/es.yaml index 3e7a4fde..db719e70 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -613,6 +613,7 @@ bot_info: | User info: ${user_info} +golden_honey_badger: 🍯 ¡Tejón de Miel Dorado! ¡No se cobrará comisión! 🦡 user_info: | Volume traded: ${volume_traded} Total rating: ${total_rating} diff --git a/locales/fa.yaml b/locales/fa.yaml index 11ae7030..9139bf29 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -612,6 +612,7 @@ bot_info: | User info: ${user_info} +golden_honey_badger: 🍯 گورکن عسل طلایی! هیچ کارمزدی دریافت نخواهد شد! 🦡 user_info: | Volume traded: ${volume_traded} Total rating: ${total_rating} diff --git a/locales/fr.yaml b/locales/fr.yaml index f97d75d4..b59174f2 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -612,6 +612,7 @@ bot_info: | User info: ${user_info} +golden_honey_badger: 🍯 Blaireau de Miel Doré ! Aucun frais ne sera facturé ! 🦡 user_info: | Volume traded: ${volume_traded} Total rating: ${total_rating} diff --git a/locales/it.yaml b/locales/it.yaml index eb20d467..aafe528e 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -610,6 +610,7 @@ bot_info: | User info: ${user_info} +golden_honey_badger: 🍯 Tasso del Miele Dorato! Non verrà addebitata alcuna commissione! 🦡 user_info: | Volume traded: ${volume_traded} Total rating: ${total_rating} diff --git a/locales/ko.yaml b/locales/ko.yaml index e1e16ec8..484921fd 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -609,6 +609,7 @@ bot_info: | 사용자 정보: ${user_info} +golden_honey_badger: 🍯 골든 허니 배저! 수수료가 부과되지 않습니다! 🦡 user_info: | Volume traded: ${volume_traded} Total rating: ${total_rating} diff --git a/locales/pt.yaml b/locales/pt.yaml index fa37dd4b..343a6aee 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -610,6 +610,7 @@ bot_info: | User info: ${user_info} +golden_honey_badger: 🍯 Texugo de Mel Dourado! Nenhuma taxa será cobrada! 🦡 user_info: | Volume traded: ${volume_traded} Total rating: ${total_rating} diff --git a/locales/ru.yaml b/locales/ru.yaml index 976e625f..a7951588 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -613,6 +613,7 @@ bot_info: | User info: ${user_info} +golden_honey_badger: 🍯 Золотой медоед! Комиссия не будет взиматься! 🦡 user_info: | Volume traded: ${volume_traded} Total rating: ${total_rating} diff --git a/locales/uk.yaml b/locales/uk.yaml index c16f5449..55ff70ba 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -609,6 +609,7 @@ bot_info: | User info: ${user_info} +golden_honey_badger: 🍯 Золотий медоїд! Комісія не стягуватиметься! 🦡 user_info: | Volume traded: ${volume_traded} Total rating: ${total_rating} diff --git a/models/order.ts b/models/order.ts index d1a3e0dc..143d90c1 100644 --- a/models/order.ts +++ b/models/order.ts @@ -45,6 +45,7 @@ export interface IOrder extends Document { is_frozen: boolean; is_public: boolean; random_image: string; + is_golden_honey_badger?: boolean; } const orderSchema = new Schema({ @@ -140,6 +141,7 @@ const orderSchema = new Schema({ is_public: { type: Boolean, default: true }, is_frozen: { type: Boolean, default: false }, random_image: { type: String }, + is_golden_honey_badger: { type: Boolean, default: false }, }); export default mongoose.model('Order', orderSchema); diff --git a/util/index.ts b/util/index.ts index a161e1e0..5f2ef6c9 100644 --- a/util/index.ts +++ b/util/index.ts @@ -426,17 +426,27 @@ const isDisputeSolver = (community: ICommunity | null, user: UserDocument) => { // Return the fee the bot will charge to the seller // this fee is a combination from the global bot fee and the community fee -const getFee = async (amount: number, communityId: string) => { +// When isGoldenHoneyBadger=true, only the community fee is charged (botFee=0) +const getFee = async (amount: number, communityId: string, isGoldenHoneyBadger = false) => { const maxFee = Math.round(amount * Number(process.env.MAX_FEE)); - if (!communityId) return maxFee; + if (!communityId) { + // if no community, return 0 if golden honey badger, otherwise return max fee + return isGoldenHoneyBadger ? 0 : maxFee; + } + // Calculate fees const botFee = maxFee * Number(process.env.FEE_PERCENT); let communityFee = Math.round(maxFee - botFee); const community = await Community.findOne({ _id: communityId }); if (community === null) throw Error("Community was not found in DB"); communityFee = communityFee * (community.fee / 100); - return botFee + communityFee; + + if (isGoldenHoneyBadger) { + return communityFee; + } else { + return botFee + communityFee; + } }; const itemsFromMessage = (str: string) => { @@ -532,22 +542,71 @@ export const removeLightningPrefix = (invoice: string) => { const generateRandomImage = async (nonce: string) => { let randomImage = ''; + let isGoldenHoneyBadger = false; try { - const files = await fs.readdir('images'); - const imageFiles = files.filter(file => - ['.png'].includes(path.extname(file).toLowerCase()) - ); - - const randomFile = imageFiles[Math.floor(Math.random() * imageFiles.length)]; - const fallbackImage = await fs.readFile(`images/${randomFile}`); - - randomImage = Buffer.from(fallbackImage, 'binary').toString('base64'); + const honeybadgerFilename = 'Honeybadger.png'; + const honeybadgerFullPath = `images/${honeybadgerFilename}`; + + let honeybadgerExists = false; + try { + await fs.access(honeybadgerFullPath); + honeybadgerExists = true; + } catch (err) { + logger.error(`Honeybadger image not found: ${err}`); + honeybadgerExists = false; + } + + let wasHoneybadgerSelected = false; + + if (honeybadgerExists) { + const goldenProbability = parseInt(process.env.GOLDEN_HONEY_BADGER_PROBABILITY || '100'); + if (isNaN(goldenProbability)) { + logger.warn("GOLDEN_HONEY_BADGER_PROBABILITY not configured properly, using default 100"); + } + + const probability = isNaN(goldenProbability) ? 100 : Math.max(1, goldenProbability); + const luckyNumber = Math.floor(Math.random() * probability) + 1; + const winningNumber = 1; + + logger.debug(`Golden Honey Badger probability check: ${luckyNumber}/${probability} (wins if ${luckyNumber}=${winningNumber})`); + + if (luckyNumber === winningNumber) { + wasHoneybadgerSelected = true; + + try { + const goldenImage = await fs.readFile(honeybadgerFullPath); + randomImage = Buffer.from(goldenImage, 'binary').toString('base64'); + isGoldenHoneyBadger = true; + logger.info(`🏆 GOLDEN HONEY BADGER ASSIGNED to order with nonce: ${nonce} - FEES WILL BE ZERO`); + } catch (error) { + logger.error(`Error loading Golden Honey Badger image: ${error}`); + isGoldenHoneyBadger = false; + wasHoneybadgerSelected = false; + } + } + } + + if (!wasHoneybadgerSelected) { + const files = await fs.readdir('images'); + const imageFiles = files.filter(file => + ['.png'].includes(path.extname(file).toLowerCase()) && + file !== honeybadgerFilename + ); + + if (imageFiles.length > 0) { + const randomFile = imageFiles[Math.floor(Math.random() * imageFiles.length)]; + const fallbackImage = await fs.readFile(`images/${randomFile}`); + randomImage = Buffer.from(fallbackImage, 'binary').toString('base64'); + } else { + logger.error('No PNG images found in the images directory'); + } + } } catch (fallbackError) { - logger.error(fallbackError); + logger.error(`Error in generateRandomImage: ${fallbackError}`); } - return randomImage; -} + return { randomImage, isGoldenHoneyBadger }; +}; const generateQRWithImage = async (request, randomImage) => { const canvas = createCanvas(400, 400);