rmyndharis--openwa
4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
1579 行
49 KiB
Markdown
1579 行
49 KiB
Markdown
# 03 - System Architecture
|
|
|
|
## 3.1 Architecture Overview
|
|
|
|
### High-Level Architecture
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph Clients["Clients"]
|
|
C1[External Apps]
|
|
C2[Dashboard]
|
|
C3[n8n/Automation]
|
|
end
|
|
|
|
subgraph OpenWA["OpenWA Platform"]
|
|
subgraph API["API Layer"]
|
|
REST[REST API<br/>NestJS]
|
|
WS[WebSocket<br/>Real-time]
|
|
SWAGGER[Swagger<br/>Documentation]
|
|
end
|
|
|
|
subgraph Core["Core Services"]
|
|
SM[Session<br/>Manager]
|
|
MM[Message<br/>Manager]
|
|
WH[Webhook<br/>Manager]
|
|
QM[Queue<br/>Manager]
|
|
end
|
|
|
|
subgraph Engine["WhatsApp Engine (pluggable)"]
|
|
WW[whatsapp-web.js<br/>Puppeteer/Chromium]
|
|
BY[Baileys<br/>WebSocket/No browser]
|
|
end
|
|
|
|
subgraph Storage["Storage Layer"]
|
|
DB[(Database<br/>PostgreSQL/SQLite)]
|
|
REDIS[(Redis<br/>Cache/Queue)]
|
|
FS[File Storage<br/>Media Files]
|
|
end
|
|
end
|
|
|
|
subgraph External["External"]
|
|
WA[WhatsApp<br/>Servers]
|
|
WEBHOOK[Webhook<br/>Endpoints]
|
|
end
|
|
|
|
Clients --> API
|
|
API --> Core
|
|
Core --> Engine
|
|
Core --> Storage
|
|
Engine --> WA
|
|
Core --> WEBHOOK
|
|
```
|
|
|
|
### Component Interaction
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Client
|
|
participant API as REST API
|
|
participant SM as Session Manager
|
|
participant Engine as WA Engine
|
|
participant DB as Database
|
|
participant WA as WhatsApp
|
|
|
|
Client->>API: Create Session
|
|
API->>SM: createSession()
|
|
SM->>DB: Save session config
|
|
SM->>Engine: Initialize
|
|
Engine->>WA: Connect
|
|
WA-->>Engine: QR Code
|
|
Engine-->>SM: QR Ready
|
|
SM-->>API: QR Code data
|
|
API-->>Client: QR Code response
|
|
```
|
|
|
|
## 3.2 Pluggable Architecture Philosophy
|
|
|
|
OpenWA is designed with a **Pluggable Architecture** that allows infrastructure components to be swapped without changing application code. This enables flexible deployments ranging from minimal single-session bots to larger single-node, multi-session installs.
|
|
|
|
> **Note — single-instance:** the live WhatsApp engine layer is stateful and held in-process
|
|
> (an in-memory `Map` in `SessionService`). OpenWA currently runs as **one API instance per
|
|
> session-data volume**; horizontal scaling across multiple API replicas is a future design
|
|
> (not implemented). See [13 - Horizontal Scaling](13-horizontal-scaling.md).
|
|
|
|
### Design Philosophy
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph Philosophy["Core Design Principles"]
|
|
P1[Program to Interfaces]
|
|
P2[Dependency Injection]
|
|
P3[Configuration-driven]
|
|
P4[Zero Code Changes]
|
|
end
|
|
|
|
subgraph Benefits["Benefits"]
|
|
B1[Scale Up/Down Freely]
|
|
B2[Test in Isolation]
|
|
B3[Swap Implementations]
|
|
B4[Environment-specific Config]
|
|
end
|
|
|
|
P1 --> B3
|
|
P2 --> B2
|
|
P3 --> B4
|
|
P4 --> B1
|
|
```
|
|
|
|
**Key Principles:**
|
|
|
|
| Principle | Description | Example |
|
|
|-----------|-------------|---------|
|
|
| **Program to Interfaces** | Core code depends on the `IWhatsAppEngine` abstraction, never on a concrete library | `IWhatsAppEngine` instead of `whatsapp-web.js` `Client` |
|
|
| **Dependency Injection** | Services are wired via NestJS DI (constructor injection of `EngineFactory`, `StorageService`, `CacheService`) | `constructor(private engineFactory: EngineFactory)` |
|
|
| **Configuration-driven** | Backend selection via environment variables | `STORAGE_TYPE=s3`, `ENGINE_TYPE=baileys` |
|
|
| **Zero Code Changes** | Switch backends without modifying application code | Change `.env`, restart |
|
|
|
|
### Adapter Categories
|
|
|
|
The WhatsApp engine is the one true plug-in interface (`IWhatsAppEngine`, with concrete
|
|
adapters resolved through the plugin loader). The other "pluggable" backends are not behind a
|
|
formal `I*Adapter` interface — they are single services that branch internally on a config value:
|
|
`StorageService` (`storageType` = `local` | `s3`), `CacheService` (Redis or fail-open no-op), and
|
|
the TypeORM `data` connection (`sqlite` | `postgres`).
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
subgraph Core["Application Core"]
|
|
APP[Business Logic]
|
|
end
|
|
|
|
subgraph Boundaries["Swappable Boundaries"]
|
|
IE[IWhatsAppEngine<br/>interface + plugin loader]
|
|
SS[StorageService<br/>storageType branch]
|
|
CS[CacheService<br/>Redis / no-op]
|
|
DC[TypeORM 'data' conn<br/>sqlite / postgres]
|
|
end
|
|
|
|
subgraph Implementations["Backends"]
|
|
subgraph Engine
|
|
E1[whatsapp-web.js]
|
|
E2[Baileys]
|
|
end
|
|
subgraph Database
|
|
D1[SQLite]
|
|
D2[PostgreSQL]
|
|
end
|
|
subgraph Storage
|
|
S1[Local FS]
|
|
S2[S3 / MinIO]
|
|
end
|
|
subgraph Cache
|
|
C1[Redis]
|
|
C2[Disabled - no-op]
|
|
end
|
|
end
|
|
|
|
APP --> Boundaries
|
|
IE -.-> Engine
|
|
DC -.-> Database
|
|
SS -.-> Storage
|
|
CS -.-> Cache
|
|
```
|
|
|
|
### WhatsApp Identity Contract (engine-neutral ids)
|
|
|
|
WhatsApp addresses the same entity through several id dialects, and each engine speaks a different one:
|
|
whatsapp-web.js uses `<phone>@c.us`, while Baileys speaks the raw protocol forms `<phone>@s.whatsapp.net`
|
|
and `<lid>@lid` (a privacy id whose number is **not** a phone number). To keep application code, the
|
|
REST/webhook payloads, and plugins free of that, the **engine boundary is an anti-corruption layer**:
|
|
every WhatsApp id an engine emits in a neutral field (`from` / `to` / `chatId` / `author`, contact and
|
|
chat `id`) is reduced to one small **neutral dialect**:
|
|
|
|
| Neutral form | Meaning |
|
|
| --- | --- |
|
|
| `<phone>@c.us` | a user, by phone (the raw `@s.whatsapp.net` form folds into this) |
|
|
| `<id>@g.us` | a group |
|
|
| `<lid>@lid` | a user known **only** by privacy id - phone genuinely unknown (a first-class state) |
|
|
| `status@broadcast`, `<id>@newsletter`, `<id>@broadcast` | special channels |
|
|
|
|
Never `@s.whatsapp.net`, never a `:device` suffix. **Resolution rule:** prefer `@c.us` (resolve a lid
|
|
to its phone when the mapping is known), and fall back to `@lid` only when it can't be resolved - an
|
|
unresolved lid is never faked into a phone number.
|
|
|
|
The shared implementation lives in `src/engine/identity/wa-id.ts` (`parseWaId` / `toNeutralJid`); the
|
|
contract is documented on the `IWhatsAppEngine` interface.
|
|
|
|
> **Rollout status:** the contract is applied per-engine. It currently covers the **Baileys inbound
|
|
> read path** (message / revoked / reaction payloads). Outbound id de-normalization (neutral -> engine
|
|
> dialect on send) and contact/chat list ids are tracked follow-ups.
|
|
|
|
### Engine Lifecycle State Machine
|
|
|
|
A WhatsApp engine moves through the `EngineStatus` enum
|
|
(`engine/interfaces/whatsapp-engine.interface.ts`). The adapter reports the current value via
|
|
`getStatus()` and pushes transitions to the host through the `onStateChanged` callback supplied to
|
|
`initialize()`:
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
[*] --> Disconnected: Create instance
|
|
Disconnected --> Initializing: initialize(callbacks)
|
|
Initializing --> QrReady: QR emitted
|
|
QrReady --> Authenticating: QR scanned
|
|
Authenticating --> Ready: Auth success
|
|
Initializing --> Failed: Terminal error (onError)
|
|
Authenticating --> Failed: Credentials rejected
|
|
Ready --> Disconnected: disconnect() / dropped link
|
|
Ready --> Failed: Fatal error
|
|
Disconnected --> [*]
|
|
Failed --> [*]
|
|
```
|
|
|
|
```typescript
|
|
// engine/interfaces/whatsapp-engine.interface.ts
|
|
export enum EngineStatus {
|
|
DISCONNECTED = 'disconnected',
|
|
INITIALIZING = 'initializing',
|
|
QR_READY = 'qr_ready',
|
|
AUTHENTICATING = 'authenticating',
|
|
READY = 'ready',
|
|
FAILED = 'failed',
|
|
}
|
|
```
|
|
|
|
There is no generic `IAdapterLifecycle`/`AdapterState` abstraction — only the engine carries an
|
|
explicit status enum. Storage, cache, and the database connection have no separate lifecycle type;
|
|
they follow the standard NestJS provider lifecycle (`OnModuleInit` / `OnModuleDestroy`).
|
|
|
|
### Dependency Injection & Module Wiring
|
|
|
|
OpenWA does **not** use a dynamic `AdaptersModule` or string DI tokens. `AppModule`
|
|
(`src/app.module.ts`) imports concrete feature modules directly and configures two **named TypeORM
|
|
connections**:
|
|
|
|
- **`main`** — always SQLite (`./data/main.sqlite`); owns the auth (`api_keys`) and audit
|
|
(`audit_logs`) entities. Fixed boot config, not pluggable.
|
|
- **`data`** — the pluggable user-data connection: `sqlite` (default) or `postgres`, selected by
|
|
`DATABASE_TYPE`. Owns the session/webhook/message/template/engine entities.
|
|
|
|
The engine is provided by `EngineModule` as the `EngineFactory` **class** (a normal injectable, not a
|
|
string token). Storage and cache are provided as the `StorageService` and `CacheService` classes by
|
|
their respective modules.
|
|
|
|
```typescript
|
|
// src/app.module.ts (shape)
|
|
@Module({
|
|
imports: [
|
|
ConfigModule.forRoot({ isGlobal: true, load: [configuration], validate: validateEnv }),
|
|
|
|
// Auth + audit — always SQLite
|
|
TypeOrmModule.forRootAsync({ name: 'main', /* ... ./data/main.sqlite ... */ }),
|
|
|
|
// Pluggable user data — sqlite | postgres via DATABASE_TYPE
|
|
TypeOrmModule.forRootAsync({ name: 'data', /* ... */ }),
|
|
|
|
CacheModule, // provides CacheService
|
|
StorageModule, // provides StorageService
|
|
EngineModule, // provides EngineFactory
|
|
SessionModule, MessageModule, WebhookModule, /* ...other feature modules... */
|
|
],
|
|
})
|
|
export class AppModule {}
|
|
```
|
|
|
|
### Using the Backends in Services
|
|
|
|
Services receive the backends by **constructor injection of the concrete class** — there is no
|
|
`@Inject('…_ADAPTER')` token:
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class SomeService {
|
|
constructor(
|
|
private readonly storage: StorageService, // branches local vs s3 internally
|
|
private readonly cache: CacheService, // Redis when enabled, else a no-op
|
|
) {}
|
|
|
|
async saveMedia(filePath: string, data: Buffer) {
|
|
await this.storage.putFile(filePath, data); // path-safety guarded
|
|
await this.cache.setSessionStatus('id', 'READY'); // no-op if Redis disabled
|
|
}
|
|
}
|
|
```
|
|
|
|
### Runtime Configuration Flow
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant Env as .env File
|
|
participant Config as ConfigService
|
|
participant Svc as StorageService
|
|
participant App as Application
|
|
|
|
Note over Env: STORAGE_TYPE=s3
|
|
Env->>Config: Load + validateEnv
|
|
Config->>Svc: storage.type = 's3'
|
|
Svc->>Svc: construct S3Client (forcePathStyle: true)
|
|
App->>Svc: putFile / getFile (unaware of backend)
|
|
```
|
|
|
|
### Backend Selection Matrix
|
|
|
|
| Environment | Database | Storage | Cache | Engine | Use Case |
|
|
|-------------|----------|---------|-------|--------|----------|
|
|
| **Development** | SQLite | Local | Disabled | whatsapp-web.js | Fast iteration, testing |
|
|
| **Testing** | SQLite | Local | Disabled | whatsapp-web.js | CI/CD, unit tests |
|
|
| **Staging** | PostgreSQL | Local | Redis | whatsapp-web.js | Pre-production validation |
|
|
| **Production (Small)** | SQLite | Local | Disabled | whatsapp-web.js | 1-3 sessions, VPS |
|
|
| **Production (Medium)** | PostgreSQL | Local | Redis | whatsapp-web.js | 5-10 sessions |
|
|
| **Production (Large)** | PostgreSQL | S3/MinIO | Redis | whatsapp-web.js | 10+ sessions, HA |
|
|
|
|
### Hot-Swap Considerations
|
|
|
|
> **Note:** Adapter hot-swap (changing adapter without restart) is **not supported** in v1.0. Changing adapter requires application restart.
|
|
|
|
Future considerations for hot-swap:
|
|
- Graceful connection draining
|
|
- State migration between adapters
|
|
- Zero-downtime switching
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
A[Config Change Detected] --> B{Hot-swap Supported?}
|
|
B -->|v1.0: No| C[Log Warning]
|
|
C --> D[Require Restart]
|
|
|
|
B -->|Future: Yes| E[Drain Connections]
|
|
E --> F[Initialize New Adapter]
|
|
F --> G[Migrate State]
|
|
G --> H[Switch Traffic]
|
|
H --> I[Shutdown Old Adapter]
|
|
```
|
|
|
|
## 3.3 Layered Architecture
|
|
|
|
### Layered Architecture Pattern
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph Presentation["Presentation Layer"]
|
|
direction LR
|
|
REST[REST Controllers]
|
|
WS[WebSocket Gateways]
|
|
SWAGGER[OpenAPI Docs]
|
|
end
|
|
|
|
subgraph Application["Application Layer"]
|
|
direction LR
|
|
SESS[Session Service]
|
|
MSG[Message Service]
|
|
WH[Webhook Service]
|
|
AUTH[Auth Service]
|
|
end
|
|
|
|
subgraph Domain["Domain Layer"]
|
|
direction LR
|
|
ENT[Entities]
|
|
REPO[Repository Interfaces]
|
|
EVT[Domain Events]
|
|
end
|
|
|
|
subgraph Infrastructure["Infrastructure Layer"]
|
|
direction LR
|
|
DB[Database]
|
|
CACHE[Redis]
|
|
ENGINE[WA Engine]
|
|
HTTP[HTTP Client]
|
|
end
|
|
|
|
Presentation --> Application
|
|
Application --> Domain
|
|
Application --> Infrastructure
|
|
Domain --> Infrastructure
|
|
```
|
|
|
|
## 3.4 Module Structure
|
|
|
|
### NestJS Module Organization
|
|
|
|
```
|
|
src/
|
|
├── main.ts # Application entry point
|
|
├── app.module.ts # Root module
|
|
│
|
|
├── common/ # Shared utilities
|
|
│ ├── decorators/
|
|
│ ├── filters/
|
|
│ ├── guards/
|
|
│ ├── interceptors/
|
|
│ ├── pipes/
|
|
│ └── utils/
|
|
│
|
|
├── config/ # Configuration
|
|
│ ├── config.module.ts
|
|
│ ├── config.service.ts
|
|
│ └── configuration.ts
|
|
│
|
|
├── modules/
|
|
│ ├── session/ # Session management
|
|
│ │ ├── session.module.ts
|
|
│ │ ├── session.controller.ts
|
|
│ │ ├── session.service.ts
|
|
│ │ ├── session.repository.ts
|
|
│ │ ├── dto/
|
|
│ │ └── entities/
|
|
│ │
|
|
│ ├── message/ # Message handling
|
|
│ │ ├── message.module.ts
|
|
│ │ ├── message.controller.ts
|
|
│ │ ├── message.service.ts
|
|
│ │ └── dto/
|
|
│ │
|
|
│ ├── webhook/ # Webhook management
|
|
│ │ ├── webhook.module.ts
|
|
│ │ ├── webhook.controller.ts
|
|
│ │ ├── webhook.service.ts
|
|
│ │ └── dto/
|
|
│ │
|
|
│ ├── contact/ # Contact management
|
|
│ │ ├── contact.module.ts
|
|
│ │ ├── contact.controller.ts
|
|
│ │ └── contact.service.ts
|
|
│ │
|
|
│ ├── group/ # Group management
|
|
│ │ ├── group.module.ts
|
|
│ │ ├── group.controller.ts
|
|
│ │ └── group.service.ts
|
|
│ │
|
|
│ ├── auth/ # Authentication
|
|
│ │ ├── auth.module.ts
|
|
│ │ ├── auth.guard.ts
|
|
│ │ └── api-key.strategy.ts
|
|
│ │
|
|
│ └── health/ # Health checks
|
|
│ ├── health.module.ts
|
|
│ └── health.controller.ts
|
|
│
|
|
├── engine/ # WhatsApp engine wrapper
|
|
│ ├── engine.module.ts
|
|
│ ├── engine.service.ts
|
|
│ ├── engine.factory.ts
|
|
│ └── interfaces/
|
|
│
|
|
├── queue/ # Job queue
|
|
│ ├── queue.module.ts
|
|
│ ├── processors/
|
|
│ └── jobs/
|
|
│
|
|
└── database/ # Database
|
|
├── database.module.ts
|
|
├── migrations/
|
|
└── seeds/
|
|
```
|
|
|
|
## 3.5 Core Components Design
|
|
|
|
### 3.5.1 Session Manager
|
|
|
|
```mermaid
|
|
classDiagram
|
|
class SessionManager {
|
|
-sessions: Map~string, Session~
|
|
-repository: SessionRepository
|
|
-engineFactory: EngineFactory
|
|
+createSession(config): Session
|
|
+getSession(id): Session
|
|
+deleteSession(id): void
|
|
+getAllSessions(): Session[]
|
|
+restoreSessions(): void
|
|
}
|
|
|
|
class Session {
|
|
+id: string
|
|
+name: string
|
|
+status: SessionStatus
|
|
+engine: WhatsAppEngine
|
|
+config: SessionConfig
|
|
+createdAt: Date
|
|
+start(): void
|
|
+stop(): void
|
|
+getQR(): string
|
|
}
|
|
|
|
class SessionStatus {
|
|
<<enumeration>>
|
|
CREATED
|
|
INITIALIZING
|
|
QR_READY
|
|
AUTHENTICATED
|
|
READY
|
|
DISCONNECTED
|
|
FAILED
|
|
}
|
|
|
|
class WhatsAppEngine {
|
|
<<interface>>
|
|
+initialize(callbacks): Promise~void~
|
|
+sendTextMessage(chatId, text): Promise~MessageResult~
|
|
+getStatus(): EngineStatus
|
|
+getContacts(): Promise~Contact[]~
|
|
+disconnect(): Promise~void~
|
|
}
|
|
|
|
SessionManager --> Session
|
|
Session --> SessionStatus
|
|
Session --> WhatsAppEngine
|
|
```
|
|
|
|
### 3.5.2 Message Flow
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph Outbound["Outbound Message Flow"]
|
|
A1[API Request] --> V1[Validate]
|
|
V1 --> Q1[Queue Job]
|
|
Q1 --> P1[Process]
|
|
P1 --> E1[Engine Send]
|
|
E1 --> R1[Response]
|
|
end
|
|
|
|
subgraph Inbound["Inbound Message Flow"]
|
|
E2[Engine Event] --> P2[Process]
|
|
P2 --> S2[Store]
|
|
S2 --> W2[Webhook Queue]
|
|
W2 --> D2[Deliver]
|
|
end
|
|
```
|
|
|
|
### 3.5.3 Webhook System
|
|
|
|
```mermaid
|
|
classDiagram
|
|
class WebhookManager {
|
|
-webhooks: Webhook[]
|
|
-httpClient: HttpService
|
|
-queue: Queue
|
|
+registerWebhook(config): Webhook
|
|
+removeWebhook(id): void
|
|
+dispatch(event): void
|
|
-deliverWithRetry(webhook, payload): void
|
|
}
|
|
|
|
class Webhook {
|
|
+id: string
|
|
+url: string
|
|
+events: EventType[]
|
|
+secret: string
|
|
+active: boolean
|
|
+retryCount: number
|
|
+headers: Record
|
|
}
|
|
|
|
class WebhookPayload {
|
|
+event: EventType
|
|
+timestamp: Date
|
|
+sessionId: string
|
|
+data: any
|
|
+signature: string
|
|
}
|
|
|
|
class EventType {
|
|
<<enumeration>>
|
|
MESSAGE_RECEIVED
|
|
MESSAGE_SENT
|
|
MESSAGE_ACK
|
|
SESSION_STATUS
|
|
QR_CODE
|
|
}
|
|
|
|
WebhookManager --> Webhook
|
|
WebhookManager --> WebhookPayload
|
|
Webhook --> EventType
|
|
```
|
|
|
|
## 3.6 Data Flow Diagrams
|
|
|
|
### 3.6.1 Send Message Flow
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
subgraph Request["1. Request"]
|
|
A[Client] -->|POST /messages| B[Controller]
|
|
end
|
|
|
|
subgraph Validation["2. Validation"]
|
|
B --> C{Valid?}
|
|
C -->|No| D[400 Error]
|
|
C -->|Yes| E[Service]
|
|
end
|
|
|
|
subgraph Processing["3. Processing"]
|
|
E --> F[Get Session]
|
|
F --> G{Session Ready?}
|
|
G -->|No| H[400 Error]
|
|
G -->|Yes| I[Queue Job]
|
|
end
|
|
|
|
subgraph Execution["4. Execution"]
|
|
I --> J[Worker]
|
|
J --> K[Engine]
|
|
K --> L[WhatsApp]
|
|
end
|
|
|
|
subgraph Response["5. Response"]
|
|
L --> M[Success]
|
|
M --> N[Store]
|
|
N --> O[Response]
|
|
end
|
|
```
|
|
|
|
### 3.6.2 Webhook Delivery Flow
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
A[Event Triggered] --> B[Create Payload]
|
|
B --> C[Sign Payload]
|
|
C --> D[Queue Delivery Job]
|
|
D --> E[Worker Process]
|
|
E --> F{Deliver}
|
|
F -->|Success| G[Mark Delivered]
|
|
F -->|Failed| H{Retry < 3?}
|
|
H -->|Yes| I[Delay & Retry]
|
|
I --> E
|
|
H -->|No| J[Mark Failed]
|
|
J --> K[Log Error]
|
|
```
|
|
|
|
## 3.7 Technology Architecture
|
|
|
|
### 3.7.1 Runtime Environment
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph Container["Docker Container"]
|
|
subgraph Node["Node.js Runtime"]
|
|
NEST[NestJS Application]
|
|
WW[whatsapp-web.js<br/>or Baileys]
|
|
end
|
|
|
|
subgraph Browser["Headless Browser (wwebjs only)"]
|
|
CHROME[Chromium]
|
|
end
|
|
|
|
Node -.->|ENGINE_TYPE=whatsapp-web.js| Browser
|
|
end
|
|
|
|
subgraph External["External Services"]
|
|
PG[(PostgreSQL)]
|
|
RD[(Redis)]
|
|
end
|
|
|
|
Container --> External
|
|
```
|
|
|
|
### 3.7.2 Deployment Architecture
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph Production["Production Environment"]
|
|
LB[Load Balancer] --> I1[Instance 1]
|
|
LB --> I2[Instance 2]
|
|
LB --> I3[Instance N]
|
|
|
|
I1 --> DB[(PostgreSQL)]
|
|
I2 --> DB
|
|
I3 --> DB
|
|
|
|
I1 --> REDIS[(Redis)]
|
|
I2 --> REDIS
|
|
I3 --> REDIS
|
|
end
|
|
|
|
subgraph Storage["Shared Storage"]
|
|
S3[S3/MinIO<br/>Media Files]
|
|
end
|
|
|
|
I1 --> S3
|
|
I2 --> S3
|
|
I3 --> S3
|
|
```
|
|
|
|
## 3.8 API Architecture
|
|
|
|
### RESTful API Design
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
subgraph Endpoints["API Endpoints"]
|
|
direction TB
|
|
S["/api/sessions"]
|
|
M["/api/sessions/:sessionId/messages"]
|
|
W["/api/sessions/:sessionId/webhooks"]
|
|
C["/api/sessions/:sessionId/contacts"]
|
|
G["/api/sessions/:sessionId/groups"]
|
|
H["/health"]
|
|
end
|
|
|
|
subgraph Methods["HTTP Methods"]
|
|
GET
|
|
POST
|
|
PUT
|
|
DELETE
|
|
end
|
|
|
|
subgraph Format["Response Format"]
|
|
JSON[JSON Response]
|
|
ERR[Error Format]
|
|
PAGE[Pagination]
|
|
end
|
|
```
|
|
|
|
### API Response Structure
|
|
|
|
Responses are the **raw handler payload** — there is no `{success, data, meta}` envelope.
|
|
A controller that returns an object sends exactly that object; a list endpoint returns a bare array.
|
|
Errors use the NestJS default shape.
|
|
|
|
```typescript
|
|
// Success Response — the resource itself
|
|
{
|
|
"id": "abc",
|
|
"name": "my-session",
|
|
"status": "READY"
|
|
}
|
|
|
|
// List Response — a bare array
|
|
[
|
|
{ "id": "abc", "name": "my-session", "status": "READY" },
|
|
{ "id": "def", "name": "other-session", "status": "DISCONNECTED" }
|
|
]
|
|
|
|
// Error Response — NestJS default shape
|
|
{
|
|
"statusCode": 404,
|
|
"message": "Session with id 'xxx' not found",
|
|
"error": "Not Found"
|
|
}
|
|
```
|
|
|
|
## 3.9 Security Architecture
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph External["External Request"]
|
|
R[Request]
|
|
end
|
|
|
|
subgraph Security["Security Layers"]
|
|
R --> HTTPS[HTTPS/TLS]
|
|
HTTPS --> CORS[CORS Check]
|
|
CORS --> RATE[Rate Limiter]
|
|
RATE --> AUTH[API Key Auth]
|
|
AUTH --> VAL[Input Validation]
|
|
VAL --> APP[Application]
|
|
end
|
|
|
|
subgraph Internal["Internal Security"]
|
|
APP --> ENC[Data Encryption]
|
|
ENC --> LOG[Audit Logging]
|
|
end
|
|
```
|
|
|
|
## 3.10 Error Handling Architecture
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
E[Error Occurs] --> T{Error Type}
|
|
|
|
T -->|Validation| V[ValidationException]
|
|
T -->|Not Found| N[NotFoundException]
|
|
T -->|Auth| A[UnauthorizedException]
|
|
T -->|Business| B[BusinessException]
|
|
T -->|System| S[InternalException]
|
|
|
|
V --> F[Exception Filter]
|
|
N --> F
|
|
A --> F
|
|
B --> F
|
|
S --> F
|
|
|
|
F --> R[Formatted Response]
|
|
F --> L[Log Error]
|
|
|
|
L -->|Critical| AL[Alert]
|
|
```
|
|
|
|
## 3.11 Scalability Considerations
|
|
|
|
### Horizontal Scaling Strategy
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph Scaling["Scaling Strategy"]
|
|
direction TB
|
|
|
|
subgraph Stateless["Stateless Components"]
|
|
API[API Servers]
|
|
WORKER[Queue Workers]
|
|
end
|
|
|
|
subgraph Stateful["Stateful Components"]
|
|
SESSION[Session Instances]
|
|
end
|
|
|
|
subgraph Shared["Shared State"]
|
|
DB[(Database)]
|
|
REDIS[(Redis)]
|
|
S3[(Object Storage)]
|
|
end
|
|
end
|
|
|
|
Stateless --> Shared
|
|
Stateful --> Shared
|
|
```
|
|
|
|
### Session Affinity
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
subgraph Router["Request Router"]
|
|
R[Request] --> H{Has Session ID?}
|
|
H -->|Yes| A[Route to Affinity]
|
|
H -->|No| B[Round Robin]
|
|
end
|
|
|
|
A --> I1[Instance 1<br/>Session A, B]
|
|
A --> I2[Instance 2<br/>Session C, D]
|
|
B --> I1
|
|
B --> I2
|
|
```
|
|
|
|
---
|
|
|
|
## 3.12 Engine Abstraction Layer
|
|
|
|
> [!IMPORTANT]
|
|
> Engine abstraction is critical to mitigate **R001: WhatsApp Protocol Changes** in Risk Management. OpenWA ships two production-ready engines selectable via `ENGINE_TYPE`: `whatsapp-web.js` (default, Chromium/Puppeteer-based) and `baileys` (browser-free, WebSocket/Noise protocol). With the abstraction layer, adding further engines requires no changes to application code.
|
|
|
|
### Strategy Pattern for Engine
|
|
|
|
```mermaid
|
|
classDiagram
|
|
class IWhatsAppEngine {
|
|
<<interface>>
|
|
+initialize(callbacks): Promise~void~
|
|
+disconnect(): Promise~void~
|
|
+logout(): Promise~void~
|
|
+destroy(): Promise~void~
|
|
+forceDestroy(): Promise~void~
|
|
+getStatus(): EngineStatus
|
|
+getQRCode(): string | null
|
|
+requestPairingCode(phone): Promise~string~
|
|
+sendTextMessage(chatId, text): Promise~MessageResult~
|
|
+sendImageMessage(chatId, media): Promise~MessageResult~
|
|
}
|
|
|
|
class WhatsAppWebJsAdapter {
|
|
-client: Client
|
|
+initialize(callbacks): Promise~void~
|
|
+sendTextMessage(): Promise~MessageResult~
|
|
}
|
|
|
|
class BaileysAdapter {
|
|
-socket: WASocket
|
|
+initialize(callbacks): Promise~void~
|
|
+sendTextMessage(): Promise~MessageResult~
|
|
}
|
|
|
|
class EngineFactory {
|
|
+create(options: EngineCreateOptions): IWhatsAppEngine
|
|
}
|
|
|
|
IWhatsAppEngine <|.. WhatsAppWebJsAdapter
|
|
IWhatsAppEngine <|.. BaileysAdapter
|
|
EngineFactory --> IWhatsAppEngine
|
|
```
|
|
|
|
### Engine Interface Definition
|
|
|
|
Events are **not** delivered through an `on`/`off`/`once` emitter. Instead, the host passes a single
|
|
`EngineEventCallbacks` object to `initialize()`; the adapter invokes the registered callbacks for the
|
|
lifetime of the engine. Status is an `EngineStatus` **enum** (not a string union), `getQRCode()` is
|
|
**synchronous** (`string | null`), and there is no `connect()` / `isReady()` / `getAuthState()` — the
|
|
adapter connects inside `initialize()`.
|
|
|
|
```typescript
|
|
// engine/interfaces/whatsapp-engine.interface.ts
|
|
export enum EngineStatus {
|
|
DISCONNECTED = 'disconnected',
|
|
INITIALIZING = 'initializing',
|
|
QR_READY = 'qr_ready',
|
|
AUTHENTICATING = 'authenticating',
|
|
READY = 'ready',
|
|
FAILED = 'failed',
|
|
}
|
|
|
|
// All inbound signals arrive through callbacks supplied once to initialize().
|
|
export interface EngineEventCallbacks {
|
|
onQRCode?: (qr: string) => void;
|
|
onReady?: (phone: string, pushName: string) => void;
|
|
onMessage?: (message: IncomingMessage) => void;
|
|
onMessageCreate?: (message: IncomingMessage) => void; // outgoing (incl. linked-phone sends)
|
|
onMessageAck?: (messageId: string, status: DeliveryStatus) => void;
|
|
onMessageRevoked?: (message: RevokedMessage) => void;
|
|
onMessageReaction?: (event: ReactionEvent) => void;
|
|
onHistoryMessages?: (messages: IncomingMessage[]) => void; // bulk initial sync; persist, don't dispatch
|
|
onDisconnected?: (reason: string) => void; // recoverable -> reconnect
|
|
onStateChanged?: (state: EngineStatus) => void;
|
|
onError?: (reason: string) => void; // terminal init/auth failure
|
|
}
|
|
|
|
export interface IWhatsAppEngine {
|
|
// Lifecycle — connecting happens inside initialize(); callbacks are registered here.
|
|
initialize(callbacks: EngineEventCallbacks): Promise<void>;
|
|
disconnect(): Promise<void>; // close, keep session (reconnect without QR)
|
|
logout(): Promise<void>; // clear session (requires QR scan again)
|
|
destroy(): Promise<void>;
|
|
forceDestroy(): Promise<void>; // kill this engine's own resources, then graceful teardown
|
|
|
|
// Status / auth
|
|
getStatus(): EngineStatus;
|
|
getQRCode(): string | null; // synchronous
|
|
requestPairingCode(phoneNumber: string): Promise<string>;
|
|
getPhoneNumber(): string | null;
|
|
getPushName(): string | null;
|
|
|
|
// Messaging (selected)
|
|
sendTextMessage(chatId: string, text: string): Promise<MessageResult>;
|
|
sendImageMessage(chatId: string, media: MediaInput): Promise<MessageResult>;
|
|
sendLocationMessage(chatId: string, location: LocationInput): Promise<MessageResult>;
|
|
sendContactMessage(chatId: string, contact: ContactCard): Promise<MessageResult>;
|
|
|
|
// Contacts / groups / chats — see the interface file for the full method set.
|
|
getContacts(): Promise<Contact[]>;
|
|
getGroups(): Promise<Group[]>;
|
|
getChats(): Promise<ChatSummary[]>;
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### Engine Factory
|
|
|
|
The factory resolves the engine through the **plugin loader**, not a hard-coded `switch`. The
|
|
configured engine (`engine.type`, default `'whatsapp-web.js'`) is read once in the constructor; the
|
|
built-in `whatsapp-web.js` and `baileys` plugins are registered and the configured one is enabled in
|
|
`onModuleInit()`. `create()` takes an **options object** (engine-neutral per-call config —
|
|
`sessionId` / `proxyUrl` / `proxyType`), not a `type` argument. There is no `EngineType` union, no
|
|
`switch`, and no `Unknown engine type` throw: if the plugin is unavailable it logs a warning and
|
|
**falls back** to constructing a `WhatsAppWebJsAdapter` directly. (A typo in `ENGINE_TYPE` is rejected
|
|
at boot by `validateEnv`, which whitelists `whatsapp-web.js` | `baileys`.)
|
|
|
|
```typescript
|
|
// engine/engine.factory.ts
|
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { IWhatsAppEngine } from './interfaces/whatsapp-engine.interface';
|
|
import { WhatsAppWebJsAdapter } from './adapters/whatsapp-web-js.adapter';
|
|
import { PluginLoaderService, PluginType, IEnginePlugin } from '../core/plugins';
|
|
|
|
export interface EngineCreateOptions {
|
|
sessionId: string;
|
|
proxyUrl?: string;
|
|
proxyType?: 'http' | 'https' | 'socks4' | 'socks5';
|
|
}
|
|
|
|
@Injectable()
|
|
export class EngineFactory implements OnModuleInit {
|
|
private readonly engineType: string;
|
|
|
|
constructor(
|
|
private readonly configService: ConfigService,
|
|
private readonly pluginLoader: PluginLoaderService,
|
|
/* ...message-store + lid-mapping deps... */
|
|
) {
|
|
this.engineType = this.configService.get<string>('engine.type') ?? 'whatsapp-web.js';
|
|
}
|
|
|
|
async onModuleInit(): Promise<void> {
|
|
// Register the built-in whatsapp-web.js + baileys engine plugins, then enable the configured one.
|
|
await this.registerBuiltInEngines();
|
|
}
|
|
|
|
create(options: EngineCreateOptions): IWhatsAppEngine {
|
|
const enginePlugin = this.pluginLoader.getPlugin(this.engineType);
|
|
|
|
if (enginePlugin?.instance && this.isEnginePlugin(enginePlugin.instance)) {
|
|
// Engine-specific config (e.g. Puppeteer) was handed to the plugin as an opaque blob at
|
|
// registration, so the factory passes only engine-neutral per-call options here.
|
|
return enginePlugin.instance.createEngine({
|
|
sessionId: options.sessionId,
|
|
proxyUrl: options.proxyUrl,
|
|
proxyType: options.proxyType,
|
|
}) as IWhatsAppEngine;
|
|
}
|
|
|
|
// Plugin missing -> warn and fall back to the direct whatsapp-web.js adapter (no throw).
|
|
return this.createFallbackEngine(options);
|
|
}
|
|
}
|
|
```
|
|
|
|
### WhatsApp-Web.js Adapter
|
|
|
|
```typescript
|
|
// engine/adapters/whatsapp-web-js.adapter.ts
|
|
import { Client, LocalAuth } from 'whatsapp-web.js';
|
|
import {
|
|
IWhatsAppEngine,
|
|
EngineEventCallbacks,
|
|
EngineStatus,
|
|
MessageResult,
|
|
} from '../interfaces/whatsapp-engine.interface';
|
|
|
|
export class WhatsAppWebJsAdapter implements IWhatsAppEngine {
|
|
private client: Client | null = null;
|
|
private status: EngineStatus = EngineStatus.DISCONNECTED;
|
|
private callbacks: EngineEventCallbacks = {};
|
|
|
|
// The host registers all event callbacks here; the adapter also connects inside initialize().
|
|
async initialize(callbacks: EngineEventCallbacks): Promise<void> {
|
|
this.callbacks = callbacks;
|
|
this.setStatus(EngineStatus.INITIALIZING);
|
|
|
|
this.client = new Client({
|
|
authStrategy: new LocalAuth({ clientId: this.sessionId, dataPath: this.sessionDataPath }),
|
|
puppeteer: { headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] },
|
|
});
|
|
|
|
this.setupEventHandlers();
|
|
await this.client.initialize();
|
|
}
|
|
|
|
private setupEventHandlers(): void {
|
|
this.client!.on('qr', (qr) => {
|
|
this.setStatus(EngineStatus.QR_READY);
|
|
this.callbacks.onQRCode?.(qr);
|
|
});
|
|
this.client!.on('ready', () => {
|
|
this.setStatus(EngineStatus.READY);
|
|
this.callbacks.onReady?.(this.phoneNumber ?? '', this.pushName ?? '');
|
|
});
|
|
this.client!.on('disconnected', (reason) => {
|
|
this.setStatus(EngineStatus.DISCONNECTED);
|
|
this.callbacks.onDisconnected?.(String(reason));
|
|
});
|
|
this.client!.on('message', (message) => {
|
|
this.callbacks.onMessage?.(this.toIncomingMessage(message)); // mapped to the neutral shape
|
|
});
|
|
}
|
|
|
|
private setStatus(status: EngineStatus): void {
|
|
this.status = status;
|
|
this.callbacks.onStateChanged?.(status);
|
|
}
|
|
|
|
async disconnect(): Promise<void> {
|
|
await this.client?.destroy(); // keep session; logout() clears it
|
|
this.setStatus(EngineStatus.DISCONNECTED);
|
|
}
|
|
|
|
async sendTextMessage(chatId: string, text: string): Promise<MessageResult> {
|
|
const message = await this.client!.sendMessage(chatId, text);
|
|
return { id: message.id._serialized, timestamp: message.timestamp };
|
|
}
|
|
|
|
// ... full method set per the interface
|
|
}
|
|
```
|
|
|
|
### Baileys Adapter (Alternative Engine)
|
|
|
|
```typescript
|
|
// engine/adapters/baileys.adapter.ts
|
|
import makeWASocket, { DisconnectReason, useMultiFileAuthState } from '@whiskeysockets/baileys';
|
|
import {
|
|
IWhatsAppEngine,
|
|
EngineEventCallbacks,
|
|
EngineStatus,
|
|
MessageResult,
|
|
} from '../interfaces/whatsapp-engine.interface';
|
|
|
|
export class BaileysAdapter implements IWhatsAppEngine {
|
|
private socket: ReturnType<typeof makeWASocket> | null = null;
|
|
private status: EngineStatus = EngineStatus.DISCONNECTED;
|
|
private callbacks: EngineEventCallbacks = {};
|
|
|
|
// Baileys connects during initialize(); callbacks are registered here, same as the wwebjs adapter.
|
|
async initialize(callbacks: EngineEventCallbacks): Promise<void> {
|
|
this.callbacks = callbacks;
|
|
this.setStatus(EngineStatus.INITIALIZING);
|
|
|
|
const { state, saveCreds } = await useMultiFileAuthState(`${this.authDir}/${this.sessionId}`);
|
|
this.socket = makeWASocket({ auth: state });
|
|
this.socket.ev.on('creds.update', saveCreds);
|
|
this.setupEventHandlers();
|
|
}
|
|
|
|
private setupEventHandlers(): void {
|
|
this.socket!.ev.on('connection.update', (update) => {
|
|
const { connection, lastDisconnect, qr } = update;
|
|
if (qr) {
|
|
this.setStatus(EngineStatus.QR_READY);
|
|
this.callbacks.onQRCode?.(qr);
|
|
}
|
|
if (connection === 'open') {
|
|
this.setStatus(EngineStatus.READY);
|
|
this.callbacks.onReady?.(this.phoneNumber ?? '', this.pushName ?? '');
|
|
}
|
|
if (connection === 'close') {
|
|
const loggedOut = (lastDisconnect?.error as any)?.output?.statusCode === DisconnectReason.loggedOut;
|
|
this.setStatus(loggedOut ? EngineStatus.FAILED : EngineStatus.DISCONNECTED);
|
|
this.callbacks.onDisconnected?.(loggedOut ? 'logged_out' : 'connection_closed');
|
|
}
|
|
});
|
|
|
|
this.socket!.ev.on('messages.upsert', ({ messages }) => {
|
|
for (const msg of messages) {
|
|
if (!msg.key.fromMe) this.callbacks.onMessage?.(this.toIncomingMessage(msg)); // neutral ids
|
|
}
|
|
});
|
|
}
|
|
|
|
private setStatus(status: EngineStatus): void {
|
|
this.status = status;
|
|
this.callbacks.onStateChanged?.(status);
|
|
}
|
|
|
|
async sendTextMessage(chatId: string, text: string): Promise<MessageResult> {
|
|
const result = await this.socket!.sendMessage(chatId, { text });
|
|
return { id: result!.key.id!, timestamp: Math.floor(Date.now() / 1000) };
|
|
}
|
|
|
|
// ... full method set per the interface
|
|
}
|
|
```
|
|
|
|
### Engine Selection Configuration
|
|
|
|
```bash
|
|
# .env
|
|
ENGINE_TYPE=whatsapp-web.js # Options: whatsapp-web.js (default), baileys
|
|
|
|
# Switch to the browser-free engine
|
|
ENGINE_TYPE=baileys
|
|
```
|
|
|
|
### Migration Strategy
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph Current["Available Engines"]
|
|
A[whatsapp-web.js\nPuppeteer-based\ndefault]
|
|
A2[Baileys\nWebSocket-based\nENGINE_TYPE=baileys]
|
|
end
|
|
|
|
subgraph Risk["Risk Detection"]
|
|
B{Protocol\nBreaking?}
|
|
end
|
|
|
|
subgraph Migration["Migration Path"]
|
|
C[Update whatsapp-web.js]
|
|
D[Switch to Baileys]
|
|
E[Community Fork]
|
|
end
|
|
|
|
subgraph Resolution["Resolution"]
|
|
F[Service Restored]
|
|
end
|
|
|
|
A --> B
|
|
B -->|Minor| C --> F
|
|
B -->|Major wwebjs| D --> F
|
|
B -->|Major Both| E --> F
|
|
```
|
|
|
|
### Engine Comparison
|
|
|
|
| Feature | whatsapp-web.js | Baileys |
|
|
|---------|-----------------|---------|
|
|
| **Protocol** | Web (Puppeteer) | Native WebSocket |
|
|
| **Resource Usage** | High (~500MB/session) | Low (~50MB/session) |
|
|
| **Stability** | Good | Good |
|
|
| **Community** | Large | Large |
|
|
| **Multi-device** | ✅ | ✅ |
|
|
| **QR Code** | ✅ | ✅ |
|
|
| **Phone Link** | ❌ | ✅ |
|
|
| **Maintenance** | Active | Active |
|
|
|
|
### Benefits of Abstraction
|
|
|
|
1. **Risk Mitigation** - Swap engines without changing application code
|
|
2. **Testing** - The single `IWhatsAppEngine` boundary makes the engine trivial to stub/mock in unit tests
|
|
3. **Flexibility** - Run different engines per deployment via `ENGINE_TYPE`
|
|
4. **Future-proof** - New engines register as plugins; no changes to application code
|
|
5. **Comparison** - Evaluate engine resource/behavior trade-offs per environment
|
|
|
|
---
|
|
|
|
## 3.13 Pluggable Adapters
|
|
|
|
OpenWA uses the adapter pattern for infrastructure components that can be swapped per deployment needs. This allows users with limited resources to run OpenWA without heavyweight external dependencies.
|
|
|
|
### Adapter Overview
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph Core["OpenWA Core"]
|
|
APP[Application Logic]
|
|
end
|
|
|
|
subgraph Adapters["Pluggable Backends"]
|
|
subgraph Engine["WhatsApp Engine"]
|
|
E1[whatsapp-web.js]
|
|
E2[Baileys]
|
|
end
|
|
|
|
subgraph Database["Database"]
|
|
D1[SQLite]
|
|
D2[PostgreSQL]
|
|
end
|
|
|
|
subgraph Storage["Media Storage"]
|
|
S1[Local Filesystem]
|
|
S2[S3 / MinIO]
|
|
end
|
|
|
|
subgraph Cache["Cache"]
|
|
C1[Redis]
|
|
C2[Disabled - no-op]
|
|
end
|
|
end
|
|
|
|
APP --> Engine
|
|
APP --> Database
|
|
APP --> Storage
|
|
APP --> Cache
|
|
```
|
|
|
|
### Adapter Options
|
|
|
|
| Component | Options (`ENV`) | Default | Notes |
|
|
|-----------|---------|---------|-------|
|
|
| **WhatsApp Engine** | whatsapp-web.js, Baileys (`ENGINE_TYPE`) | whatsapp-web.js | Baileys is browser-free |
|
|
| **Database** | SQLite, PostgreSQL (`DATABASE_TYPE`) | SQLite | PostgreSQL for large-scale production |
|
|
| **Media Storage** | local, s3 (`STORAGE_TYPE`) | local | MinIO is the `s3` backend (`forcePathStyle`) |
|
|
| **Cache** | Redis or disabled (`REDIS_ENABLED`) | Disabled | When disabled/unreachable, cache fails open (no-op) |
|
|
|
|
### 3.13.1 Storage Service
|
|
|
|
Media storage is a **single service** (`src/common/storage/storage.service.ts`) that branches
|
|
internally on `storageType` — there is no `I*Adapter` interface, separate adapter classes, or a
|
|
`StorageFactory`. The two backends are `local` (the default; files under `./data/media`) and `s3`.
|
|
**MinIO is not a separate type** — it is the `s3` backend; the S3 client is always created with
|
|
`forcePathStyle: true`, which MinIO requires, and any S3-compatible endpoint works. The public method
|
|
set is `putFile` / `getFile` / `listFiles` / `createExportStream` (export) / `importFromStream`
|
|
(import), plus `getFileCount` and `getCurrentStorageType`.
|
|
|
|
```typescript
|
|
// src/common/storage/storage.service.ts
|
|
@Injectable()
|
|
export class StorageService {
|
|
private readonly storageType: string; // 'local' | 's3'
|
|
private readonly localPath: string;
|
|
private s3Client: S3Client | null = null;
|
|
|
|
constructor(private readonly configService: ConfigService) {
|
|
this.storageType = this.configService.get<string>('storage.type') || 'local';
|
|
this.localPath = this.configService.get<string>('storage.localPath') || './data/media';
|
|
|
|
if (this.storageType === 's3') {
|
|
const endpoint = process.env.S3_ENDPOINT; // S3 / MinIO endpoint
|
|
const accessKeyId = process.env.S3_ACCESS_KEY_ID; // legacy S3_ACCESS_KEY also read
|
|
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
|
|
if (endpoint && accessKeyId && secretAccessKey) {
|
|
this.s3Client = new S3Client({
|
|
endpoint,
|
|
region: process.env.S3_REGION || 'us-east-1',
|
|
credentials: { accessKeyId, secretAccessKey },
|
|
forcePathStyle: true, // Required for MinIO; harmless for AWS S3
|
|
});
|
|
// bucket auto-created if missing (HeadBucket -> CreateBucket)
|
|
}
|
|
}
|
|
if (!fs.existsSync(this.localPath)) fs.mkdirSync(this.localPath, { recursive: true });
|
|
}
|
|
|
|
// Both backends share one path-safety guard (isSafeStorageKey) at this boundary.
|
|
async putFile(filePath: string, data: Buffer): Promise<void> {
|
|
if (!isSafeStorageKey(filePath)) throw new Error(`Refusing unsafe storage key: ${filePath}`);
|
|
return this.storageType === 's3' && this.s3Client
|
|
? this.putS3File(filePath, data) // keyed under media/<filePath>
|
|
: this.putLocalFile(filePath, data);
|
|
}
|
|
|
|
async getFile(filePath: string): Promise<Buffer> { /* mirrors putFile */ }
|
|
async listFiles(): Promise<string[]> { /* local recurse, or S3 ListObjectsV2 under media/ */ }
|
|
// createExportStream(): tar.gz of all files; importFromStream(): extract with zip-bomb caps
|
|
}
|
|
```
|
|
|
|
### 3.13.2 Database Adapter
|
|
|
|
OpenWA supports SQLite for lightweight deployments and PostgreSQL for high-volume production.
|
|
|
|
#### Database Comparison
|
|
|
|
| Feature | SQLite | PostgreSQL |
|
|
|---------|--------|------------|
|
|
| **Setup** | Zero config | Requires server |
|
|
| **Concurrent writes** | Limited (1 writer) | Excellent |
|
|
| **Horizontal scaling** | ❌ | ✅ |
|
|
| **Table partitioning** | ❌ | ✅ |
|
|
| **Memory footprint** | ~10MB | ~100MB+ |
|
|
| **Backup** | Copy file | pg_dump |
|
|
| **Best for** | 1-5 sessions | 5+ sessions |
|
|
|
|
#### TypeORM Configuration
|
|
|
|
Database wiring lives inline in `AppModule` (`src/app.module.ts`) as two named
|
|
`TypeOrmModule.forRootAsync` connections — there is no standalone `getDatabaseConfig` helper. The
|
|
`data` connection is the one shown below; the `main` connection is always SQLite (auth + audit). The
|
|
`data` connection's type comes from `DATABASE_TYPE` (`sqlite` default, or `postgres`):
|
|
|
|
```typescript
|
|
// shape of the 'data' connection useFactory in src/app.module.ts
|
|
const dbType = configService.get<'sqlite' | 'postgres'>('dataDatabase.type', 'sqlite');
|
|
const baseConfig = {
|
|
entities: [/* session, webhook, message, template, engine entities */],
|
|
migrations: [__dirname + '/database/migrations/*{.ts,.js}'],
|
|
logging: configService.get<boolean>('dataDatabase.logging', false),
|
|
};
|
|
|
|
if (dbType === 'postgres') {
|
|
return {
|
|
...baseConfig, name: 'data', type: 'postgres',
|
|
host: configService.get('dataDatabase.host'),
|
|
port: configService.get('dataDatabase.port'),
|
|
username: configService.get('dataDatabase.username'),
|
|
password: configService.get('dataDatabase.password'),
|
|
database: configService.get('dataDatabase.name', 'openwa'),
|
|
synchronize: configService.get('dataDatabase.synchronize', false), // migrations in prod
|
|
migrationsRun: true,
|
|
extra: { max: configService.get('dataDatabase.poolSize', 10) },
|
|
};
|
|
}
|
|
|
|
// SQLite (default): migration-managed unless DATABASE_SYNCHRONIZE=true
|
|
const synchronize = configService.get<boolean>('dataDatabase.synchronize', false);
|
|
return {
|
|
...baseConfig, name: 'data', type: 'sqlite',
|
|
database: configService.get('dataDatabase.database', './data/openwa.sqlite'),
|
|
synchronize,
|
|
migrationsRun: !synchronize,
|
|
};
|
|
```
|
|
|
|
#### SQLite Considerations
|
|
|
|
> **Note:** OpenWA does not currently apply SQLite-specific concurrency hardening. There is **no**
|
|
> `journal_mode = WAL` PRAGMA, no `SqliteWriteQueueService`, and no application-level write
|
|
> serialization or session cap in the source. SQLite is used with TypeORM's defaults, so its standard
|
|
> single-writer behavior applies. For high write-concurrency or multi-session deployments, use
|
|
> PostgreSQL (`DATABASE_TYPE=postgres`). Cross-dialect schema differences are handled at migration
|
|
> time (see below), not by a runtime optimizations layer.
|
|
|
|
#### Migration Strategy
|
|
|
|
```typescript
|
|
// database/migrations/utils/database-aware-migration.ts
|
|
|
|
/**
|
|
* Helper for writing migrations compatible with SQLite and PostgreSQL
|
|
*/
|
|
export abstract class DatabaseAwareMigration {
|
|
protected isPostgres(queryRunner: QueryRunner): boolean {
|
|
return queryRunner.connection.options.type === 'postgres';
|
|
}
|
|
|
|
protected isSqlite(queryRunner: QueryRunner): boolean {
|
|
return queryRunner.connection.options.type === 'sqlite';
|
|
}
|
|
|
|
/**
|
|
* Generate UUID default based on database type
|
|
*/
|
|
protected getUuidDefault(queryRunner: QueryRunner): string {
|
|
if (this.isPostgres(queryRunner)) {
|
|
return 'gen_random_uuid()';
|
|
}
|
|
// SQLite: UUID must be generated at the application level
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Get timestamp type based on database
|
|
*/
|
|
protected getTimestampType(queryRunner: QueryRunner): string {
|
|
if (this.isPostgres(queryRunner)) {
|
|
return 'TIMESTAMP WITH TIME ZONE';
|
|
}
|
|
return 'DATETIME';
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3.13.3 Cache Service
|
|
|
|
There is **no** cache-manager / `CacheModuleOptions` / `redisStore` setup and **no** in-memory cache.
|
|
`CacheService` (`src/common/cache/cache.service.ts`) talks to **ioredis directly** and is gated by
|
|
`REDIS_ENABLED` (falling back to the `cache.enabled` config flag). When caching is disabled — or Redis
|
|
is unreachable — the service **fails open**: every read returns `null` and every write is a silent
|
|
no-op, so the app keeps serving from its source of truth. In other words, "no cache configured" means
|
|
**no cache** (recompute), not an in-process LRU. Cache is therefore a pure optimization layer (session
|
|
status/info/QR/list/stats, each with its own short TTL); it is never the source of truth.
|
|
|
|
```typescript
|
|
// src/common/cache/cache.service.ts
|
|
@Injectable()
|
|
export class CacheService implements OnModuleDestroy {
|
|
private redis: Redis | null = null;
|
|
private readonly enabled: boolean;
|
|
|
|
constructor(private readonly configService: ConfigService) {
|
|
// REDIS_ENABLED is the primary switch; cache.enabled is the legacy fallback.
|
|
this.enabled = process.env.REDIS_ENABLED === 'true' || configService.get<boolean>('cache.enabled', false);
|
|
// Lazy connect: the first isAvailable() call dials Redis (bounded retries).
|
|
}
|
|
|
|
async isAvailable(): Promise<boolean> {
|
|
if (!this.enabled) return false; // disabled -> always "no cache"
|
|
if (!this.redis) await this.tryConnect(); // bounded attempts
|
|
return this.ping();
|
|
}
|
|
|
|
// Fail-open reads/writes: unavailable Redis is a no-op, never an error to the caller.
|
|
async getSessionStatus(id: string): Promise<string | null> {
|
|
if (!(await this.isAvailable())) return null;
|
|
try { return await this.redis!.get(`session:${id}:status`); }
|
|
catch { return null; }
|
|
}
|
|
|
|
async setSessionStatus(id: string, status: string): Promise<void> {
|
|
if (!(await this.isAvailable())) return; // no-op when disabled/unreachable
|
|
try { await this.redis!.setex(`session:${id}:status`, /* TTL */ 300, status); }
|
|
catch { /* logged + swallowed */ }
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3.13.4 Deployment Profiles
|
|
|
|
OpenWA provides several deployment profiles for different needs:
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
subgraph Minimal["🪶 Minimal Profile"]
|
|
M1[SQLite]
|
|
M2[Local Storage]
|
|
M3[No Cache]
|
|
M4[Single Session]
|
|
end
|
|
|
|
subgraph Standard["⚡ Standard Profile"]
|
|
S1[PostgreSQL]
|
|
S2[Local Storage]
|
|
S3[Redis]
|
|
S4[Multi Session]
|
|
end
|
|
|
|
subgraph Enterprise["🏢 Enterprise Profile"]
|
|
E1[PostgreSQL Cluster]
|
|
E2[S3/MinIO]
|
|
E3[Redis Cluster]
|
|
E4[Horizontal Scaling]
|
|
end
|
|
```
|
|
|
|
| Profile | Database | Storage | Cache | Sessions | RAM | Use Case |
|
|
|---------|----------|---------|-------|----------|-----|----------|
|
|
| **Minimal** | SQLite | Local | None | 1-3 | 512MB | Personal bot, testing |
|
|
| **Standard** | PostgreSQL | Local | Redis | 5-10 | 2GB | Small business |
|
|
| **Enterprise** | PostgreSQL | S3/MinIO | Redis | 10+ | 4GB+ | Agency, high volume |
|
|
|
|
> Session counts are guidance only by default. Set `MAX_CONCURRENT_SESSIONS` to a positive integer
|
|
> to cap concurrently running or initializing engines; the default `0` keeps the historical
|
|
> unlimited behavior.
|
|
|
|
### Configuration Examples
|
|
|
|
#### Minimal Profile (.env)
|
|
|
|
```bash
|
|
# Database
|
|
DATABASE_TYPE=sqlite
|
|
DATABASE_NAME=./data/openwa.sqlite
|
|
|
|
# Storage
|
|
STORAGE_TYPE=local
|
|
STORAGE_LOCAL_PATH=./data/media
|
|
|
|
# Cache: omit / leave Redis disabled -> the cache layer no-ops (no in-memory cache)
|
|
REDIS_ENABLED=false
|
|
```
|
|
|
|
#### Standard Profile (.env)
|
|
|
|
```bash
|
|
# Database (Postgres uses discrete host/port/credentials, not a single URL)
|
|
DATABASE_TYPE=postgres
|
|
DATABASE_HOST=localhost
|
|
DATABASE_PORT=5432
|
|
DATABASE_NAME=openwa
|
|
DATABASE_USERNAME=openwa
|
|
DATABASE_PASSWORD=password
|
|
|
|
# Storage
|
|
STORAGE_TYPE=local
|
|
STORAGE_LOCAL_PATH=./data/media
|
|
|
|
# Cache
|
|
REDIS_ENABLED=true
|
|
REDIS_HOST=localhost
|
|
REDIS_PORT=6379
|
|
```
|
|
|
|
#### Enterprise Profile (.env)
|
|
|
|
```bash
|
|
# Database
|
|
DATABASE_TYPE=postgres
|
|
DATABASE_HOST=db-cluster
|
|
DATABASE_PORT=5432
|
|
DATABASE_NAME=openwa
|
|
DATABASE_USERNAME=openwa
|
|
DATABASE_PASSWORD=password
|
|
DATABASE_POOL_SIZE=50
|
|
|
|
# Storage (S3 or any S3-compatible endpoint; MinIO uses the same vars)
|
|
STORAGE_TYPE=s3
|
|
S3_BUCKET=openwa-media
|
|
S3_REGION=ap-southeast-1
|
|
S3_ACCESS_KEY_ID=xxx
|
|
S3_SECRET_ACCESS_KEY=xxx
|
|
S3_ENDPOINT=https://s3.ap-southeast-1.amazonaws.com
|
|
# For MinIO, point S3_ENDPOINT at it (path-style is always on):
|
|
# S3_ENDPOINT=http://minio:9000
|
|
|
|
# Cache
|
|
REDIS_ENABLED=true
|
|
REDIS_HOST=redis-cluster
|
|
REDIS_PORT=6379
|
|
```
|
|
|
|
> OpenWA runs as a single API instance per session-data volume; there is no cluster-mode flag.
|
|
> "Enterprise" here describes vertical headroom (RAM, Postgres, S3, Redis), not multi-replica
|
|
> horizontal scaling — see the single-instance note in §3.2.
|
|
|
|
### Choosing a Profile
|
|
|
|
OpenWA does not auto-detect a profile at runtime; pick one by available resources and expected load:
|
|
|
|
| Available RAM | Suggested profile | Backends |
|
|
|---------------|-------------------|----------|
|
|
| < ~1 GB | Minimal | SQLite + Local Storage, Redis disabled (no cache) |
|
|
| ~1–4 GB | Standard | PostgreSQL + Local Storage + Redis |
|
|
| > ~4 GB | Enterprise | PostgreSQL + S3/MinIO + Redis |
|
|
|
|
All profiles still run as a single API instance per session-data volume (see §3.2). Enterprise here
|
|
means more vertical headroom and external backends, not multi-replica clustering.
|
|
|
|
---
|
|
|
|
<div align="center">
|
|
|
|
[← 02 - Requirements Specification](./02-requirements-specification.md) · [Documentation Index](./README.md) · [Next: 04 - Security Design →](./04-security-design.md)
|
|
|
|
</div>
|