Architecture - gabrielg2020/10000-beers GitHub Wiki
This guide explains the system architecture, design patterns, and code organisation of the 10,000 Beers bot.
- System Overview
- Message Flow
- Service Layer
- Data Model
- Code Structure
- Design Patterns
- Error Handling
- Configuration System
The 10,000 Beers bot is a TypeScript application that integrates WhatsApp, PostgreSQL, and optionally Google Gemini AI to track beer submissions.
βββββββββββββββββββ
β WhatsApp User β
ββββββββββ¬βββββββββ
β Sends image
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β WhatsApp Web (Puppeteer) β
ββββββββββ¬βββββββββββββββββββββββββββββββββ
β Message event
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Message Handler β
β βββββββββββββββββββββββββββββββββββ β
β β Command routing or beer flow β β
β βββββββββββββββββββββββββββββββββββ β
ββββββββββ¬βββββββββββββββββββββββββββββββββ
β
ββββββββββββββββ¬βββββββββββββββ
β β β
βΌ βΌ βΌ
ββββββββββββββββ ββββββββββββ ββββββββββββββββ
β Beer Service β β User β β Image β
β β β Service β β Service β
ββββββββ¬ββββββββ ββββββ¬ββββββ ββββββββ¬ββββββββ
β β β
β βΌ β
β βββββββββββββββ β
β β Prisma ORM β β
β ββββββββ¬βββββββ β
β β β
β βΌ β
β βββββββββββββββ β
β β PostgreSQL β β
β βββββββββββββββ β
β β
βββββββββββββββ¬ββββββββββββββββ
β
βΌ
ββββββββββββββββ
β AI Service β
β (Optional) β
ββββββββ¬ββββββββ
β
βΌ
ββββββββββββββββ
βGoogle Gemini β
ββββββββββββββββ
| Component | Technology | Purpose |
|---|---|---|
| WhatsApp Client | whatsapp-web.js, Puppeteer | WhatsApp Web interface automation |
| Message Handler | TypeScript | Routes messages to commands or beer submission |
| Command Handler | TypeScript | Parses and executes bot commands |
| Beer Service | TypeScript | Orchestrates beer submission workflow |
| User Service | TypeScript | User creation and lookup |
| Image Service | TypeScript | Image validation, storage, hashing |
| AI Service | TypeScript | Optional Gemini classification |
| Statistics Service | TypeScript | Leaderboard calculations |
| Database | PostgreSQL 16, Prisma ORM | Persistent data storage |
| Logger | Pino | Structured logging |
When a user sends an image to the WhatsApp group, the following 8-step process occurs:
1. WhatsApp event received
ββ> messageHandler.handleMessage()
2. Check message origin
ββ> Ignore if not from configured group
3. Check for commands
ββ> Route to commandHandler if command
4. Check for media
ββ> Ignore if no image attached
5. Download and validate
ββ> Get contact info
ββ> Download media
ββ> Validate JPEG format
6. Build submission request
ββ> Create BeerSubmissionRequest object
7. Submit beer
ββ> beerService.submitBeer()
β ββ> userService.findOrCreateUser()
β ββ> imageService.processImage()
β β ββ> Validate mimetype and size
β β ββ> Save to disk
β β ββ> Calculate SHA256 hash
β ββ> beerService.checkDuplicate()
β ββ> aiService.classifyBeer() [if enabled]
β ββ> Create beer record in DB
β ββ> Get total beer count
ββ> Return success message
8. Reply to user
ββ> message.reply() with confirmation
1. User sends "!command args"
ββ> messageHandler.handleMessage()
2. Command detected
ββ> commandHandler.isCommand()
3. Parse command
ββ> Extract command name
ββ> Extract arguments
4. Lookup command
ββ> commandRegistry.getCommand()
5. Check admin permissions
ββ> If adminOnly, verify sender in ADMIN_IDS
6. Execute command
ββ> command.execute(message, args)
7. Reply with result
ββ> message.reply() with command output
The application uses a service-oriented architecture with focused, single-responsibility services.
File: src/services/beerService.ts
Responsibilities:
- Orchestrate beer submission workflow
- Check for duplicate submissions
- Remove beers (admin/undo)
Key methods:
class BeerService {
async submitBeer(request: BeerSubmissionRequest): Promise<BeerSubmissionResult>
async checkDuplicate(userId: string, imageHash: string): Promise<DuplicateCheckResult>
async removeLastBeer(whatsappId: string, timeWindowMinutes?: number): Promise<RemovalResult>
}8-step submission flow:
- Find or create user
- Process image (validate, store, hash)
- Check for duplicates
- AI validation (if enabled)
- Create beer record
- Get total beer count
- Build success message
- Return result
File: src/services/userService.ts
Responsibilities:
- Create and update users
- Lookup users by WhatsApp ID
- Count beers (per-user and total)
Key methods:
class UserService {
async findOrCreateUser(whatsappId: string, displayName: string): Promise<UserInfo>
async getUserBeerCount(userId: string): Promise<number>
async getTotalBeerCount(): Promise<number>
}User creation logic:
- Lookup by
whatsappId - If not found, create new user
- If found, update
displayNameif changed - Return user info with
isNewUserflag
File: src/services/imageService.ts
Responsibilities:
- Download images from WhatsApp
- Validate format (JPEG only) and size
- Store images to disk
- Calculate SHA256 hashes for duplicate detection
- Delete images
Key methods:
class ImageService {
async processImage(media: MessageMedia, userId: string): Promise<ImageProcessingResult>
async deleteImage(imagePath: string): Promise<void>
calculateHash(buffer: Buffer): string
}Image processing steps:
- Validate mimetype (must be
image/jpeg) - Validate file size (β€
MAX_IMAGE_SIZE_MB) - Generate unique filename:
beer_<timestamp>_<random>.jpg - Write to
IMAGE_STORAGE_PATH - Calculate SHA256 hash
- Return path and hash
File: src/services/aiService.ts
Responsibilities:
- Classify images using Google Gemini
- Determine if image contains a beer
- Detect beer type (can, bottle, draught)
Key methods:
class AiService {
async classifyBeer(imagePath: string): Promise<AiClassificationResult>
}Classification logic:
- If
AI_ENABLED=false, always returns valid with no beer type - If enabled, sends image to Gemini with system prompt
- Gemini returns:
isValid,beerType,confidence - If
confidence >= AI_CONFIDENCE_THRESHOLD, accept classification - If
confidence < threshold, setbeerType = nullbut accept submission
File: src/services/statisticsService.ts
Responsibilities:
- Calculate leaderboard rankings
- Aggregate beer counts by user
Key methods:
class StatisticsService {
async getLeaderboard(limit?: number): Promise<LeaderboardEntry[]>
}Leaderboard calculation:
- Counts beers per user
- Sorts by beer count (descending)
- Returns top N users with rank, name, count
The application uses PostgreSQL with Prisma ORM. Schema is defined in src/database/schema.prisma.
model User {
id String @id @default(uuid()) @map("id")
whatsappId String @unique @map("whatsapp_id")
displayName String @map("display_name")
nickname String? @map("nickname")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
beers Beer[]
@@map("users")
}Fields:
-
id- UUID primary key -
whatsappId- Unique WhatsApp ID (e.g.,[email protected]) -
displayName- User's WhatsApp display name (auto-updated) -
nickname- Optional custom nickname (future feature) -
isActive- Soft delete flag -
createdAt- Account creation timestamp
Indexes:
- Unique index on
whatsappId
model Beer {
id String @id @default(uuid()) @map("id")
userId String @map("user_id")
submittedAt DateTime @map("submitted_at")
imagePath String @map("image_path")
imageHash String @map("image_hash")
beerType BeerType? @map("beer_type")
classificationConfidence Float? @map("classification_confidence")
isVerified Boolean @default(false) @map("is_verified")
notes String? @map("notes")
user User @relation(fields: [userId], references: [id])
@@index([userId])
@@index([submittedAt])
@@index([imageHash])
@@map("beers")
}Fields:
-
id- UUID primary key -
userId- Foreign key to User -
submittedAt- When the beer was submitted (from WhatsApp message timestamp) -
imagePath- File system path to stored image -
imageHash- SHA256 hash for duplicate detection -
beerType- Enum:can,bottle,draught, ornull -
classificationConfidence- AI confidence score (0.0-1.0) -
isVerified- Manual verification flag (future feature) -
notes- Admin notes (future feature)
Indexes:
-
userId- Fast user beer lookups -
submittedAt- Chronological queries -
imageHash- Duplicate detection
enum BeerType {
can
bottle
draught
}User (1) ββββ (N) Beer
β β
β ββ> imagePath β File system
β ββ> imageHash β Duplicate check
β
ββ> whatsappId β WhatsApp
src/
βββ index.ts # Application entry point
βββ config/
β βββ index.ts # Configuration loader
β βββ types.ts # Config interfaces
β βββ puppeteer.ts # Puppeteer setup
βββ handlers/
β βββ messageHandler.ts # WhatsApp message routing
β βββ commandHandler.ts # Command execution
βββ commands/
β βββ commandRegistry.ts # Command registration system
β βββ types.ts # Command interfaces
β βββ index.ts # Command initialization
β βββ undoCommand.ts # !undo implementation
β βββ leaderboardCommand.ts # !leaderboard implementation
β βββ removeLastCommand.ts # !removeLast implementation
βββ services/
β βββ beerService.ts # Beer submission orchestration
β βββ userService.ts # User management
β βββ imageService.ts # Image processing
β βββ aiService.ts # AI classification
β βββ statisticsService.ts # Leaderboard calculations
βββ database/
β βββ schema.prisma # Prisma schema
β βββ client.ts # Prisma singleton
β βββ index.ts # Database exports
βββ types/
β βββ submission.ts # Beer submission types
β βββ image.ts # Image operation types
β βββ ai.ts # AI classification types
β βββ statistics.ts # Statistics types
βββ utils/
β βββ logger.ts # Pino logger
β βββ imageValidation.ts # Image validation
β βββ fileSystem.ts # File operations
β βββ chromeCleanup.ts # Chromium cleanup
βββ whatsapp/
β βββ client.ts # WhatsApp client factory
βββ system_instruction.md # Gemini AI prompt
Handlers - Entry points for external events
-
messageHandler- WhatsApp message events -
commandHandler- Command parsing and dispatch
Commands - Individual command implementations
-
commandRegistry- Central command registry -
undoCommand,leaderboardCommand, etc. - Command logic
Services - Business logic layer
- Focused, single-responsibility services
- No direct WhatsApp or database coupling
- Testable with mocked dependencies
Database - Data persistence
- Prisma schema and client
- Query logging extension
Types - TypeScript definitions
- Domain types (Beer, User, etc.)
- Request/response types
- Error types
Utils - Shared utilities
- Logger, file system, validation
- No business logic
Services are exported as singleton instances:
// src/services/beerService.ts
export class BeerService {
// ...
}
export const beerService = new BeerService();Usage:
import { beerService } from './services/beerService';
const result = await beerService.submitBeer(request);Benefits:
- Single shared instance across application
- Simple dependency management
- Easy to mock in tests
Commands implement a common interface:
// src/commands/types.ts
export interface Command {
name: string;
aliases: string[];
description: string;
adminOnly: boolean;
execute(message: Message, args: string[]): Promise<void>;
}Registration:
// src/commands/index.ts
commandRegistry.register(new UndoCommand());
commandRegistry.register(new LeaderboardCommand());Benefits:
- Easy to add new commands
- Consistent command structure
- Centralised command management
Prisma client acts as a repository:
// src/database/client.ts
export const prisma = new PrismaClient()
.$extends(queryLoggingExtension);Usage:
const beer = await prisma.beer.create({
data: { userId, imagePath, imageHash }
});Benefits:
- Type-safe database queries
- Automatic SQL generation
- Migration management
WhatsApp client creation:
// src/whatsapp/client.ts
export function createWhatsAppClient(): Client {
return new Client({
authStrategy: new LocalAuth({ clientId: '10000-beers' }),
puppeteer: puppeteerConfig,
});
}All custom errors extend Error and include:
-
message- Technical error message (logged) -
code- Machine-readable error code -
userMessage- Human-readable message (sent to WhatsApp)
export class BeerSubmissionError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly userMessage?: string,
) {
super(message);
this.name = 'BeerSubmissionError';
}
}Error types:
-
BeerSubmissionError- Beer submission failures -
ImageServiceError- Image processing failures -
FileSystemError- File I/O failures -
AiServiceError- AI classification failures -
CommandError- Command execution failures -
ConfigValidationError- Configuration validation
1. Error occurs in service
ββ> Throw custom error with code and userMessage
2. Handler catches error
ββ> Log technical details
ββ> Send userMessage to WhatsApp
3. User receives friendly error
ββ> "Doesn't look like a beer to me mate π€"
Example:
// Service throws custom error
throw new BeerSubmissionError(
'AI rejected beer submission: low confidence',
'AI_VALIDATION_FAILED',
"Doesn't look like a beer to me mate π€",
);
// Handler catches and replies
catch (error) {
if (error instanceof BeerSubmissionError) {
if (error.userMessage) {
await message.reply(error.userMessage);
}
logger.warn({ code: error.code }, 'Beer submission rejected');
}
}All configuration is loaded and validated at startup:
// src/config/index.ts
export const config = loadConfig();Configuration structure:
interface AppConfig {
database: DatabaseConfig;
whatsapp: WhatsAppConfig;
storage: StorageConfig;
bot: BotConfig;
application: ApplicationConfig;
ai: AiConfig;
}Each config section has a validator function:
function validateDatabaseConfig(config: DatabaseConfig): void {
if (!config.url.startsWith('postgresql://') &&
!config.url.startsWith('postgres://')) {
throw new ConfigValidationError(
'Invalid database URL',
['database.url']
);
}
}Startup behavior:
- All config validated before application starts
- Invalid config throws
ConfigValidationErrorwith field details - Application exits immediately on validation failure
Services access config via the singleton:
import { config } from '../config';
class BeerService {
constructor() {
this.replyOnSubmission = config.bot.replyOnSubmission;
}
}Decision: Export services as singleton instances
Rationale:
- Simple dependency injection
- Shared state (e.g., config) across application
- Easy to mock in tests
- No need for complex DI framework
Decision: Use Prisma instead of raw SQL or other ORMs
Rationale:
- Type-safe queries
- Automatic migrations
- Excellent TypeScript integration
- Schema-first development
- Built-in connection pooling
Decision: Use whatsapp-web.js instead of official WhatsApp API
Rationale:
- No official API for group chats
- Free (no API costs)
- Good TypeScript support
- Active community
- Session persistence
Decision: Store images on file system, not in database
Rationale:
- Better performance (no BLOB queries)
- Easier backups (file system snapshots)
- Simpler image serving (direct file access)
- Lower database size
- Hash stored in DB for duplicate detection
Decision: Use SHA256 for duplicate detection
Rationale:
- Collision-resistant (duplicate detection reliable)
- Fast to compute
- Fixed size (64 hex characters)
- Standard library support
- Indexed in database for fast lookups
Three indexes on Beer table:
-
userId- Fast user beer lookups (leaderboard, counts) -
submittedAt- Chronological queries (recent submissions) -
imageHash- Duplicate detection
Prisma handles connection pooling automatically:
- Default pool size: based on CPU cores
- Configurable via
DATABASE_URLparameters
Images stored on file system:
- No database BLOB queries
- Direct file access
- Consider cleanup strategy for old images (future)
When AI is enabled:
- Adds ~1-3 seconds to submission flow
- Consider disabling for faster submissions
- Use
gemini-1.5-flashfor lower latency
All inputs validated:
- WhatsApp IDs (format validation)
- Image mimetypes (JPEG only)
- Image sizes (β€
MAX_IMAGE_SIZE_MB) - Database URL format
- Environment variable types
Prisma prevents SQL injection:
- Parameterized queries
- Type-safe query builder
- No raw SQL (unless explicitly used)
File operations use safe paths:
- No user-controlled file paths
- Images stored in dedicated directory
- Filenames generated server-side
WhatsApp session:
- Stored in
.wwebjs_auth/ - Should not be committed to version control
- Protected via file system permissions
- Development Guide - Coding standards and patterns
- Commands - Command system deep dive