docs: WhatsApp research and native C++ build spec

No native Python WhatsApp library exists (neonize is whatsmeow over
ctypes, yowsup is dead) and no browser is needed. Spec for detz::wa:
native C++ transpiled from wa-go (MIT, golden vectors) with whatsmeow as
referee, libcurl WebSocket plus OpenSSL, no new dependencies, build vs
deep-research table, caps, tests, phases, and a whatsapp-mcp shortcut.
Research archive: research/whatsapp-automation (70 sources, 3
conclusions). Nothing built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 16:55:18 +01:00
co-authored by Claude Opus 5.5
parent 4875d40ac2
commit 1a1448ddd0
76 changed files with 2663 additions and 6 deletions
+349
View File
@@ -0,0 +1,349 @@
# whatsapp (taicpp): research result and build specification
Status: **specified, nothing built.** This file states what was found, what was
decided, and the exact plan to build a native C++ WhatsApp client into detz.
Research archive: [`../../research/whatsapp-automation/`](../../research/whatsapp-automation/)
(sources and three conclusions). Every number below was measured on 2026-10-27
on this machine unless a line says otherwise.
## 1. Goal
One sentence must work in detz:
> "Create screenshot and explain what is genius about it and send to Niggel on WhatsApp"
The flow breaks into four steps. Three of them already exist:
| Step | Owner | Exists |
|---|---|---|
| take a screenshot | `shell` tool (`grim`, `scrot`, `import`) | yes |
| explain it | `describe_image` tool (src/agent/tools_core.cpp) | yes |
| resolve "Niggel" to a WhatsApp JID | `WaContacts` (this spec) | no |
| send text plus image | `whatsapp_send` tool over `WaClient` (this spec) | no |
The missing part is one module: a WhatsApp companion device written in C++.
## 2. Answers to the questions asked
### 2.1 Best Python libraries for a normal phone number
There is no Python library that speaks the current WhatsApp protocol itself.
Measured on the cloned repositories:
| Library | Language of the protocol code | How it works | Verdict |
|---|---|---|---|
| neonize (Apache-2.0, 759 stars, 62 commits in 90 days) | **Go** (whatsmeow) | 4362 lines of Go export 93 C functions (`//export`), 14918 lines of Python call them through `ctypes` | best "Python" option, but it is whatsmeow with a Python skin |
| whatsfly | Go (whatsmeow) | same idea, PyPI 0.0.1, linux 32 bit only | early, not usable |
| whatsapp-mcp (lharries, MIT) | Go (whatsmeow) | Go bridge with HTTP `/api/send`, Python MCP server with `send_message`, `send_file` | usable shortcut, see section 10 |
| yowsup (GPL-3.0) | Python | old phone-registration protocol (`e1.whatsapp.net:443`), last commit 2021-12-14, README warns that registration gives an instant ban | dead, do not use |
| pywhatkit, Astra, wa-automation, whalesong | Python | drive WhatsApp Web in a real browser | browser, rejected |
| Supbot2 | Python | drives the Android app with Appium | needs a phone or emulator, rejected |
| pywa and other Cloud API clients | Python | official Meta Business Cloud API | needs a business number; the number cannot stay on the normal app, rejected for a personal number |
Conclusion: transpiling Python gets us nothing. The real implementations are:
| Project | Language | License | Activity (commits in last 90 days) | Size (non-blank, non-test, non-generated) |
|---|---|---|---|---|
| whatsmeow (tulir) | Go | MPL-2.0 | 78 | 24,301 lines, plus 107,816 generated protobuf lines |
| libsignal-protocol-go (tulir), used by whatsmeow | Go | **GPL-3.0** | not measured | 5,416 |
| wa-go (jfelipesjc) | Go | **MIT** | 17, created 2026-06-26, 1 star | 23,278 (wire 2778, signal 1625, keys 311, media 517, client 14379) |
| Baileys (WhiskeySockets) | TypeScript | MIT | 7 | 48,898 |
| whatsapp-rust (oxidezap) | Rust | MIT | at least 100 (API page limit) | about 650,000 including VoIP and plugins |
| libsignal-protocol-c (signalapp) | C | **GPL-3.0** | last commit 2020-03-27 | 29,456 |
No C or C++ library implements the multi-device protocol. Search found only
WebKit wrappers (WasIstLos, YAWF) and a REST wrapper (Green-API).
### 2.2 Is a browser required
**No.** whatsmeow, Baileys, wa-go and whatsapp-rust all speak the protocol
directly over one WebSocket. The phone is needed exactly once, to link the
device: scan a QR code shown in the terminal, or type an 8-character pairing
code into WhatsApp > Linked devices. After that the companion runs alone. The
phone must come online now and then; one directory page reports
re-authentication after about 20 days (not verified).
### 2.3 Can it be done in C++ with what detz already links
Yes. detz already links libcurl and OpenSSL:
- libcurl 8.18.0 on this machine lists `ws wss` as protocols.
`curl -H "Origin: https://web.whatsapp.com" wss://web.whatsapp.com/ws/chat`
answered **`101 Switching Protocols`**, so the transport is proven reachable
from here with the library we already ship.
- OpenSSL 3.5.5 covers every primitive except XEdDSA signing (section 5).
- Protobuf needs no library: we need about 15 messages, and a hand-written
varint and length-delimited writer and reader is about 200 lines.
New dependencies: **none.**
## 3. Decision: native C++, transpiled
Build `detz::wa` as a native C++ module. Transpile from two sources:
1. **wa-go (MIT) is the code source** for wire, Noise, binary codec, keys,
Signal (X3DH, Double Ratchet), media crypto and pairing. Reasons: MIT, small
(the whole Signal layer is 1625 lines against 3585 for the libsignal-go
subset), layered exactly like detz wants (`internal/wire`, `signal`, `keys`,
`media`), and it ships **788 KB of golden vectors captured from real Baileys**
(`testdata/traces/connect_pair`, `testdata/signal/session_ab.json`,
`testdata/media/media_vectors.json`, `testdata/paircode/vectors.json`). Those
become our C++ test fixtures as they are.
2. **whatsmeow (MPL-2.0) is the behaviour reference**: it is the most used and
most maintained (78 commits in 90 days). When wa-go and whatsmeow disagree,
whatsmeow wins. The current client version, the server certificate key and
the node shapes are read from whatsmeow at build time of each release.
Do **not** transpile libsignal-go or libsignal-protocol-c: both are GPL-3.0,
and detz is commercial. MPL-2.0 is file-level: a C++ file transpiled from a
whatsmeow file keeps the MPL notice and its source stays available. Copying
code from wa-go, or reading whatsmeow to learn a value, does not trigger this.
This is a fact about the licenses, not legal advice.
## 4. Module layout
Namespace `detz::wa`, sources in `src/whatsapp/`, one class per `.h`/`.cpp`
pair, as the rest of taicpp.
| Class | Responsibility | Transpiled from (wa-go / whatsmeow) |
|---|---|---|
| `WaConstants` | URL, origin, Noise pattern, header, dict version, cert key, client version, all caps (section 8) | `socket/constants.go`, `store/clientpayload.go` |
| `WaProtoWriter`, `WaProtoReader` | protobuf wire format: varint, zigzag not needed, length-delimited, fixed32 | none, hand-written |
| `WaMessages` | the ~15 protobuf messages as plain structs with `encode()`/`decode()` | field numbers from whatsmeow `proto/*.proto` |
| `WaCrypto` | static: X25519, AES-256-GCM, AES-256-CTR, AES-256-CBC, HKDF-SHA256, HMAC-SHA256, SHA-256, SHA-512, PBKDF2-SHA256, random, over OpenSSL EVP | `util/*util`, wa-go `keys` |
| `WaXeddsa` | XEdDSA sign and verify over Curve25519 keys | libsignal-go `ecc/SignCurve25519.go` logic, wa-go `keys` (section 5) |
| `WaSocket` | libcurl WebSocket, 3-byte big-endian frame length, `WA` header on first frame, 16 MiB frame cap | wa-go `wire/framing.go`, `ws` |
| `WaNoise` | Noise XX handshake and the post-handshake read/write ciphers | wa-go `wire/noise.go`, whatsmeow `socket/noisehandshake.go`, `handshake.go` |
| `WaNode`, `WaBinaryCodec`, `WaTokens` | binary XMPP-like nodes, token dictionary v3 (236 single-byte, about 1030 double-byte tokens), nibble/hex packing, JID encoding | wa-go `wire/codec.go`, `token.go`, `node.go` |
| `WaStore` | SQLite tables: device identity, signed prekey, prekeys, sessions, identities, contacts, aliases | wa-go `store`, whatsmeow `store/sqlstore` |
| `WaPairing` | QR pairing (`pair-device`, ref rotation, `pair-success` with ADV signature check) and phone pairing code | wa-go `client` pairing, whatsmeow `pair.go`, `pair-code.go` |
| `WaSignal` | X3DH, Double Ratchet, `pkmsg`/`msg` encrypt and decrypt, session records | wa-go `internal/signal` (x3dh, ratchet, session, message) |
| `WaPrekeys` | upload 50 prekeys plus signed prekey after pairing, refill below threshold | whatsmeow `prekeys.go` |
| `WaUsync` | device list of a recipient (`usync` iq), "is this number on WhatsApp" | whatsmeow `user.go` (subset) |
| `WaMedia` | media key expansion (`WhatsApp Image Keys`), AES-CBC plus HMAC, `media_conn` iq, HTTPS upload to `/mms/image/<token>` | wa-go `media`, whatsmeow `upload.go`, `mediaconn.go` |
| `WaSender` | 1:1 send: device fan-out, encrypt per device, own other devices get a `DeviceSentMessage`, `<message><participants>` node, `phash` check | whatsmeow `send.go` (DM path only) |
| `WaReceiver` | decrypt incoming 1:1 text, send receipts and acks | whatsmeow `message.go`, `receipt.go` (subset) |
| `WaClient` | owns the above; connect, login, keepalive, reconnect, event loop on one thread | whatsmeow `client.go`, `keepalive.go`, `connectionevents.go` |
| `WaContacts` | name to JID: user aliases first, then contacts learned from push names, then fuzzy match with `detz::FuzzyMatcher` (floor 0.3) | none, detz design |
| `WaTools` | agent tools `whatsapp_send`, `whatsapp_contacts`, `whatsapp_alias` | none, detz design |
Out of scope for v1, listed so nobody assumes otherwise: groups (sender keys),
app-state sync (LTHash contact list), history sync, calls, status, newsletters,
receiving media, editing, reactions.
## 5. Protocol walkthrough and crypto mapping
Order of one session, with the OpenSSL call for each primitive.
1. **Connect.** WebSocket to `wss://web.whatsapp.com/ws/chat`, header
`Origin: https://web.whatsapp.com`. Every frame is a 3-byte big-endian
length plus payload; the first frame is prefixed with
`'W','A',6,3` (magic 6, dictionary version 3). Frame cap `1 << 24`.
2. **Noise XX handshake.** Pattern string
`Noise_XX_25519_AESGCM_SHA256\0\0\0\0` (32 bytes, used as the initial hash
as is). Send `HandshakeMessage.ClientHello{ephemeral}`; receive
`ServerHello{ephemeral, static (encrypted), payload (encrypted cert)}`;
mix X25519 shared secrets with HKDF-SHA256 into AES-256-GCM keys, counter IV;
verify the certificate chain against the pinned key `WACertPubKey` in
whatsmeow `handshake.go`; send `ClientFinish{static (encrypted noise key),
payload (encrypted ClientPayload)}`. Then split into a read and a write key.
The proto also defines post-quantum modes (`XXKEM`, `IKKEM`, `WA_PQ`);
whatsmeow does not use them today, so neither do we.
3. **ClientPayload.** Register (unpaired: `devicePairingData` with registration
id, identity public key, signed prekey and its signature, build hash = MD5 of
the version string) or login (paired: `username` = phone number, `device` =
device id). The client version is a constant
(`2.3000.1050728964` in whatsmeow on 2026-10-27). A stale version gives
connect failure **405 client outdated**; whatsmeow can fetch the live value
from `web.whatsapp.com` (`"client_revision":N`).
4. **Pairing.** QR: the server sends `pair-device` with refs; the QR string is
`https://wa.me/settings/linked_devices#ref,noisePub,identityPub,advSecret,clientType`
(keys base64), shown in the terminal, refs
rotate. Phone code: `PairPhone` sends a `link_code_companion_reg` iq; the
8-character code derives an AES-256-CTR key with **PBKDF2-SHA256, 131072
(2<<16) iterations**. On `pair-success` verify the account signature
(XEdDSA), sign the device identity (XEdDSA), send `pair-device-sign`, store
the JID, reconnect and log in.
5. **Prekeys.** Upload signed prekey plus a batch of 50 one-time prekeys (iq
`xmlns="encrypt"`, whatsmeow `WantedPreKeyCount`). Refill when the server
reports fewer than 5 (`MinPreKeyCount`).
6. **Send to a contact.** `usync` iq for the recipient's device list (and our
own); fetch prekey bundles for devices without a session; X3DH, Double
Ratchet encrypt `Message{conversation}` or `Message{imageMessage}` padded;
send `<message to type="text|media"><participants><to jid><enc type="pkmsg|msg"
v="2">` and compare the returned `phash`. Addresses can be phone JIDs
(`@s.whatsapp.net`) or LID JIDs (`@lid`); whatsmeow `send.go` mentions LID 45
times, so LID handling is part of the send path, not an extra.
7. **Image.** Random 32-byte media key; HKDF-SHA256 with info
`WhatsApp Image Keys` to 112 bytes (IV, cipher key, MAC key, ref key);
AES-256-CBC, HMAC-SHA256 truncated to 10 bytes appended; `media_conn` iq gives
hosts and auth; HTTPS POST to `https://<host>/mms/image/<token>`; put
`url`, `directPath`, `mediaKey`, `fileSha256`, `fileEncSha256`,
`fileLength`, `mimetype`, `caption` into `ImageMessage`.
| Primitive | Where | OpenSSL 3.5 |
|---|---|---|
| X25519 | Noise, X3DH, ratchet | `EVP_PKEY_X25519`, `EVP_PKEY_derive` |
| AES-256-GCM | Noise transport | `EVP_aes_256_gcm` |
| AES-256-CTR | pairing code | `EVP_aes_256_ctr` |
| AES-256-CBC | Signal message keys, media | `EVP_aes_256_cbc` |
| HKDF-SHA256 | Noise, ratchet chains, media keys | `EVP_KDF "HKDF"` |
| HMAC-SHA256 | ratchet, media MAC | `EVP_MAC "HMAC"` |
| PBKDF2-SHA256 | pairing code | `PKCS5_PBKDF2_HMAC` |
| SHA-256, SHA-512, MD5 | everywhere, XEdDSA, build hash | `EVP_Digest` |
| **XEdDSA sign** | signed prekey, pairing device signature | **not in OpenSSL** |
| XEdDSA verify | pairing account signature | Montgomery u to Edwards y, then `EVP_PKEY_ED25519` verify (to be proven) |
XEdDSA needs Edwards25519 scalar and point arithmetic, which OpenSSL does not
expose. The algorithm is 60 lines in libsignal-go `ecc/SignCurve25519.go`
(clamped scalar a, A = aB with sign bit forced to 0, r = SHA-512 of a
prefix, a, message and 64 random bytes, R = rB, s = r + SHA-512(R,A,M) a mod L).
The arithmetic underneath (filippo.io/edwards25519, BSD-3-Clause) is what has to
be ported or replaced. That is the one hard crypto item, see section 6.
## 6. Build versus deep research, decided up front
**Build** means mechanical transpile with a known oracle. **Deep research** means
the answer is not written down anywhere we have read, and has to be
established first, with an archive entry, before any code.
| Item | Kind | Oracle or open question |
|---|---|---|
| protobuf writer/reader, message structs | build | field numbers in whatsmeow `.proto`; wa-go `client_payload.json` trace |
| `WaCrypto` wrappers | build | RFC test vectors; wa-go `media_vectors.json` |
| framing, Noise, binary codec, tokens | build | wa-go `traces/connect_pair` (`noise.json`, `frames_raw.jsonl`, `nodes.jsonl`), `codec_battery/nodes.jsonl` |
| Signal X3DH, ratchet, session | build | wa-go `testdata/signal/session_ab.json` |
| pairing code crypto | build | wa-go `testdata/paircode/vectors.json` |
| media encrypt, upload | build | wa-go `media_vectors.json`, then one live send |
| usync, prekey upload, send node shape | build | whatsmeow source, then one live send |
| **XEdDSA sign** | **deep research** | port the Edwards25519 field/scalar/point subset (BSD-3, from filippo.io/edwards25519) or find a permissive C implementation; verify against wa-go `keys/testdata/verify_sig.mjs` style vectors |
| **current client version and 405 policy** | **deep research** | how often whatsmeow bumps `waVersion`; whether to fetch `client_revision` at start (adds one HTTPS call, kills the treadmill) |
| **LID addressing** | **deep research** | when the server hands out `@lid`, how sessions map phone JID to LID; read whatsmeow `send.go` and `store` LID tables |
| **ban behaviour** | **deep research, measured only with a sacrificial number** | no maintainer publishes heuristics; one vendor article names account age, message rate and reports |
| contact names without app-state | build | push names in incoming messages plus user aliases; app-state sync is v2 |
## 7. User flow (the customer view)
Two commands, nothing else to configure:
```
detz --whatsapp-link # shows a QR in the terminal, scan in WhatsApp > Linked devices
detz --whatsapp-link 31612345678 # prints an 8-character code to type on the phone instead
```
Then, in any detz session:
```
> remember that Niggel is 31687654321 on WhatsApp
> create a screenshot, explain what is genius about it and send it to Niggel on WhatsApp
```
The first line calls `whatsapp_alias`. The second runs `shell` (screenshot),
`describe_image`, then `whatsapp_send{to:"Niggel", text, image}`. `whatsapp_send`
resolves the name through `WaContacts`; when the best match is not exact, the
tool result names what it resolved to, the same note pattern as `@agent`
mentions. `detz --whatsapp-unlink` logs the device out and deletes its rows.
State lives in the profile's vault (SQLite, the existing `store`), not in a new
file. The link step is the only step that touches the phone.
Agent tools:
| Tool | Arguments | Result |
|---|---|---|
| `whatsapp_send` | `to` (name, alias or number), `text`, optional `image` (path) | message id, resolved JID and name, `phash` match |
| `whatsapp_contacts` | optional `query` | known names and JIDs |
| `whatsapp_alias` | `name`, `number` | stored alias, after a usync check that the number is on WhatsApp |
## 8. Caps (all static, per CLAUDE.md "everything gets a hard cap")
| Constant | Value | Why |
|---|---|---|
| `kWaFrameMax` | 16 MiB (`1 << 24`) | protocol limit |
| `kWaImageMax` | 16 MiB | upload cap; refuse larger, loud |
| `kWaSendPerMinute` | 10 | ban exposure; the agent must not burst |
| `kWaSendPerDay` | 200 | same |
| `kWaRecipientsPerSend` | 1 | v1 is 1:1 only |
| `kWaPrekeyBatch` | 50 | whatsmeow `WantedPreKeyCount` |
| `kWaConnectTimeout` | 20 s | handshake plus login |
| `kWaPairTimeout` | 120 s | QR or code wait |
| `kWaReconnectFloor` | 5 s, doubling to 300 s | never hammer the server |
| `kWaContactsMax` | 5000 | contact and alias rows per profile |
These are judgments, not measurements. They become measured once the live
tests below run.
## 9. Tests
Unit (`tests/whatsapp_test.cpp`, `make test`):
- every golden vector from wa-go `testdata/` (copied with its MIT notice into
`tests/whatsapp_data/`): Noise transcript, raw frames, decoded nodes, codec
battery, ClientPayload bytes, Signal session A to B, pairing code, media.
- XEdDSA: sign then verify round trip, plus verify of a signature made by
libsignal (vector to be produced once, archived).
- protobuf: encode equals the bytes in the traces, decode of every trace.
Integration (`tests/whatsapp.py`, real binary, real WhatsApp, per AGENTS.md):
- needs a sacrificial number linked once (`WA_TEST_PEER` names a second number);
- cases: link state survives restart, send text, send image, receive the
reply text, `405` handling with a forced old version;
- verification reads vault rows (message id, session rows), never CLI text.
## 10. The shortcut for the day-after-tomorrow promise
The native module is roughly the size measured in section 2.1, so it does
not ship in two days. What can work now, with no C++:
1. clone `lharries/whatsapp-mcp`, **bump its whatsmeow** (`go.mod` pins
`v0.0.0-20250318233852`, 19 months old; a stale whatsmeow risks the 405
client-outdated failure), run `whatsapp-bridge` once and scan the QR;
2. add the MCP server to detz (`mcp_add`, stdio transport,
`uv --directory .../whatsapp-mcp-server run main.py`);
3. the sentence then runs as `shell` + `describe_image` + `send_file`.
This path was **not run end to end**: it needs a linked phone, and a local
probe (an unpaired whatsmeow connect that prints QR events, which proves the
full handshake without a phone) was refused by this session's permission
classifier because it executes downloaded code. To run it yourself:
```
mkdir probe && cd probe && go mod init probe
go get go.mau.fi/whatsmeow@latest modernc.org/sqlite
# main.go: sqlstore.New("sqlite", "file:probe.db"), NewDevice, NewClient,
# GetQRChannel, Connect, print each event; expect "code" events within seconds
go run .
```
## 11. Phases
| Phase | Content | Exit test |
|---|---|---|
| 0 | MCP shortcut (section 10) | the sentence works once, live |
| 1 | `WaProto*`, `WaCrypto`, `WaXeddsa`, `WaSocket`, `WaNoise`, codec, tokens | all wire and crypto golden vectors green; live: QR events from the real server |
| 2 | `WaStore`, `WaPairing`, `WaPrekeys`, login | live: link by QR and by code, restart stays logged in |
| 3 | `WaSignal`, `WaUsync`, `WaSender`, `WaReceiver` | Signal vectors green; live: text to a peer and its reply decrypted |
| 4 | `WaMedia`, `WaContacts`, `WaTools`, CLI flags | live: the full sentence, then the MCP shortcut is removed |
Size guide (measured Go source to transpile, non-blank, non-comment): whatsmeow
files on the 1:1 path (handshake, pair, pair-code, prekeys, send, message,
receipt, upload, mediaconn, user, request, keepalive, client,
connectionevents, qrchan, retry, socket, binary, keys, crypto utils) total
8,616 lines, and only part of send, message and user is needed; wa-go wire,
keys, signal and media total 5,231. The C++ size is not measured until it exists.
## 12. Risks and gaps
- **Terms of Service.** Every unofficial client breaks WhatsApp's terms; every
project README and the one vendor article found say a number can be banned.
Use a sacrificial number for tests. The sanctioned path (Business Cloud API)
cannot use a personal number.
- **Protocol drift.** whatsmeow changes about 6 times a week (78 commits in
90 days). We follow whatsmeow; each release re-checks the version constant,
the cert key and the token dictionary.
- **wa-go is young** (created 2026-06-26, one author, 1 star). Its golden
vectors come from Baileys, which keeps them honest, but its code is not
battle tested. whatsmeow stays the referee.
- **Unverified here:** the full handshake (probe refused), XEdDSA verify via
OpenSSL Ed25519, the 20-day re-auth claim, the 405 behaviour.