wazap-mcp 0.14.0 → 0.16.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/AGENT.md CHANGED
@@ -4,15 +4,17 @@ You are setting up WhatsApp access for the person you are working with. Use the
4
4
 
5
5
  wazap keeps stdout for the MCP protocol and writes every human-readable line to stderr, so read stderr as well: run each command with `2>&1`.
6
6
 
7
- 1. Run `npx wazap-mcp status --json`. If `linked` is true, skip to step 5.
7
+ 1. Run `npx wazap-mcp status --json`. `linked` is true when any configured account is linked; `accounts[]` lists each account's own credentials. If `linked` is true, skip to step 5. If it is false but `accounts[]` shows an account other than the default, link that account instead: pass `--account <id>` to `login` in step 3, or `account_id` to `link_account`.
8
8
  2. Ask the person for their WhatsApp number in international format (e.g. +15550100) and whether the agent may send messages (default: no).
9
9
  3. If you already have the `whatsapp` tools (the Claude Desktop bundle, or any client wazap is connected to), call `link_account` with the number. It returns the code within ~10 s and needs no terminal. Otherwise start the link IN THE BACKGROUND and keep reading its output:
10
10
  `npx wazap-mcp login --phone <number> --no-writes --yes 2>&1` (or `--writes`).
11
11
  Within ~10 s the output contains a line `pairing code: XXXX-XXXX`.
12
12
  4. Show the person exactly: "On your phone: WhatsApp → Settings → Linked devices → Link a device → Link with phone number instead → enter XXXX-XXXX". After `link_account`, call `get_status` every 10 seconds until it says `connected` (up to 3 minutes); after the command, keep reading the output until `Linked as …` and then `Synced N chats …` appear. If the code expired, run step 3 again.
13
- 5. If step 1 reported `install.kind: "npx"`, run `npm i -g wazap-mcp` first: Claude Desktop and the background service cannot launch from the npx cache, which npm clears. Register wazap with the client you are running in: `npx wazap-mcp connect <client>` where <client> is claude-code, claude-desktop, cursor, codex, vscode or gemini. Tell the person what the command printed as `Next`. Then, for claude-code, codex, cursor and opencode, run `npx wazap-mcp skills install <client>`, which copies the five workflow skills where that client reads them. The other clients need no such step, because the server hands them the same five workflows as MCP prompts.
13
+ 5. If step 1 reported `install.kind: "npx"`, run `npm i -g wazap-mcp` first: Claude Desktop and the background service cannot launch from the npx cache, which npm clears. Register wazap with the client you are running in: `npx wazap-mcp connect <client>` where <client> is claude-code, claude-desktop, cursor, codex, vscode, gemini, windsurf or opencode. Tell the person what the command printed as `Next`. Then, for claude-code, codex, cursor and opencode, run `npx wazap-mcp skills install <client>`, which copies the five workflow skills where that client reads them. The other clients need no such step, because the server hands them the same five workflows as MCP prompts.
14
14
  6. Verify: `npx wazap-mcp status --json` must show `linked: true` and no failing check. If the client needs a restart, say so; otherwise call the `get_status` tool and then `learn`.
15
15
  7. Done when `get_status` returns `connected`. Then offer: "what did I miss on WhatsApp today?"
16
16
  8. Ask whether wazap should keep running after this client closes, and whether cloud agents should reach it. For the first, run `npx wazap-mcp service install`, which writes a launchd agent or a systemd user unit and starts it. For the second, also run `npx wazap-mcp expose`. It opens a tunnel, prints an https URL and a consent password, and restarts the service. Give an agent the URL only. The password is for the person, on their consent page; never paste it into a chat, a config file or a message.
17
17
 
18
- Errors: every wazap error prints `✗ message` and `→ fix`; do what the fix says, do not retry blindly. One process owns the session, so while the `login` of step 3 runs, no server and no other wazap command may touch the same data dir; two servers on one data dir refuse to start. Stop the other one first.
18
+ More than one WhatsApp account: `npx wazap-mcp account add <id>` creates a slot, then link it with `login --account <id>` or `link_account(account_id)`. `account list` shows them; `account default <id>` picks the one tools use when a call does not say otherwise. A running server learns a new account only on restart — `account add`/`enable`/`disable`/`default` say so when they change the registry. When several accounts could own a chat, write tools return AMBIGUOUS_ACCOUNT; pass `account_id`. `list_accounts` is the read tool that shows them all.
19
+
20
+ Errors: every wazap error prints `✗ message` and `→ fix`; do what the fix says, do not retry blindly. One process owns the session, so while the `login` of step 3 runs, no server and no other wazap command may touch the same data dir; two servers on one data dir refuse to start. Stop the other one first — `wazap service stop` when the holder is the background service.
package/README.md CHANGED
@@ -502,31 +502,57 @@ trace, so an agent can decide whether to retry, ask the user, or stop.
502
502
  ## Data directory
503
503
 
504
504
  Everything lives in `~/.wazap` (override with `--data-dir` or `WAZAP_DATA_DIR`),
505
- created `0700` with credentials written `0600`:
505
+ created `0700` with credentials written `0600`. A data dir from before several
506
+ accounts moves into `accounts/default/` the first time a wazap command runs.
506
507
 
507
508
  ```
508
509
  ~/.wazap/
509
- auth/ WhatsApp credentials treat this like a password
510
- media/ downloads from download_media
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
514
- models/ whisper.cpp models, when transcription runs locally
515
- store.json chat-list snapshot
516
- server.lock pid of the running server
517
- daemon.json loopback endpoint a second wazap bridges to
518
- oauth.json registered agents and hashed OAuth grants, when OAuth is on
519
- .env optional settings, see .env.example
510
+ accounts.json which accounts exist, and which is default
511
+ accounts/<id>/
512
+ auth/ WhatsApp credentials treat this like a password
513
+ media/ downloads from download_media
514
+ history/ per-chat message history, so a restart is not amnesia
515
+ previews/ one small JPEG per photo or video already previewed
516
+ notes.json notes on contacts and "handled" marks; never sent anywhere
517
+ store.json chat-list snapshot
518
+ qr.png last QR, when login showed one
519
+ models/ whisper.cpp models, when transcription runs locally
520
+ server.lock pid of the running server
521
+ daemon.json loopback endpoint a second wazap bridges to
522
+ oauth.json registered agents and hashed OAuth grants, when OAuth is on
523
+ .env optional settings, see .env.example
524
+ migration.json written once when a flat dir moved into accounts/default
520
525
  ```
521
526
 
522
527
  Credential writes go to a temp file and are renamed into place, so killing the
523
528
  process mid-write cannot leave you re-linking your phone.
524
529
 
530
+ ## Several accounts
531
+
532
+ One `wazap serve` holds every enabled account in the data dir. Each account is
533
+ its own Baileys socket and its own folder under `accounts/<id>/`. The first
534
+ account is `default`. Add another with `wazap account add work --name Work`,
535
+ then `wazap login --account work`.
536
+
537
+ `--account` picks one on `login`, `logout`, `status`, `config writes` and
538
+ `webhook test`. MCP tools take an optional `account_id`. Call `list_accounts`
539
+ first when more than one is linked. A chat only one account knows selects that
540
+ account. A send to a chat no account knows, with two or more accounts, fails
541
+ `AMBIGUOUS_ACCOUNT` instead of falling back to default.
542
+
543
+ Reads without a chat and without `account_id` use the default account; the
544
+ response still carries `account_id`. `link_account` needs an account that
545
+ already exists. Five accounts is advice, not a cap. One phone number is one
546
+ account.
547
+
548
+ An account can override the global webhook URL and secret in `accounts.json`
549
+ (`webhook_url`, `webhook_secret`). `wazap webhook test --account work` posts
550
+ with that account's id and name.
551
+
525
552
  ## Several clients at once
