wazap-mcp 0.9.2 → 0.9.5

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 22 tools any MCP client can
11
+ chats, messages, media, contacts, groups — behind 23 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
@@ -101,13 +101,14 @@ The `skills/` folder follows the [Agent Skills](https://agentskills.io) format,
101
101
  | Tool | Kind | What it does |
102
102
  | --- | --- | --- |
103
103
  | `learn` | read | The guide to every tool, id format and error code. Call it first. |
104
- | `get_status` | read | Connection status, sync state, linked account, versions, data dir. |
104
+ | `get_status` | read | Connection status, sync state, linked account, named-contact count, versions, data dir. |
105
105
  | `list_chats` | read | Conversations newest-first; filter `all`/`unread`/`groups`/`individual`/`archived`. |
106
106
  | `read_messages` | read | Messages in a chat; `before` pages further back, pulling older history from the phone. |
107
- | `get_recent_messages` | read | Everything from the last N hours, grouped by chat. The catch-up tool. |
107
+ | `get_recent_messages` | read | Everything from the last N hours, grouped by chat. The catch-up tool. `include_system` adds WhatsApp's own notices. |
108
108
  | `search_messages` | read | Text search across the locally held messages. |
109
109
  | `get_message` | read | One message in full, with its quoted message and reactions. |
110
110
  | `search_contacts` | read | Find contacts by name or number. |
111
+ | `sync_contacts` | read | Fetch the phone's address book from WhatsApp again, when names are missing. |
111
112
  | `get_contact` | read | Name, number, about text, profile picture. |
112
113
  | `get_group_info` | read | Participants, admins, announcement mode, invite link (when you are admin). |
113
114
  | `download_media` | read | Save an attachment to disk; small images also come back inline. |
@@ -186,14 +187,32 @@ created `0700` with credentials written `0600`:
186
187
  history/ per-chat message history, so a restart is not amnesia
187
188
  store.json chat-list snapshot
188
189
  server.lock pid of the running server
190
+ daemon.json loopback endpoint a second wazap bridges to
189
191
  .env optional settings, see .env.example
190
192
  ```
191
193
 
192
194
  Credential writes go to a temp file and are renamed into place, so killing the
193
195
  process mid-write cannot leave you re-linking your phone.
194
196
 
195
- One server per data directory: a second `wazap serve` on the same directory
196
- exits with code 2 and tells you the pid of the one already running.
197
+ ## Several clients at once
198
+
199
+ Claude Desktop, Claude Code and Cursor each launch their own `wazap`. WhatsApp
200
+ allows one socket per linked device, so they share one session instead of
201
+ fighting over it. The first `wazap` on a data directory owns the session and
202
+ opens an MCP endpoint on `127.0.0.1`; every later one bridges to it over that
203
+ endpoint. There is nothing to configure, and no client can tell the difference.
204
+ The owner publishes `<data-dir>/daemon.json` (`0600`) with its pid, its port
205
+ and the token a bridge authenticates with.
206
+
207
+ A bridge serves whatever the owner exposes, so an owner started `--read-only`
208
+ makes every client read-only, whatever flags that client was launched with.
209
+
210
+ When the owner exits, the bridges exit with it, and the next `wazap` a client
211
+ starts becomes the new owner.
212
+
213
+ `WAZAP_NO_SHARE=1` opts out: a second `wazap` on the same directory exits with
214
+ code 2 naming the pid of the one already running. An explicit `--http` is a
215
+ server of its own rather than a bridge, and is refused the same way.
197
216
 
198
217
  ## Read-only mode
199
218
 
@@ -289,6 +308,10 @@ Flags beat environment variables, which beat `<data-dir>/.env`.
289
308
  - **`@lid` ids.** Newer accounts are addressed by a privacy id rather than a
290
309
  phone number. wazap translates them back to phone numbers when it has learned
291
310
  the mapping, and passes the `@lid` through when it has not.
311
+ - **Names come from the phone's address book.** WhatsApp delivers it as an app
312
+ state sync, and only to a connection asking for it from scratch. If contacts
313
+ read as phone numbers and `get_status` shows `contacts_named: 0`, ask for it
314
+ again with the `sync_contacts` tool or `wazap contacts resync`.
292
315
  - **Your phone must stay reachable.** A linked device stops receiving once the
293
316
  phone has been offline long enough; `get_status` says so in `hint`.
294
317
 
@@ -117,3 +117,28 @@ export function readLinkedAccount(dir) {
117
117
  export function clearAuth(dir) {
118
118
  rmSync(dir, { recursive: true, force: true });
119
119
  }
120
+ const APP_STATE_SYNC_VERSION = "app-state-sync-version";
121
+ /**
122
+ * The same auth state with the app state sync journal held at zero: reads of it
123
+ * find nothing and writes to it are dropped, every other key type untouched.
124
+ *
125
+ * WhatsApp delivers the phone's address book to a companion as `contactAction`
126
+ * mutations in the app state sync, once per stored collection version. Whichever
127
+ * socket saves those versions consumes that one delivery; every later connection
128
+ * resyncs from the version it left behind and receives nothing. The login socket
129
+ * has no store to put contacts in, so it must leave the journal for the service
130
+ * that follows.
131
+ */
132
+ export function withoutAppStateSync(state) {
133
+ return {
134
+ creds: state.creds,
135
+ keys: {
136
+ get: async (type, ids) => type === APP_STATE_SYNC_VERSION ? {} : state.keys.get(type, ids),
137
+ set: async (data) => {
138
+ const rest = { ...data };
139
+ delete rest[APP_STATE_SYNC_VERSION];
140
+ await state.keys.set(rest);
141
+ },
142
+ },
143
+ };
144
+ }
package/dist/bridge.js ADDED
@@ -0,0 +1,64 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { CallToolRequestSchema, CallToolResultSchema, ListToolsRequestSchema, ListToolsResultSchema, } from "@modelcontextprotocol/sdk/types.js";
6
+ import { WAZAP_VERSION } from "./config.js";
7
+ import { readDaemon } from "./daemon.js";
8
+ import { log } from "./logger.js";
9
+ const HEARTBEAT_MS = 1_000;
10
+ /**
11
+ * Serve this client from the session another process already owns: an MCP server
12
+ * on our stdio, every tool call forwarded to the daemon's loopback endpoint and
13
+ * its answer returned untouched.
14
+ *
15
+ * `daemonFile` is here because the heartbeat re-reads the sidecar, and DaemonInfo
16
+ * carries no path.
17
+ */
18
+ export async function runBridge(daemon, daemonFile) {
19
+ let left = false;
20
+ /** Exit 1 so the client restarts us, and the restart becomes the new daemon. */
21
+ const leave = (reason) => {
22
+ if (left)
23
+ return;
24
+ left = true;
25
+ log(`${reason}, exiting so the next start can own the session`);
26
+ process.exit(1);
27
+ };
28
+ const client = new Client({ name: "wazap-bridge", version: WAZAP_VERSION });
29
+ await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${daemon.port}/mcp`), {
30
+ requestInit: { headers: { Authorization: `Bearer ${daemon.token}` } },
31
+ }));
32
+ const caps = client.getServerCapabilities() ?? {};
33
+ const server = new Server(client.getServerVersion() ?? { name: "wazap", version: daemon.version }, {
34
+ // Only what we forward: the daemon has no resources or prompts, and we have
35
+ // no handler for them.
36
+ capabilities: { tools: caps.tools ?? {} },
37
+ instructions: client.getInstructions(),
38
+ });
39
+ server.setRequestHandler(ListToolsRequestSchema, (req) => client.request({ method: "tools/list", params: req.params }, ListToolsResultSchema));
40
+ server.setRequestHandler(CallToolRequestSchema, (req) => client.request({ method: "tools/call", params: req.params }, CallToolResultSchema));
41
+ client.onclose = () => leave(`the session holder (pid ${daemon.pid}) closed the connection`);
42
+ client.onerror = () => leave(`lost the connection to the session holder (pid ${daemon.pid})`);
43
+ // A dead daemon does not close the client: the transport retries its stream and
44
+ // reports nothing, measured. So the liveness of the pid is ours to watch.
45
+ const heartbeat = setInterval(() => {
46
+ if (readDaemon(daemonFile)?.pid !== daemon.pid) {
47
+ leave(`the session holder (pid ${daemon.pid}) gave up the session`);
48
+ return;
49
+ }
50
+ try {
51
+ process.kill(daemon.pid, 0);
52
+ }
53
+ catch {
54
+ leave(`the session holder (pid ${daemon.pid}) is gone`);
55
+ }
56
+ }, HEARTBEAT_MS);
57
+ heartbeat.unref();
58
+ await server.connect(new StdioServerTransport());
59
+ log(`sharing the WhatsApp session held by pid ${daemon.pid}`);
60
+ // Our own client leaving is not a failure. The upstream stream holds the event
61
+ // loop open, so without this the bridge outlives the client it was started for.
62
+ process.stdin.on("end", () => process.exit(0));
63
+ process.stdin.on("close", () => process.exit(0));
64
+ }
package/dist/cli.js CHANGED
@@ -1,22 +1,26 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { mkdirSync, rmSync } from "node:fs";
2
3
  import { createInterface } from "node:readline/promises";
