wazap-mcp 0.11.0 → 0.14.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/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  ```
9
9
 
10
10
  **WhatsApp for your AI agent.** An MCP server that puts your WhatsApp account —
11
- chats, messages, media, contacts, groups — behind 25 tools any MCP client can
11
+ chats, messages, media, contacts, groups — behind 33 tools any MCP client can
12
12
  call. Pairing-code login, no browser, no phone-number reseller, ~20 MB of RAM.
13
13
 
14
14
  Built on [Baileys](https://github.com/WhiskeySockets/Baileys), which speaks the
@@ -24,11 +24,13 @@ npx wazap-mcp setup
24
24
 
25
25
  That is the whole install. It links your account, finds the MCP clients
26
26
  installed on this machine, writes their config, copies the five skills where
27
- that client reads them, and tells you what to restart. When you started through
28
- `npx`, `setup` offers to install wazap globally so Claude Desktop and the
29
- background service have a path that does not change. It also offers to
30
- `brew install` whisper-cpp, ffmpeg or Tailscale when a step needs one and it is
31
- missing, and to restart Claude Desktop itself once it has connected it.
27
+ that client reads them, and tells you what to restart. At a terminal it is one
28
+ black, centered screen per step: ghosted ASCII logo, step number, then the
29
+ QR or the question. Piped output stays a log. When you
30
+ started through `npx`, `setup` offers to install wazap globally so Claude
31
+ Desktop and the background service have a path that does not change. It also
32
+ offers to `brew install` whisper-cpp, ffmpeg or Tailscale when a step needs one
33
+ and it is missing, and to restart Claude Desktop itself once it has connected it.
32
34
 
33
35
  ### Or the path your harness prefers
34
36
 
@@ -42,10 +44,11 @@ missing, and to restart Claude Desktop itself once it has connected it.
42
44
  | Codex CLI | `npx wazap-mcp connect codex`, then `npx wazap-mcp skills install codex` |
43
45
  | OpenCode | `npx wazap-mcp connect opencode`, then `npx wazap-mcp skills install opencode` |
44
46
  | Windsurf | `npx wazap-mcp connect windsurf` |
47
+ | Grok Bot | [Grok Bot / remote MCP](#grok-bot--remote-mcp) |
45
48
  | Anything else | the MCP entry `npx -y wazap-mcp` over stdio, or a [self-hosted](#self-host) URL |
46
49
 
47
- Each of those registers the server. Linking the WhatsApp account is a separate,
48
- one-time step in every one of them: `npx wazap-mcp login`.
50
+ Each local harness registers the server. Grok Bot is a URL you paste. Linking
51
+ the WhatsApp account is a separate, one-time step: `npx wazap-mcp login`.
49
52
 
50
53
  Or have your agent do it. Paste this:
51
54
 
@@ -87,8 +90,28 @@ it would write.
87
90
  | `gemini` | `~/.gemini/settings.json` |
88
91
  | `windsurf` | `~/.codeium/windsurf/mcp_config.json` |
89
92
  | `opencode` | `mcp.whatsapp` in `~/.config/opencode/opencode.json` |
93
+ | Grok Bot | client's MCP URL field: `http://<host>:<port>/mcp` with header `Authorization: Bearer <token>` (see [Grok Bot / remote MCP](#grok-bot--remote-mcp)) |
90
94
  | anything remote | client's MCP URL field: `https://your-host/mcp` with header `Authorization: Bearer <token>`, or just the URL once [OAuth](#hosted-agents-oauth) is on (see [Self-host](#self-host)) |
91
95
 