526
553
 
527
- Claude Desktop, Claude Code and Cursor each launch their own `wazap`. WhatsApp
528
- allows one socket per linked device, so they share one session instead of
529
- fighting over it. The first `wazap` on a data directory owns the session and
554
+ Claude Desktop, Claude Code and Cursor each launch their own `wazap`. The first
555
+ one on a data directory owns every enabled account, each on its own socket, and
530
556
  opens an MCP endpoint on `127.0.0.1`; every later one bridges to it over that
531
557
  endpoint. There is nothing to configure, and no client can tell the difference.
532
558
  The owner publishes `<data-dir>/daemon.json` (`0600`) with its pid, its port
@@ -684,15 +710,17 @@ is not posted. The only event is `message_received`.
684
710
  ```bash
685
711
  npx wazap-mcp config webhook on # asks for URL + secret (secret is not echoed)
686
712
  npx wazap-mcp webhook test # POST a probe event
713
+ npx wazap-mcp webhook test --account work
687
714
  npx wazap-mcp config webhook off
688
715
  ```
689
716
 
690
717
  On without a URL or secret fails `wazap status`, doctor and setup. A failed
691
718
  delivery retries twice (200 ms, then 500 ms), then sets `webhook.last_error`
692
- and leaves WhatsApp and MCP running.
719
+ and leaves WhatsApp and MCP running. An account may set `webhook_url` and
720
+ `webhook_secret` in `accounts.json`; those win over the global URL and secret.
693
721
 
694
722
  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
723
+ JSON body with the secret that signed it. Verify that raw body, not a
696
724
  re-serialized object. HTTPS only, except `http://` on loopback.
697
725
 
698
726
  ```json
@@ -702,7 +730,9 @@ re-serialized object. HTTPS only, except `http://` on loopback.
702
730
  "chat_id": "15550100@s.whatsapp.net",
703
731
  "ts": "2026-09-08T14:00:00+00:00",
704
732
  "text": "hello, or a short preview",
705
- "message_id": "false_15550100@s.whatsapp.net_3EB0…"
733
+ "message_id": "false_15550100@s.whatsapp.net_3EB0…",
734
+ "account_id": "default",
735
+ "account_name": "default"
706
736
  }
707
737
  ```
708
738
 
@@ -730,8 +760,8 @@ re-serialized object. HTTPS only, except `http://` on loopback.
730
760
  | `WAZAP_TRANSCRIBE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL. |
731
761
  | `WAZAP_TRANSCRIBE_MODEL` | `gpt-4o-mini-transcribe` | Model at that URL. |
732
762
  | `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. |
763
+ | `WAZAP_WEBHOOK_URL` | unset | HTTPS endpoint. `http://` only on loopback. An account `webhook_url` wins. |
764
+ | `WAZAP_WEBHOOK_SECRET` | unset | Shared secret for `X-Wazap-Signature`. Never a flag. An account `webhook_secret` wins. |
735
765
 
736
766
  Flags beat environment variables, which beat `<data-dir>/.env`.
737
767
 
@@ -1,11 +1,12 @@
1
- import { AccountRegistry } from "./accounts.js";
1
+ import { AccountRegistry, ownerNumber } from "./accounts.js";
2
2
  import { readLinkedAccount } from "./auth-state.js";
3
- import { ask, leftoverFix } from "./cli.js";
3
+ import { ask, leftoverFix, warnIfServerRunning } from "./cli.js";
4
4
  import { ACCOUNT_USAGE, MIGRATE_USAGE, accountPaths, paths } from "./config.js";
5
5
  import { WazapError } from "./errors.js";
6
6
  import { lockHolder } from "./lock.js";
7
7
  import { say } from "./logger.js";
8
8
  import { rollbackMigration } from "./migrate.js";
9
+ import { serviceHolding } from "./service.js";
9
10
  import { brand, info, maskNumber, ok } from "./ui.js";
