# WhatsApp Channels

WhatsApp connects via Baileys multi-account:

| Provider | Use Case | Connection |
|----------|----------|------------|
| **baileys** | All WhatsApp accounts | WebSocket via QR code (linked device) |

## Primary Architecture: Multi-Account Baileys

Users link one or more WhatsApp numbers via QR. Each account is an independent Baileys connection with its own credentials, policies, and agent routing. Multiple accounts are optional — most setups use a single account.

WhatsApp Business **app** vs personal WhatsApp is irrelevant from the platform's perspective — Baileys connects to both identically via QR. The Business app just adds business profile, catalog, and labels in the phone UI.

**Linked device note:** The primary phone must come online periodically (~every 14 days) to keep the Baileys linked-device session alive. It does NOT need to be on 24/7.

## Managing Accounts via UI

The Setup page (`/setup`) supports multi-account management in its status dashboard (shown after initial setup is complete):

- **Add Account:** Click "+ Add WhatsApp Account" below the status dashboard, enter a name (e.g. "Business"), and scan the QR code to link
- **Relink:** Each account has its own relink button (rotate icon) that shows a per-account QR code inline
- **Remove:** Each account (except the last one) has a remove button (X icon) that disconnects and removes the account from config
- **Per-account QR:** QR codes appear inline under the specific account being linked

The initial first-run wizard flow stays single-account. Multi-account is managed from the status dashboard that appears once setup is complete.

## Multi-Account Config Example

```yaml
channels:
  whatsapp:
    adminPhones:
      - "<admin-phone>"    # E.164 phone the user messages from
    accounts:
      default:
        provider: baileys
        name: "Main"
```

Accounts are linked via QR in the Channels UI. Outbound routing (`src/web/outbound-routing.ts`) selects the right account automatically:

| Target | Account | Reason |
|--------|---------|--------|
| Admin phones | Account with admin binding | Admin's registered number |
| Self-chat | Account with selfChatMode | Admin talking to themselves |
| Group messages | Account that is a group member | Whichever is in the group |
| Customer DMs | Account with open DM policy | Dedicated agent number |
| Inbound replies | Same account that received | Closures capture the right provider |

## Baileys Risk Profile

Baileys uses the same WebSocket protocol as WhatsApp Web/Desktop linked devices. Meta cannot blanket-block it without breaking their own official clients for hundreds of millions of users.

**Responsibility model:** The platform and its other users are not at risk — bans are per-number, targeting individual accounts for abusive behaviour. Users should be told: *"Don't use this for bulk messaging or spam. Meta may ban your number for abusive patterns. Normal business conversations are fine."*

- Normal conversational use (replies, scheduling, quotes) = effectively zero risk
- Ack reactions, typing indicators, and reply delays mimic real linked-device behaviour
- Risk increases only with spam patterns: mass messaging, rapid-fire sends to unknown contacts, broadcasting
- If a user's number is banned, it's between them and Meta — other users are unaffected

## WhatsApp Outbound Policy — No Automated Broadcasts

**WhatsApp's Terms of Service explicitly prohibit automated bulk/broadcast messaging.** Violations result in permanent number bans with no appeal.

The platform enforces this at multiple levels:

1. **Broadcast action blocked** — The `message broadcast` action (which sends to multiple targets) always excludes WhatsApp as a target channel, regardless of config. This is a hard block that cannot be overridden.
2. **Agent tool suppression** — When an agent session is on WhatsApp, the `broadcast` action is removed from the message tool schema entirely. Agents cannot invoke it.
3. **Broadcast disabled by default** — `tools.message.broadcast.enabled` defaults to `false`. Even when enabled for other channels (Discord, Telegram, Slack), WhatsApp remains blocked.

**What IS allowed on WhatsApp:**
- Replying to incoming messages (the core use case)
- Sending messages to a single known contact via the `send` action
- Heartbeat/scheduled messages to the admin's own number
- Owner outbound mirroring (detecting manual WhatsApp Web sends)

**What is NOT allowed:**
- Sending the same message to multiple WhatsApp contacts in a loop
- Any form of automated outreach to contacts who have not messaged first
- Using the broadcast action with WhatsApp as a target channel
- Building "campaign" or "blast" workflows that target WhatsApp contacts

