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 +28 -5
- package/dist/auth-state.js +25 -0
- package/dist/bridge.js +64 -0
- package/dist/cli.js +159 -31
- package/dist/config.js +3 -0
- package/dist/daemon.js +101 -0
- package/dist/ids.js +21 -0
- package/dist/index.js +6 -2
- package/dist/lock.js +24 -1
- package/dist/messages.js +61 -4
- package/dist/server.js +31 -18
- package/dist/tools.js +45 -5
- package/dist/whatsapp.js +328 -47
- package/package.json +1 -1
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
|
|
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
|
-
|
|
196
|
-
|
|
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
|
|
package/dist/auth-state.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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}`,
|
|
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",
|
|
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
|
-
|
|
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.`, "
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
292
|
+
process.on("exit", () => {
|
|
293
|
+
removeDaemon(p.daemonFile);
|
|
294
|
+
releaseLock(p.lockFile);
|
|
295
|
+
});
|
|
197
296
|
const wa = new WhatsAppService(config);
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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 =
|
|
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;
|