96
+ ### Grok Bot / remote MCP
97
+
98
+ Grok Bot is an HTTP MCP client. It does not launch wazap over stdio.
99
+
100
+ 1. On the machine that will run wazap, `npx wazap-mcp login` until the CLI says the account is linked. `get_status` and `link_account` are MCP tools; they need HTTP already serving (step 3) and Grok already connected (step 4).
101
+ 2. Answer writes yes or no at login. Writes stay on when `WAZAP_READ_ONLY` is unset. A Bearer write token is not writes being enabled. If write tools are missing, you need writes on (`wazap config writes on` and restart) and a write Bearer on this session. Config alone is not enough on HTTP.
102
+ 3. Serve HTTP with a read token:
103
+
104
+ ```bash
105
+ WAZAP_READ_TOKEN=$(openssl rand -hex 32) \
106
+ npx wazap-mcp serve --http
107
+ ```
108
+
109
+ Set `WAZAP_WRITE_TOKEN` too only if this client should send, and put that value in the header in step 4.
110
+
111
+ 4. In Grok Bot, add an MCP server at `http://<host>:<port>/mcp` with header `Authorization: Bearer <token>`. Then call `learn`, then `get_status`. `connected` means the WhatsApp socket is up. This session can send only when write tools are registered (`write_tools: true`, or send tools appear in the tool list). Then read.
112
+
113
+ `wazap setup` asks `Remote client (Grok Bot / HTTP MCP)?` and prints the same URL and header. Answering yes does not start `expose`.
114
+
92
115
  ### Other MCP clients
93
116
 
94
117
  Cursor and VS Code install from a link:
@@ -249,12 +272,18 @@ them. `--dry-run` prints the plan and touches nothing.
249
272
  | Tool | Kind | What it does |
250
273
  | --- | --- | --- |
251
274
  | `learn` | read | The guide to every tool, id format and error code. Call it first. |
252
- | `get_status` | read | Connection status, sync state, linked account, named-contact count, versions, data dir. |
253
- | `link_account` | read | Pair the account without a terminal: returns the code to type into the phone. Registered in read-only mode too. |
275
+ | `get_status` | read | Connection status, sync state, linked account, named-contact count, versions, data dir. Top-level fields are the default account; `accounts` lists every live one. Optional `account_id` on this and every other tool. |
276
+ | `list_accounts` | read | Every configured account: id, name, status, masked phone, owner name, writes policy. Call this first when more than one account is linked. |
277
+ | `link_account` | read | Pair an account that already exists (`wazap account add`). Returns the code to type into the phone. Registered in read-only mode too. |
254
278
  | `list_chats` | read | Conversations newest-first; filter `all`/`unread`/`groups`/`individual`/`archived`. |
255
- | `read_messages` | read | Messages in a chat; `before` pages further back, pulling older history from the phone; `types` narrows to one or more message types, e.g. `["call"]`. |
256
- | `get_recent_messages` | read | Everything from the last N hours, grouped by chat. The catch-up tool. `include_system` adds WhatsApp's own notices, `types` narrows to one or more message types. |
257
- | `search_messages` | read | Text search across the locally held messages. |
279
+ | `read_messages` | read | Messages in a chat; `before` pages further back, pulling older history from the phone; `types` narrows to one or more message types, e.g. `["call"]`; `include_previews` attaches a small image of each photo. |
280
+ | `get_recent_messages` | read | Everything from the last N hours, grouped by chat. The catch-up tool. `include_system` adds WhatsApp's own notices, `types` narrows to one or more message types, `include_previews` attaches a small image of each photo, `compact` halves it for a routine catch-up. |
281
+ | `get_unanswered` | read | Who is waiting on the user: chats whose last word is theirs and asks for something, with the ask quoted. Groups only when the user was @-mentioned or replied to. |
282
+ | `set_contact_note` | local | Remember something about a person, on this machine only; it then shows next to their name everywhere. |
283
+ | `mark_handled` | local | Take a chat off `get_unanswered` until the other side writes again. Nothing changes on WhatsApp. |
284
+ | `get_stories` | read | The stories (status updates) received in the last day, by author, with previews on request. They show nowhere else. |
285
+ | `wait_for_messages` | read | Block up to 55 s until a message arrives, then return it with a cursor for the next call. `addressed_to_me` wakes only for direct messages, @-mentions and replies. |
286
+ | `search_messages` | read | Text search across the locally held messages; `since`, `until` and `from` narrow it. |
258
287
  | `get_message` | read | One message in full, with its quoted message and reactions. |
