# ultron-wa — multi-account WhatsApp connector (Baileys)

Always-on VPS service (systemd, **never Railway** — its restarts + ephemeral disk =
dropped sockets and re-link storms). A **supervisor** process keeps one forked
**worker** per WhatsApp number and exposes a localhost control API the Ultron
dashboard drives. Read-only w.r.t. WhatsApp except a gated DeepSeek auto-ack.

Implements CRM-PLAN.md → "ULTRON CRM RESTRUCTURE" → **R4**.

## Architecture

```
supervisor.js  (systemd, one process)
  ├─ reconciler (60s): wa_accounts.status  ⇄  forked workers
  ├─ control API (http, bearer-auth): /status /link /qr-stream /stop /unlink /autoreply
  └─ fork() ──► worker.js  (one per account, ACCOUNT_ID in env)
                  ├─ Baileys 6.7.23 socket (auth state in Postgres)
                  ├─ store pipeline → wa.threads / wa.messages / sparse leads
                  ├─ lidmap.js → public.wa_lid_map (shared LID↔phone map)
                  └─ autoreply.js → DeepSeek ack (opt-in, gated, fail-closed)
```

- ~**80 MB RAM per connected socket**. The current 2-core box also runs the
  scrapers — measure at 2–3 numbers, likely move to a dedicated wa-box beyond that.
- One child per number = crash/ban/event-loop isolation. Each worker is
  **force-recycled every ~3h** (±15min jitter) to dodge the ~48h silent
  disconnect.

## Environment (all read from `/root/ultron/.env` via the unit's EnvironmentFile)

| Var | Required | Purpose |
|---|---|---|
| `SUPABASE_URL` | yes* | project URL; the `db.<ref>.supabase.co` host is derived from it (falls back to `SUPABASE_REST`, then the known ref) |
| `SUPABASE_DB_PASSWORD` | **yes** | Postgres password (user `postgres`) |
| `WA_CTRL_TOKEN` | **yes** | bearer token for EVERY control-API request; supervisor refuses to start without it |
| `WA_CTRL_PORT` | no | control API port (default `3100`) |
| `WA_CTRL_BIND` | no | control API bind address (default `0.0.0.0`; set `127.0.0.1` if the dashboard is same-host) |
| `DEEPSEEK_API_KEY` | no | auto-ack text generation; **missing ⇒ no auto-replies ever** (fail-closed) |
| `WA_AUTOREPLY_KILL` | no | set to any non-empty value to globally disable auto-reply |

\* `SUPABASE_URL` isn't strictly required (there's a hardcoded ref fallback) but
set it — it's already in `.env`.

**Deployer must add to `/root/ultron/.env`:** `WA_CTRL_TOKEN` (generate:
`openssl rand -hex 32`). `DEEPSEEK_API_KEY` if auto-ack is wanted. Optionally
`WA_CTRL_PORT`, `WA_CTRL_BIND`, `WA_AUTOREPLY_KILL`. `SUPABASE_URL` and
`SUPABASE_DB_PASSWORD` already exist for the rest of Ultron.

## Install / deploy

```bash
# from the Mac: rsync this dir to the VPS (deps install on the VPS only)
rsync -az --exclude node_modules -e ssh ultron-wa/ ultron:/root/ultron/ultron-wa/

ssh ultron
cd /root/ultron/ultron-wa
npm ci                      # installs Baileys 6.7.23 (pinned), pg, pino

cp ultron-wa.service /etc/systemd/system/ultron-wa.service
systemctl daemon-reload
systemctl enable --now ultron-wa
journalctl -u ultron-wa -f  # watch it come up
```

The DB tables (`wa_accounts`, `wa_auth_state`, `wa.threads/messages`,
`lead_phones`, `wa_lid_map`, `leads`) are owned by **another session's**
`db/schema.sql` (§15) — this service creates none of them and assumes they exist.

## Link flow (dashboard → supervisor)

1. Dashboard creates a `wa_accounts` row (status `new`) for the user, then:
2. `POST /link {account_id, method:'qr'|'code', phone?}` → supervisor sets
   status `linking` and (re)forks the worker in link mode.
3. Dashboard opens `GET /qr-stream?account_id=…` (SSE) and renders:
   - `{type:'qr', data}` → render the QR string client-side (rotates ~20–30s;
     re-render on each event). **No `qrcode` dep here** — the browser draws it.
   - `{type:'code', data}` → show the 8-char pairing code (headless/code method).
   - `{type:'status', status}` and `{type:'connected', phone}` → update the card.
