Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 77 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- **@grammyjs/i18n**: Internationalization
- **winston**: Logging with timeout monitoring
- **canvas**: QR code generation with random backgrounds
5 changes: 5 additions & 0 deletions app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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<Telegraf.Options<CommunityContext>> = { handlerTimeout };
Expand Down
2 changes: 1 addition & 1 deletion bot/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}]
);

Expand Down
3 changes: 1 addition & 2 deletions bot/ordersActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion jobs/check_solvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const notifyAdmin = async (community: ICommunity, bot: Telegraf<MainContext>) =>
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,
Expand Down
119 changes: 119 additions & 0 deletions util/imageCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { logger } from '../logger';
import path from 'path';

const fs = require('fs').promises;

interface ImageCache {
honeybadgerImage: string | null;
regularImages: string[];
isInitialized: boolean;
}

class ImageCacheManager {
private cache: ImageCache = {
honeybadgerImage: null,
regularImages: [],
isInitialized: false
};

async initialize(): Promise<void> {
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 = goldenImage.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 = imageData.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();
72 changes: 4 additions & 68 deletions util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -541,72 +539,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) => {
Expand Down
Loading