3
4
  import { setTimeout as sleep } from "node:timers/promises";
4
5
  import makeWASocket, { DisconnectReason, } from "baileys";
5
6
  import qrcode from "qrcode";
6
7
  import qrcodeTerminal from "qrcode-terminal";
7
- import { clearAuth, readLinkedAccount, useAtomicAuthState } from "./auth-state.js";
8
+ import { clearAuth, readLinkedAccount, useAtomicAuthState, withoutAppStateSync, } from "./auth-state.js";
8
9
  import { banner } from "./banner.js";
10
+ import { runBridge } from "./bridge.js";
9
11
  import { BAILEYS_VERSION, WAZAP_VERSION, paths } from "./config.js";
10
12
  import { connectNext } from "./connect.js";
13
+ import { decideRole, readDaemon, removeDaemon, writeDaemon } from "./daemon.js";
11
14
  import { checkLine, checkLines, runChecks } from "./doctor.js";
12
15
  import { RELINK_FIX, WazapError, asWazapError } from "./errors.js";
13
16
  import { normalizePhone } from "./ids.js";
14
17
  import { lockHolder, releaseLock, writeLock } from "./lock.js";
15
18
  import { log, logError, say } from "./logger.js";
16
19
  import { formatAge } from "./messages.js";
17
- import { runHttp, runStdio } from "./server.js";
20
+ import { RateLimiter } from "./ratelimit.js";
21
+ import { runHttp, runStdio, startLoopbackEndpoint } from "./server.js";
18
22
  import { applyWrites } from "./settings.js";