## DM Policy & Access Control

**Precise definitions — these terms are NOT interchangeable:**

| Term | Definition | Code |
|------|-----------|------|
| **Self phone** | The WhatsApp account's own phone number (the phone that scanned the QR code). Self-chat only. | `phonesMatch(senderPhone, selfPhone)` in `access-control.ts` |
| **Admin phones** | Phones listed in `adminPhones` in `account.json`. The user's personal phone is auto-registered on QR link; additional phones are added via `whatsapp-config action: "add-admin-phone"`. | `isAdminPhone()` in `access-control.ts` — iterates `adminPhones` |
| **Public/unknown** | Any phone that is not self and not in `adminPhones`. Subject to DM policy gating. | Everything else in `checkDmAccess()` |

**Critical distinction:** The *self phone* is the paired device's number (the WhatsApp account Maxy controls). The *admin phone* is the user's personal phone (the phone they message *from*). These are typically different numbers. If the user's personal phone is not in `adminPhones`, their messages route as public — not admin.

**`dmPolicy` behaviour (per-account):**

| Policy | Self phone | Admin phones (in `adminPhones`) | Public/unknown |
|--------|-----------|-------------------------------|----------------|
| `"open"` | Allowed | Allowed | Allowed |
| `"allowlist"` | Allowed | Allowed | Allowed only if in `allowFrom` (wildcard `"*"` counts) |
| `"disabled"` | Allowed | Allowed | **Blocked** |

Admin phones always bypass DM policy — the `isAdminPhone()` check runs before any policy-specific logic. Self phone is always allowed regardless of policy.

**Schema constraint:** `dmPolicy: "open"` requires `allowFrom` to include `"*"` (enforced by Zod validation).

**Key file:** `access-control.ts` — `checkDmAccess()` and `checkGroupAccess()`.

**Config path:** Per-account at `whatsapp.accounts.{accountId}.dmPolicy` in `account.json`. Falls back to `whatsapp.dmPolicy`. Changes are conversational — the admin agent writes to config via `whatsapp-config action: "update-config"`.

For config field definitions (field names, types, valid values), use `whatsapp-config action: schema`.

## Owner Outbound Mirroring

When the business owner sends a WhatsApp message directly via WhatsApp Web (not through the agent), Baileys detects the echo with `key.fromMe = true`. The inbound monitor (`src/web/inbound/monitor.ts`) checks `access.isOwnerOutbound` and calls `mirrorOwnerReplyToSession()`, which writes a `[Owner reply] {body}` delivery-mirror into the public agent's session transcript via `appendAssistantMessageToSessionTranscript`. This ensures the public agent knows about the owner's direct intervention when the customer next replies.

The same path is used when an **admin agent** sends to a customer via the `message` tool (cross-agent echo): `fireCrossAgentEcho` in `src/infra/outbound/cross-agent-echo.ts` resolves the public agent's session key and delegates to `mirrorOwnerReplyToSession`. For established sessions the mirror is written to disk immediately; for first-contact customers with no session yet, an ephemeral system event is queued instead.

### Agent-sent echo suppression

Every outbound Baileys message echoes back as a `fromMe` `messages.upsert` event. To prevent the monitor from treating agent-sent echoes as manual owner messages, all send paths must track message IDs via `trackAgentSentMessage(id)` in `src/web/inbound/dedupe.ts`. The monitor then checks `isAgentSentMessage(id)` before calling `mirrorOwnerReplyToSession`.

There are two send paths — both must track IDs:
- **IPC send API** (`src/web/inbound/send-api.ts`): used by the message tool, gateway RPC, and `sendPoll`. Tracks IDs after `sock.sendMessage`.
- **Auto-reply closures** (`monitor.ts` lines 390-401): the `reply()` and `sendMedia()` functions on `WebInboundMessage`, used by `deliverWebReply`. Track IDs after `sock.sendMessage`.

The dedupe cache (`agentSentMessages`) has a 2-minute TTL and 500-entry cap, which is sufficient because Baileys echoes arrive within seconds.

