wazap-mcp 0.13.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 31 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
@@ -44,10 +44,11 @@ and it is missing, and to restart Claude Desktop itself once it has connected it
44
44
  | Codex CLI | `npx wazap-mcp connect codex`, then `npx wazap-mcp skills install codex` |
45
45
  | OpenCode | `npx wazap-mcp connect opencode`, then `npx wazap-mcp skills install opencode` |
46
46
  | Windsurf | `npx wazap-mcp connect windsurf` |
47
+ | Grok Bot | [Grok Bot / remote MCP](#grok-bot--remote-mcp) |
47
48
  | Anything else | the MCP entry `npx -y wazap-mcp` over stdio, or a [self-hosted](#self-host) URL |
48
49
 
49
- Each of those registers the server. Linking the WhatsApp account is a separate,
50
- 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`.
51
52
 
52
53
  Or have your agent do it. Paste this:
53
54
 
@@ -89,8 +90,28 @@ it would write.
89
90
  | `gemini` | `~/.gemini/settings.json` |
90
91
  | `windsurf` | `~/.codeium/windsurf/mcp_config.json` |
91
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)) |
92
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)) |
93
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
+
94
115
  ### Other MCP clients
95
116
 
96
117
  Cursor and VS Code install from a link:
@@ -251,8 +272,9 @@ them. `--dry-run` prints the plan and touches nothing.
251
272
  | Tool | Kind | What it does |
252
273
  | --- | --- | --- |
253
274
  | `learn` | read | The guide to every tool, id format and error code. Call it first. |
254
- | `get_status` | read | Connection status, sync state, linked account, named-contact count, versions, data dir. |
255
- | `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. |
256
278
  | `list_chats` | read | Conversations newest-first; filter `all`/`unread`/`groups`/`individual`/`archived`. |
257
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. |
258
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. |
@@ -278,6 +300,7 @@ them. `--dry-run` prints the plan and touches nothing.
278
300
  | `forward_message` | write | Draft a forward to another chat. Does not send. |
279
301
  | `confirm_send` | write | Send a draft after the user has seen the preview and said yes. |
280
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. |
281
304
  | `manage_chat` | write | Archive, pin, mute (8h by default), mark read/unread. |
282
305
  | `create_group` | write | Create a group and add participants. |
283
306
  | `manage_group` | write | Add, remove, promote, demote, leave, rename, invite links. |
@@ -465,12 +488,15 @@ trace, so an agent can decide whether to retry, ask the user, or stop.
465
488
  | `CHAT_NOT_FOUND` / `MESSAGE_NOT_FOUND` / `CONTACT_NOT_FOUND` / `GROUP_NOT_FOUND` | Unknown id. |
466
489
  | `NOT_A_PARTICIPANT` / `NOT_ADMIN` / `GROUP_ANNOUNCEMENT_ONLY` | Group permissions. |
467
490
  | `MEDIA_UNAVAILABLE` | WhatsApp expired the file, or it was never synced here. |
468
- | `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. |
469
492
  | `TEXT_TOO_LONG` | Over WhatsApp's message limit. |
470
493
  | `EDIT_WINDOW_EXPIRED` / `RETRACT_WINDOW_EXPIRED` / `NOT_OWN_MESSAGE` | WhatsApp's own limits on editing and deleting. |
471
494
  | `READ_ONLY` | wazap is running read-only. |
472
495
  | `RATE_LIMITED` | Too many writes; `fix` says how long to wait. |
473
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. |
474
500
  | `TIMEOUT` / `WHATSAPP_ERROR` | WhatsApp did not answer, or rejected the operation. |
475
501
 
476
502
  ## Data directory
@@ -518,13 +544,16 @@ server of its own rather than a bridge, and is refused the same way.
518
544
 
519
545
  ## Read-only mode
520
546
 
521
- Writes are opt-in. `login` asks once and stores the answer in
522
- `<data-dir>/.env`; `wazap config writes on|off` changes it, and `wazap config`
523
- 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.
524
552
 
525
- `WAZAP_READ_ONLY=1` or `wazap serve --read-only` does not register the write
526
- tools at all. The agent never sees them, so it cannot message anyone from your
527
- 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.
528
557
 
529
558
  Writes are also rate limited to `WAZAP_RATE_LIMIT` per minute (default 20, `0`
530
559
  disables). Sending faster than a human is how accounts get banned.
@@ -541,10 +570,13 @@ Streamable HTTP at `/mcp`, with a health check at `/healthz`. That check answers
541
570
  `{ ok, status, since }`. It turns 503 once the socket has been anything but
542
571
  connected for two minutes, so a tunnel or a monitor sees a real outage rather
543
572
  than a reconnect in progress. Two bearer tokens:
544
- the read token gets the read tools, the write token also unlocks the write
545
- tools, so a leaked read token can never message anyone. wazap refuses to bind a
546
- non-loopback address without a read token. Agents that cannot carry a header
547
- 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.
548
580
 
549
581
  ## Self-host
550
582
 
@@ -644,12 +676,42 @@ What to know before exposing it:
644
676
  - A read grant never sees a write tool, whatever scope the agent requested.
645
677
  The radio button on the consent page is the only thing that decides.
646
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
+
647
709
  ## Settings
648
710
 
649
711
  | Variable | Default | Meaning |
650
712
  | --- | --- | --- |
651
713
  | `WAZAP_DATA_DIR` | `~/.wazap` | Where everything is stored. |
652
- | `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. |
653
715
  | `WAZAP_SYNC_FULL_HISTORY` | `0` | Ask WhatsApp for a fuller history sync. |
654
716
  | `WAZAP_PERSIST_HISTORY` | `1` | Keep chats and messages across restarts. |
655
717
  | `WAZAP_RATE_LIMIT` | `20` | Write tool calls per minute; `0` disables. |
@@ -667,6 +729,9 @@ What to know before exposing it:
667
729
  | `WAZAP_TRANSCRIBE_API_KEY` | unset | API key; `OPENAI_API_KEY` is the fallback. Never a flag. |
668
730
  | `WAZAP_TRANSCRIBE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL. |
669
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. |
670
735
 
671
736
  Flags beat environment variables, which beat `<data-dir>/.env`.
672
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
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Per-call account resolution and the list/status overlays that need the hub.
3
+ * Tool handlers stay in tools.ts.
4
+ */
5
+ import { WazapError } from "./errors.js";
6
+ import { maskNumber } from "./ui.js";
7
+ const FIX_ADD_ACCOUNT = "Run `wazap account add`";
8
+ const FIX_PASS_ACCOUNT = "Pass account_id";
9
+ function ok(text, structured) {
10
+ return { content: [{ type: "text", text }], structuredContent: structured };
11
+ }
12
+ export function stringArg(args, key) {
13
+ const value = args[key];
14
+ return typeof value === "string" && value.length > 0 ? value : undefined;
15
+ }
16
+ export function attachAccountId(result, accountId) {
17
+ return { ...result, structuredContent: { ...(result.structuredContent ?? {}), account_id: accountId } };
18
+ }
19
+ /** Write tools register when any live account allows writes. */
20
+ export function anyAccountAllowsWrites(hub) {
21
+ return hub.bindings().some((row) => row.wa.getStatus().read_only !== true);
22
+ }
23
+ function pickFromMatches(matches, write, hub, what) {
24
+ if (matches.length === 1)
25
+ return matches[0];
26
+ if (matches.length > 1) {
27
+ throw new WazapError("AMBIGUOUS_ACCOUNT", `Several accounts have that ${what}: ${matches.map((row) => row.id).join(", ")}.`, FIX_PASS_ACCOUNT);
28
+ }
29
+ if (write) {
30
+ throw new WazapError("AMBIGUOUS_ACCOUNT", `No account knows that ${what}.`, FIX_PASS_ACCOUNT);
31
+ }
32
+ return hub.defaultBinding();
33
+ }
34
+ function resolveGivenId(hub, requested, toolName) {
35
+ const record = hub.record(requested);
36
+ const live = hub.binding(requested);
37
+ if (record !== undefined && !record.enabled) {
38
+ throw new WazapError("ACCOUNT_DISABLED", `Account "${requested}" is disabled.`, `Run \`wazap account enable ${requested}\` and restart the server`);
39
+ }
40
+ if (live === undefined) {
41
+ const fix = toolName === "link_account" ? FIX_ADD_ACCOUNT : `${FIX_ADD_ACCOUNT}, or call list_accounts`;
42
+ throw new WazapError("ACCOUNT_NOT_FOUND", `No account "${requested}".`, fix);
43
+ }
44
+ return live;
45
+ }
46
+ export function resolveToolAccount(hub, args, tool) {
47
+ if (tool.name === "list_accounts")
48
+ return hub.defaultBinding();
49
+ const requested = stringArg(args, "account_id");
50
+ if (requested !== undefined)
51
+ return resolveGivenId(hub, requested, tool.name);
52
+ const live = hub.bindings();
53
+ if (live.length === 1)
54
+ return live[0];
55
+ const chatId = stringArg(args, "chat_id") ?? stringArg(args, "group_id") ?? stringArg(args, "contact_id");
56
+ const messageId = stringArg(args, "message_id");
57
+ const draftId = stringArg(args, "draft_id");
58
+ if (chatId !== undefined)
59
+ return pickFromMatches(hub.findByChat(chatId), tool.write, hub, "chat");
60
+ if (messageId !== undefined)
61
+ return pickFromMatches(hub.findByMessage(messageId), tool.write, hub, "message");
62
+ if (draftId !== undefined)
63
+ return pickFromMatches(hub.findByDraft(draftId), false, hub, "draft");
64
+ return hub.defaultBinding();
65
+ }
66
+ function listedFromStatus(s, enabled, id = s.account_id) {
67
+ return {
68
+ id,
69
+ name: s.account_name,
70
+ status: s.status,
71
+ phone_masked: s.account ? maskNumber(s.account.number) : null,
72
+ owner_name: s.account?.name ?? null,
73
+ write_tools: !s.read_only,
74
+ enabled,
75
+ };
76
+ }
77
+ function listedFromRecord(record) {
78
+ return {
79
+ id: record.id,
80
+ name: record.name,
81
+ status: record.enabled ? "disconnected" : "disabled",
82
+ phone_masked: null,
83
+ owner_name: null,
84
+ write_tools: false,
85
+ enabled: record.enabled,
86
+ };
87
+ }
88
+ function renderAccountLines(rows) {
89
+ return rows.map((row) => {
90
+ const who = row.owner_name ? `${row.owner_name}${row.phone_masked ? ` (${row.phone_masked})` : ""}` : "not linked";
91
+ const writes = row.write_tools ? "writes on" : "writes off";
92
+ const flag = row.enabled ? row.status : "disabled";
93
+ return `- **${row.id}** (${row.name}) · ${flag} · ${who} · ${writes}`;
94
+ });
95
+ }
96
+ export function renderListAccounts(hub) {
97
+ const accounts = [];
98
+ for (const record of hub.records()) {
99
+ const live = hub.binding(record.id);
100
+ accounts.push(live === undefined ? listedFromRecord(record) : listedFromStatus(live.wa.getStatus(), record.enabled, live.id));
101
+ }
102
+ const text = [`# Accounts (${accounts.length})`, "", ...renderAccountLines(accounts)].join("\n");
103
+ return ok(text, { count: accounts.length, default: hub.defaultBinding().id, accounts });
104
+ }
105
+ function webhookStatusLine(webhook) {
106
+ if (!webhook.enabled)
107
+ return "- **webhook**: off";
108
+ if (!webhook.valid)
109
+ return `- **webhook**: invalid${webhook.last_error ? ` · ${webhook.last_error}` : ""}`;
110
+ return `- **webhook**: on${webhook.last_error ? ` · last error: ${webhook.last_error}` : ""}`;
111
+ }
112
+ /** The get_status body: `write_tools` is this session's, not the process default. */
113
+ export function renderGetStatus(s, writeTools, hub) {
114
+ const account = s.account ? `${s.account.name || "(no name)"} (${s.account.number})` : "none";
115
+ const writeLine = writeTools
116
+ ? "registered"
117
+ : s.read_only
118
+ ? "not registered (server is read-only; run `wazap config writes on` and restart)"
119
+ : "not registered (this session used a read token)";
120
+ const accounts = hub.bindings().map((row) => listedFromStatus(row.wa.getStatus(), true, row.id));
121
+ const text = [
122
+ `# WhatsApp: ${s.status} (sync: ${s.sync})`,
123
+ `- **account**: ${account}`,
124
+ `- **last message received**: ${s.last_message_received_at ?? "never"}`,
125
+ `- **contacts named**: ${s.contacts_named}`,
126
+ `- **data dir**: ${s.data_dir} · **read-only**: ${s.read_only} · **write tools**: ${writeLine} · **rate limit**: ${s.rate_limit}/min`,
127
+ `- **versions**: wazap ${s.wazap_version}, baileys ${s.baileys_version}`,
128
+ s.pairing ? `- **pairing code**: ${s.pairing.code} for ${s.pairing.phone_masked}, until ${s.pairing.expires_at}` : null,
129
+ webhookStatusLine(s.webhook),
130
+ s.last_error ? `- **last error**: ${s.last_error}` : null,
131
+ s.hint ? `- **hint**: ${s.hint}` : null,
132
+ accounts.length > 1 ? `- **accounts**: ${accounts.map((row) => row.id).join(", ")}` : null,
133
+ ]
134
+ .filter((line) => line !== null)
135
+ .join("\n");
136
+ return ok(text, { ...s, write_tools: writeTools, accounts });
137
+ }