19
- import { bold, box, brand, humanLayout, dim, fail, info, maskNumber, next, ok, shortPath, spinner, step, tilde, warn, } from "./ui.js";
23
+ import { bold, box, brand, humanLayout, dim, fail, fix, info, maskNumber, next, ok, shortPath, spinner, step, tilde, warn, } from "./ui.js";
20
24
  import { WA_BROWSER, WhatsAppService } from "./whatsapp.js";
21
25
  const LOGIN_TIMEOUT_MS = 120_000;
22
26
  const LIVE_TIMEOUT_MS = 15_000;
@@ -30,6 +34,8 @@ const SETTLED_STATUSES = [
30
34
  ];
31
35
  const LOGOUT_TIMEOUT_MS = 10_000;
32
36
  const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"];
37
+ /** Bind addresses a loopback bridge can still reach; the wildcards include 127.0.0.1. */
38
+ const SHAREABLE_HOSTS = [...LOOPBACK_HOSTS, "0.0.0.0", "::"];
33
39
  const SILENT_LOGGER = {
34
40
  level: "silent",
35
41
  child: () => SILENT_LOGGER,
@@ -49,6 +55,11 @@ export async function runStatus(config) {
49
55
  catch {
50
56
  unreadable = true;
51
57
  }
58
+ // A sidecar outliving the process that wrote it is stale, so only the lock
59
+ // holder's own record counts as a session being shared.
60
+ const serverPid = lockHolder(p.lockFile);
61
+ const daemon = readDaemon(p.daemonFile);
62
+ const sharing = daemon !== null && daemon.pid === serverPid ? { pid: daemon.pid, port: daemon.port } : null;
52
63
  const report = {
53
64
  data_dir: config.dataDir,
54
65
  linked: account !== null,
@@ -56,7 +67,8 @@ export async function runStatus(config) {
56
67
  account,
57
68
  wazap_version: WAZAP_VERSION,
58
69
  baileys_version: BAILEYS_VERSION,
59
- server_pid: lockHolder(p.lockFile),
70
+ server_pid: serverPid,
71
+ daemon: sharing,
60
72
  checks: await runChecks(config),
61
73
  };
62
74
  if (config.live)
@@ -89,9 +101,16 @@ function plainStatus(report) {
89
101
  else {
90
102
  lines.push("linked: no");
91
103
  }
92
- lines.push(`wazap: ${report.wazap_version}`, `baileys: ${report.baileys_version}`, report.server_pid === null ? "server: not running" : `server: running (pid ${report.server_pid})`, "", "checks:", ...report.checks.map(checkLine));
104
+ lines.push(`wazap: ${report.wazap_version}`, `baileys: ${report.baileys_version}`, `server: ${serverState(report)}`, "", "checks:", ...report.checks.map(checkLine));
93
105
  return lines;
94
106
  }
107
+ /** The one place the sidecar becomes words, so the two renderers cannot drift. */
108
+ function serverState(report) {
109
+ if (report.server_pid === null)
110
+ return "not running";
111
+ const shared = report.daemon === null ? "" : `, sharing on 127.0.0.1:${report.daemon.port}`;
112
+ return `running (pid ${report.server_pid}${shared})`;
113
+ }
95
114
  const LABEL_WIDTH = 8;
96
115
  function row(label, value) {
97
116
  return `${dim(label.padEnd(LABEL_WIDTH))} ${value}`;
@@ -106,7 +125,7 @@ function richStatus(report) {
106
125
  `${bold(`wazap ${report.wazap_version}`)}${dim(` · baileys ${report.baileys_version}`)}`,
107
126
  row("data dir", tilde(report.data_dir)),
108
127
  row("account", account),
109
- row("server", report.server_pid === null ? "not running" : `running (pid ${report.server_pid})`),
128
+ row("server", serverState(report)),
110
129
  "",
111
130
  ...report.checks.flatMap(checkLines),
112
131
  ];
@@ -120,15 +139,29 @@ function liveLines(live) {
120
139
  `live: last message ${live.last_message_age ?? "unknown"}`,
121
140
  ];
122
141
  }
142
+ /**
143
+ * Hold the session for a one-shot command: null once the lock is ours, otherwise
144
+ * the pid that owns it. The claim only fails while the file exists, so a lost
145
+ * race is looked up again rather than reported as a missing pid.
146
+ */
147
+ function takeSessionLock(lockFile) {
148
+ for (let attempt = 0; attempt < 5; attempt++) {
149
+ const running = lockHolder(lockFile);
150
+ if (running !== null)
151
+ return running;
152
+ if (writeLock(lockFile))
153
+ return null;
154
+ }
155
+ throw new WazapError("WHATSAPP_ERROR", `Could not take the session lock in ${lockFile}.`, "Run the command again");
156
+ }
123
157
  /** One process owns the session, so a probe only runs when no server holds the lock. */
124
158
  async function runLiveProbe(config) {
125
159
  const p = paths(config.dataDir);
126
- const running = lockHolder(p.lockFile);
160
+ // The probe owns the session for as long as it runs, exactly like the server.
161
+ const running = takeSessionLock(p.lockFile);
127
162
  if (running !== null) {
128
- throw new WazapError("WHATSAPP_ERROR", `A server (pid ${running}) already owns this session.`, "Ask it through your MCP client instead: call get_status");
163
+ throw new WazapError("WHATSAPP_ERROR", `A server (pid ${running}) already owns this session.`, "use get_status through your client, or wazap status");
129
164
  }
130
- // The probe owns the session for as long as it runs, exactly like the server.
131
- writeLock(p.lockFile);
132
165
  const wa = new WhatsAppService(config);
133
166
  const deadline = Date.now() + LIVE_TIMEOUT_MS;
134
167
  try {
@@ -161,6 +194,53 @@ async function runLiveProbe(config) {
161
194
  releaseLock(p.lockFile);
162
195
  }
163
196
  }
197
+ /**
198
+ * `wazap contacts resync`. One process owns the session, so this refuses while a
199
+ * server holds it rather than fighting for the socket: that server has the
200
+ * sync_contacts tool, which does the same thing.
201
+ */
202
+ export async function runContacts(config) {
203
+ if (config.args[0] !== "resync") {
204
+ say(fail(`Unknown contacts command "${config.args[0]}".`));
205
+ say(fix("Run `wazap contacts resync`"));
206
+ process.exit(2);
207
+ }
208
+ const p = paths(config.dataDir);
209
+ const running = takeSessionLock(p.lockFile);
210
+ if (running !== null) {
211
+ say(fail(`wazap is running (pid ${running}).`));
212
+ say(fix("ask your agent for the sync_contacts tool, or stop the server and run this again"));
213
+ process.exit(1);
214
+ }
215
+ const wa = new WhatsAppService(config);
216
+ const spin = spinner("Asking WhatsApp for your address book…");
217
+ try {
218
+ await wa.start();
219
+ const deadline = Date.now() + LIVE_TIMEOUT_MS;
220
+ let probe = wa.getStatus();
221
+ while (!SETTLED_STATUSES.includes(probe.status) && Date.now() < deadline) {
222
+ await sleep(250);
223
+ probe = wa.getStatus();
224
+ }
225
+ const result = await wa.syncContacts();
226
+ spin.stop(result.named_after > result.named_before
227
+ ? ok(`${result.named_after} contacts have a name (was ${result.named_before})`)
228
+ : result.named_after > 0
229
+ ? ok(`Already up to date: ${result.named_after} contacts have a name`)
230
+ : warn("WhatsApp sent no names at all. The phone has no saved contacts for these people."));
231
+ }
232
+ catch (err) {
233
+ const failure = asWazapError(err);
234
+ spin.stop(fail(failure.message));
235
+ if (failure.fix)
236
+ say(fix(failure.fix));
237
+ process.exitCode = 1;
238
+ }
239
+ finally {
240
+ await wa.stop();
241
+ releaseLock(p.lockFile);
242
+ }
243
+ }
164
244
  /** Bare `wazap` at a terminal: where you stand, and the one command to run next. */
165
245
  export async function runGreet(config) {
166
246
  say(banner());
@@ -181,22 +261,45 @@ export async function runGreet(config) {
181
261
  }
182
262
  export async function runServe(config) {
183
263
  const p = paths(config.dataDir);
184
- const running = lockHolder(p.lockFile);
185
- if (running !== null) {
186
- say(fail(`wazap is already running (pid ${running}) using ${config.dataDir}. Stop it first or use --data-dir.`));
187
- process.exit(2);
264
+ // Losing the atomic claim means another `serve` won it, and the next pass finds
265
+ // its sidecar and becomes a bridge onto it.
266
+ let claimed = false;
267
+ for (let attempt = 0; attempt < 5; attempt++) {
268
+ const role = await decideRole(config, p);
269
+ if (role.kind === "refuse") {
270
+ say(fail(role.message));
271
+ process.exit(2);
272
+ }
273
+ if (role.kind === "bridge") {
274
+ await runBridge(role.daemon, p.daemonFile);
275
+ return;
276
+ }
277
+ // Loopback with no token only gets runHttp's warning; off-loopback is refused.
278
+ if (config.transport === "http" && !config.readToken && !LOOPBACK_HOSTS.includes(config.httpHost)) {
279
+ say(fail(`Refusing to serve ${config.httpHost} without a token. Set WAZAP_READ_TOKEN, or bind 127.0.0.1.`));
280
+ process.exit(1);
281
+ }
282
+ mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
283
+ if (writeLock(p.lockFile)) {
284
+ claimed = true;
285
+ break;
286
+ }
188
287
  }
189
- // Loopback with no token only gets runHttp's warning; off-loopback is refused.
190
- if (config.transport === "http" && !config.readToken && !LOOPBACK_HOSTS.includes(config.httpHost)) {
191
- say(fail(`Refusing to serve ${config.httpHost} without a token. Set WAZAP_READ_TOKEN, or bind 127.0.0.1.`));
192
- process.exit(1);
288
+ if (!claimed) {
289
+ say(fail(`Another wazap keeps taking ${config.dataDir} as this one starts. Run it again.`));
290
+ process.exit(2);
193
291
  }
194
- mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
195
- writeLock(p.lockFile);
196
- process.on("exit", () => releaseLock(p.lockFile));
292
+ process.on("exit", () => {
293
+ removeDaemon(p.daemonFile);
294
+ releaseLock(p.lockFile);
295
+ });
197
296
  const wa = new WhatsAppService(config);
198
- const shutdown = (signal) => {
199
- log(`received ${signal}, shutting down`);
297
+ let stopping = false;
298
+ const shutdown = (reason) => {
299
+ if (stopping)
300
+ return;
301
+ stopping = true;
302
+ log(`received ${reason}, shutting down`);
200
303
  // A wedged socket must not cost the user a kill -9; the lock goes on "exit".
201
304
  setTimeout(() => process.exit(0), 3_000).unref();
202
305
  void wa.stop().finally(() => process.exit(0));
@@ -206,10 +309,31 @@ export async function runServe(config) {
206
309
  // Connecting in the background: MCP startup never waits on WhatsApp, and the
207
310
  // tools answer NOT_LINKED until a session exists.
208
311
  wa.start().catch((err) => logError("whatsapp start", err));
209
- if (config.transport === "http")
210
- await runHttp(wa, config);
211
- else
212
- await runStdio(wa, config);
312
+ const token = config.share ? randomBytes(32).toString("hex") : null;
313
+ // One bucket for the process, not per endpoint: a bridge writing through the
314
+ // loopback endpoint spends from the same allowance as the daemon's own client.
315
+ const limiter = new RateLimiter(config.rateLimitPerMinute);
316
+ if (config.transport === "http") {
317
+ const port = await runHttp(wa, config, limiter, token === null ? undefined : { token, write: true });
318
+ // Off-loopback binds get no sidecar: a bridge on this machine could not reach them.
319
+ if (token !== null && SHAREABLE_HOSTS.includes(config.httpHost)) {
320
+ writeDaemon(p.daemonFile, { pid: process.pid, port, token, version: WAZAP_VERSION });
321
+ }
322
+ return;
323
+ }
324
+ if (token !== null) {
325
+ const port = await startLoopbackEndpoint(wa, config, token, limiter);
326
+ writeDaemon(p.daemonFile, { pid: process.pid, port, token, version: WAZAP_VERSION });
327
+ }
328
+ await runStdio(wa, config, limiter);
329
+ if (token === null)
330
+ return;
331
+ // The loopback endpoint keeps the event loop alive, so stdin EOF no longer ends
332
+ // the process on its own. When the daemon's own client goes away the daemon goes
333
+ // with it, rather than lingering for bridges. Registered after runStdio, so the
334
+ // transport is already reading and "end" fires.
335
+ process.stdin.on("end", () => shutdown("stdin end"));
336
+ process.stdin.on("close", () => shutdown("stdin close"));
213
337
  }
214
338
  export function stepper(total) {
215
339
  let n = 0;
@@ -239,13 +363,11 @@ export async function runLogin(config) {
239
363
  */
240
364
  export async function linkAndSync(config, announce = () => { }) {
241
365
  const p = paths(config.dataDir);
242
- const running = lockHolder(p.lockFile);
366
+ const running = takeSessionLock(p.lockFile);
243
367
  if (running !== null) {
244
368
  say(fail(`wazap is running (pid ${running}). Stop it first (or quit the client that launched it), then run this again.`));
245
369
  process.exit(1);
246
370
  }
247
- mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
248
- writeLock(p.lockFile);
249
371
  const release = () => releaseLock(p.lockFile);
250
372
  const onInterrupt = () => {
251
373
  release();
@@ -505,10 +627,16 @@ async function linkSession(authDir, opts) {
505
627
  if (expired)
506
628
  throw timedOut();
507
629
  const { state, saveCreds } = await useAtomicAuthState(authDir);
630
+ // This socket pairs and nothing else. It has no store, so anything it
631
+ // syncs is thrown away — and WhatsApp sends the history and the address
632
+ // book once. Refusing the history keeps it out of Baileys' sync state
633
+ // machine, which is what would otherwise bump `accountSyncCounter` and
634
+ // leave the service permanently past its own first sync.
508
635
  const sock = makeWASocket({
509
- auth: state,
636
+ auth: withoutAppStateSync(state),
510
637
  browser: WA_BROWSER,
511
638
  markOnlineOnConnect: false,
639
+ shouldSyncHistoryMessage: () => false,
512
640
  logger: SILENT_LOGGER,
513
641
  });
514
642
  current = sock;
package/dist/config.js CHANGED
@@ -15,6 +15,7 @@ export function paths(dataDir) {
15
15
  historyDir: join(dataDir, "history"),
16
16
  storeFile: join(dataDir, "store.json"),
17
17
  lockFile: join(dataDir, "server.lock"),
18
+ daemonFile: join(dataDir, "daemon.json"),
18
19
  envFile: join(dataDir, ".env"),
19
20
  qrFile: join(dataDir, "qr.png"),
20
21
  };
@@ -28,6 +29,7 @@ const COMMAND_ARGS = {
28
29
  logout: [0],
29
30
  connect: [1],
30
31
  config: [0, 2],
32
+ contacts: [1],
31
33
  };
32
34
  const COMMANDS = Object.keys(COMMAND_ARGS);
33
35
  export function defaultDataDir() {
@@ -119,6 +121,7 @@ export function parseCli(argv = process.argv.slice(2)) {
119
121
  httpPort: values.port ? asInt(values.port, 8766) : asInt(process.env.WAZAP_PORT, 8766),
120
122
  readToken: (process.env.WAZAP_READ_TOKEN ?? "").trim() || null,
121
123
  writeToken: (process.env.WAZAP_WRITE_TOKEN ?? "").trim() || null,
124
+ share: !asBool(process.env.WAZAP_NO_SHARE, false),
122
125
  rateLimitPerMinute: asInt(process.env.WAZAP_RATE_LIMIT, 20),
123
126
  sources: {
124
127
  // Resolved before dotenv runs, so the data dir's own .env cannot name it.
package/dist/daemon.js ADDED
@@ -0,0 +1,101 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import { lockHolder } from "./lock.js";
5
+ function isPositiveInt(value) {
6
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
7
+ }
8
+ /** The sidecar another process left behind, or null if it is missing, corrupt or the wrong shape. */
9
+ export function readDaemon(file) {
10
+ let parsed;
11
+ try {
12
+ parsed = JSON.parse(readFileSync(file, "utf8"));
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ if (typeof parsed !== "object" || parsed === null)
18
+ return null;
19
+ const { pid, port, token, version } = parsed;
20
+ if (!isPositiveInt(pid) || !isPositiveInt(port))
21
+ return null;
22
+ if (typeof token !== "string" || token === "")
23
+ return null;
24
+ if (typeof version !== "string" || version === "")
25
+ return null;
26
+ return { pid, port, token, version };
27
+ }
28
+ export function writeDaemon(file, info) {
29
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
30
+ // Written aside and renamed in: a bridge polling for the sidecar reads either
31
+ // the old record or the new one, never half a token. The mode argument only
32
+ // applies when a file is created, so the chmod covers a leftover temp file
33
+ // from an earlier run keeping looser permissions.
34
+ const temp = `${file}.${process.pid}.tmp`;
35
+ writeFileSync(temp, `${JSON.stringify(info, null, 2)}\n`, { mode: 0o600 });
36
+ chmodSync(temp, 0o600);
37
+ renameSync(temp, file);
38
+ }
39
+ /** Remove the sidecar, but only if it is still ours. */
40
+ export function removeDaemon(file) {
41
+ try {
42
+ if (readDaemon(file)?.pid !== process.pid)
43
+ return;
44
+ unlinkSync(file);
45
+ }
46
+ catch {
47
+ /* already gone */
48
+ }
49
+ }
50
+ /** Liveness of the loopback endpoint recorded in a sidecar. Never throws. */
51
+ export async function daemonHealthy(port, timeoutMs) {
52
+ const controller = new AbortController();
53
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
54
+ try {
55
+ const res = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: controller.signal });
56
+ if (!res.ok)
57
+ return false;
58
+ const body = await res.json();
59
+ return typeof body === "object" && body !== null && body.ok === true;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+ const ROLE_TIMEOUT_MS = 3_000;
69
+ const ROLE_POLL_MS = 100;
70
+ /**
71
+ * Who we are for this data dir: the process that owns the session, a bridge onto
72
+ * the one that already does, or neither. The lock is re-read every pass because
73
+ * the winner of a simultaneous start needs a moment to bind its port and publish
74
+ * the sidecar, and because a winner that crashes frees the lock mid-loop.
75
+ */
76
+ export async function decideRole(config, p) {
77
+ const deadline = Date.now() + ROLE_TIMEOUT_MS;
78
+ for (;;) {
79
+ const running = lockHolder(p.lockFile);
80
+ if (running === null)
81
+ return { kind: "daemon" };
82
+ // An explicit --http asks for an HTTP server of its own, not a stdio bridge.
83
+ if (config.share === false || config.transport === "http") {
84
+ return {
85
+ kind: "refuse",
86
+ message: `wazap is already running (pid ${running}) using ${config.dataDir}. Stop it first or use --data-dir.`,
87
+ };
88
+ }
89
+ const info = readDaemon(p.daemonFile);
90
+ if (info !== null && info.pid === running && (await daemonHealthy(info.port, 2_000))) {
91
+ return { kind: "bridge", daemon: info };
92
+ }
93
+ if (Date.now() >= deadline) {
94
+ return {
95
+ kind: "refuse",
96
+ message: `wazap is running (pid ${running}) but is not sharing its session (older version?). Stop it and start again.`,
97
+ };
98
+ }
99
+ await sleep(ROLE_POLL_MS);
100
+ }
101
+ }
package/dist/ids.js CHANGED
@@ -11,6 +11,27 @@ export function normalizePhone(input) {
11
11
  export function isGroupId(jid) {
12
12
  return jid.endsWith("@g.us");
13
13
  }
14
+ /**
15
+ * Jids that address nobody: the status feed, the `0@s.whatsapp.net` pseudo-chat
16
+ * WhatsApp files its own notices under, and anything malformed. They must never
17
+ * reach a chat list, a digest or the store.
18
+ *
19
+ * Stated as what to refuse rather than what to keep, so a jid kind wazap has
20
+ * not met yet — a broadcast list, a channel — still reaches the user instead of
21
+ * being silently swallowed, and a stored one is never purged.
22
+ */
23
+ export function isNoiseJid(jid) {
24
+ const at = jid.lastIndexOf("@");
25
+ if (at === -1)
26
+ return true;
27
+ const user = jid.slice(0, at);
28
+ const domain = jid.slice(at + 1).toLowerCase();
29
+ if (domain === "broadcast")
30
+ return user.toLowerCase() === "status";
31
+ if (domain === "s.whatsapp.net" || domain === "c.us")
32
+ return /^0+$/.test(user) || !/^\d+$/.test(user);
33
+ return user === "";
34
+ }
14
35
  /**
15
36
  * Canonicalize anything a caller may pass as a chat id.
16
37
  * Individuals become `<digits>@s.whatsapp.net`, groups stay `<id>@g.us`.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { BANNER } from "./banner.js";
3
- import { runGreet, runLogin, runLogout, runServe, runStatus } from "./cli.js";
3
+ import { runContacts, runGreet, runLogin, runLogout, runServe, runStatus } from "./cli.js";
4
4
  import { WAZAP_VERSION, parseCli, pickDefaultAction } from "./config.js";
5
5
  import { CLIENT_NAMES, runConnect } from "./connect.js";
6
6
  import { runSetup } from "./setup.js";
@@ -16,6 +16,7 @@ Usage:
16
16
  wazap setup [--agent] [--client <name>] Link, connect your client and finish, in one command
17
17
  wazap connect <client> [--dry-run] Register wazap with an MCP client
18
18
  wazap config [writes on|off] Show the effective settings, or allow/refuse writes
19
+ wazap contacts resync Fetch the phone's address book from WhatsApp again
19
20
  wazap status [--live] [--json] Check the install, the session and the server
20
21
  wazap logout Unlink and delete local credentials
21
22
 
@@ -42,7 +43,7 @@ Options:
42
43
 
43
44
  Environment: WAZAP_DATA_DIR, WAZAP_READ_ONLY, WAZAP_SYNC_FULL_HISTORY, WAZAP_PERSIST_HISTORY,
44
45
  WAZAP_TRANSPORT, WAZAP_HOST, WAZAP_PORT, WAZAP_READ_TOKEN, WAZAP_WRITE_TOKEN, WAZAP_RATE_LIMIT,
45
- WAZAP_NO_UPDATE_CHECK.
46
+ WAZAP_NO_SHARE, WAZAP_NO_UPDATE_CHECK.
46
47
  An optional <data-dir>/.env is loaded if present.`;
47
48
  async function main() {
48
49
  const invocation = parseCli();
@@ -75,6 +76,9 @@ async function main() {
75
76
  case "config":
76
77
  runConfig(config);
77
78
  return;
79
+ case "contacts":
80
+ await runContacts(config);
81
+ return;
78
82
  case "status":
79
83
  await runStatus(config);
80
84
  return;