**Key files:**
| File | Purpose |
|------|---------|
| `src/web/inbound/monitor.ts` | Detects `fromMe` echoes, calls `mirrorOwnerReplyToSession`; auto-reply `reply`/`sendMedia` closures |
| `src/web/inbound/owner-mirror.ts` | `mirrorOwnerReplyToSession()` — canonical implementation |
| `src/web/inbound/access-control.ts` | `isOwnerOutbound` flag for `fromMe` DMs |
| `src/web/inbound/send-api.ts` | IPC send path — tracks agent-sent message IDs |
| `src/web/inbound/dedupe.ts` | `trackAgentSentMessage` / `isAgentSentMessage` — echo suppression cache |
| `src/infra/outbound/cross-agent-echo.ts` | Adapter: agent tool → `mirrorOwnerReplyToSession` |

## Offline Message Recovery

Baileys is a WhatsApp Web protocol client. When the gateway is offline, WhatsApp's servers hold incoming messages and push them back as history (`append`-type `messages.upsert` events) when Baileys reconnects — so the system self-recovers automatically on restart. These replayed messages are stored in the UI message cache and marked as read, but **not** dispatched to the agent (no auto-reply on catch-up). The recovery window is determined entirely by WhatsApp's server-side delivery queue (approximately 30 days); `syncFullHistory: false` only prevents requesting full chat history on first pairing and does not restrict this window.

## Voice Notes

### Inbound (transcription)

Inbound voice notes are transcribed to text before reaching the agent. The pipeline runs in `manager.ts` after `downloadInboundMedia` succeeds for audio messages:

1. **ffmpeg** converts OGG Opus → WAV (16 kHz, mono, PCM s16le)
2. **whisper.cpp** (`/opt/whisper.cpp/build/bin/whisper-cli` with `ggml-base` model) transcribes WAV → raw text

The raw whisper transcript is set on `extracted.text` so both agent paths receive identical content. On any failure, the pipeline degrades silently — the agent sees the standard media placeholder instead of transcribed text.

**Source:** `platform/ui/app/lib/stt/index.ts` (transcription)

**Log tag:** `[stt]`

### Outbound (PTT)

Outbound TTS audio is sent as native WhatsApp PTT (Push-To-Talk) voice notes. The pipeline:

1. **TTS output format:** `resolveOutputFormat("whatsapp")` returns Opus (OGG container) instead of MP3 (`src/tts/tts.ts`)
2. **ffmpeg normalization:** `normalizePttAudio()` re-encodes to WhatsApp-native format — OGG Opus, 48 kHz, mono, 64 kbps, VoIP mode (`src/media/ptt-normalize.ts`)
3. **Baileys metadata:** `music-metadata` and `audio-decode` npm packages compute `seconds` (duration) and `waveform` fields for the PTT message. Without these, WhatsApp renders audio as a generic file attachment.
4. **Send:** `ptt: true` flag on the audio message, no caption (voice IS the content)

**System dependency:** ffmpeg must be on PATH. Installed automatically by `install.sh` (fresh) and `postinstall.js` (upgrades). Best-effort — if ffmpeg is unavailable, the original audio is sent as-is.

**Key files:**
| File | Purpose |
|------|---------|
| `src/media/ptt-normalize.ts` | ffmpeg re-encoding to WhatsApp-native OGG Opus |
| `src/web/auto-reply/deliver-reply.ts` | Auto-reply path (calls normalizePttAudio for audio) |
| `src/web/inbound/send-api.ts` | Baileys send wrapper (sets `ptt: true`) |
| `src/tts/tts.ts` | TTS output format resolution (Opus for WhatsApp) |

## Session Credentials (Baileys Auth)

Baileys stores WhatsApp session credentials (linked-device state) on disk. The auth directory contains `creds.json` (session keys) and Signal protocol pre-keys.

**Path resolution** (`src/web/accounts.ts` → `resolveWhatsAppAuthDir`):

| Priority | Path | When |
|----------|------|------|
| 1. Explicit config | `channels.whatsapp.accounts.{id}.authDir` | User overrides in config |
| 2. Default | `~/.maxy/credentials/whatsapp/{accountId}/` | Normal operation |

**Diagnostics on the Pi:**