259
288
  | `search_contacts` | read | Find contacts by name or number. |
260
289
  | `sync_contacts` | read | Fetch the phone's address book from WhatsApp again, when names are missing. |
@@ -262,18 +291,48 @@ them. `--dry-run` prints the plan and touches nothing.
262
291
  | `get_group_info` | read | Participants, admins, announcement mode, invite link (when you are admin). |
263
292
  | `download_media` | read | Save an attachment to disk; small images also come back inline. |
264
293
  | `transcribe_audio` | read | Turn a voice note or audio message into text, with the local or the API provider. |
265
- | `send_message` | write | Send text, optionally as a reply, with @-mentions. |
266
- | `send_media` | write | Send an image, video, audio, voice note or document from a path or URL. |
267
- | `send_poll` | write | Send a poll with 2–12 options. |
268
- | `send_location` | write | Send a map pin. |
294
+ | `send_message` | write | Draft text, optionally as a reply, with @-mentions. Does not send. |
295
+ | `send_media` | write | Draft an image, video, audio, voice note, document or GIF (`as_gif`: an mp4 loops, a .gif is converted with ffmpeg) from a path or URL. Does not send. |
296
+ | `send_poll` | write | Draft a poll with 2–12 options. Does not send. |
297
+ | `send_location` | write | Draft a map pin. Does not send. |
269
298
  | `edit_message` | write | Edit your own message, within WhatsApp's 15-minute window. |
270
299
  | `react_to_message` | write | Add or remove an emoji reaction. |
271
- | `forward_message` | write | Forward a message to another chat. |
300
+ | `forward_message` | write | Draft a forward to another chat. Does not send. |
301
+ | `confirm_send` | write | Send a draft after the user has seen the preview and said yes. |
272
302
  | `delete_message` | write | Retract your own message, within WhatsApp's 2-day window. |
303
+ | `set_profile_picture` | write | Set the linked account's own profile photo from a local path or URL. Hits WhatsApp immediately. |
273
304
  | `manage_chat` | write | Archive, pin, mute (8h by default), mark read/unread. |
274
305
  | `create_group` | write | Create a group and add participants. |
275
306
  | `manage_group` | write | Add, remove, promote, demote, leave, rename, invite links. |
276
307
 
308
+ ### Seeing, waiting, following up
309
+
310
+ `include_previews: true` on `get_recent_messages` or `read_messages` attaches a
311
+ small JPEG of each photo as an image block, newest first, up to 12 per call,
312
+ and labels each message line with the preview it belongs to, so a catch-up can
313
+ say "a photo of a receipt" without a download. WhatsApp used to ship such a
314
+ preview inside every image message and in 2026 almost never does, so when
315
+ none is there wazap downloads the photo once, shrinks it to 320 px on this
316
+ machine with pure JavaScript, and keeps the result as a file under
317
+ `previews/` in the data directory, so a restart does not redo it. A video gets
318
+ one frame, taken by ffmpeg a second in, when ffmpeg is installed. The first
319
+ call over a day of photos takes a few seconds; the next is instant.
320
+
321
+ `wait_for_messages` blocks until something arrives, up to 55 seconds, then
322
+ returns it with a `cursor`. Calling it again with that cursor replays whatever
323
+ landed in between, so an agent can sit in a loop and miss nothing. With
324
+ `addressed_to_me` only direct messages, @-mentions of the user and replies to
325
+ their messages wake it; group chatter does not. The user's own messages and
326
+ WhatsApp's notices never do.
327
+
328
+ `get_unanswered` returns the chats whose last word is the other side's and reads
329
+ as an ask: a question mark, a request word, or a voice note nobody has heard
330
+ yet. "Ok, thanks" is not an ask, a link is not a question, and an ask older
331
+ than two weeks (`max_age_hours`) was abandoned rather than left waiting. People
332
+ come first, then the oldest wait, each with the ask quoted and how long they
333
+ have been waiting; a WhatsApp Business account is marked, since its asks are
334
+ often automatic replies.
335
+
277
336
  Every message comes back with a non-empty `text`: media and system messages