4. User scans / types the code. The immediate `restartRequired` disconnect after
   a successful scan is **normal** — the worker reconnects on saved creds by
   itself. On `open` the worker writes `status='connected'` + the phone.
5. `loggedOut` → worker sets `status='logged_out'` and exits (no hammering).
   `403 forbidden` → `status='banned'` and exits.

## Control API (all require `Authorization: Bearer $WA_CTRL_TOKEN`)

```bash
T=$WA_CTRL_TOKEN; H="Authorization: Bearer $T"; B=http://127.0.0.1:3100

curl -s -H "$H" $B/status | jq                        # accounts + worker running-state
curl -s -H "$H" -X POST $B/link    -d '{"account_id":"<uuid>","method":"qr"}'
curl -s -H "$H" -X POST $B/link    -d '{"account_id":"<uuid>","method":"code","phone":"9665XXXXXXXX"}'
curl -s -N -H "$H" "$B/qr-stream?account_id=<uuid>"    # SSE (QR/code/status)
curl -s -H "$H" -X POST $B/stop     -d '{"account_id":"<uuid>"}'    # SIGTERM worker, KEEP auth
curl -s -H "$H" -X POST $B/unlink   -d '{"account_id":"<uuid>"}'    # logout + wipe auth + logged_out
curl -s -H "$H" -X POST $B/autoreply -d '{"account_id":"<uuid>","enabled":true}'
```

Unknown routes 404. Missing/wrong token 401.

> **Note on `/stop`:** it SIGTERMs the worker but keeps auth and does **not**
> change `status`. If the account is still `connected`/`disconnected`/`linking`,
> the 60s reconciler brings it back (self-healing). To keep a number down, set
> its `wa_accounts.status` to `new`/`logged_out`/`banned`, or use `/unlink`.

## LID rules (why numbers look wrong until they resolve)

A peer JID number in an `@lid` chat is a **privacy LID**, not a phone. Only a
`lid_map`-resolved `peer_phone` **or** a raw number normalizing to exactly
`^9665\d{8}$` is treated as a real phone. LID-only threads store `place_id = NULL`
until a pairing lands; `lidmap.js` mines every Baileys 6.7.23 channel
(contacts/chats/group-meta/phone-share/message-key) into the **shared**
`public.wa_lid_map` (upsert only fills a NULL phone), and back-fills
`wa.threads.peer_phone`. Growing this map is the #1 lever for thread visibility.

Sparse leads for resolved unknowns: INSERT-only, `place_id='wa:'+phone`,
`source='WhatsApp'`, plus a `public.lead_phones` row. Existing lead rows are
**never** updated (COALESCE invariant).

## Auto-reply (opt-in per number, fail-closed)

Fires only when ALL hold: `wa_accounts.auto_reply` true, `WA_AUTOREPLY_KILL`
unset, inbound DM (not group/fromMe), no outbound in the thread in 6h,
`auto_reply_last_ts` older than 24h (or null), Riyadh time 09:00–21:00, peer not
on the denylist, message isn't a stop word. Then: DeepSeek `deepseek-v4-flash`
one-sentence same-language ack (≤160 chars, no prices/promises/questions/emojis),
random 5–40s delay, then send. Per-account cap **25/day**, global **100/day**
(reset at Riyadh midnight). Any DeepSeek error or missing key ⇒ **send nothing**.

**Opt-out sentinel (no extra column):** a peer sending `إيقاف`/`stop` sets
`wa.threads.auto_reply_last_ts = 'infinity'::timestamptz` (node-pg reads it back
as JS `Infinity`) = never auto-reply again, and that message is not acked.

## Kill switches

- Per-account: `POST /autoreply {enabled:false}` (or set `wa_accounts.auto_reply=false`).
- Global auto-reply: `WA_AUTOREPLY_KILL=1` in `.env`, then `systemctl restart ultron-wa`.
- A whole number: `/unlink`, or set its `wa_accounts.status` out of the runnable set.
- Everything: `systemctl stop ultron-wa` (control-group kill takes the workers with it).

## Privacy

`personal.json` (this dir, array of phone strings, auto-created as `[]`): matching
peers never have messages stored and their thread is forced `visibility='private'`.

## RAM expectations

~80 MB per connected socket + ~60 MB supervisor. Unit caps at `MemoryMax=2G`.
Watch `systemd-cgtop` / `journalctl -u ultron-wa`; provision a dedicated wa-box
before pushing past ~3 numbers next to the scrapers.
