# OpenWA Integration Guide

## Overview

OpenWA is the WhatsApp engine that actually sends messages. We run it natively on the Mac mini (or Linux server) and control it via:

1. **CLI commands** (preferred for simple actions)
2. **REST API** (if OpenWA's API is enabled)
3. **Webhooks** (to receive status updates)

Our integration layer consists of:

- `SessionManager` (PHP service) - creates/deletes sessions, sends messages
- `OpenwaCLI` wrapper - executes `openwa` commands
- `WebhookReceiver` - Laravel route that accepts OpenWA webhooks
- `HealthChecker` - background job that polls session status

---

## Installation on Mac Mini

```bash
# Prerequisites: Node.js 22+, npm
git clone https://github.com/rmyndharis/OpenWA.git ~/openwa
cd ~/openwa
npm install
npm run build
# Create a config file or use environment variables
cp .env.example .env
# Edit .env:
# - PORT=2785 (API)
# - SESSION_DIR=/opt/openwa/sessions
# - ENABLE_WEBHOOK=true
# - WEBHOOK_URL=https://api.yourdomain.com/webhooks/openwa
```

**Process Management** (PM2):

```bash
# Start OpenWA
pm2 start npm --name "openwa" -- start
# Or via our manager script:
pm2 start ~/openwa-integration/bin/manage-openwa.php --interpreter php --name openwa-manager
pm2 save
```

---

## Session Directory Layout

We use **per-user session directories** to isolate data:

```
/opt/openwa/sessions/
├── user_1_session_550e8400/
│   ├── auth.json
│   ├── qr.png
│   ├── localStorage
│   └── logs/
├── user_2_session_a1b2c3/
└── ...
```

Each OpenWA instance runs with `--session=/opt/openwa/sessions/{folder}`. This ensures session data doesn't collide.

**Important**: OpenWA must be compiled with support for the `--session` flag (check docs). If not, we can set `SESSION_DIR` environment variable globally and reuse same session, but that's not isolated. Better to run separate processes per user (or per few users, each with unique session dir).

---

## Starting a New Session

When a user clicks "Add WhatsApp Number", we:

1. Generate UUID for session: `$sessionUuid = Str::uuid();`
2. Create record in `wa_sessions` with `session_id = "user_{$userId}_session_{$sessionUuid}"`
3. Ensure directory exists: `/opt/openwa/sessions/user_{$userId}_session_{$sessionUuid}`
4. Launch OpenWA process (if not already running for that session) via:

```bash
cd ~/openwa
SESSION_ID=user_{$userId}_session_{$sessionUuid} npm start
```

We use a **process pool** approach:

- Keep a global OpenWA instance running (single process) that can manage multiple sessions via its API? Actually OpenWA typically manages one session per process. So we may need to spawn multiple processes.

**Simpler approach for MVP**: Run **one OpenWA instance total** (not per user). But then all users share the same phone number - not acceptable.

**Better**: Each user's session is a separate OpenWA process. Use a **manager script** that maintains a map of session => PID. When API call to create session comes, manager spawns OpenWA with `--session=path`.

**Our manager script** (`bin/manage-openwa.php`) will:

- Expose HTTP endpoints (or read stdin) to create/kill sessions
- Maintain `running_sessions.json` (session_id => pid)
- Start process: `exec("cd /opt/openwa && SESSION_DIR=/opt/openwa/sessions/{$sessionId} npm start > /opt/openwa/logs/{$sessionId}.log 2>&1 & echo $!");`
- Store PID, check PID health

**Laravel controller** calls manager via:

```php
$cmd = "php /opt/openwa-integration/bin/manage-openwa.php create {$sessionId}";
exec($cmd, $output, $returnVar);
```

Manager responds with JSON: `{"session_id":"...","status":"creating","pid":12345}`

---

## QR Code Retrieval

OpenWA writes QR code to disk in the session directory as `qr.png` (path may vary). Our manager or a cron poll:

- Check if `qr.png` exists
- Convert to base64 or serve via Laravel route: `Storage::url("app/sessions/{$sessionId}/qr.png")` (but the file is on Mac mini, not in Laravel storage)

**Solution**: The OpenWA process runs on the same Mac mini as the Laravel backend? Actually your Laravel is on Namecheap, OpenWA on Mac mini. They are separate machines. So we need a way to get QR from Mac mini to Laravel.

**Options**:

1. **Shared filesystem**: NFS/SMB mount from Laravel to Mac mini's `/opt/openwa/sessions`. Not ideal for cloud hosting.
2. **HTTP endpoint**: OpenWA serves QR via its own API (if built-in) or we add a small static file server in manager. Expose via Cloudflare tunnel: `https://qr.yourdomain.com/{sessionId}.png` → forwards to Mac mini's `/sessions/{sessionId}/qr.png`.
3. **Store in DB**: Manager reads `qr.png`, base64 encodes, sends to Laravel API which saves it to storage and returns URL. This is simplest: after creating session, Laravel endpoint `/sessions/create` calls manager, manager waits until QR generated, then returns `qr_base64`. Laravel decodes and stores in `storage/app/qr_codes/{$sessionUuid}.png`, records path in DB.

We'll go with option 3.

**Manager create flow**:

```php
// manage-openwa.php create {sessionId}
// 1. mkdir sessions/{sessionId}
// 2. spawn OpenWA process with SESSION_DIR
// 3. poll for qr.png for up to 30s
// 4. if found: read file, base64_encode, output JSON with qr_base64
// 5. Laravel receives, writes to its own storage/app/qr_codes, stores path
```

---

## Sending a Message

We have two options:

1. **CLI command**: `openwa send --to=+85212345678 --text="Hello"` (if such CLI exists)
2. **REST API**: OpenWA exposes HTTP API on port 2785; we POST to `/api/sessions/{sessionId}/messages/send`

We'll use **OpenWA's native REST API** (it's designed for this). Check OpenWA docs for exact endpoint.

Assuming OpenWA's API:

- Session-specific: `POST /api/sessions/{sessionId}/messages/send-text`
- Body: `{ "to": "+85212345678", "text": "Hello" }`
- Response: `{ "messageId": "abc123", "status": "queued" }`

**Our `OpenWaService`**:

```php
class OpenWaService {
    private string $baseUrl; // e.g. http://localhost:2785, or https://worker.yourdomain.com

    public function sendText(string $sessionId, string $to, string $text): array {
        $url = "{$this->baseUrl}/api/sessions/{$sessionId}/messages/send-text";
        $response = Http::withHeaders([
            'X-API-Key' => config('openwa.api_key') // if OpenWA requires auth
        ])->post($url, [
            'to' => $to,
            'text' => $text
        ]);

        if ($response->failed()) {
            throw new OpenWaException($response->body());
        }

        return $response->json();
    }

    public function sendImage(string $sessionId, string $to, string $caption, string $mediaUrl): array {
        // Media must be publicly accessible URL that WhatsApp can fetch
        $url = "{$this->baseUrl}/api/sessions/{$sessionId}/messages/send-image";
        return Http::post($url, [
            'to' => $to,
            'url' => $mediaUrl,
            'caption' => $caption
        ])->json();
    }
}
```

**Rate limiting**: Insert `usleep(1500000)` (1.5s) between messages in the job loop.

---

## Webhooks (Status Updates)

OpenWA can be configured to POST events to our Laravel webhook endpoint:

```env
# In OpenWA .env
WEBHOOK_URL=https://api.yourdomain.com/webhooks/openwa
WEBHOOK_SECRET=super-secret-shared-key
```

Events we care about:

- `session.connected` - includes phone number
- `session.disconnected`
- `session.banned`
- `message.sent` - message accepted by WhatsApp server
- `message.delivered` - delivered to device
- `message.read` - read by recipient
- `message.failed` - with error code

**Laravel route** (`routes/api.php`):

```php
Route::post('/webhooks/openwa', function (Request $request) {
    // Verify signature: hash_hmac('sha256', $request->getContent(), config('openwa.webhook_secret'))
    $event = $request->json()->all();
    Log::info('OpenWA webhook', $event);

    match ($event['event']) {
        'session.connected' => $this->handleSessionConnected($event),
        'session.banned' => $this->handleSessionBanned($event),
        'message.delivered' => $this->handleMessageDelivered($event),
        default => logger()->debug('Unhandled webhook', $event),
    };

    return response()->json(['status' => 'ok']);
});
```

**Handler examples**:

```php
private function handleSessionConnected(array $event): void {
    $sessionId = $event['session_id']; // matches our wa_sessions.session_id
    WaSession::where('session_id', $sessionId)->update([
        'status' => 'connected',
        'phone_number' => $event['phone_number'] ?? null,
        'last_connected_at' => now(),
    ]);
}

private function handleSessionBanned(array $event): void {
    WaSession::where('session_id', $event['session_id'])->update([
        'status' => 'banned',
        'ban_reason' => $event['reason'] ?? null,
    ]);
    // Notify user via email: "Your WhatsApp number was banned. Please replace."
}

private function handleMessageDelivered(array $event): void {
    $msgId = $event['message_id']; // what WhatsApp returned
    CampaignMessage::where('whatsapp_message_id', $msgId)
        ->update(['delivered_at' => now(), 'status' => 'delivered']);
}
```

---

## Health Checking

We need to know if a session is still alive. Implement `CheckSessionHealthJob` that runs every 5 minutes:

```php
class CheckSessionHealthJob implements ShouldQueue {
    public function handle() {
        $sessions = WaSession::whereIn('status', ['connected','qr_ready'])->get();
        foreach ($sessions as $session) {
            $healthy = $this->sessionManager->checkHealth($session);
            if (!$healthy) {
                // Mark disconnected, send notification
                $session->update(['status' => 'disconnected']);
                Notification::send($session->user, new SessionDisconnected($session));
            }
        }
    }
}
```

**How to check health**:

Option A: OpenWA exposes `/api/sessions/{sessionId}/health` endpoint. Call it.
Option B: Check the log file for recent activity (e.g., lines in last 2 min).
Option C: Send a "ping" message to ourselves? Not ideal.

We'll adopt Option A if OpenWA provides it. If not, we can add a simple handler:

- OpenWA webhook sends `session.heartbeat` every minute when connected. We update `last_seen_at` in `wa_sessions`. Health job checks if `last_seen_at` > 2 min ago → disconnected.

---

## Rate Limiting & Throttling

- **Per-user quota**: `User::messages_limit` and `messages_used_this_month`. Enforced in `CampaignScheduler::schedule()`.
- **Per-session gap**: In `SendCampaignJob::handle()`, after each successful send, `usleep(rand(1200000, 1800000))` (1.2-1.8s) to avoid hammering.
- **Global limit**: Laravel's `ThrottleRequests` middleware on API (60/min).

---

## Error Handling & Retries

**Transient errors** (network timeout, server busy):
- Throw `OpenWaTransientException`
- `SendCampaignJob` catches, increments `attempts`, if `< 3` requeue with delay exponential (5s, 25s, 125s)

**Permanent errors**:
- `Number blocked` → mark `campaign_messages.status = 'bounced'`, session may be banned
- `Invalid number` → mark as bounced, don't retry
- `Media download failed` → fallback to text if campaign allows, else mark failed

---

## Session Manager CLI Tool

`bin/manage-openwa.php` provides commands for manual ops:

```bash
php manage-openwa.php create <sessionId>
php manage-openwa.php delete <sessionId>
php manage-openwa.php list
php manage-openwa.php restart <sessionId>
php manage-openwa.php logs <sessionId> [--tail]
```

Used by:
- Our Laravel service (via exec)
- Sysadmin for debugging

Implement as Symfony Console command.

---

## Scaling Strategy

### Vertical Scale
- Upgrade Mac mini to more RAM/CPU
- Increase number of OpenWA processes (each ~200-500MB)

### Horizontal Scale
- Add more worker machines (Mac minis or Linux)
- Each worker runs OpenWA and a small agent that registers with central Laravel API
- Sessions assigned to workers by调度算法 (round-robin, least-loaded)
- Database shared (PostgreSQL)

**Worker agent**:
- Small Node/PHP script that polls `GET /worker/assignments`
- Returns heartbeat
- Receives session creation/deletion commands

Not needed for MVP.

---

## Environment Variables

**Laravel `.env`**:
```
OPENWA_BASE_URL=http://localhost:2785
OPENWA_API_KEY=optional-if-enabled
OPENWA_WEBHOOK_SECRET=shared-secret
OPENWA_SESSIONS_PATH=/opt/openwa/sessions
OPENWA_LOGS_PATH=/opt/openwa/logs
```

**OpenWA `.env`** (on Mac mini):
```
SESSION_DIR=/opt/openwa/sessions
WEBHOOK_URL=https://api.yourdomain.com/webhooks/openwa
WEBHOOK_SECRET=super-secret-shared-key
PORT=2785
```

---

## Security

- All communication between Laravel and OpenWA should be over localhost (or internal network). Cloudflare tunnel only exposes Laravel API, not OpenWA directly.
- OpenWA webhook secret must be strong and stored in both places.
- Session directories readable only by OpenWA process user (chmod 700).
- Laravel stores `session_id` (the folder name) but not session cookies/blobs. Blobs stay on Mac mini. If Mac fails, sessions lost - acceptable for MVP; later we could back up to S3.

---

## Monitoring

Key metrics to track:

- Number of active sessions (status=connected)
- Messages sent per minute
- Failure rate (%)
- Session disconnect events
- OpenWA process memory/CPU (via PM2)

Set up Grafana or simple dashboard in Laravel admin panel.

---

## Testing

- Mock OpenWA with a fake server that responds to `/api/sessions/*/messages/*`
- Use environment variable `OPENWA_BASE_URL` to switch between real and mock.
- Integration tests: create test session, send test message to your own phone (use a throwaway number).

---

## Troubleshooting

| Symptom | Check |
|---------|-------|
| QR never appears | Is OpenWA process running? Check `pm2 logs`. EnsureSESSION_DIR exists and writable. |
| Session shows `creating` forever | Manager script might have timed out waiting for QR. Look at OpenWA logs for errors. |
| Messages not sending | Session status must be `connected`. Check webhook received? Check `wa_sessions.last_connected_at`. |
| Media not delivered | WhatsApp only fetches media from publicly accessible URLs. Ensure your Laravel storage URL is public (signed temporary URL recommended) or host on S3 with public read. |
| High failure rate | Could be rate limiting. Check job logs for "rate_limited" errors. Increase gap to 2s. |
| Number banned | WhatsApp banned the phone number. Session status becomes `banned`. User must scan a new number. Investigate: was it spammy? Too many messages to unsaved contacts? |

---

## Next Steps

1. Set up OpenWA on your Mac mini and get it working manually (CLI).
2. Write the manager script (`manage-openwa.php`) to create/delete sessions and read QR.
3. Implement `OpenWaService` in Laravel.
4. Set up webhook endpoint and configure OpenWA to point to it.
5. Build a simple console command in Laravel to test: `php artisan wa:test-session`.
6. Integrate into `SessionController` and `CampaignController`.
7. Write tests mocking OpenWA responses.

---

See also: `docs/deployment-guide.md` for step-by-step setup.