278
337
  carry a placeholder such as `[image] caption`, `[voice message · 0:42]`, `[deleted]` or
279
338
  `[poll] Pizza or pasta?`. Timestamps are ISO 8601 with the machine's UTC offset,
@@ -429,11 +488,15 @@ trace, so an agent can decide whether to retry, ask the user, or stop.
429
488
  | `CHAT_NOT_FOUND` / `MESSAGE_NOT_FOUND` / `CONTACT_NOT_FOUND` / `GROUP_NOT_FOUND` | Unknown id. |
430
489
  | `NOT_A_PARTICIPANT` / `NOT_ADMIN` / `GROUP_ANNOUNCEMENT_ONLY` | Group permissions. |
431
490
  | `MEDIA_UNAVAILABLE` | WhatsApp expired the file, or it was never synced here. |
432
- | `FILE_NOT_FOUND` / `FILE_TOO_LARGE` / `URL_FETCH_FAILED` | Outbound media problems. |
491
+ | `FILE_NOT_FOUND` / `FILE_TOO_LARGE` / `URL_FETCH_FAILED` / `INVALID_IMAGE` | Outbound media problems. |
433
492
  | `TEXT_TOO_LONG` | Over WhatsApp's message limit. |
434
493
  | `EDIT_WINDOW_EXPIRED` / `RETRACT_WINDOW_EXPIRED` / `NOT_OWN_MESSAGE` | WhatsApp's own limits on editing and deleting. |
435
494
  | `READ_ONLY` | wazap is running read-only. |
436
495
  | `RATE_LIMITED` | Too many writes; `fix` says how long to wait. |
496
+ | `DRAFT_NOT_FOUND` / `DRAFT_EXPIRED` | The preview was already sent, unknown, or older than 15 minutes. Draft again. |
497
+ | `AMBIGUOUS_ACCOUNT` | More than one account could handle this, or a write named a chat no account knows. Pass `account_id`. |
498
+ | `ACCOUNT_NOT_FOUND` | No account with that id. Run `wazap account add`, or call `list_accounts`. |
499
+ | `ACCOUNT_DISABLED` | That account is disabled. `wazap account enable <id>` and restart. |
437
500
  | `TIMEOUT` / `WHATSAPP_ERROR` | WhatsApp did not answer, or rejected the operation. |
438
501
 
439
502
  ## Data directory
@@ -446,6 +509,8 @@ created `0700` with credentials written `0600`:
446
509
  auth/ WhatsApp credentials — treat this like a password
447
510
  media/ downloads from download_media
448
511
  history/ per-chat message history, so a restart is not amnesia
512
+ previews/ one small JPEG per photo or video already previewed
513
+ notes.json notes on contacts and "handled" marks; never sent anywhere
449
514
  models/ whisper.cpp models, when transcription runs locally
450
515
  store.json chat-list snapshot
451
516
  server.lock pid of the running server
@@ -479,13 +544,16 @@ server of its own rather than a bridge, and is refused the same way.
479
544
 
480
545
  ## Read-only mode
481
546
 
482
- Writes are opt-in. `login` asks once and stores the answer in
483
- `<data-dir>/.env`; `wazap config writes on|off` changes it, and `wazap config`
484
- alone prints every effective setting with where it came from.
547
+ Writes are opt-in at `login` (the question defaults to no and stores the
548
+ answer in `<data-dir>/.env`). `wazap config writes on|off` changes it later.
549
+ `wazap config` and `wazap status` print the effective setting and where it
550
+ came from. If that line says off, write tools are not registered: run
551
+ `wazap config writes on` and restart the server.
485
552
 
486
- `WAZAP_READ_ONLY=1` or `wazap serve --read-only` does not register the write
487
- tools at all. The agent never sees them, so it cannot message anyone from your
488
- number even by mistake useful when the linked account is your personal one.
553
+ An unset `WAZAP_READ_ONLY` and `WAZAP_READ_ONLY=0` both register write tools
554
+ (`wazap config` then says "writes: on"). `WAZAP_READ_ONLY=1` or
555
+ `wazap serve --read-only` does not register them at all. The agent never
556
+ sees them, so it cannot message anyone from your number even by mistake.
489
557
 