10
11
  function parseAccountVerb(verb) {
11
12
  switch (verb) {
@@ -14,6 +15,7 @@ function parseAccountVerb(verb) {
14
15
  case "remove":
15
16
  case "enable":
16
17
  case "disable":
18
+ case "default":
17
19
  return verb;
18
20
  default:
19
21
  throw new WazapError("INVALID_ID", `Unknown account command "${verb ?? ""}".`, ACCOUNT_USAGE);
@@ -26,7 +28,11 @@ export function describeAccount(account) {
26
28
  }
27
29
  export function describeStatusAccount(row) {
28
30
  const flag = row.enabled ? "enabled" : "disabled";
29
- const who = row.account === null ? "not linked" : describeAccount(row.account);
31
+ const who = row.account !== null
32
+ ? describeAccount(row.account)
33
+ : row.owner !== null
34
+ ? `was ${maskNumber(ownerNumber(row.owner))}`
35
+ : "not linked";
30
36
  return `${row.id} ${flag} ${who}`;
31
37
  }
32
38
  export function accountRows(config) {
@@ -45,6 +51,7 @@ export function accountRows(config) {
45
51
  enabled: record.enabled,
46
52
  default: record.id === registry.defaultId(),
47
53
  account: linked,
54
+ owner: record.owner ?? null,
48
55
  };
49
56
  });
50
57
  }
@@ -77,6 +84,11 @@ export async function runAccount(config) {
77
84
  throw new WazapError("INVALID_ID", "Missing account id.", ACCOUNT_USAGE);
78
85
  enableAccount(config, id, false);
79
86
  return;
87
+ case "default":
88
+ if (id === undefined)
89
+ throw new WazapError("INVALID_ID", "Missing account id.", ACCOUNT_USAGE);
90
+ defaultAccount(config, id);
91
+ return;
80
92
  default: {
81
93
  const _exhaustive = verb;
82
94
  return _exhaustive;
@@ -91,15 +103,18 @@ function listAccounts(config) {
91
103
  function addAccount(config, id) {
92
104
  const record = AccountRegistry.load(config.dataDir).add(id, config.accountName);
93
105
  say(ok(`Account "${record.id}" added.`));
106
+ warnIfServerRunning(config);
94
107
  }
95
108
  async function removeAccount(config, id) {
96
109
  const registry = AccountRegistry.load(config.dataDir);
97
110
  if (registry.get(id) === undefined) {
98
111
  throw new WazapError("INVALID_ID", `No account "${id}".`, "Run `wazap account list`");
99
112
  }
113
+ // Removing an account under a live hub would orphan its socket; even the
114
+ // service must be stopped by hand, so the fix names whichever holder it is.
100
115
  const running = lockHolder(paths(config.dataDir).lockFile);
101
116
  if (running !== null) {
102
- throw new WazapError("INVALID_ID", `wazap is running (pid ${running}).`, leftoverFix(running));
117
+ throw new WazapError("INVALID_ID", `wazap is running (pid ${running}).`, leftoverFix(running, serviceHolding(config.dataDir, running) !== null));
103
118
  }
104
119
  if (!config.assumeYes) {
105
120
  if (process.stdin.isTTY !== true) {
@@ -121,6 +136,12 @@ function enableAccount(config, id, enabled) {
121
136
  else
122
137
  registry.disable(id);
123
138
  say(ok(`Account "${id}" ${enabled ? "enabled" : "disabled"}.`));
139
+ warnIfServerRunning(config);
140
+ }
141
+ function defaultAccount(config, id) {
142
+ AccountRegistry.load(config.dataDir).setDefault(id);
143
+ say(ok(`Default account: "${id}".`));
144
+ warnIfServerRunning(config);
124
145
  }
125
146
  export function runMigrate(config) {
126
147
  const [verb] = config.args;
@@ -3,6 +3,7 @@
3
3
  * a live service for each enabled one, with its own reconnect loop and write
4
4
  * bucket. MCP tools resolve a service per call.
5
5
  */
6
+ import { AccountRegistry } from "./accounts.js";
6
7
  import { accountPaths } from "./config.js";
7
8
  import { WazapError } from "./errors.js";
8
9
  import { logError } from "./logger.js";
@@ -48,6 +49,9 @@ export function singletonSource(wa) {
48
49
  findByDraft: (did) => (typeof wa.hasDraft === "function" && wa.hasDraft(did) ? [binding] : []),
49
50
  record: (requested) => (requested === id ? self() : undefined),
50
51
  records: () => [self()],
52
+ // The stub has no disk: what it knows is all there is.
53
+ recordOnDisk: (requested) => (requested === id ? self() : undefined),
54
+ noteOwner: () => { },
51
55
  };
52
56
  }
53
57
  export class AccountHub {
@@ -56,9 +60,11 @@ export class AccountHub {
56
60
  givenUp = new Set();
57
61
  primary;
58
62
  primaryId;
63
+ dataDir;
59
64
  /** Process exit hook. Fires only after every enabled account has given up. */
60
65
  onGiveUp = null;
61
66
  constructor(config, registry) {
67
+ this.dataDir = config.dataDir;
62
68
  for (const account of registry.all()) {
63
69
  this.known.set(account.id, { ...account });
64
70
  }
@@ -69,6 +75,7 @@ export class AccountHub {
69
75
  for (const account of enabled) {
70
76
  const wa = new WhatsAppService(config, account, accountPaths(config.dataDir, account.id));
71
77
  wa.onGiveUp = () => this.noteGiveUp(account.id);
78
+ wa.onLinked = (linked) => this.noteOwner(account.id, linked.id);
72
79
  this.services.set(account.id, wa);
73
80
  }
74
81
  const firstId = enabled[0].id;
@@ -119,6 +126,30 @@ export class AccountHub {
119
126
  records() {
120
127
  return [...this.known.values()].map((account) => ({ ...account }));
121
128
  }
129
+ /**
130
+ * The registry re-read from disk, so a tool can tell "account added after
131
+ * this server started" apart from "no such account" and ask for a restart.
132
+ */
133
+ recordOnDisk(id) {
134
+ try {
135
+ return AccountRegistry.load(this.dataDir).get(id);
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ noteOwner(id, owner) {
142
+ try {
143
+ AccountRegistry.load(this.dataDir).setOwner(id, owner);
144
+ const record = this.known.get(id);
145
+ if (record !== undefined)
146
+ record.owner = owner;
147
+ }
148
+ catch (err) {
149
+ // A linked session still works; the display field is what is lost.
150
+ logError(`owner persist ${id}`, err);
151
+ }
152
+ }
122
153
  noteGiveUp(id) {
123
154
  if (this.givenUp.has(id))
124
155
  return;
@@ -2,6 +2,7 @@
2
2
  * Per-call account resolution and the list/status overlays that need the hub.
3
3
  * Tool handlers stay in tools.ts.
4
4
  */
5
+ import { ownerNumber } from "./accounts.js";
5
6
  import { WazapError } from "./errors.js";
6
7
  import { maskNumber } from "./ui.js";
7
8
  const FIX_ADD_ACCOUNT = "Run `wazap account add`";
@@ -38,6 +39,14 @@ function resolveGivenId(hub, requested, toolName) {
38
39
  throw new WazapError("ACCOUNT_DISABLED", `Account "${requested}" is disabled.`, `Run \`wazap account enable ${requested}\` and restart the server`);
39
40
  }
40
41
  if (live === undefined) {
42
+ // The registry may have grown since this server started; `record` reads a
43
+ // snapshot, so check disk before calling the id unknown.
44
+ const onDisk = hub.recordOnDisk(requested);
45
+ if (onDisk !== undefined) {
46
+ throw new WazapError("ACCOUNT_NOT_FOUND", `Account "${requested}" was added after this server started.`, onDisk.enabled
47
+ ? "Restart the server so it picks up the account (`wazap service restart`, or restart your client)"
48
+ : `Run \`wazap account enable ${requested}\`, then restart the server`);
49
+ }
41
50
  const fix = toolName === "link_account" ? FIX_ADD_ACCOUNT : `${FIX_ADD_ACCOUNT}, or call list_accounts`;
42
51
  throw new WazapError("ACCOUNT_NOT_FOUND", `No account "${requested}".`, fix);
43
52
  }
@@ -79,7 +88,7 @@ function listedFromRecord(record) {
79
88
  id: record.id,
80
89
  name: record.name,
81
90
  status: record.enabled ? "disconnected" : "disabled",
82
- phone_masked: null,
91
+ phone_masked: record.owner === null ? null : maskNumber(ownerNumber(record.owner)),
83
92
  owner_name: null,
84
93
  write_tools: false,
85
94
  enabled: record.enabled,
@@ -87,7 +96,9 @@ function listedFromRecord(record) {
87
96
  }
88
97
  function renderAccountLines(rows) {
89
98
  return rows.map((row) => {
90
- const who = row.owner_name ? `${row.owner_name}${row.phone_masked ? ` (${row.phone_masked})` : ""}` : "not linked";
99
+ const who = row.owner_name
100
+ ? `${row.owner_name}${row.phone_masked ? ` (${row.phone_masked})` : ""}`
101
+ : (row.phone_masked ?? "not linked");
91
102
  const writes = row.write_tools ? "writes on" : "writes off";
92
103
  const flag = row.enabled ? row.status : "disabled";
93
104
  return `- **${row.id}** (${row.name}) · ${flag} · ${who} · ${writes}`;
package/dist/accounts.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
+ import { readLinkedAccount } from "./auth-state.js";
3
4
  import { accountPaths, paths } from "./config.js";
4
5
  import { WazapError } from "./errors.js";
5
6
  export const DEFAULT_ACCOUNT_ID = "default";
@@ -71,7 +72,24 @@ function parseAccountRecord(value, file) {
71
72
  }
72
73
  record.rate_limit = value.rate_limit;
73
74
  }
74
- return record;
75
+ return { ...record, ...webhookFields(value.id, value.webhook_url, value.webhook_secret, ` in ${file}`) };
76
+ }
77
+ /** Shared by load and `setWebhook` so a writer cannot persist what load refuses. */
78
+ function webhookFields(id, url, secret, where = "", fix = "Fix or remove accounts.json") {
79
+ const fields = {};
80
+ if (url !== undefined) {
81
+ if (typeof url !== "string" || url.trim() === "") {
82
+ throw new WazapError("INVALID_ID", `Account "${id}"${where} has a bad webhook_url.`, fix);
83
+ }
84
+ fields.webhook_url = url.trim().replace(/\/+$/, "");
85
+ }
86
+ if (secret !== undefined) {
87
+ if (typeof secret !== "string" || secret === "") {
88
+ throw new WazapError("INVALID_ID", `Account "${id}"${where} has a bad webhook_secret.`, fix);
89
+ }
90
+ fields.webhook_secret = secret;
91
+ }
92
+ return fields;
75
93
  }
76
94
  function parseAccountsFile(value, file) {
77
95
  if (!isRecord(value) || value.v !== 2 || typeof value.default !== "string" || !Array.isArray(value.accounts)) {
@@ -179,6 +197,28 @@ export class AccountRegistry {
179
197
  setWrites(id, writes) {
180
198
  this.commit(this.withAccount(id, (account) => ({ ...account, writes })));
181
199
  }
200
+ setWebhook(id, webhook) {
201
+ this.commit(this.withAccount(id, (account) => ({
202
+ ...account,
203
+ ...webhookFields(id, webhook.url, webhook.secret, "", "Set a non-empty webhook URL or secret"),
204
+ })));
205
+ }
206
+ /** Drop the per-account override so the account follows the global webhook again. */
207
+ clearWebhook(id) {
208
+ this.commit(this.withAccount(id, (account) => {
209
+ const next = { ...account };
210
+ delete next.webhook_url;
211
+ delete next.webhook_secret;
212
+ return next;
213
+ }));
214
+ }
215
+ setDefault(id) {
216
+ const slug = parseAccountId(id);
217
+ if (!this.file.accounts.some((account) => account.id === slug)) {
218
+ throw new WazapError("INVALID_ID", `No account "${slug}".`, FIX_LIST);
219
+ }
220
+ this.commit({ ...this.file, default: slug });
221
+ }
182
222
  commit(next) {
183
223
  writeJsonFile(paths(this.dataDir).accountsFile, next);
184
224
  this.file = next;
@@ -215,3 +255,21 @@ export function ensureAccountsFile(dataDir) {
215
255
  registry.save();
216
256
  return registry;
217
257
  }
258
+ /** The digits of an owner jid like `40734…:75@s.whatsapp.net`, for display. */
259
+ export function ownerNumber(owner) {
260
+ return owner.split(/[:@]/)[0] ?? "";
261
+ }
262
+ /**
263
+ * Whether any configured account has linked credentials on disk. `status` and
264
+ * the doctor checks answer for the data dir, not only the selected account.
265
+ */
266
+ export function anyAccountLinked(dataDir, registry = AccountRegistry.load(dataDir)) {
267
+ return registry.all().some((account) => {
268
+ try {
269
+ return readLinkedAccount(accountPaths(dataDir, account.id).authDir) !== null;
270
+ }
271
+ catch {
272
+ return false;
273
+ }
274
+ });
275
+ }
package/dist/cli.js CHANGED
@@ -68,16 +68,20 @@ export async function runStatus(config) {
68
68
  const serverPid = lockHolder(p.lockFile);
69
69
  const daemon = readDaemon(p.daemonFile);
70
70
  const sharing = daemon !== null && daemon.pid === serverPid ? { pid: daemon.pid, port: daemon.port } : null;
71
+ const rows = accountRows(config);
71
72
  const report = {
72
73
  data_dir: config.dataDir,
73
- linked: account !== null,
74
+ // The dir is linked when any account is; `account` still describes the
75
+ // selected one, and `accounts` carries each row's own credentials.
76
+ linked: account !== null || rows.some((row) => row.account !== null),
74
77
  credentials_readable: !unreadable,
75
78
  account,
76
- accounts: accountRows(config),
79
+ accounts: rows,
77
80
  wazap_version: WAZAP_VERSION,
78
81
  baileys_version: BAILEYS_VERSION,
79
82
  install: whereInstalled(),
80
83
  server_pid: serverPid,
84
+ server_is_service: serverPid !== null && serviceHolding(config.dataDir, serverPid) !== null,
81
85
  daemon: sharing,
82
86
  checks: await runChecks(config),
83
87
  };
@@ -102,15 +106,10 @@ export async function runStatus(config) {
102
106
  /** Today's phrasing, kept verbatim so pipes and log captures keep parsing. */
103
107
  function plainStatus(report) {
104
108
  const lines = [`data dir: ${report.data_dir}`];
105
- if (!report.credentials_readable) {
106
- lines.push("linked: no (credentials unreadable run `wazap logout` then `wazap login`)");
107
- }
108
- else if (report.account) {
109
- lines.push("linked: yes", `account: ${describeAccount(report.account)}`);
110
- }
111
- else {
112
- lines.push("linked: no");
113
- }
109
+ const credsNote = report.credentials_readable ? "" : " (credentials unreadable — run `wazap logout` then `wazap login`)";
110
+ lines.push(`linked: ${report.linked ? "yes" : "no"}${credsNote}`);
111
+ if (report.account)
112
+ lines.push(`account: ${describeAccount(report.account)}`);
114
113
  if (report.accounts.length > 1) {
115
114
  lines.push("accounts:");
116
115
  for (const row of report.accounts) {
@@ -360,9 +359,19 @@ async function testTranscribe(settings, file) {
360
359
  say(ok(facts.filter((fact) => fact !== undefined).join(" · ")));
361
360
  say(`"${transcript.text}"`);
362
361
  }
363
- /** The one line that names the leftover pid, so greet, login and logout cannot drift. */
364
- export function leftoverFix(pid) {
365
- return `stop it first: kill ${pid}`;
362
+ /**
363
+ * The one line that names the leftover pid, so greet, login and logout cannot
364
+ * drift. `kill` on a service-held pid just gets it restarted by the supervisor,
365
+ * so that case names the service verb instead.
366
+ */
367
+ export function leftoverFix(pid, heldByService = false) {
368
+ return heldByService ? "stop it first: `wazap service stop`" : `stop it first: kill ${pid}`;
369
+ }
370
+ /** A mutation the running server cannot see asks for a restart, on the spot. */
371
+ export function warnIfServerRunning(config) {
372
+ const running = lockHolder(paths(config.dataDir).lockFile);
373
+ if (running !== null)
374
+ say(warn(`A server is running (pid ${running}); restart it for this to apply.`));
366
375
  }
367
376
  /**
368
377
  * A client-launched wazap holding the session, or null if the lock is free or
@@ -382,7 +391,7 @@ export function greetNext(report) {
382
391
  if (report.server_pid !== null) {
383
392
  lines.push(info(`A server is already running (pid ${report.server_pid}).`));
384
393
  if (!report.linked || !report.credentials_readable) {
385
- lines.push(fix(leftoverFix(report.server_pid)));
394
+ lines.push(fix(leftoverFix(report.server_pid, report.server_is_service === true)));
386
395
  }
387
396
  }
388
397
  if (!report.credentials_readable) {
@@ -717,7 +726,7 @@ const SERVICE_STOP_MS = 10_000;
717
726
  * quit; the background service is ours to stop, which is why the returned
718
727
  * function starts it again. What `~/.wazap/link.sh` did by hand.
719
728
  */
720
- export async function yieldSession(config, lockFile) {
729
+ export async function yieldSession(config, lockFile, why = "pairing") {
721
730
  const running = takeSessionLock(lockFile);
722
731
  if (running === null)
723
732
  return () => { };
@@ -725,7 +734,7 @@ export async function yieldSession(config, lockFile) {
725
734
  if (held === null) {
726
735
  throw leftoverRefusal(config) ?? new WazapError("WHATSAPP_ERROR", `wazap is running (pid ${running}).`, leftoverFix(running));
727
736
  }
728
- say(info("Stopping the wazap service for pairing"));
737
+ say(info(`Stopping the wazap service for ${why}`));
729
738
  held.supervisor.stop(held.record);
730
739
  let resumed = false;
731
740
  const resume = () => {
@@ -841,44 +850,45 @@ class Countdown {
841
850
  export async function runLogout(config) {
842
851
  const p = paths(config.dataDir);
843
852
  const selected = resolveAccount(config.dataDir, config.accountId);
844
- const running = lockHolder(p.lockFile);
845
- if (running !== null) {
846
- say(fail(`wazap is running (pid ${running}).`));
847
- say(fix(leftoverFix(running)));
848
- process.exit(1);
849
- }
850
- let linked = null;
851
- let unreadable = false;
853
+ const resumeService = await yieldSession(config, p.lockFile, "logout");
852
854
  try {
853
- linked = readLinkedAccount(selected.paths.authDir);
854
- }
855
- catch {
856
- // Unreadable creds are exactly what logout exists to clear, so keep going.
857
- unreadable = true;
858
- }
859
- if (!linked && !unreadable) {
860
- say(info("Not linked."));
861
- return;
862
- }
863
- if (linked) {
864
- const deadline = Date.now() + LOGOUT_TIMEOUT_MS;
855
+ let linked = null;
856
+ let unreadable = false;
865
857
  try {
866
- const sock = await linkSession(selected.paths.authDir, { deadline });
867
- await withDeadline(sock.logout(), deadline, "WhatsApp did not confirm the unlink in time.");
858
+ linked = readLinkedAccount(selected.paths.authDir);
868
859
  }
869
- catch (err) {
870
- if (alreadyUnlinked(err)) {
871
- say(info("WhatsApp had already unlinked this device."));
860
+ catch {
861
+ // Unreadable creds are exactly what logout exists to clear, so keep going.
862
+ unreadable = true;
863
+ }
864
+ if (!linked && !unreadable) {
865
+ say(info("Not linked."));
866
+ return;
867
+ }
868
+ if (linked) {
869
+ const deadline = Date.now() + LOGOUT_TIMEOUT_MS;
870
+ try {
871
+ const sock = await linkSession(selected.paths.authDir, { deadline });
872
+ await withDeadline(sock.logout(), deadline, "WhatsApp did not confirm the unlink in time.");
872
873
  }
873
- else {
874
- logError("unlink from WhatsApp", err);
875
- say(warn("Could not tell WhatsApp to unlink; remove this device from your phone if it is still listed."));
874
+ catch (err) {
875
+ if (alreadyUnlinked(err)) {
876
+ say(info("WhatsApp had already unlinked this device."));
877
+ }
878
+ else {
879
+ logError("unlink from WhatsApp", err);
880
+ say(warn("Could not tell WhatsApp to unlink; remove this device from your phone if it is still listed."));
881
+ }
876
882
  }
877
883
  }
884
+ clearSession(selected.paths);
885
+ selected.registry.setOwner(selected.account.id, null);
886
+ say(ok("Logged out. Local credentials deleted."));
887
+ }
888
+ finally {
889
+ releaseLock(p.lockFile);
890
+ resumeService();
878
891
  }
879
- clearSession(selected.paths);
880
- selected.registry.setOwner(selected.account.id, null);
881
- say(ok("Logged out. Local credentials deleted."));
882
892
  process.exit(0);
883
893
  }
884
894
  /**
package/dist/config.js CHANGED
@@ -56,7 +56,7 @@ const COMMAND_ARGS = {
56
56
  migrate: [1],
57
57
  };
58
58
  const COMMANDS = Object.keys(COMMAND_ARGS);
59
- export const ACCOUNT_USAGE = "Run `wazap account add <id> [--name <name>]`, `wazap account remove|enable|disable <id>`, or `wazap account list`";
59
+ export const ACCOUNT_USAGE = "Run `wazap account add <id> [--name <name>]`, `wazap account remove|enable|disable|default <id>`, or `wazap account list`";
60
60
  export const MIGRATE_USAGE = "Run `wazap migrate rollback`";
61
61
  /**
62
62
  * What to type instead of `--help` when the arity is wrong. Literals, not
package/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { accessSync, constants, statSync } from "node:fs";
2
- import { accountPolicy, resolveAccount } from "./accounts.js";
2
+ import { AccountRegistry, accountPolicy, anyAccountLinked, resolveAccount } from "./accounts.js";
3
3
  import { readLinkedAccount } from "./auth-state.js";
4
- import { WAZAP_VERSION, WRITES_ENABLE_FIX, WRITE_TOKEN_NOTE, isRemoteHttp, paths } from "./config.js";
4
+ import { WAZAP_VERSION, WRITES_ENABLE_FIX, WRITE_TOKEN_NOTE, accountPaths, isRemoteHttp, paths } from "./config.js";
5
5
  import { asWazapError } from "./errors.js";
6
6
  import { lockHolder, lockPid } from "./lock.js";
7
7
  import { oauthProblem, readGrants } from "./oauth.js";
@@ -36,7 +36,7 @@ export async function runChecks(config) {
36
36
  checks.push(...[await check(config)].flat());
37
37
  let linked = false;
38
38
  try {
39
- linked = readLinkedAccount(resolveAccount(config.dataDir, config.accountId).paths.authDir) !== null;
39
+ linked = anyAccountLinked(config.dataDir);
40
40
  }
41
41
  catch {
42
42
  linked = false;
@@ -122,7 +122,7 @@ function checkService(config) {
122
122
  if (pid === null) {
123
123
  return { name: "service", state: "fail", detail: "installed but not running", fix: "run `wazap service start`" };
124
124
  }
125
- if (record.installedVersion !== WAZAP_VERSION) {
125
+ if (isNewer(WAZAP_VERSION, record.installedVersion)) {
126
126
  return {
127
127
  name: "service",
128
128
  state: "info",
@@ -130,21 +130,42 @@ function checkService(config) {
130
130
  fix: "run `wazap service restart`",
131
131
  };
132
132
  }
133
+ if (isNewer(record.installedVersion, WAZAP_VERSION)) {
134
+ return {
135
+ name: "service",
136
+ state: "info",
137
+ detail: `runs ${record.installedVersion}, but only ${WAZAP_VERSION} is installed`,
138
+ fix: "run `wazap update` — restarting keeps the newer build",
139
+ };
140
+ }
133
141
  return { name: "service", state: "ok", detail: `running (pid ${pid}, ${supervisor.name})` };
134
142
  }
135
143
  function checkCredentials(config) {
136
- const authDir = resolveAccount(config.dataDir, config.accountId).paths.authDir;
144
+ // The data dir is linked when any account is; every corrupt record fails,
145
+ // naming its account.
146
+ let records;
137
147
  try {
138
- const account = readLinkedAccount(authDir);
139
- // The number is deliberately absent: status is the thing people screenshot.
140
- return account === null
141
- ? { name: "credentials", state: "info", detail: "no account linked yet" }
142
- : { name: "credentials", state: "ok", detail: "readable" };
148
+ records = AccountRegistry.load(config.dataDir).all();
143
149
  }
144
150
  catch (err) {
145
151
  const wazap = err;
146
152
  return { name: "credentials", state: "fail", detail: wazap.message, fix: wazap.fix };
147
153
  }
154
+ const linkedIds = [];
155
+ for (const record of records) {
156
+ try {
157
+ if (readLinkedAccount(accountPaths(config.dataDir, record.id).authDir) !== null)
158
+ linkedIds.push(record.id);
159
+ }
160
+ catch (err) {
161
+ const wazap = err;
162
+ return { name: "credentials", state: "fail", detail: `${record.id}: ${wazap.message}`, fix: wazap.fix };
163
+ }
164
+ }
165
+ if (linkedIds.length === 0)
166
+ return { name: "credentials", state: "info", detail: "no account linked yet" };
167
+ // The number is deliberately absent: status is the thing people screenshot.
168
+ return { name: "credentials", state: "ok", detail: records.length > 1 ? `readable (${linkedIds.join(", ")})` : "readable" };
148
169
  }
149
170
  function checkWrites(config) {
150
171
  const selected = resolveAccount(config.dataDir, config.accountId);
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ Usage:
26
26
  wazap expose [tailscale|cloudflare|off] Give the running service a public https URL cloud agents can reach
27
27
  wazap config [writes on|off] [transcribe local|openai|off] [webhook on|off]
28
28
  Show the effective settings, or change one
29
- wazap webhook test POST a test message_received to the configured webhook
29
+ wazap webhook test [--account <id>] POST a test message_received to the configured webhook
30
30
  wazap transcribe download [--model <alias>] Fetch the whisper.cpp model into the data dir
31
31
  wazap transcribe test <audio file> Transcribe a local file with the configured provider
32
32
  wazap contacts resync Fetch the phone's address book from WhatsApp again
@@ -34,7 +34,7 @@ Usage:
34
34
  wazap status [--live] [--json] [--account <id>] Check the install, the session and the server
35
35
  wazap logout [--account <id>] Unlink and delete local credentials
36
36
  wazap account add <id> [--name <name>] Add an account slot
37
- wazap account remove|enable|disable <id> Change an account, or delete its local data
37
+ wazap account remove|enable|disable|default <id> Change an account, or delete its local data
38
38
  wazap account list List accounts in this data dir
39
39
  wazap migrate rollback Undo a flat-layout move into accounts/
40
40
 
@@ -44,7 +44,8 @@ Tunnel providers for wazap expose: ${PROVIDER_NAMES}.
44
44
 
45
45
  Options:
46
46
  --data-dir <path> Where wazap keeps its data (default ~/.wazap, or $WAZAP_DATA_DIR)
47
- --account <id> With login, logout, status, contacts, config writes: pick this account
47
+ --account <id> With login, logout, status, contacts, config writes|webhook, webhook test:
48
+ pick this account. account add|enable|disable|default apply on server restart
48
49
  --name <name> With account add: a display name
49
50
  --read-only Refuse every write; the write tools are not registered at all
50
51
  --http Serve Streamable HTTP instead of stdio
@@ -89,8 +90,11 @@ async function main() {
89
90
  }
90
91
  const { config } = invocation;
91
92
  // Rollback is the inverse of this move. Running it first would re-apply a
92
- // half-finished migrate and then fail to undo it.
93
- if (config.command !== "migrate")
93
+ // half-finished migrate and then fail to undo it. The other exempt commands
94
+ // never open account state, and `service stop` is how a lock that blocks the
95
+ // migration is released.
96
+ const MIGRATE_EXEMPT = new Set(["migrate", "service", "connect", "skills", "expose", "update", "transcribe"]);
97
+ if (!MIGRATE_EXEMPT.has(config.command))
94
98
  migrateLayout(config.dataDir);
95
99
  switch (config.command) {
96
100
  case "serve":
package/dist/migrate.js CHANGED
@@ -2,8 +2,9 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmdirSync,
2
2
  import { join } from "node:path";
3
3
  import { AccountRegistry, DEFAULT_ACCOUNT_ID, ensureAccountsFile, isRecord, writeJsonFile } from "./accounts.js";
4
4
  import { readLinkedAccount } from "./auth-state.js";
5
- import { accountPaths } from "./config.js";
5
+ import { accountPaths, paths } from "./config.js";
6
6
  import { WazapError } from "./errors.js";
7
+ import { lockHolder } from "./lock.js";
7
8
  /** Flat-layout names that move into accounts/default/. qr.png is leftover login art. */
8
9
  export const LAYOUT_ENTRIES = ["auth", "store.json", "history", "media", "previews", "notes.json", "qr.png"];
9
10
  function isDir(path) {
@@ -37,6 +38,17 @@ function leftoverEntries(dataDir) {
37
38
  function rollbackFix(dataDir) {
38
39
  return `Run \`wazap migrate rollback --data-dir ${dataDir}\``;
39
40
  }
41
+ /**
42
+ * Moving `auth/` out from under a live process breaks it, and whatever it
43
+ * recreates at the root then blocks both migrate and rollback. The holder can
44
+ * only be a pre-migration build: every newer one migrated at its own start.
45
+ */
46
+ function refuseWhileRunning(dataDir) {
47
+ const running = lockHolder(paths(dataDir).lockFile);
48
+ if (running === null)
49
+ return;
50
+ throw new WazapError("WHATSAPP_ERROR", `wazap is running (pid ${running}) on the old data layout.`, `stop it first (\`wazap service stop\` if it is the background service, otherwise \`kill ${running}\`), then run this again`);
51
+ }
40
52
  function migrateFail(dataDir, message) {
41
53
  return new WazapError("WHATSAPP_ERROR", message, rollbackFix(dataDir));
42
54
  }
@@ -165,6 +177,7 @@ export function migrateLayout(dataDir) {
165
177
  const manifest = readManifest(manifestFile, dataDir);
166
178
  const defaultRoot = accountPaths(dataDir, DEFAULT_ACCOUNT_ID).root;
167
179
  if (leftover.length > 0) {
180
+ refuseWhileRunning(dataDir);
168
181
  applyMigration(dataDir, manifestFile, manifest);
169
182
  return;
170
183
  }
@@ -176,6 +189,7 @@ export function migrateLayout(dataDir) {
176
189
  * later add cannot be smashed back into a flat v0 dir.
177
190
  */
178
191
  export function rollbackMigration(dataDir) {
192
+ refuseWhileRunning(dataDir);
179
193
  const manifestFile = join(dataDir, "migration.json");
180
194
  const manifest = readManifest(manifestFile, dataDir);
181
195
  if (manifest === null) {
package/dist/settings.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { AccountRegistry, accountPolicy, resolveAccount } from "./accounts.js";
4
- import { ask, askSecret } from "./cli.js";
4
+ import { ask, askSecret, warnIfServerRunning } from "./cli.js";
5
5
  import { paths, writesHints } from "./config.js";
6
6
  import { WazapError, asWazapError } from "./errors.js";
7
7
  import { lockHolder } from "./lock.js";
8
8
  import { say } from "./logger.js";
9
9
  import { maskKey, readTranscribeSettings, requireSafeUrl, stripPasted, transcribeReady, } from "./transcribe/index.js";
10
10
  import { brand, dim, fix, ok, shortPath, warn } from "./ui.js";
11
- import { WEBHOOK_ON_FIX, WEBHOOK_TEST_FIX, WebhookSink, readWebhookSettings, requireWebhookUrl } from "./webhook.js";
11
+ import { WEBHOOK_ON_FIX, WEBHOOK_TEST_FIX, WebhookSink, readWebhookSettings, requireWebhookUrl, } from "./webhook.js";
12
12
  /**
13
13
  * dotenv 16 treats an unquoted `#` as a comment, even with no space, and
14
14
  * trims unquoted padding. Quote so a secret like `p@ss#word` round-trips.
@@ -152,16 +152,20 @@ function transcribeRows(config) {
152
152
  rows.push(`api key: ${maskKey(settings.apiKey)}`);
153
153
  return rows;
154
154
  }
155
+ function accountWebhook(config) {
156
+ const selected = resolveAccount(config.dataDir, config.accountId);
157
+ const override = { url: selected.account.webhook_url, secret: selected.account.webhook_secret };
158
+ const source = override.url !== undefined || override.secret !== undefined ? "accounts.json" : config.sources.webhook;
159
+ return { override, source };
160
+ }
155
161
  function webhookRows(config) {
156
- const settings = readWebhookSettings(process.env);
162
+ const { override, source } = accountWebhook(config);
163
+ const settings = readWebhookSettings(process.env, override);
157
164
  switch (settings.kind) {
158
165
  case "off":
159
- return [`webhook: off (${config.sources.webhook})`];
166
+ return [`webhook: off (${source})`];
160
167
  case "ready":
161
- return [
162
- `webhook: on (${new URL(settings.url).host}) (${config.sources.webhook})`,
163
- `secret: ${maskKey(settings.secret)}`,
164
- ];
168
+ return [`webhook: on (${new URL(settings.url).host}) (${source})`, `secret: ${maskKey(settings.secret)}`];
165
169
  case "invalid":
166
170
  return [`webhook: ${settings.detail}${settings.fix === "" ? "" : ` — ${settings.fix}`}`];
167
171
  default: {
@@ -176,6 +180,16 @@ async function applyWebhook(config, value) {
176
180
  return;
177
181
  }
178
182
  if (value === "off") {
183
+ if (config.accountId !== undefined) {
184
+ // The on/off switch is global; only the endpoint is per-account. `off`
185
+ // scoped to an account drops its override so it follows the global again.
186
+ AccountRegistry.load(config.dataDir).clearWebhook(config.accountId);
187
+ const global = readWebhookSettings(process.env);
188
+ say(ok(`webhook override removed for ${config.accountId} — the global webhook applies${global.kind === "off" ? " (off)" : ""}.`));
189
+ say(dim(`Stored in ${shortPath(paths(config.dataDir).accountsFile)}.`));
190
+ warnIfServerRunning(config);
191
+ return;
192
+ }
179
193
  setWebhookFlag(config, "off");
180
194
  say(ok("webhook: off — live inbound messages are not posted anywhere. Turn it on with `wazap config webhook on`."));
181
195
  say(dim(`Stored in ${shortPath(paths(config.dataDir).envFile)}.`));
@@ -189,9 +203,12 @@ export async function runWebhook(config) {
189
203
  if (verb !== "test") {
190
204
  throw new WazapError("INVALID_ID", `Cannot run \`wazap webhook ${config.args.join(" ")}\`.`, WEBHOOK_TEST_FIX);
191
205
  }
192
- await testWebhook();
206
+ await testWebhook(config);
193
207
  }
194
208
  async function enableWebhook(config) {
209
+ // An unknown --account fails before any prompt asks for a URL or a secret.
210
+ if (config.accountId !== undefined)
211
+ resolveAccount(config.dataDir, config.accountId);
195
212
  // A pipe is consumed whole by the first readline, so a script sets the URL
196
213
  // in the environment and only types the secret, the way transcribe does.
197
214
  let url;
@@ -214,6 +231,15 @@ async function enableWebhook(config) {
214
231
  throw new WazapError("INVALID_ID", "No webhook secret was typed.", "Run `wazap config webhook on` again");
215
232
  }
216
233
  const p = paths(config.dataDir);
234
+ if (config.accountId !== undefined) {
235
+ // The switch stays global; the URL and secret are this account's override.
236
+ AccountRegistry.load(config.dataDir).setWebhook(config.accountId, { url, secret });
237
+ setEnvSetting(p.envFile, "WAZAP_WEBHOOK", "on");
238
+ say(ok(`webhook: on for ${config.accountId} — live inbound messages POST to ${new URL(url).host} as message_received.`));
239
+ say(dim(`URL and secret stored in ${shortPath(p.accountsFile)}; WAZAP_WEBHOOK=on in ${shortPath(p.envFile)}.`));
240
+ warnIfServerRunning(config);
241
+ return;
242
+ }
217
243
  setEnvSetting(p.envFile, "WAZAP_WEBHOOK", "on");
218
244
  setEnvSetting(p.envFile, "WAZAP_WEBHOOK_URL", url);
219
245
  setEnvSetting(p.envFile, "WAZAP_WEBHOOK_SECRET", secret);
@@ -224,19 +250,15 @@ async function enableWebhook(config) {
224
250
  function setWebhookFlag(config, value) {
225
251
  setEnvSetting(paths(config.dataDir).envFile, "WAZAP_WEBHOOK", value);
226
252
  }
227
- async function testWebhook() {
228
- const result = await new WebhookSink(process.env).sendTest();
253
+ async function testWebhook(config) {
254
+ const selected = resolveAccount(config.dataDir, config.accountId);
255
+ const result = await new WebhookSink(process.env, { account: selected.account }).sendTest();
229
256
  if (result.ok) {
230
257
  say(ok("webhook: test delivered"));
231
258
  return;
232
259
  }
233
260
  throw new WazapError("INVALID_ID", result.error, result.fix === "" ? WEBHOOK_ON_FIX : result.fix);
234
261
  }
235
- function warnIfServerRunning(config) {
236
- const running = lockHolder(paths(config.dataDir).lockFile);
237
- if (running !== null)
238
- say(warn(`A server is running (pid ${running}); restart it for this to apply.`));
239
- }
240
262
  const DEFAULT_URL = "https://api.openai.com/v1";
241
263
  const TRANSCRIBE_SAID = {
242
264
  local: "transcribe: local — whisper.cpp runs here, and the audio never leaves this machine.",
@@ -301,6 +323,10 @@ export function applyWrites(config, allowWrites) {
301
323
  ? `writes: on for ${config.accountId} — the agent can send from this account. Turn it off with \`wazap config writes off --account ${config.accountId}\`.`
302
324
  : `writes: off for ${config.accountId} — the agent can only read this account. Turn it on with \`wazap config writes on --account ${config.accountId}\`.`));
303
325
  say(dim(`Stored in ${shortPath(p.accountsFile)}.`));
326
+ // The account flag is set, but the global switch is a hard off either way.
327
+ if (allowWrites && config.readOnly) {
328
+ say(warn("Global read-only is still on, so writes stay off until `wazap config writes on` clears it."));
329
+ }
304
330
  }
305
331
  else {
306
332
  setEnvSetting(p.envFile, "WAZAP_READ_ONLY", allowWrites ? "0" : "1");
package/dist/webhook.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * W1 outbound webhook: one URL, one shared secret, one live event
3
- * (`message_received`). Delivery never throws into the WhatsApp or MCP path.
2
+ * W1 outbound webhook: one live event (`message_received`). Global URL and
3
+ * secret live in `.env`; an account may override either. Delivery never throws
4
+ * into the WhatsApp or MCP path.
4
5
  */
5
6
  import { createHmac, timingSafeEqual } from "node:crypto";
6
7
  import { setTimeout as sleep } from "node:timers/promises";
@@ -19,7 +20,7 @@ const OFF = new Set(["", "off", "0", "no", "none", "false"]);
19
20
  const ON = new Set(["on", "1", "true", "yes"]);
20
21
  const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
21
22
  /** The one place the webhook environment becomes typed. */
22
- export function readWebhookSettings(env = process.env) {
23
+ export function readWebhookSettings(env = process.env, override = {}) {
23
24
  const raw = stripPasted(env.WAZAP_WEBHOOK ?? "");
24
25
  const flag = raw.toLowerCase();
25
26
  if (OFF.has(flag))
@@ -31,8 +32,10 @@ export function readWebhookSettings(env = process.env) {
31
32
  fix: "set WAZAP_WEBHOOK to on or off",
32
33
  };
33
34
  }
34
- const urlRaw = stripPasted(env.WAZAP_WEBHOOK_URL ?? "").replace(/\/+$/, "");
35
- const secret = stripPasted(env.WAZAP_WEBHOOK_SECRET ?? "");
35
+ const overrideUrl = stripPasted(override.url ?? "");
36
+ const overrideSecret = stripPasted(override.secret ?? "");
37
+ const urlRaw = (overrideUrl || stripPasted(env.WAZAP_WEBHOOK_URL ?? "")).replace(/\/+$/, "");
38
+ const secret = overrideSecret || stripPasted(env.WAZAP_WEBHOOK_SECRET ?? "");
36
39
  const missingUrl = urlRaw === "";
37
40
  const missingSecret = secret === "";
38
41
  if (missingUrl && missingSecret) {
@@ -84,7 +87,7 @@ export function previewText(text) {
84
87
  return text;
85
88
  return `${text.slice(0, WEBHOOK_TEXT_MAX - 1)}…`;
86
89
  }
87
- export function asWebhookPayload(view) {
90
+ export function asWebhookPayload(view, account) {
88
91
  return {
89
92
  event: WEBHOOK_EVENT,
90
93
  from: view.sender.phone ?? view.sender.id,
@@ -92,6 +95,8 @@ export function asWebhookPayload(view) {
92
95
  ts: view.timestamp,
93
96
  text: previewText(view.text),
94
97
  message_id: view.message_id,
98
+ account_id: account.id,
99
+ account_name: account.name,
95
100
  };
96
101
  }
97
102
  export function webhookInfo(settings, lastError) {
@@ -111,16 +116,21 @@ export function webhookInfo(settings, lastError) {
111
116
  /** Posts `message_received` when the webhook is on and valid. Never throws. */
112
117
  export class WebhookSink {
113
118
  env;
119
+ lastError = null;
114
120
  post;
115
121
  retryDelays;
116
- lastError = null;
117
- constructor(env = process.env, post = fetch, retryDelays = WEBHOOK_RETRY_DELAYS_MS) {
122
+ account;
123
+ constructor(env = process.env, opts = {}) {
118
124
  this.env = env;
119
- this.post = post;
120
- this.retryDelays = retryDelays;
125
+ this.post = opts.post ?? fetch;
126
+ this.retryDelays = opts.retryDelays ?? WEBHOOK_RETRY_DELAYS_MS;
127
+ this.account = opts.account;
121
128
  }
122
129
  settings() {
123
- return readWebhookSettings(this.env);
130
+ return readWebhookSettings(this.env, {
131
+ url: this.account?.webhook_url,
132
+ secret: this.account?.webhook_secret,
133
+ });
124
134
  }
125
135
  info() {
126
136
  return webhookInfo(this.settings(), this.lastError);
@@ -147,7 +157,7 @@ export class WebhookSink {
147
157
  case "invalid":
148
158
  return { ok: false, error: settings.detail, fix: settings.fix };
149
159
  case "ready":
150
- return this.postEvent(testPayload(), settings);
160
+ return this.postEvent(testPayload(this.account), settings);
151
161
  default: {
152
162
  const _exhaustive = settings;
153
163
  return _exhaustive;
@@ -198,7 +208,7 @@ export class WebhookSink {
198
208
  }
199
209
  }
200
210
  }
201
- function testPayload() {
211
+ function testPayload(account) {
202
212
  return {
203
213
  event: WEBHOOK_EVENT,
204
214
  from: "wazap",
@@ -206,6 +216,8 @@ function testPayload() {
206
216
  ts: new Date().toISOString(),
207
217
  text: "wazap webhook test",
208
218
  message_id: "test",
219
+ account_id: account?.id ?? "default",
220
+ account_name: account?.name ?? "default",
209
221
  };
210
222
  }
211
223
  function hostOf(url) {
package/dist/whatsapp.js CHANGED
@@ -141,6 +141,8 @@ export class WhatsAppService {
141
141
  * fix. The CLI turns it into an exit code, so a supervisor gets its turn.
142
142
  */
143
143
  onGiveUp = null;
144
+ /** Fires when a `link` lands, so the hub can persist the owner on the record. */
145
+ onLinked = null;
144
146
  account = null;
145
147
  /** The pairing in flight, from the first `link` call until it settles either way. */
146
148
  linking = null;
@@ -185,13 +187,14 @@ export class WhatsAppService {
185
187
  transcribing = new Map();
186
188
  drafts = new DraftStore();
187
189
  writes;
188
- webhook = new WebhookSink();
190
+ webhook;
189
191
  accountRecord;
190
192
  effectiveReadOnly;
191
193
  effectiveRateLimit;
192
194
  constructor(config, account, paths) {
193
195
  this.config = config;
194
196
  this.accountRecord = account;
197
+ this.webhook = new WebhookSink(process.env, { account });
195
198
  const policy = accountPolicy(account, config);
196
199
  this.effectiveReadOnly = policy.readOnly;
197
200
  this.effectiveRateLimit = policy.rateLimit;
@@ -319,6 +322,7 @@ export class WhatsAppService {
319
322
  this.pairing = null;
320
323
  this.account = account;
321
324
  this.lastError = null;
325
+ this.onLinked?.(account);
322
326
  await this.start();
323
327
  }
324
328
  abandonLink(err) {
@@ -2033,7 +2037,7 @@ export class WhatsAppService {
2033
2037
  try {
2034
2038
  const jid = this.canonical(raw.key.remoteJid ?? "");
2035
2039
  const sid = messageIdFor(raw.key, jid);
2036
- void this.webhook.notify(asWebhookPayload(this.viewOf(sid, jid))).catch((err) => {
2040
+ void this.webhook.notify(asWebhookPayload(this.viewOf(sid, jid), this.accountRecord)).catch((err) => {
2037
2041
  logError("webhook", err);
2038
2042
  });
2039
2043
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "wazap-mcp",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "mcpName": "io.github.razvangirgiz/wazap",
5
- "description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, 32 tools, stdio or HTTP with bearer tokens and OAuth.",
5
+ "description": "WhatsApp for your AI agent. MCP server over Baileys: pairing-code login, several accounts, 33 tools, stdio or HTTP.",
6
6
  "license": "MIT",
7
7
  "author": "Răzvan Girgiz",
8
8
  "repository": {