whatsappd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-07-08
10
+
11
+ Initial release.
12
+
13
+ ### Added
14
+
15
+ - WhatsApp session engine over Baileys: a narrow, fully-typed surface
16
+ (`createSession`) with status, inbound, update, contact, group, and presence
17
+ streams, plus `send`/`markRead`/`setTyping` commands. No protocol types cross
18
+ the public surface.
19
+ - Pluggable credential stores: `memoryStore`, `fileStore`, and an optional
20
+ `libsqlStore` (via the `whatsappd/stores/libsql` subpath).
21
+ - QR and pairing-code auth strategies (`qrAuth`, `pairingAuth`).
22
+ - Framework-agnostic channel adapter (`createChannelAdapter`) and eight
23
+ plug-and-play agent tools (`whatsappd/tools`).
24
+ - HTTP sidecar (`whatsappd/sidecar`, and the `whatsappd` CLI):
25
+ one process per WhatsApp number, forwarding inbound events and serving media
26
+ on demand.
27
+ - Eve framework adapter (`whatsappd/adapters/eve`).
28
+
29
+ [Unreleased]: https://github.com/AaronAbuUsama/whatsappd/compare/v0.1.0...HEAD
30
+ [0.1.0]: https://github.com/AaronAbuUsama/whatsappd/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaron Griffith
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,369 @@
1
+ <h1 align="center">whatsappd</h1>
2
+
3
+ <p align="center">
4
+ Turn a WhatsApp number into an AI agent channel — a sealed
5
+ <a href="https://github.com/WhiskeySockets/Baileys">Baileys</a> engine,
6
+ plug-and-play agent tools, an HTTP sidecar, and an
7
+ <a href="https://eve.dev">Eve</a> adapter.
8
+ </p>
9
+
10
+ <p align="center">
11
+ <a href="https://www.npmjs.com/package/whatsappd"><img src="https://img.shields.io/npm/v/whatsappd.svg" alt="npm version"></a>
12
+ <a href="https://github.com/AaronAbuUsama/whatsappd/actions/workflows/ci.yml"><img src="https://github.com/AaronAbuUsama/whatsappd/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
13
+ <a href="./LICENSE"><img src="https://img.shields.io/npm/l/whatsappd.svg" alt="MIT license"></a>
14
+ <img src="https://img.shields.io/node/v/whatsappd.svg" alt="Node version">
15
+ </p>
16
+
17
+ A **deep module** over [Baileys](https://github.com/WhiskeySockets/Baileys): a
18
+ narrow, fully-typed WhatsApp client whose entire connection / auth / message space
19
+ is modeled — derived from Baileys' real behaviour, not invented. The Baileys
20
+ internals are **sealed**: no Baileys type ever crosses the surface (`grep baileys`
21
+ the published `.d.ts` and you get zero hits). You learn one function, three streams,
22
+ and two ports; you never reopen the rest.
23
+
24
+ > **Scope:** `createSession` is tenant-naive — one account per session, storage
25
+ > pluggable. The whole package is deliberately single-account: one sidecar
26
+ > process per WhatsApp number (see [Multiple accounts](#multiple-accounts)).
27
+
28
+ See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the full state-machine model and the Baileys
29
+ landmines it seals.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ npm install whatsappd
35
+ # optional, only if you use the libsql store:
36
+ npm install @libsql/client
37
+ ```
38
+
39
+ Requires Node ≥ 20.
40
+
41
+ ## Quick start
42
+
43
+ ```ts
44
+ import { createSession, qrAuth, refOf } from "whatsappd";
45
+ import { fileStore } from "whatsappd";
46
+
47
+ const session = createSession({
48
+ store: fileStore("./.wa-auth"), // WHERE creds live (pluggable)
49
+ auth: qrAuth(), // or pairingAuth("+15551234567")
50
+ // logger optional; metrics, pacing, status filter all have sane defaults
51
+ });
52
+
53
+ // 1) Lifecycle — read the connection as a stream of facts.
54
+ void (async () => {
55
+ for await (const ev of session.connection) {
56
+ if (ev.phase === "pairing" && ev.pairing.step === "challenge_live") {
57
+ console.log("scan/enter:", ev.pairing.qr ?? ev.pairing.code);
58
+ }
59
+ if (ev.phase === "online") console.log("ready to send");
60
+ // logged_out / suspended are terminal and end this stream
61
+ }
62
+ })();
63
+
64
+ // 2) Inbound — messages as a stream.
65
+ void (async () => {
66
+ for await (const m of session.inbound) {
67
+ if (m.fromMe || !m.live) continue;
68
+ if (m.kind === "text" && m.text === "ping") {
69
+ await session.send(m.chatId, { text: "pong" }, { quote: refOf(m) });
70
+ }
71
+ }
72
+ })();
73
+
74
+ await session.start();
75
+ // …later
76
+ await session.stop(); // intentional teardown — never reported as a fault
77
+ ```
78
+
79
+ ## The surface
80
+
81
+ ```ts
82
+ const session = createSession(config);
83
+
84
+ session.status // current Status (sync read)
85
+ session.connection // AsyncIterable<Status> — lifecycle
86
+ session.inbound // AsyncIterable<InboundMessage> — messages in
87
+ session.updates // AsyncIterable<Update> — receipts/reactions/edits/revokes
88
+
89
+ session.start() // Promise<void> — connect + supervise (auto-reconnect)
90
+ session.send(to, msg, opts?) // Promise<MessageRef>
91
+ session.markRead(refs) // Promise<void> — blue ticks
92
+ session.setTyping(chatId, on) // Promise<void> — typing indicator
93
+ session.stop() // Promise<void> — intentional teardown
94
+ ```
95
+
96
+ ### Inbound messages
97
+
98
+ A closed discriminated union with an `unsupported` catch-all — it is
99
+ type-impossible to crash on or silently drop a message. `switch (m.kind)` is
100
+ exhaustive:
101
+
102
+ ```ts
103
+ for await (const m of session.inbound) {
104
+ switch (m.kind) {
105
+ case "text":
106
+ m.text;
107
+ break;
108
+ case "image":
109
+ case "video":
110
+ case "audio":
111
+ case "document":
112
+ case "sticker":
113
+ m.media;
114
+ m.text /* caption */;
115
+ break;
116
+ case "location":
117
+ m.lat;
118
+ m.lng;
119
+ m.name;
120
+ break;
121
+ case "contacts":
122
+ m.contacts;
123
+ break;
124
+ case "poll":
125
+ m.name;
126
+ m.options;
127
+ break;
128
+ case "unsupported":
129
+ m.rawType;
130
+ break;
131
+ }
132
+ }
133
+ ```
134
+
135
+ Every message carries `id`, `chatId`, `from`, `fromMe`, `timestamp`, `live`
136
+ (`true` = arrived now, `false` = history backfill), `isGroup`, and optionally
137
+ `context` (quote/mentions), `addressing` (LID/PN), and `flags`
138
+ (viewOnce/ephemeral/edited).
139
+
140
+ **Media is lazy** — bytes never sit in the stream. Pull them when you're ready;
141
+ expired media is transparently re-uploaded and re-fetched:
142
+
143
+ ```ts
144
+ if (m.kind === "image") {
145
+ const bytes: Buffer = await m.media.download();
146
+ // m.media also has mimetype, fileLength, width, height, caption, …
147
+ }
148
+ ```
149
+
150
+ ### Sending
151
+
152
+ `send` is one polymorphic verb and returns a `MessageRef` so you can act on what
153
+ you just sent:
154
+
155
+ ```ts
156
+ const ref = await session.send(to, { text: "hello" });
157
+ await session.send(to, { image: buffer, caption: "hi" }); // Buffer | {url} | {stream}
158
+ await session.send(to, { audio: buf, ptt: true }); // voice note
159
+ await session.send(to, { location: { lat, lng, name } });
160
+ await session.send(to, { contacts: { displayName, vcards } });
161
+
162
+ // reference-based ops — no proto, just a MessageRef
163
+ await session.send(to, { react: { to: ref, emoji: "👍" } }); // emoji "" clears it
164
+ await session.send(to, { edit: { target: ref, text: "fixed" } });
165
+ await session.send(to, { delete: ref });
166
+
167
+ // options
168
+ await session.send(to, { text: "re:" }, { quote: ref, mentions: ["1555…@s.whatsapp.net"] });
169
+ ```
170
+
171
+ `refOf(inboundMessage)` builds a `MessageRef` from something you received.
172
+
173
+ ### Updates
174
+
175
+ Changes to _existing_ messages arrive on a separate stream:
176
+
177
+ ```ts
178
+ for await (const u of session.updates) {
179
+ switch (u.kind) {
180
+ case "receipt":
181
+ u.status;
182
+ /* server_ack → delivered → read → played */ break;
183
+ case "reaction":
184
+ u.emoji;
185
+ u.removed;
186
+ u.by;
187
+ break;
188
+ case "edit":
189
+ u.message;
190
+ /* the new content, same shape as inbound */ break;
191
+ case "revoke":
192
+ u.by;
193
+ break;
194
+ }
195
+ }
196
+ ```
197
+
198
+ ## The two ports
199
+
200
+ **`SessionStore`** — where credentials live. An opaque key/value store; the module
201
+ serializes all Baileys auth state into plain strings, so your store needs **zero
202
+ Baileys knowledge**:
203
+
204
+ ```ts
205
+ interface SessionStore {
206
+ read(key: string): Promise<string | null>;
207
+ write(entries: Record<string, string | null>): Promise<void>; // null = delete
208
+ clear(): Promise<void>;
209
+ }
210
+ ```
211
+
212
+ Three are included:
213
+
214
+ ```ts
215
+ import { memoryStore, fileStore } from "whatsappd";
216
+ import { libsqlStore } from "whatsappd/stores/libsql";
217
+
218
+ memoryStore(); // ephemeral (tests, scripts)
219
+ fileStore("./.wa-auth"); // one file per key
220
+ libsqlStore({ url: "file:wa.db", account: "1555…" }); // local SQLite…
221
+ libsqlStore({ url: "libsql://…turso.io", authToken, account }); // …or remote Turso
222
+ ```
223
+
224
+ The `account` field namespaces rows, so one libsql database can hold many
225
+ accounts. Bring your own (Redis, Postgres, DynamoDB, …) by implementing the
226
+ three methods over a `(key, value)` table.
227
+
228
+ **`AuthStrategy`** — how you log in: `qrAuth()` or `pairingAuth(phone)` (E.164,
229
+ validated at the edge).
230
+
231
+ ## Multiple accounts
232
+
233
+ This package is deliberately **single-account**: one session, one adapter, one
234
+ sidecar process per WhatsApp number. Running several numbers means running
235
+ several sidecar processes — each with its own store dir, port, and
236
+ `WHATSAPP_ACCOUNT` label — all forwarding into the same app. Events carry
237
+ `accountId`, so the receiver can tell the numbers apart.
238
+
239
+ Process-per-account is a feature, not a limitation: Baileys sessions
240
+ reconnect, re-pair, and occasionally crash-loop; isolating each number in its
241
+ own process means one account's bad day never touches another's, and "which
242
+ QR is this?" always has an obvious answer. (An in-process multi-account
243
+ supervisor used to live here; it was removed — see git history if you ever
244
+ need the pattern back.)
245
+
246
+ **Single ownership still applies.** A WhatsApp number is exactly one
247
+ linked-device session — two processes on the same account kick each other off
248
+ (the `440 connection_replaced` fault). Point exactly one sidecar at each
249
+ credential store.
250
+
251
+ ## Configuration
252
+
253
+ ```ts
254
+ createSession({
255
+ store,
256
+ auth,
257
+ logger, // optional; defaults to pino at WA_LOG_LEVEL (or "warn")
258
+ receiveStatusBroadcast, // default false — drop status@broadcast story posts
259
+ sendMinGapMs, // default 1000 — anti-ban gap between sends; 0 disables
260
+ metrics, // (e: MetricEvent) => void — fire-and-forget hook
261
+ verdictWindowMs, // pairing-rejection (silent-400) window
262
+ syncGraceMs,
263
+ reconnectBaseMs,
264
+ reconnectMaxMs,
265
+ });
266
+ ```
267
+
268
+ **Send pacing** is on by default: outbound sends are funnelled through a FIFO
269
+ queue with a minimum gap, because WhatsApp flags bursty accounts. Set
270
+ `sendMinGapMs: 0` to disable. (`markRead`/`setTyping` are not paced.)
271
+
272
+ **Metrics** is an optional observability seam — the session calls it on
273
+ `transition`, `message_in`, `update_in`, `message_out`, and `reconnect`. A thrown
274
+ hook can never break the connection.
275
+
276
+ ## Connection model (summary)
277
+
278
+ `status.phase`: `disconnected → connecting → pairing → authenticated → online`,
279
+ with `backing_off` for retryable drops and two terminal sinks:
280
+
281
+ - **`logged_out`** (401/440, or a rejected pairing) — creds are dead; the store is
282
+ wiped for you before the event fires; re-pair.
283
+ - **`suspended`** (403/411/500) — account/device problem; re-pairing won't help.
284
+
285
+ `online` ≠ `authenticated`: the device is only sendable once history sync settles.
286
+ The pairing flow honestly reports WhatsApp's silent 400 rejection via a verdict
287
+ window. Full detail and Baileys citations in [`ARCHITECTURE.md`](./ARCHITECTURE.md).
288
+
289
+ ## Agent channel: sidecar + Eve adapter
290
+
291
+ The deep module above is transport. On top of it this package ships an **agent
292
+ channel**: a sidecar process that owns the Baileys socket, plus a plug-and-play
293
+ adapter for the [Eve framework](https://eve.dev). The sidecar POSTs inbound
294
+ events to your app; your app POSTs replies back. No Baileys import ever enters
295
+ the framework side.
296
+
297
+ ```
298
+ WhatsApp ←Baileys WS→ sidecar (this package) ←HTTP→ Eve app (thin adapter)
299
+ ```
300
+
301
+ **1. Run the sidecar** (one process per number; first run prints a QR):
302
+
303
+ ```bash
304
+ WHATSAPP_FORWARD_URLS=https://my-app.example/api/channels/whatsapp/event \
305
+ WHATSAPP_SIDECAR_TOKEN=s3cret \
306
+ npx whatsappd
307
+ ```
308
+
309
+ or programmatically: `import { runSidecar } from "whatsappd/sidecar"`.
310
+
311
+ **2. Drop the channel into your Eve app** as `agent/channels/whatsapp.ts`:
312
+
313
+ ```ts
314
+ export { default } from "whatsappd/adapters/eve";
315
+ // reads WHATSAPP_SIDECAR_URL / WHATSAPP_SIDECAR_TOKEN, or configure:
316
+ // import { whatsappChannel } from "whatsappd/adapters/eve";
317
+ // export default whatsappChannel({ sidecarUrl: "http://localhost:8788" });
318
+ ```
319
+
320
+ One WhatsApp conversation ↔ one Eve session (`continuationToken` = chat JID).
321
+ Replies deliver on `message.completed`; read receipts + typing presence fire on
322
+ `turn.started`; inbound media stages through Eve's `fetchFile` from the
323
+ sidecar's `/media/...` endpoint.
324
+
325
+ For non-Eve hosts, `whatsappd/tools` exports the 8 agent tools
326
+ (`sendText`, `sendMedia`, `reply`, `markRead`, `setTyping`, `react`, `edit`,
327
+ `deleteMsg`) over the same `WhatsAppChannelAdapter` surface, and the sidecar's
328
+ HTTP API (`/send`, `/markRead`, `/setTyping`, `/media`, `/health`) is
329
+ framework-neutral.
330
+
331
+ ## Develop & test
332
+
333
+ ```bash
334
+ npm test # no-phone tests (mappers, machine, stores, auth round-trip, pacer, channel, sidecar, eve adapter)
335
+ npm run typecheck
336
+ npm run build # → dist/ (ESM + types; Baileys kept external; 0 baileys leaks in .d.ts)
337
+ ```
338
+
339
+ ### Live proof (needs a phone)
340
+
341
+ ```bash
342
+ npm run proof # QR login; message "ping" from another phone → "pong"
343
+ npm run proof -- +1555… # pairing-code login
344
+ npm run e2e 1555… # full outbound suite + self-driven react/edit/delete (loopback)
345
+ npm run store-proof # QR once, then reconnect from libsql with no QR
346
+ npm run proof:reset # kill proof + wipe ./.wa-auth for a fresh QR
347
+ ```
348
+
349
+ Always `Ctrl-C` (or `proof:reset`) before clearing auth — never delete the auth
350
+ directory under a running session.
351
+
352
+ ## Disclaimer
353
+
354
+ This package uses **Baileys**, an unofficial reverse-engineered implementation
355
+ of the WhatsApp Web multi-device protocol. It is **not affiliated with,
356
+ endorsed by, or connected to WhatsApp or Meta** in any way. Automating a
357
+ personal WhatsApp account can violate WhatsApp's Terms of Service and may lead
358
+ to your number being **temporarily or permanently banned**. Use it at your own
359
+ risk, ideally with a number you can afford to lose, and make sure your usage
360
+ complies with WhatsApp's terms and any laws that apply to you.
361
+
362
+ ## Contributing
363
+
364
+ Bug reports and pull requests are welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md).
365
+ For security issues, see [SECURITY.md](./SECURITY.md).
366
+
367
+ ## License
368
+
369
+ [MIT](./LICENSE) © Aaron Griffith
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * whatsappd — the WhatsApp daemon CLI.
4
+ *
5
+ * Runs one WhatsApp account session behind an HTTP surface, configured entirely
6
+ * through environment variables (see `whatsappd/sidecar` for the full list).
7
+ * First run prints a QR (or pairing code) to link the device.
8
+ *
9
+ * WHATSAPP_FORWARD_URLS=https://my-app.example/api/channels/whatsapp/event \
10
+ * WHATSAPP_SIDECAR_TOKEN=secret \
11
+ * npx whatsappd
12
+ */
13
+ import { runSidecar } from "../dist/sidecar/index.mjs";
14
+
15
+ runSidecar().catch((err) => {
16
+ console.error(err);
17
+ process.exit(1);
18
+ });