From f0f6ab68237ac1cf729c97c5f6de848cb206a4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Mon, 30 Jun 2025 21:26:19 -0300 Subject: [PATCH 1/4] Image Caching Optimization What was implemented: 1. ImageCacheManager class - Manages all image operations in memory 2. Startup initialization - Images are loaded once when the application starts 3. Optimized generateRandomImage - Now synchronous and uses cached data 4. Zero file I/O during order creation - All images served from memory --- CLAUDE.md | 81 +++++++++++++++++++++++++++-- app.ts | 5 ++ bot/ordersActions.ts | 3 +- util/imageCache.ts | 119 +++++++++++++++++++++++++++++++++++++++++++ util/index.ts | 70 ++----------------------- 5 files changed, 206 insertions(+), 72 deletions(-) create mode 100644 util/imageCache.ts diff --git a/CLAUDE.md b/CLAUDE.md index fab34865..a2bededa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,9 +88,82 @@ Copy `.env-sample` to `.env` and configure: ### Testing Tests are in TypeScript and use Mocha with Chai assertions. Test compilation uses a separate tsconfig.test.json that includes the tests directory. +```bash +npm test # Run all tests +npm run pretest # Compile tests only +export NODE_ENV=test && mocha --exit 'dist/tests/**/*.spec.js' # Run specific test pattern +``` + +### Key Architectural Details + +#### Lightning Network Hold Invoice Flow +Two distinct trading patterns: + +**Sell Orders (Seller has Bitcoin)**: +1. Seller creates order → Published to channel +2. Buyer takes order → Seller pays hold invoice (funds locked) +3. Buyer sends fiat → Seller confirms receipt +4. Hold invoice settled → Buyer receives Bitcoin + +**Buy Orders (Buyer wants Bitcoin)**: +1. Buyer creates order → Published to channel +2. Seller takes order → Seller pays hold invoice (funds locked) +3. Buyer provides invoice → Seller sends fiat +4. Buyer confirms fiat receipt → Hold invoice settled → Buyer receives Bitcoin + +#### Context Types and Middleware Chain +- **MainContext**: Base context with i18n, user, admin properties +- **CommunityContext**: Extends MainContext with wizard state for multi-step flows +- Middleware chain: User validation → Admin checking → Context enhancement → Command routing + +#### Job Scheduling Intervals +Critical background processes with specific timing: +- **Pending payments**: Every 5 minutes (configurable via `PENDING_PAYMENT_WINDOW`) +- **Order cancellation**: Every 20 seconds +- **Order deletion**: Every hour at 25 minutes past +- **Community earnings**: Every 10 minutes +- **Node health**: Every minute +- **Solver availability**: Daily at midnight + +#### Multi-language Support +- 10 supported languages via YAML files in `locales/` +- User-specific language preferences stored in database +- Dynamic language switching with `@grammyjs/i18n` +- Message templates with interpolation support + +### Database Schema Patterns + +#### Order Status Lifecycle +Orders follow specific state transitions: +- PENDING → ACTIVE → FIAT_SENT → COMPLETED +- Dispute states: DISPUTE, CANCELED_BY_ADMIN +- Failed states: EXPIRED, CANCELED + +#### Community Features +- Custom fee structures per Telegram group +- Solver assignment and dispute resolution +- Automated earnings calculation and distribution +- Ban management (global and community-level) + +### Development Patterns + +#### Error Handling +- Winston logger with configurable levels and timeout monitoring +- Global unhandled rejection handlers in app.ts +- Try-catch blocks throughout with proper error context +- Graceful shutdown handling (SIGINT/SIGTERM) + +#### TypeScript Configuration +- Strict mode enabled for better type safety +- Separate test configuration (tsconfig.test.json) +- Comprehensive interface definitions for all models +- Custom type extensions for Telegraf contexts + ### Key Dependencies -- **telegraf**: Telegram bot framework -- **mongoose**: MongoDB ODM -- **lightning**: LND node integration +- **telegraf**: Telegram bot framework (4.8.0) +- **mongoose**: MongoDB ODM (6.13.6) +- **lightning**: LND node integration (10.25.0) - **node-schedule**: Cron job scheduling -- **@grammyjs/i18n**: Internationalization \ No newline at end of file +- **@grammyjs/i18n**: Internationalization +- **winston**: Logging with timeout monitoring +- **canvas**: QR code generation with random backgrounds \ No newline at end of file diff --git a/app.ts b/app.ts index 250dd3da..90a02abc 100644 --- a/app.ts +++ b/app.ts @@ -6,6 +6,7 @@ import { resubscribeInvoices } from './ln'; import { logger } from "./logger"; import { Telegraf } from "telegraf"; import { delay } from './util'; +import { imageCache } from './util/imageCache'; import { CommunityContext } from "./bot/modules/community/communityContext"; (async () => { @@ -25,6 +26,10 @@ import { CommunityContext } from "./bot/modules/community/communityContext"; mongoose.connection .once('open', async () => { logger.info('Connected to Mongo instance.'); + + // Initialize image cache for faster order creation + await imageCache.initialize(); + // Use configurable bot handler timeout, default to 60 seconds const handlerTimeout = parseInt(process.env.BOT_HANDLER_TIMEOUT || '60000'); let options: Partial> = { handlerTimeout }; diff --git a/bot/ordersActions.ts b/bot/ordersActions.ts index 04d271a7..4c4042d3 100644 --- a/bot/ordersActions.ts +++ b/bot/ordersActions.ts @@ -98,8 +98,7 @@ const createOrder = async ( let isGoldenHoneyBadgerOrder = false; if (type === 'sell') { - - const result = await generateRandomImage(user._id.toString()); + const result = generateRandomImage(user._id.toString()); randomImage = result.randomImage; isGoldenHoneyBadger = result.isGoldenHoneyBadger; isGoldenHoneyBadgerOrder = isGoldenHoneyBadger; diff --git a/util/imageCache.ts b/util/imageCache.ts new file mode 100644 index 00000000..477ff259 --- /dev/null +++ b/util/imageCache.ts @@ -0,0 +1,119 @@ +import { logger } from '../logger'; + +const fs = require('fs').promises; +const path = require('path'); + +interface ImageCache { + honeybadgerImage: string | null; + regularImages: string[]; + isInitialized: boolean; +} + +class ImageCacheManager { + private cache: ImageCache = { + honeybadgerImage: null, + regularImages: [], + isInitialized: false + }; + + async initialize(): Promise { + try { + logger.info('Initializing image cache...'); + + const honeybadgerFilename = 'Honeybadger.png'; + const honeybadgerFullPath = `images/${honeybadgerFilename}`; + + // Try to load Honeybadger image + try { + const goldenImage = await fs.readFile(honeybadgerFullPath); + this.cache.honeybadgerImage = Buffer.from(goldenImage, 'binary').toString('base64'); + logger.info('Golden Honey Badger image cached successfully'); + } catch (err) { + logger.warning(`Honeybadger image not found: ${err}`); + this.cache.honeybadgerImage = null; + } + + // Load all regular images + try { + const files = await fs.readdir('images'); + const imageFiles = files.filter((file: string) => + ['.png'].includes(path.extname(file).toLowerCase()) && + file !== honeybadgerFilename + ); + + for (const imageFile of imageFiles) { + try { + const imageData = await fs.readFile(`images/${imageFile}`); + const base64Image = Buffer.from(imageData, 'binary').toString('base64'); + this.cache.regularImages.push(base64Image); + } catch (error) { + logger.error(`Error loading image ${imageFile}: ${error}`); + } + } + + logger.info(`Cached ${this.cache.regularImages.length} regular images`); + } catch (error) { + logger.error(`Error reading images directory: ${error}`); + } + + this.cache.isInitialized = true; + logger.info('Image cache initialization completed'); + } catch (error) { + logger.error(`Error initializing image cache: ${error}`); + this.cache.isInitialized = false; + } + } + + generateRandomImage(nonce: string): { randomImage: string; isGoldenHoneyBadger: boolean } { + if (!this.cache.isInitialized) { + logger.warning('Image cache not initialized, returning empty image'); + return { randomImage: '', isGoldenHoneyBadger: false }; + } + + let randomImage = ''; + let isGoldenHoneyBadger = false; + + try { + // Check for Golden Honey Badger + if (this.cache.honeybadgerImage) { + const goldenProbability = parseInt(process.env.GOLDEN_HONEY_BADGER_PROBABILITY || '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) { + randomImage = this.cache.honeybadgerImage; + isGoldenHoneyBadger = true; + logger.info(`🏆 GOLDEN HONEY BADGER ASSIGNED to order with nonce: ${nonce} - FEES WILL BE ZERO`); + return { randomImage, isGoldenHoneyBadger }; + } + } + + // Select random regular image + if (this.cache.regularImages.length > 0) { + const randomIndex = Math.floor(Math.random() * this.cache.regularImages.length); + randomImage = this.cache.regularImages[randomIndex]; + } else { + logger.error('No regular images available in cache'); + } + + } catch (error) { + logger.error(`Error in generateRandomImage: ${error}`); + } + + return { randomImage, isGoldenHoneyBadger }; + } + + getStats(): { honeybadgerCached: boolean; regularImagesCount: number; isInitialized: boolean } { + return { + honeybadgerCached: this.cache.honeybadgerImage !== null, + regularImagesCount: this.cache.regularImages.length, + isInitialized: this.cache.isInitialized + }; + } +} + +// Export singleton instance +export const imageCache = new ImageCacheManager(); \ No newline at end of file diff --git a/util/index.ts b/util/index.ts index 013fbc88..a8c5bb93 100644 --- a/util/index.ts +++ b/util/index.ts @@ -541,72 +541,10 @@ export const removeLightningPrefix = (invoice: string) => { return invoice; }; -const generateRandomImage = async (nonce: string) => { - let randomImage = ''; - let isGoldenHoneyBadger = false; - try { - 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.warning("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: string) => - ['.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(`Error in generateRandomImage: ${fallbackError}`); - } - - return { randomImage, isGoldenHoneyBadger }; +const generateRandomImage = (nonce: string) => { + // Import imageCache here to avoid circular dependency + const { imageCache } = require('./imageCache'); + return imageCache.generateRandomImage(nonce); }; const generateQRWithImage = async (request: string, randomImage: string) => { From b21178d66c4ce02abba9db6a09f7713327d2ed6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Mon, 30 Jun 2025 21:31:23 -0300 Subject: [PATCH 2/4] Remove non used imports --- util/imageCache.ts | 2 +- util/index.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/util/imageCache.ts b/util/imageCache.ts index 477ff259..812e44e2 100644 --- a/util/imageCache.ts +++ b/util/imageCache.ts @@ -1,7 +1,7 @@ import { logger } from '../logger'; const fs = require('fs').promises; -const path = require('path'); +import path from 'path'; interface ImageCache { honeybadgerImage: string | null; diff --git a/util/index.ts b/util/index.ts index a8c5bb93..dccdd80f 100644 --- a/util/index.ts +++ b/util/index.ts @@ -13,8 +13,6 @@ import { logger } from "../logger"; import QRCode from "qrcode"; import { Image, createCanvas } from 'canvas'; -const fs = require('fs').promises; -const path = require('path'); const { I18n } = require('@grammyjs/i18n'); // ISO 639-1 language codes From bc9c3945ce79b9e6dd6a43b12093dede5a457e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Mon, 30 Jun 2025 21:36:24 -0300 Subject: [PATCH 3/4] Fix TS syntax --- bot/messages.ts | 2 +- jobs/check_solvers.ts | 2 +- util/imageCache.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/messages.ts b/bot/messages.ts index cd3c115d..f6481424 100644 --- a/bot/messages.ts +++ b/bot/messages.ts @@ -366,7 +366,7 @@ const beginTakeBuyMessage = async (ctx: MainContext, bot: HasTelegram, seller: U await bot.telegram.sendMediaGroup(seller.tg_id, [{ type: 'photo', media: { source: Buffer.from(order.random_image, 'base64') }, - caption: caption, + caption, }] ); diff --git a/jobs/check_solvers.ts b/jobs/check_solvers.ts index 181b56ea..6970e179 100644 --- a/jobs/check_solvers.ts +++ b/jobs/check_solvers.ts @@ -44,7 +44,7 @@ const notifyAdmin = async (community: ICommunity, bot: Telegraf) => const i18nCtx: I18nContext = await getUserI18nContext(admin); const remainingDays: number = (Number(process.env.MAX_ADMIN_WARNINGS_BEFORE_DEACTIVATION) - 1) - community.warning_messages_count; - const message = remainingDays === 0 ? i18nCtx.t('check_solvers_last_warning', { communityName: community.name }) : i18nCtx.t('check_solvers', { communityName: community.name, remainingDays: remainingDays }); + const message = remainingDays === 0 ? i18nCtx.t('check_solvers_last_warning', { communityName: community.name }) : i18nCtx.t('check_solvers', { communityName: community.name, remainingDays }); await bot.telegram.sendMessage( admin.tg_id, diff --git a/util/imageCache.ts b/util/imageCache.ts index 812e44e2..7ea86b51 100644 --- a/util/imageCache.ts +++ b/util/imageCache.ts @@ -1,7 +1,7 @@ import { logger } from '../logger'; +import path from 'path'; const fs = require('fs').promises; -import path from 'path'; interface ImageCache { honeybadgerImage: string | null; From d79b5cf80209c3c0b93b2d5d3ca0420f9c1c279e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Calder=C3=B3n?= Date: Mon, 30 Jun 2025 21:41:13 -0300 Subject: [PATCH 4/4] Fix/Consistent buffer encoding usage. The 'binary' encoding parameter in Buffer.from() is unnecessary when reading files, as fs.readFile() already returns a Buffer. --- util/imageCache.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/imageCache.ts b/util/imageCache.ts index 7ea86b51..dd02cb1b 100644 --- a/util/imageCache.ts +++ b/util/imageCache.ts @@ -26,7 +26,7 @@ class ImageCacheManager { // Try to load Honeybadger image try { const goldenImage = await fs.readFile(honeybadgerFullPath); - this.cache.honeybadgerImage = Buffer.from(goldenImage, 'binary').toString('base64'); + this.cache.honeybadgerImage = goldenImage.toString('base64'); logger.info('Golden Honey Badger image cached successfully'); } catch (err) { logger.warning(`Honeybadger image not found: ${err}`); @@ -44,7 +44,7 @@ class ImageCacheManager { for (const imageFile of imageFiles) { try { const imageData = await fs.readFile(`images/${imageFile}`); - const base64Image = Buffer.from(imageData, 'binary').toString('base64'); + const base64Image = imageData.toString('base64'); this.cache.regularImages.push(base64Image); } catch (error) { logger.error(`Error loading image ${imageFile}: ${error}`);