490
558
  Writes are also rate limited to `WAZAP_RATE_LIMIT` per minute (default 20, `0`
491
559
  disables). Sending faster than a human is how accounts get banned.
@@ -502,10 +570,13 @@ Streamable HTTP at `/mcp`, with a health check at `/healthz`. That check answers
502
570
  `{ ok, status, since }`. It turns 503 once the socket has been anything but
503
571
  connected for two minutes, so a tunnel or a monitor sees a real outage rather
504
572
  than a reconnect in progress. Two bearer tokens:
505
- the read token gets the read tools, the write token also unlocks the write
506
- tools, so a leaked read token can never message anyone. wazap refuses to bind a
507
- non-loopback address without a read token. Agents that cannot carry a header
508
- sign in with [OAuth](#hosted-agents-oauth) instead.
573
+ the read token gets the read tools, the write token can unlock the write
574
+ tools. A leaked read token can never message anyone. A write token is not
575
+ the same as writes being enabled: if the server is read-only, even a write
576
+ token session has no write tools. `get_status` says so and how to turn
577
+ writes on. wazap refuses to bind a non-loopback address without a read
578
+ token. Agents that cannot carry a header sign in with
579
+ [OAuth](#hosted-agents-oauth) instead.
509
580
 
510
581
  ## Self-host
511
582
 
@@ -605,12 +676,42 @@ What to know before exposing it:
605
676
  - A read grant never sees a write tool, whatever scope the agent requested.
606
677
  The radio button on the consent page is the only thing that decides.
607
678
 
679
+ ## Outbound webhook
680
+
681
+ A live inbound message can POST to one URL. Off by default. History sync
682
+ is not posted. The only event is `message_received`.
683
+
684
+ ```bash
685
+ npx wazap-mcp config webhook on # asks for URL + secret (secret is not echoed)
686
+ npx wazap-mcp webhook test # POST a probe event
687
+ npx wazap-mcp config webhook off
688
+ ```
689
+
690
+ On without a URL or secret fails `wazap status`, doctor and setup. A failed
691
+ delivery retries twice (200 ms, then 500 ms), then sets `webhook.last_error`
692
+ and leaves WhatsApp and MCP running.
693
+
694
+ HMAC: `X-Wazap-Signature` is `sha256=<hex>`, HMAC-SHA256 of the exact raw
695
+ JSON body with `WAZAP_WEBHOOK_SECRET`. Verify that raw body, not a
696
+ re-serialized object. HTTPS only, except `http://` on loopback.
697
+
698
+ ```json
699
+ {
700
+ "event": "message_received",
701
+ "from": "+15550100",
702
+ "chat_id": "15550100@s.whatsapp.net",
703
+ "ts": "2026-09-08T14:00:00+00:00",
704
+ "text": "hello, or a short preview",
705
+ "message_id": "false_15550100@s.whatsapp.net_3EB0…"
706
+ }
707
+ ```
708
+
608
709
  ## Settings
609
710
 
610
711
  | Variable | Default | Meaning |
611
712
  | --- | --- | --- |
612
713
  | `WAZAP_DATA_DIR` | `~/.wazap` | Where everything is stored. |
613
- | `WAZAP_READ_ONLY` | `0` | Do not register the write tools. |
714
+ | `WAZAP_READ_ONLY` | unset (`0`) | `1` does not register the write tools. Unset and `0` both do. |
614
715
  | `WAZAP_SYNC_FULL_HISTORY` | `0` | Ask WhatsApp for a fuller history sync. |
615
716
  | `WAZAP_PERSIST_HISTORY` | `1` | Keep chats and messages across restarts. |
616
717
  | `WAZAP_RATE_LIMIT` | `20` | Write tool calls per minute; `0` disables. |
@@ -628,6 +729,9 @@ What to know before exposing it:
628
729
  | `WAZAP_TRANSCRIBE_API_KEY` | unset | API key; `OPENAI_API_KEY` is the fallback. Never a flag. |
629
730
  | `WAZAP_TRANSCRIBE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL. |
630
731
  | `WAZAP_TRANSCRIBE_MODEL` | `gpt-4o-mini-transcribe` | Model at that URL. |
732
+ | `WAZAP_WEBHOOK` | `off` | `on` posts live inbound messages to the webhook URL. |
733
+ | `WAZAP_WEBHOOK_URL` | unset | HTTPS endpoint. `http://` only on loopback. |
734
+ | `WAZAP_WEBHOOK_SECRET` | unset | Shared secret for `X-Wazap-Signature`. Never a flag. |
631
735
 
632
736
  Flags beat environment variables, which beat `<data-dir>/.env`.
633
737
 
@@ -0,0 +1,132 @@
1
+ import { AccountRegistry } from "./accounts.js";
2
+ import { readLinkedAccount } from "./auth-state.js";
3
+ import { ask, leftoverFix } from "./cli.js";
4
+ import { ACCOUNT_USAGE, MIGRATE_USAGE, accountPaths, paths } from "./config.js";
5
+ import { WazapError } from "./errors.js";
6
+ import { lockHolder } from "./lock.js";
7
+ import { say } from "./logger.js";
8
+ import { rollbackMigration } from "./migrate.js";
9
+ import { brand, info, maskNumber, ok } from "./ui.js";
10
+ function parseAccountVerb(verb) {
11
+ switch (verb) {
12
+ case "list":
13
+ case "add":
14
+ case "remove":
15
+ case "enable":
16
+ case "disable":
17
+ return verb;
18
+ default:
19
+ throw new WazapError("INVALID_ID", `Unknown account command "${verb ?? ""}".`, ACCOUNT_USAGE);
20
+ }
21
+ }
22
+ /** The number is masked: a status screenshot should not carry it. */
23
+ export function describeAccount(account) {
24
+ const number = maskNumber(account.number);
25
+ return account.name ? `${account.name} (${number})` : number;
26
+ }
27
+ export function describeStatusAccount(row) {
28
+ const flag = row.enabled ? "enabled" : "disabled";
29
+ const who = row.account === null ? "not linked" : describeAccount(row.account);
30
+ return `${row.id} ${flag} ${who}`;
31
+ }
32
+ export function accountRows(config) {
33
+ const registry = AccountRegistry.load(config.dataDir);
34
+ return registry.all().map((record) => {
35
+ let linked = null;
36
+ try {
37
+ linked = readLinkedAccount(accountPaths(config.dataDir, record.id).authDir);
38
+ }
39
+ catch {
40
+ linked = null;
41
+ }
42
+ return {
43
+ id: record.id,
44
+ name: record.name,
45
+ enabled: record.enabled,
46
+ default: record.id === registry.defaultId(),
47
+ account: linked,
48
+ };
49
+ });
50
+ }
51
+ export async function runAccount(config) {
52
+ const [rawVerb, id] = config.args;
53
+ const verb = parseAccountVerb(rawVerb);
54
+ switch (verb) {
55
+ case "list":
56
+ if (id !== undefined)
57
+ throw new WazapError("INVALID_ID", `Cannot run \`wazap account list ${id}\`.`, ACCOUNT_USAGE);
58
+ listAccounts(config);
59
+ return;
60
+ case "add":
61
+ if (id === undefined)
62
+ throw new WazapError("INVALID_ID", "Missing account id.", ACCOUNT_USAGE);
63
+ addAccount(config, id);
64
+ return;
65
+ case "remove":
66
+ if (id === undefined)
67
+ throw new WazapError("INVALID_ID", "Missing account id.", ACCOUNT_USAGE);
68
+ await removeAccount(config, id);
69
+ return;
70
+ case "enable":
71
+ if (id === undefined)
72
+ throw new WazapError("INVALID_ID", "Missing account id.", ACCOUNT_USAGE);
73
+ enableAccount(config, id, true);
74
+ return;
75
+ case "disable":
76
+ if (id === undefined)
77
+ throw new WazapError("INVALID_ID", "Missing account id.", ACCOUNT_USAGE);
78
+ enableAccount(config, id, false);
79
+ return;
80
+ default: {
81
+ const _exhaustive = verb;
82
+ return _exhaustive;
83
+ }
84
+ }
85
+ }
86
+ function listAccounts(config) {
87
+ for (const row of accountRows(config)) {
88
+ say(`${describeStatusAccount(row)}${row.default ? " (default)" : ""}`);
89
+ }
90
+ }
91
+ function addAccount(config, id) {
92
+ const record = AccountRegistry.load(config.dataDir).add(id, config.accountName);
93
+ say(ok(`Account "${record.id}" added.`));
94
+ }
95
+ async function removeAccount(config, id) {
96
+ const registry = AccountRegistry.load(config.dataDir);
97
+ if (registry.get(id) === undefined) {
98
+ throw new WazapError("INVALID_ID", `No account "${id}".`, "Run `wazap account list`");
99
+ }
100
+ const running = lockHolder(paths(config.dataDir).lockFile);
101
+ if (running !== null) {
102
+ throw new WazapError("INVALID_ID", `wazap is running (pid ${running}).`, leftoverFix(running));
103
+ }
104
+ if (!config.assumeYes) {
105
+ if (process.stdin.isTTY !== true) {
106
+ throw new WazapError("INVALID_ID", `Refusing to delete account "${id}" without --yes.`, "Re-run with --yes");
107
+ }
108
+ const answer = await ask(`${brand("?")} Delete account "${id}" and its local data? [y/N] `);
109
+ if (!/^y(es)?$/i.test(answer.trim())) {
110
+ say(info("Cancelled."));
111
+ return;
112
+ }
113
+ }
114
+ registry.remove(id);
115
+ say(ok(`Account "${id}" removed.`));
116
+ }
117
+ function enableAccount(config, id, enabled) {
118
+ const registry = AccountRegistry.load(config.dataDir);
119
+ if (enabled)
120
+ registry.enable(id);
121
+ else
122
+ registry.disable(id);
123
+ say(ok(`Account "${id}" ${enabled ? "enabled" : "disabled"}.`));
124
+ }
125
+ export function runMigrate(config) {
126
+ const [verb] = config.args;
127
+ if (verb !== "rollback") {
128
+ throw new WazapError("INVALID_ID", `Cannot run \`wazap migrate ${config.args.join(" ")}\`.`, MIGRATE_USAGE);
129
+ }
130
+ rollbackMigration(config.dataDir);
131
+ say(ok("Rolled back the data-dir layout."));
132
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * One process, N WhatsApp sockets. The registry names the accounts; this holds
3
+ * a live service for each enabled one, with its own reconnect loop and write
4
+ * bucket. MCP tools resolve a service per call.
5
+ */
6
+ import { accountPaths } from "./config.js";
7
+ import { WazapError } from "./errors.js";
8
+ import { logError } from "./logger.js";
9
+ import { WhatsAppService } from "./whatsapp.js";
10
+ const FIX_ENABLE = "Run `wazap account enable <id>` or `wazap account add <id>`";
11
+ function bind(id, wa) {
12
+ return { id, wa };
13
+ }
14
+ function readSingletonId(wa) {
15
+ try {
16
+ const id = wa.getStatus().account_id;
17
+ return typeof id === "string" && id.length > 0 ? id : "default";
18
+ }
19
+ catch {
20
+ return "default";
21
+ }
22
+ }
23
+ function readSingletonName(wa, fallback) {
24
+ try {
25
+ const name = wa.getStatus().account_name;
26
+ return typeof name === "string" && name.length > 0 ? name : fallback;
27
+ }
28
+ catch {
29
+ return fallback;
30
+ }
31
+ }
32
+ /** One live service as a hub, so existing tests can keep passing a stub `wa`. */
33
+ export function singletonSource(wa) {
34
+ const id = readSingletonId(wa);
35
+ const binding = bind(id, wa);
36
+ const self = () => ({
37
+ id,
38
+ name: readSingletonName(wa, id),
39
+ enabled: true,
40
+ owner: null,
41
+ });
42
+ return {
43
+ binding: (requested) => (requested === id ? binding : undefined),
44
+ defaultBinding: () => binding,
45
+ bindings: () => [binding],
46
+ findByChat: (jid) => (typeof wa.hasChat === "function" && wa.hasChat(jid) ? [binding] : []),
47
+ findByMessage: (mid) => (typeof wa.hasMessage === "function" && wa.hasMessage(mid) ? [binding] : []),
48
+ findByDraft: (did) => (typeof wa.hasDraft === "function" && wa.hasDraft(did) ? [binding] : []),
49
+ record: (requested) => (requested === id ? self() : undefined),
50
+ records: () => [self()],
51
+ };
52
+ }
53
+ export class AccountHub {
54
+ services = new Map();
55
+ known = new Map();
56
+ givenUp = new Set();
57
+ primary;
58
+ primaryId;
59
+ /** Process exit hook. Fires only after every enabled account has given up. */
60
+ onGiveUp = null;
61
+ constructor(config, registry) {
62
+ for (const account of registry.all()) {
63
+ this.known.set(account.id, { ...account });
64
+ }
65
+ const enabled = registry.all().filter((account) => account.enabled);
66
+ if (enabled.length === 0) {
67
+ throw new WazapError("INVALID_ID", "No enabled account to serve.", FIX_ENABLE);
68
+ }
69
+ for (const account of enabled) {
70
+ const wa = new WhatsAppService(config, account, accountPaths(config.dataDir, account.id));
71
+ wa.onGiveUp = () => this.noteGiveUp(account.id);
72
+ this.services.set(account.id, wa);
73
+ }
74
+ const firstId = enabled[0].id;
75
+ this.primaryId = this.services.has(registry.defaultId()) ? registry.defaultId() : firstId;
76
+ this.primary = this.services.get(this.primaryId);
77
+ }
78
+ async start() {
79
+ await Promise.all([...this.services].map(([id, wa]) => wa.start().catch((err) => {
80
+ logError(`whatsapp start ${id}`, err);
81
+ this.noteGiveUp(id);
82
+ })));
83
+ }
84
+ async stop() {
85
+ await Promise.all([...this.services.values()].map((wa) => wa.stop()));
86
+ }
87
+ get(id) {
88
+ return this.services.get(id);
89
+ }
90
+ default() {
91
+ return this.primary;
92
+ }
93
+ all() {
94
+ return [...this.services.values()];
95
+ }
96
+ binding(id) {
97
+ const wa = this.services.get(id);
98
+ return wa === undefined ? undefined : bind(id, wa);
99
+ }
100
+ defaultBinding() {
101
+ return bind(this.primaryId, this.primary);
102
+ }
103
+ bindings() {
104
+ return [...this.services].map(([id, wa]) => bind(id, wa));
105
+ }
106
+ findByChat(jid) {
107
+ return this.bindings().filter((row) => row.wa.hasChat(jid));
108
+ }
109
+ findByMessage(id) {
110
+ return this.bindings().filter((row) => row.wa.hasMessage(id));
111
+ }
112
+ findByDraft(id) {
113
+ return this.bindings().filter((row) => row.wa.hasDraft(id));
114
+ }
115
+ record(id) {
116
+ const found = this.known.get(id);
117
+ return found === undefined ? undefined : { ...found };
118
+ }
119
+ records() {
120
+ return [...this.known.values()].map((account) => ({ ...account }));
121
+ }
122
+ noteGiveUp(id) {
123
+ if (this.givenUp.has(id))
124
+ return;
125
+ this.givenUp.add(id);
126
+ logError("whatsapp", `account ${id}: reconnects exhausted`);
127
+ if (this.givenUp.size < this.services.size)
128
+ return;
129
+ this.onGiveUp?.();
130
+ }
131
+ }