```bash
# List all WhatsApp auth directories
ls -la ~/.maxy/credentials/whatsapp/

# Check if credentials exist for an account
ls ~/.maxy/credentials/whatsapp/default/creds.json

# Check credential freshness (should be recent if just linked)
stat ~/.maxy/credentials/whatsapp/default/creds.json
```

If `creds.json` is missing, the session was never linked or was deleted. If it exists but WhatsApp rejects it (401), the session was invalidated (cleared from phone, expired after 14 days offline, or account banned).

**Key files:** `src/web/accounts.ts` (resolveWhatsAppAuthDir), `src/config/paths.ts` (resolveOAuthDir, resolveStateDir), `src/web/session.ts` (enqueueSaveCreds — writes creds on update).

## Disconnect Reasons

Baileys disconnect errors carry a numeric status code mapped to `DisconnectReason`. The monitor translates these to user-facing messages via `humanizeWhatsAppError()` in `src/web/auto-reply/monitor.ts`.

| Status | Baileys Reason | Meaning | Auto-reconnect? |
|--------|---------------|---------|-----------------|
| 401 | loggedOut | Session removed from phone or expired | No — re-link required |
| 401 + "conflict" | connectionReplaced (server override) | Another WhatsApp Web session took over | Yes |
| 403 | forbidden | Number banned or restricted | No |
| 408 | connectionLost / timedOut | Network interruption or timeout | Yes |
| 428 | connectionClosed | Server closed the connection | Yes |
| 440 | connectionReplaced | Another session replaced this one | Yes |
| 500 | badSession | Corrupted session data | No — re-link required |
| 515 | restartRequired | Server requests reconnection | Yes |

**Conflict vs logout (401 ambiguity):** WhatsApp's server can send a stream error with code 401 and reason "conflict" — Baileys normally maps "conflict" to 440, but the server's explicit `code` attribute overrides the mapping. The monitor checks for "conflict" in the error message to distinguish this from a true logout.

## Reconnection: Setup Failures vs Runtime Disconnects

> **Authoritative source:** The current runtime connection lifecycle — including reconnect bounding (`STABLE_UPTIME_MS`, `MAX_RECONNECT_ATTEMPTS`), platform-side init queries (fetchBlocklist, fetchPrivacySettings, intentional fetchProps skip), and the `/api/health` WhatsApp field — is documented in [`.docs/whatsapp.md`](../../../../.docs/whatsapp.md) under "Connection Lifecycle", "Init Query Visibility", and "Reconnect Bounding and Escape". The content below describes the historical disconnect-reason mapping which still applies; for anything about how the loop retries, aborts, or runs its init sequence, read the `.docs` file first.

The reconnection loop in `monitorWebChannel` handles two distinct failure modes:

| Failure Mode | When | Example | Handling |
|-------------|------|---------|----------|
| **Runtime disconnect** | After a successful connection drops | 408 (connectionLost), 428 (connectionClosed) | `listener.onClose` resolves → backoff retry |
| **Setup failure** | Connection never establishes | 405 (Method Not Allowed), 503 (service unavailable) | `monitorWebInbox` throws → caught by try-catch → same backoff retry |

Both paths use the same backoff policy (2s → 30s, 12 max attempts) and the same loggedOut detection. A 401 status only triggers logout if the error message does NOT contain "conflict" (see Disconnect Reasons above). Setup failures log as `"web reconnect: connection setup failed"` instead of `"web reconnect: connection closed"`.

Without the setup-failure handling, a transient WhatsApp server rejection (e.g., 405 during a protocol update) would kill the channel permanently — the error would escape the reconnection loop, and only a gateway restart could recover.

## Provider Architecture

```
src/web/
├── outbound-routing.ts  # Multi-account outbound routing logic
├── active-listener.ts   # Per-account listener registry (listActiveWebAccountIds, etc.)
├── outbound.ts          # Outbound send (routes through outbound-routing)
├── providers/
│   ├── types.ts         # WhatsAppProvider interface
│   ├── factory.ts       # createWhatsAppProvider()
│   └── baileys/         # BaileysProvider (wraps monitorWebInbox)
└── auto-reply/
    ├── monitor.ts       # Baileys reconnection loop (monitorWebChannel)
    └── monitor/
        └── on-message.ts # Message handler

extensions/whatsapp/src/
└── channel.ts           # Channel plugin
```
